Initial release: Go Jdenticon library v0.1.0

- Core library with SVG and PNG generation
- CLI tool with generate and batch commands
- Cross-platform path handling for Windows compatibility
- Comprehensive test suite with integration tests
This commit is contained in:
Kevin McIntyre
2026-01-02 23:56:48 -05:00
parent f84b511895
commit d9e84812ff
292 changed files with 19725 additions and 38884 deletions

View File

@@ -3,103 +3,232 @@ package jdenticon
import (
"fmt"
"regexp"
"github.com/ungluedlabs/go-jdenticon/internal/constants"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Config holds configuration options for identicon generation.
type Config struct {
// HueRestrictions specifies the hues (in degrees) that are allowed for the identicon.
// If empty, any hue can be used. Values should be between 0 and 360.
HueRestrictions []float64
// ColorLightnessRange specifies the lightness range for colored shapes [min, max].
// Values should be between 0.0 and 1.0. Default: [0.4, 0.8]
ColorLightnessRange [2]float64
// GrayscaleLightnessRange specifies the lightness range for grayscale shapes [min, max].
// Values should be between 0.0 and 1.0. Default: [0.3, 0.9]
GrayscaleLightnessRange [2]float64
// ColorSaturation controls the saturation of colored shapes.
// Values should be between 0.0 and 1.0. Default: 0.5
ColorSaturation float64
// GrayscaleSaturation controls the saturation of grayscale shapes.
// Values should be between 0.0 and 1.0. Default: 0.0
GrayscaleSaturation float64
// BackgroundColor sets the background color for the identicon.
// Accepts hex colors like "#fff", "#ffffff", "#ffffff80" (with alpha).
// If empty, the background will be transparent.
BackgroundColor string
// Padding controls the padding around the identicon as a percentage of the size.
// Values should be between 0.0 and 0.5. Default: 0.08
Padding float64
// hexColorRegex validates hex color strings in #RGB or #RRGGBB format.
// It's compiled once at package initialization for efficiency.
var hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// isValidHexColor checks if the given string is a valid hex color code.
// It accepts both #RGB and #RRGGBB formats, case-insensitive.
// Returns true for empty strings, which are treated as "transparent background".
func isValidHexColor(color string) bool {
if color == "" {
return true // Empty string means transparent background
}
return hexColorRegex.MatchString(color)
}
// DefaultConfig returns a default configuration that matches jdenticon-js defaults.
// Config holds the configuration options for identicon generation.
// This is the public API configuration that wraps the internal engine config.
type Config struct {
// Color configuration
ColorSaturation float64 // Saturation for colored shapes (0.0-1.0)
GrayscaleSaturation float64 // Saturation for grayscale shapes (0.0-1.0)
ColorLightnessRange [2]float64 // Lightness range for colored shapes [min, max] (0.0-1.0)
GrayscaleLightnessRange [2]float64 // Lightness range for grayscale shapes [min, max] (0.0-1.0)
// Hue restrictions - if specified, only these hues will be used
HueRestrictions []float64 // Specific hue values in degrees (0-360)
// Layout configuration
Padding float64 // Padding as percentage of icon size (0.0-0.5)
BackgroundColor string // Background color in hex format (e.g., "#ffffff") or empty for transparent
// PNG rendering configuration
PNGSupersampling int // Supersampling factor for PNG rendering (1-16)
// Security limits for DoS protection.
//
// MaxIconSize sets the maximum icon dimension in pixels to prevent memory exhaustion.
// - 0 (default): Use library default of 4096 pixels (64MB max memory)
// - Positive value: Use custom pixel limit
// - -1: Disable size limits entirely (use with caution in server environments)
MaxIconSize int
// MaxInputLength sets the maximum input string length in bytes to prevent hash DoS.
// - 0 (default): Use library default of 1MB
// - Positive value: Use custom byte limit
// - -1: Disable input length limits entirely
MaxInputLength int
// MaxComplexity sets the maximum geometric complexity score to prevent resource exhaustion.
// This is calculated as the sum of complexity scores for all shapes in an identicon.
// - 0 (default): Use library default complexity limit
// - Positive value: Use custom complexity limit
// - -1: Disable complexity limits entirely (use with caution)
MaxComplexity int
}
// DefaultConfig returns a Config with sensible default values.
func DefaultConfig() Config {
return Config{
HueRestrictions: nil, // No restrictions
ColorLightnessRange: [2]float64{0.4, 0.8},
GrayscaleLightnessRange: [2]float64{0.3, 0.9},
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
BackgroundColor: "", // Transparent
ColorLightnessRange: [2]float64{0.4, 0.8},
GrayscaleLightnessRange: [2]float64{0.3, 0.9},
HueRestrictions: nil, // No restrictions by default
Padding: 0.08,
BackgroundColor: "", // Transparent background
PNGSupersampling: 8,
}
}
// ConfigOption represents a configuration option function.
// Validate checks if the configuration values are valid.
func (c *Config) Validate() error {
// Validate saturation ranges
if c.ColorSaturation < 0.0 || c.ColorSaturation > 1.0 {
return fmt.Errorf("color saturation must be between 0.0 and 1.0, got %f", c.ColorSaturation)
}
if c.GrayscaleSaturation < 0.0 || c.GrayscaleSaturation > 1.0 {
return fmt.Errorf("grayscale saturation must be between 0.0 and 1.0, got %f", c.GrayscaleSaturation)
}
// Validate lightness ranges
if c.ColorLightnessRange[0] < 0.0 || c.ColorLightnessRange[0] > 1.0 ||
c.ColorLightnessRange[1] < 0.0 || c.ColorLightnessRange[1] > 1.0 {
return fmt.Errorf("color lightness range values must be between 0.0 and 1.0, got [%f, %f]",
c.ColorLightnessRange[0], c.ColorLightnessRange[1])
}
if c.ColorLightnessRange[0] >= c.ColorLightnessRange[1] {
return fmt.Errorf("color lightness range min must be less than max, got [%f, %f]",
c.ColorLightnessRange[0], c.ColorLightnessRange[1])
}
if c.GrayscaleLightnessRange[0] < 0.0 || c.GrayscaleLightnessRange[0] > 1.0 ||
c.GrayscaleLightnessRange[1] < 0.0 || c.GrayscaleLightnessRange[1] > 1.0 {
return fmt.Errorf("grayscale lightness range values must be between 0.0 and 1.0, got [%f, %f]",
c.GrayscaleLightnessRange[0], c.GrayscaleLightnessRange[1])
}
if c.GrayscaleLightnessRange[0] >= c.GrayscaleLightnessRange[1] {
return fmt.Errorf("grayscale lightness range min must be less than max, got [%f, %f]",
c.GrayscaleLightnessRange[0], c.GrayscaleLightnessRange[1])
}
// Validate padding
if c.Padding < 0.0 || c.Padding > 0.5 {
return fmt.Errorf("padding must be between 0.0 and 0.5, got %f", c.Padding)
}
// Validate hue restrictions
for i, hue := range c.HueRestrictions {
if hue < 0.0 || hue > 360.0 {
return fmt.Errorf("hue restriction at index %d must be between 0.0 and 360.0, got %f", i, hue)
}
}
// Validate PNG supersampling
if c.PNGSupersampling < 1 || c.PNGSupersampling > 16 {
return fmt.Errorf("PNG supersampling must be between 1 and 16, got %d", c.PNGSupersampling)
}
// Validate background color format
if !isValidHexColor(c.BackgroundColor) {
return NewErrInvalidInput("background_color", c.BackgroundColor, "must be a valid hex color in #RGB or #RRGGBB format")
}
return nil
}
// effectiveMaxIconSize resolves the configured icon size limit, applying the default if necessary.
// Returns -1 if the limit is disabled.
func (c *Config) effectiveMaxIconSize() int {
if c.MaxIconSize < 0 {
return -1 // Disabled
}
if c.MaxIconSize == 0 {
return constants.DefaultMaxIconSize
}
return c.MaxIconSize
}
// effectiveMaxInputLength resolves the configured input length limit, applying the default if necessary.
// Returns -1 if the limit is disabled.
func (c *Config) effectiveMaxInputLength() int {
if c.MaxInputLength < 0 {
return -1 // Disabled
}
if c.MaxInputLength == 0 {
return constants.DefaultMaxInputLength
}
return c.MaxInputLength
}
// effectiveMaxComplexity resolves the configured complexity limit, applying the default if necessary.
// Returns -1 if the limit is disabled.
func (c *Config) effectiveMaxComplexity() int {
if c.MaxComplexity < 0 {
return -1 // Disabled
}
if c.MaxComplexity == 0 {
return constants.DefaultMaxComplexity
}
return c.MaxComplexity
}
// toEngineColorConfig converts the public Config to the internal engine.ColorConfig.
func (c *Config) toEngineColorConfig() (engine.ColorConfig, error) {
if err := c.Validate(); err != nil {
return engine.ColorConfig{}, fmt.Errorf("invalid configuration: %w", err)
}
colorConfig := engine.ColorConfig{
ColorSaturation: c.ColorSaturation,
GrayscaleSaturation: c.GrayscaleSaturation,
ColorLightness: engine.LightnessRange{
Min: c.ColorLightnessRange[0],
Max: c.ColorLightnessRange[1],
},
GrayscaleLightness: engine.LightnessRange{
Min: c.GrayscaleLightnessRange[0],
Max: c.GrayscaleLightnessRange[1],
},
Hues: c.HueRestrictions,
BackColor: nil, // Will be set below if background color is specified
IconPadding: c.Padding,
}
// Handle background color conversion from hex string to engine.Color
if c.BackgroundColor != "" {
// Validation is already handled by c.Validate() above.
bgColor, err := engine.ParseHexColorToEngine(c.BackgroundColor)
if err != nil {
return colorConfig, fmt.Errorf("failed to parse background color: %w", err)
}
colorConfig.BackColor = &bgColor
}
return colorConfig, nil
}
// ConfigOption represents a functional option for configuring identicon generation.
type ConfigOption func(*Config) error
// WithHueRestrictions sets the allowed hues in degrees (0-360).
func WithHueRestrictions(hues []float64) ConfigOption {
return func(c *Config) error {
for _, hue := range hues {
if hue < 0 || hue >= 360 {
return fmt.Errorf("hue must be between 0 and 360, got %f", hue)
}
// Configure applies configuration options to create a new Config.
func Configure(options ...ConfigOption) (Config, error) {
config := DefaultConfig()
for _, option := range options {
if err := option(&config); err != nil {
return config, fmt.Errorf("configuration option failed: %w", err)
}
c.HueRestrictions = make([]float64, len(hues))
copy(c.HueRestrictions, hues)
return nil
}
if err := config.Validate(); err != nil {
return config, fmt.Errorf("invalid configuration after applying options: %w", err)
}
return config, nil
}
// WithColorLightnessRange sets the lightness range for colored shapes.
func WithColorLightnessRange(min, max float64) ConfigOption {
return func(c *Config) error {
if min < 0 || min > 1 || max < 0 || max > 1 {
return fmt.Errorf("lightness values must be between 0 and 1, got min=%f max=%f", min, max)
}
if min > max {
return fmt.Errorf("minimum lightness cannot be greater than maximum, got min=%f max=%f", min, max)
}
c.ColorLightnessRange = [2]float64{min, max}
return nil
}
}
// WithGrayscaleLightnessRange sets the lightness range for grayscale shapes.
func WithGrayscaleLightnessRange(min, max float64) ConfigOption {
return func(c *Config) error {
if min < 0 || min > 1 || max < 0 || max > 1 {
return fmt.Errorf("lightness values must be between 0 and 1, got min=%f max=%f", min, max)
}
if min > max {
return fmt.Errorf("minimum lightness cannot be greater than maximum, got min=%f max=%f", min, max)
}
c.GrayscaleLightnessRange = [2]float64{min, max}
return nil
}
}
// WithColorSaturation sets the saturation for colored shapes.
// WithColorSaturation sets the color saturation for colored shapes.
func WithColorSaturation(saturation float64) ConfigOption {
return func(c *Config) error {
if saturation < 0 || saturation > 1 {
return fmt.Errorf("saturation must be between 0 and 1, got %f", saturation)
if saturation < 0.0 || saturation > 1.0 {
return fmt.Errorf("color saturation must be between 0.0 and 1.0, got %f", saturation)
}
c.ColorSaturation = saturation
return nil
@@ -109,127 +238,122 @@ func WithColorSaturation(saturation float64) ConfigOption {
// WithGrayscaleSaturation sets the saturation for grayscale shapes.
func WithGrayscaleSaturation(saturation float64) ConfigOption {
return func(c *Config) error {
if saturation < 0 || saturation > 1 {
return fmt.Errorf("saturation must be between 0 and 1, got %f", saturation)
if saturation < 0.0 || saturation > 1.0 {
return fmt.Errorf("grayscale saturation must be between 0.0 and 1.0, got %f", saturation)
}
c.GrayscaleSaturation = saturation
return nil
}
}
// WithBackgroundColor sets the background color.
func WithBackgroundColor(color string) ConfigOption {
return func(c *Config) error {
if color != "" {
if err := validateHexColor(color); err != nil {
return fmt.Errorf("invalid background color: %w", err)
}
}
c.BackgroundColor = color
return nil
}
}
// WithPadding sets the padding around the identicon.
// WithPadding sets the padding as a percentage of the icon size.
func WithPadding(padding float64) ConfigOption {
return func(c *Config) error {
if padding < 0 || padding > 0.5 {
return fmt.Errorf("padding must be between 0 and 0.5, got %f", padding)
if padding < 0.0 || padding > 0.5 {
return fmt.Errorf("padding must be between 0.0 and 0.5, got %f", padding)
}
c.Padding = padding
return nil
}
}
// Configure creates a new configuration with the given options.
func Configure(options ...ConfigOption) (Config, error) {
config := DefaultConfig()
for _, option := range options {
if err := option(&config); err != nil {
return Config{}, err
// WithBackgroundColor sets the background color (hex format like "#ffffff").
// The color must be in #RGB or #RRGGBB format. An empty string means transparent background.
func WithBackgroundColor(color string) ConfigOption {
return func(c *Config) error {
if !isValidHexColor(color) {
return NewErrInvalidInput("background_color", color, "must be a valid hex color in #RGB or #RRGGBB format")
}
}
return config, nil
}
// validateHexColor validates that a color string is a valid hex color.
func validateHexColor(color string) error {
hexColorPattern := regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
if !hexColorPattern.MatchString(color) {
return fmt.Errorf("color must be a hex color like #fff, #ffffff, or #ffffff80")
}
// Validate length - must be 3, 4, 6, or 8 characters after #
length := len(color) - 1
if length != 3 && length != 4 && length != 6 && length != 8 {
return fmt.Errorf("hex color must be 3, 4, 6, or 8 characters after #")
}
return nil
}
// ParseColor normalizes a hex color string to the full #RRGGBB or #RRGGBBAA format.
func ParseColor(color string) (string, error) {
if color == "" {
return "", nil
}
if err := validateHexColor(color); err != nil {
return "", err
}
// Normalize short forms to full forms
switch len(color) {
case 4: // #RGB -> #RRGGBB
r, g, b := color[1], color[2], color[3]
return fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b), nil
case 5: // #RGBA -> #RRGGBBAA
r, g, b, a := color[1], color[2], color[3], color[4]
return fmt.Sprintf("#%c%c%c%c%c%c%c%c", r, r, g, g, b, b, a, a), nil
case 7, 9: // #RRGGBB or #RRGGBBAA - already normalized
return color, nil
default:
return "", fmt.Errorf("unsupported color format")
c.BackgroundColor = color
return nil
}
}
// GetHue returns the hue for the given original hue value, taking into account hue restrictions.
func (c *Config) GetHue(originalHue float64) float64 {
if len(c.HueRestrictions) == 0 {
return originalHue
// WithColorLightnessRange sets the lightness range for colored shapes.
func WithColorLightnessRange(min, max float64) ConfigOption {
return func(c *Config) error {
if min < 0.0 || min > 1.0 || max < 0.0 || max > 1.0 {
return fmt.Errorf("lightness values must be between 0.0 and 1.0, got min=%f, max=%f", min, max)
}
if min >= max {
return fmt.Errorf("lightness min must be less than max, got min=%f, max=%f", min, max)
}
c.ColorLightnessRange = [2]float64{min, max}
return nil
}
// Map originalHue [0,1] to one of the restricted hues
index := int(originalHue * 0.999 * float64(len(c.HueRestrictions)))
if index >= len(c.HueRestrictions) {
index = len(c.HueRestrictions) - 1
}
hue := c.HueRestrictions[index]
// Convert degrees to [0,1] range and normalize
return ((hue/360.0)+1.0) - float64(int((hue/360.0)+1.0))
}
// GetColorLightness returns a lightness value within the color lightness range.
func (c *Config) GetColorLightness(value float64) float64 {
return c.getLightness(value, c.ColorLightnessRange)
// WithGrayscaleLightnessRange sets the lightness range for grayscale shapes.
func WithGrayscaleLightnessRange(min, max float64) ConfigOption {
return func(c *Config) error {
if min < 0.0 || min > 1.0 || max < 0.0 || max > 1.0 {
return fmt.Errorf("lightness values must be between 0.0 and 1.0, got min=%f, max=%f", min, max)
}
if min >= max {
return fmt.Errorf("lightness min must be less than max, got min=%f, max=%f", min, max)
}
c.GrayscaleLightnessRange = [2]float64{min, max}
return nil
}
}
// GetGrayscaleLightness returns a lightness value within the grayscale lightness range.
func (c *Config) GetGrayscaleLightness(value float64) float64 {
return c.getLightness(value, c.GrayscaleLightnessRange)
// WithHueRestrictions restricts hues to specific values in degrees (0-360).
func WithHueRestrictions(hues []float64) ConfigOption {
return func(c *Config) error {
for i, hue := range hues {
if hue < 0.0 || hue > 360.0 {
return fmt.Errorf("hue at index %d must be between 0.0 and 360.0, got %f", i, hue)
}
}
c.HueRestrictions = make([]float64, len(hues))
copy(c.HueRestrictions, hues)
return nil
}
}
// getLightness maps a value [0,1] to the specified lightness range.
func (c *Config) getLightness(value float64, lightnessRange [2]float64) float64 {
result := lightnessRange[0] + value*(lightnessRange[1]-lightnessRange[0])
if result < 0 {
return 0
// WithPNGSupersampling sets the PNG supersampling factor (1-16).
func WithPNGSupersampling(factor int) ConfigOption {
return func(c *Config) error {
if factor < 1 || factor > 16 {
return fmt.Errorf("PNG supersampling must be between 1 and 16, got %d", factor)
}
c.PNGSupersampling = factor
return nil
}
if result > 1 {
return 1
}
// WithMaxComplexity sets the maximum geometric complexity limit for resource protection.
// Use -1 to disable complexity limits entirely (use with caution).
func WithMaxComplexity(maxComplexity int) ConfigOption {
return func(c *Config) error {
if maxComplexity < -1 {
return fmt.Errorf("max complexity must be >= -1, got %d", maxComplexity)
}
c.MaxComplexity = maxComplexity
return nil
}
return result
}
}
// WithMaxIconSize sets the maximum icon dimension in pixels for DoS protection.
// Use 0 for the library default (4096 pixels), or -1 to disable limits entirely.
func WithMaxIconSize(maxSize int) ConfigOption {
return func(c *Config) error {
if maxSize < -1 {
return fmt.Errorf("max icon size must be >= -1, got %d", maxSize)
}
c.MaxIconSize = maxSize
return nil
}
}
// WithMaxInputLength sets the maximum input string length in bytes for DoS protection.
// Use 0 for the library default (1MB), or -1 to disable limits entirely.
func WithMaxInputLength(maxLength int) ConfigOption {
return func(c *Config) error {
if maxLength < -1 {
return fmt.Errorf("max input length must be >= -1, got %d", maxLength)
}
c.MaxInputLength = maxLength
return nil
}
}

View File

@@ -1,437 +1,462 @@
package jdenticon
import (
"errors"
"strings"
"testing"
)
func TestDefaultConfig(t *testing.T) {
config := DefaultConfig()
// Test default values match jdenticon-js
if len(config.HueRestrictions) != 0 {
t.Errorf("Expected no hue restrictions, got %v", config.HueRestrictions)
}
expectedColorRange := [2]float64{0.4, 0.8}
if config.ColorLightnessRange != expectedColorRange {
t.Errorf("Expected color lightness range %v, got %v", expectedColorRange, config.ColorLightnessRange)
}
expectedGrayscaleRange := [2]float64{0.3, 0.9}
if config.GrayscaleLightnessRange != expectedGrayscaleRange {
t.Errorf("Expected grayscale lightness range %v, got %v", expectedGrayscaleRange, config.GrayscaleLightnessRange)
}
if config.ColorSaturation != 0.5 {
t.Errorf("Expected color saturation 0.5, got %f", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.0 {
t.Errorf("Expected grayscale saturation 0.0, got %f", config.GrayscaleSaturation)
}
if config.BackgroundColor != "" {
t.Errorf("Expected empty background color, got %s", config.BackgroundColor)
}
if config.Padding != 0.08 {
t.Errorf("Expected padding 0.08, got %f", config.Padding)
}
}
func TestWithHueRestrictions(t *testing.T) {
tests := []struct {
name string
hues []float64
wantErr bool
}{
{"valid hues", []float64{0, 90, 180, 270}, false},
{"single hue", []float64{120}, false},
{"empty slice", []float64{}, false},
{"negative hue", []float64{-10}, true},
{"hue too large", []float64{360}, true},
{"mixed valid/invalid", []float64{120, 400}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithHueRestrictions(tt.hues))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for hues %v, got none", tt.hues)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(config.HueRestrictions) != len(tt.hues) {
t.Errorf("Expected %d hue restrictions, got %d", len(tt.hues), len(config.HueRestrictions))
}
for i, hue := range tt.hues {
if config.HueRestrictions[i] != hue {
t.Errorf("Expected hue %f at index %d, got %f", hue, i, config.HueRestrictions[i])
}
}
})
}
}
func TestWithLightnessRanges(t *testing.T) {
tests := []struct {
name string
min float64
max float64
wantErr bool
}{
{"valid range", 0.2, 0.8, false},
{"full range", 0.0, 1.0, false},
{"equal values", 0.5, 0.5, false},
{"min > max", 0.8, 0.2, true},
{"negative min", -0.1, 0.5, true},
{"max > 1", 0.5, 1.1, true},
{"both invalid", -0.1, 1.1, true},
}
for _, tt := range tests {
t.Run("color_"+tt.name, func(t *testing.T) {
config, err := Configure(WithColorLightnessRange(tt.min, tt.max))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for range [%f, %f], got none", tt.min, tt.max)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
expected := [2]float64{tt.min, tt.max}
if config.ColorLightnessRange != expected {
t.Errorf("Expected range %v, got %v", expected, config.ColorLightnessRange)
}
})
t.Run("grayscale_"+tt.name, func(t *testing.T) {
config, err := Configure(WithGrayscaleLightnessRange(tt.min, tt.max))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for range [%f, %f], got none", tt.min, tt.max)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
expected := [2]float64{tt.min, tt.max}
if config.GrayscaleLightnessRange != expected {
t.Errorf("Expected range %v, got %v", expected, config.GrayscaleLightnessRange)
}
})
}
}
func TestWithSaturation(t *testing.T) {
tests := []struct {
name string
saturation float64
wantErr bool
}{
{"valid saturation", 0.5, false},
{"zero saturation", 0.0, false},
{"max saturation", 1.0, false},
{"negative saturation", -0.1, true},
{"saturation > 1", 1.1, true},
}
for _, tt := range tests {
t.Run("color_"+tt.name, func(t *testing.T) {
config, err := Configure(WithColorSaturation(tt.saturation))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for saturation %f, got none", tt.saturation)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.ColorSaturation != tt.saturation {
t.Errorf("Expected saturation %f, got %f", tt.saturation, config.ColorSaturation)
}
})
t.Run("grayscale_"+tt.name, func(t *testing.T) {
config, err := Configure(WithGrayscaleSaturation(tt.saturation))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for saturation %f, got none", tt.saturation)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.GrayscaleSaturation != tt.saturation {
t.Errorf("Expected saturation %f, got %f", tt.saturation, config.GrayscaleSaturation)
}
})
}
}
func TestWithBackgroundColor(t *testing.T) {
// TestIsValidHexColor tests the hex color validation helper function.
func TestIsValidHexColor(t *testing.T) {
tests := []struct {
name string
color string
wantErr bool
expected string
expected bool
}{
{"empty color", "", false, ""},
{"3-char hex", "#fff", false, "#fff"},
{"4-char hex with alpha", "#ffff", false, "#ffff"},
{"6-char hex", "#ffffff", false, "#ffffff"},
{"8-char hex with alpha", "#ffffff80", false, "#ffffff80"},
{"lowercase", "#abc123", false, "#abc123"},
{"uppercase", "#ABC123", false, "#ABC123"},
{"invalid format", "ffffff", true, ""},
{"invalid chars", "#gggggg", true, ""},
{"too short", "#ff", true, ""},
{"5-char hex", "#fffff", true, ""},
{"7-char hex", "#fffffff", true, ""},
{"9-char hex", "#fffffffff", true, ""},
// Valid cases
{
name: "empty string (transparent)",
color: "",
expected: true,
},
{
name: "valid 3-digit lowercase",
color: "#fff",
expected: true,
},
{
name: "valid 3-digit uppercase",
color: "#FFF",
expected: true,
},
{
name: "valid 3-digit mixed case",
color: "#Fa3",
expected: true,
},
{
name: "valid 6-digit lowercase",
color: "#ffffff",
expected: true,
},
{
name: "valid 6-digit uppercase",
color: "#FFFFFF",
expected: true,
},
{
name: "valid 6-digit mixed case",
color: "#Ff00Aa",
expected: true,
},
{
name: "valid with numbers",
color: "#123456",
expected: true,
},
{
name: "valid 3-digit with numbers",
color: "#123",
expected: true,
},
// Invalid cases
{
name: "missing hash prefix",
color: "ffffff",
expected: false,
},
{
name: "too short",
color: "#ff",
expected: false,
},
{
name: "too long",
color: "#fffffff",
expected: false,
},
{
name: "invalid hex characters",
color: "#gggggg",
expected: false,
},
{
name: "invalid hex characters in 3-digit",
color: "#ggg",
expected: false,
},
{
name: "just hash",
color: "#",
expected: false,
},
{
name: "double hash",
color: "##ffffff",
expected: false,
},
{
name: "color name",
color: "red",
expected: false,
},
{
name: "color name with hash",
color: "#red",
expected: false,
},
{
name: "4-digit hex",
color: "#1234",
expected: false,
},
{
name: "5-digit hex",
color: "#12345",
expected: false,
},
{
name: "7-digit hex",
color: "#1234567",
expected: false,
},
{
name: "8-digit hex (RGBA)",
color: "#12345678",
expected: false,
},
{
name: "with spaces",
color: "# ffffff",
expected: false,
},
{
name: "with special characters",
color: "#ffffff!",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithBackgroundColor(tt.color))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for color %s, got none", tt.color)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.BackgroundColor != tt.expected {
t.Errorf("Expected color %s, got %s", tt.expected, config.BackgroundColor)
}
})
}
}
func TestWithPadding(t *testing.T) {
tests := []struct {
name string
padding float64
wantErr bool
}{
{"valid padding", 0.08, false},
{"zero padding", 0.0, false},
{"max padding", 0.5, false},
{"negative padding", -0.1, true},
{"padding > 0.5", 0.6, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithPadding(tt.padding))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for padding %f, got none", tt.padding)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.Padding != tt.padding {
t.Errorf("Expected padding %f, got %f", tt.padding, config.Padding)
}
})
}
}
func TestConfigureMultipleOptions(t *testing.T) {
config, err := Configure(
WithHueRestrictions([]float64{120, 240}),
WithColorSaturation(0.7),
WithPadding(0.1),
WithBackgroundColor("#ff0000"),
)
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(config.HueRestrictions) != 2 || config.HueRestrictions[0] != 120 || config.HueRestrictions[1] != 240 {
t.Errorf("Expected hue restrictions [120, 240], got %v", config.HueRestrictions)
}
if config.ColorSaturation != 0.7 {
t.Errorf("Expected color saturation 0.7, got %f", config.ColorSaturation)
}
if config.Padding != 0.1 {
t.Errorf("Expected padding 0.1, got %f", config.Padding)
}
if config.BackgroundColor != "#ff0000" {
t.Errorf("Expected background color #ff0000, got %s", config.BackgroundColor)
}
// Check that other values are still default
expectedColorRange := [2]float64{0.4, 0.8}
if config.ColorLightnessRange != expectedColorRange {
t.Errorf("Expected default color lightness range %v, got %v", expectedColorRange, config.ColorLightnessRange)
}
}
func TestParseColor(t *testing.T) {
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{"empty", "", "", false},
{"3-char to 6-char", "#abc", "#aabbcc", false},
{"4-char to 8-char", "#abcd", "#aabbccdd", false},
{"6-char unchanged", "#abcdef", "#abcdef", false},
{"8-char unchanged", "#abcdef80", "#abcdef80", false},
{"invalid format", "abcdef", "", true},
{"invalid length", "#abcde", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseColor(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for input %s, got none", tt.input)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
result := isValidHexColor(tt.color)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
t.Errorf("isValidHexColor(%q) = %v, expected %v", tt.color, result, tt.expected)
}
})
}
}
func TestConfigGetHue(t *testing.T) {
// TestWithBackgroundColor tests the WithBackgroundColor config option.
func TestWithBackgroundColor(t *testing.T) {
tests := []struct {
name string
restrictions []float64
input float64
expected float64
name string
color string
expectError bool
expectedError string
}{
{"no restrictions", nil, 0.5, 0.5},
{"single restriction", []float64{180}, 0.5, 0.5},
{"multiple restrictions", []float64{0, 120, 240}, 0.0, 0.0},
{"multiple restrictions mid", []float64{0, 120, 240}, 0.5, 120.0 / 360.0},
{"multiple restrictions high", []float64{0, 120, 240}, 0.99, 240.0 / 360.0},
// Valid cases
{
name: "empty string (transparent)",
color: "",
expectError: false,
},
{
name: "valid 3-digit hex",
color: "#fff",
expectError: false,
},
{
name: "valid 6-digit hex",
color: "#ffffff",
expectError: false,
},
{
name: "valid mixed case",
color: "#FfAa00",
expectError: false,
},
{
name: "valid with numbers",
color: "#123456",
expectError: false,
},
// Invalid cases
{
name: "missing hash",
color: "ffffff",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "invalid hex characters",
color: "#gggggg",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "too short",
color: "#ff",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "too long",
color: "#fffffff",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "color name",
color: "red",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "4-digit hex",
color: "#1234",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "8-digit hex (RGBA)",
color: "#12345678",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := WithBackgroundColor(tt.color)
config := DefaultConfig()
err := option(&config)
if tt.expectError {
if err == nil {
t.Errorf("WithBackgroundColor(%q) expected error but got none", tt.color)
return
}
// Check that it's the right error type
var invalidInput *ErrInvalidInput
if !errors.As(err, &invalidInput) {
t.Errorf("WithBackgroundColor(%q) error type = %T, expected *ErrInvalidInput", tt.color, err)
return
}
// Check error message contains expected text
if tt.expectedError != "" {
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("WithBackgroundColor(%q) error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
}
}
// Check field name
if invalidInput.Field != "background_color" {
t.Errorf("WithBackgroundColor(%q) error field = %q, expected %q", tt.color, invalidInput.Field, "background_color")
}
// Check value is captured
if invalidInput.Value != tt.color {
t.Errorf("WithBackgroundColor(%q) error value = %q, expected %q", tt.color, invalidInput.Value, tt.color)
}
} else {
if err != nil {
t.Errorf("WithBackgroundColor(%q) unexpected error: %v", tt.color, err)
return
}
// Check that the color was set
if config.BackgroundColor != tt.color {
t.Errorf("WithBackgroundColor(%q) config.BackgroundColor = %q, expected %q", tt.color, config.BackgroundColor, tt.color)
}
}
})
}
}
// TestConfigValidateBackgroundColor tests that Config.Validate() validates background colors.
func TestConfigValidateBackgroundColor(t *testing.T) {
tests := []struct {
name string
color string
expectError bool
expectedError string
}{
{
name: "valid empty color",
color: "",
expectError: false,
},
{
name: "valid 3-digit hex",
color: "#fff",
expectError: false,
},
{
name: "valid 6-digit hex",
color: "#ffffff",
expectError: false,
},
{
name: "invalid color",
color: "invalid-color",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
{
name: "missing hash",
color: "ffffff",
expectError: true,
expectedError: "must be a valid hex color in #RGB or #RRGGBB format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := DefaultConfig()
config.HueRestrictions = tt.restrictions
result := config.GetHue(tt.input)
// Allow small floating point differences
if abs(result-tt.expected) > 0.001 {
t.Errorf("Expected hue %f, got %f", tt.expected, result)
config.BackgroundColor = tt.color
err := config.Validate()
if tt.expectError {
if err == nil {
t.Errorf("Config.Validate() with BackgroundColor=%q expected error but got none", tt.color)
return
}
// Check that it's the right error type
var invalidInput *ErrInvalidInput
if !errors.As(err, &invalidInput) {
t.Errorf("Config.Validate() with BackgroundColor=%q error type = %T, expected *ErrInvalidInput", tt.color, err)
return
}
// Check error message contains expected text
if tt.expectedError != "" {
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Config.Validate() with BackgroundColor=%q error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
}
}
} else {
if err != nil {
t.Errorf("Config.Validate() with BackgroundColor=%q unexpected error: %v", tt.color, err)
}
}
})
}
}
func TestConfigGetLightness(t *testing.T) {
config := DefaultConfig()
// Test color lightness
colorLight := config.GetColorLightness(0.0)
if colorLight != 0.4 {
t.Errorf("Expected min color lightness 0.4, got %f", colorLight)
// TestToEngineColorConfigBackgroundColor tests that toEngineColorConfig handles background colors.
// Since validation is handled by Config.Validate(), this test focuses on the successful conversion path.
func TestToEngineColorConfigBackgroundColor(t *testing.T) {
tests := []struct {
name string
color string
expectError bool
expectedError string
}{
{
name: "valid empty color",
color: "",
expectError: false,
},
{
name: "valid 3-digit hex",
color: "#fff",
expectError: false,
},
{
name: "valid 6-digit hex",
color: "#ffffff",
expectError: false,
},
{
name: "invalid color caught by Validate()",
color: "invalid-color",
expectError: true,
expectedError: "invalid configuration",
},
}
colorLight = config.GetColorLightness(1.0)
if colorLight != 0.8 {
t.Errorf("Expected max color lightness 0.8, got %f", colorLight)
}
colorLight = config.GetColorLightness(0.5)
expected := 0.4 + 0.5*(0.8-0.4)
if abs(colorLight-expected) > 0.001 {
t.Errorf("Expected mid color lightness %f, got %f", expected, colorLight)
}
// Test grayscale lightness
grayLight := config.GetGrayscaleLightness(0.0)
if grayLight != 0.3 {
t.Errorf("Expected min grayscale lightness 0.3, got %f", grayLight)
}
grayLight = config.GetGrayscaleLightness(1.0)
if abs(grayLight-0.9) > 0.001 {
t.Errorf("Expected max grayscale lightness 0.9, got %f", grayLight)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := DefaultConfig()
// Directly set the background color to bypass WithBackgroundColor validation
config.BackgroundColor = tt.color
_, err := config.toEngineColorConfig()
if tt.expectError {
if err == nil {
t.Errorf("toEngineColorConfig() with BackgroundColor=%q expected error but got none", tt.color)
return
}
// Check error message contains expected text (from c.Validate() call)
if tt.expectedError != "" {
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("toEngineColorConfig() with BackgroundColor=%q error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
}
}
} else {
if err != nil {
t.Errorf("toEngineColorConfig() with BackgroundColor=%q unexpected error: %v", tt.color, err)
}
}
})
}
}
func TestConfigureFailing(t *testing.T) {
// Test that Configure fails on invalid options
_, err := Configure(
WithHueRestrictions([]float64{400}), // Invalid hue
WithColorSaturation(0.5), // Valid option after invalid
)
if err == nil {
t.Error("Expected Configure to fail with invalid hue restriction")
// TestConfigureFunctionWithBackgroundColor tests the Configure function with background color options.
func TestConfigureFunctionWithBackgroundColor(t *testing.T) {
tests := []struct {
name string
color string
expectError bool
expectedError string
}{
{
name: "valid color through Configure",
color: "#ffffff",
expectError: false,
},
{
name: "invalid color through Configure",
color: "invalid",
expectError: true,
expectedError: "configuration option failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithBackgroundColor(tt.color))
if tt.expectError {
if err == nil {
t.Errorf("Configure(WithBackgroundColor(%q)) expected error but got none", tt.color)
return
}
if tt.expectedError != "" {
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Configure(WithBackgroundColor(%q)) error = %q, expected to contain %q", tt.color, err.Error(), tt.expectedError)
}
}
} else {
if err != nil {
t.Errorf("Configure(WithBackgroundColor(%q)) unexpected error: %v", tt.color, err)
return
}
if config.BackgroundColor != tt.color {
t.Errorf("Configure(WithBackgroundColor(%q)) config.BackgroundColor = %q, expected %q", tt.color, config.BackgroundColor, tt.color)
}
}
})
}
}
// Helper function for floating point comparison
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}

View File

@@ -1,14 +1,47 @@
// Package jdenticon provides highly recognizable identicon generation.
// Package jdenticon provides a Go library for generating highly recognizable identicons -
// geometric avatar images generated deterministically from any input string.
//
// This package is a Go port of the JavaScript library Jdenticon,
// offering the same visual quality and recognizability in Go applications.
// This package wraps the internal/engine functionality to provide a clean, thread-safe
// public API that follows Go idioms and conventions.
//
// Basic usage:
//
// icon := jdenticon.Generate("user@example.com", 200)
// svg := icon.ToSVG()
// png := icon.ToPNG()
// // Generate with default configuration
// icon, err := jdenticon.Generate("user@example.com", 200)
// if err != nil {
// log.Fatal(err)
// }
//
// The library supports both SVG and PNG output formats, with configurable
// styling options including color themes, saturation, and brightness.
package jdenticon
// // Render as SVG
// svgData, err := icon.ToSVG()
// if err != nil {
// log.Fatal(err)
// }
//
// // Render as PNG
// pngData, err := icon.ToPNG()
// if err != nil {
// log.Fatal(err)
// }
//
// Advanced usage with custom configuration:
//
// // Create custom configuration
// config := jdenticon.DefaultConfig()
// config.ColorSaturation = 0.7
// config.Padding = 0.1
// config.BackgroundColor = "#ffffff"
//
// // Create generator with caching
// generator, err := jdenticon.NewGeneratorWithConfig(config, 100)
// if err != nil {
// log.Fatal(err)
// }
//
// // Generate multiple icons efficiently
// icon1, err := generator.Generate("user1@example.com", 64)
// icon2, err := generator.Generate("user2@example.com", 64)
//
// The library is designed to be thread-safe and performant, with LRU caching
// and singleflight to prevent duplicate work.
package jdenticon

192
jdenticon/errors.go Normal file
View File

@@ -0,0 +1,192 @@
package jdenticon
import (
"fmt"
)
// Error types for structured error handling following Go best practices.
// ErrInvalidInput represents an error due to invalid input parameters.
type ErrInvalidInput struct {
Field string // The field or parameter that was invalid
Value string // The invalid value (may be truncated for display)
Reason string // Human-readable explanation of why it's invalid
}
func (e *ErrInvalidInput) Error() string {
if e.Field != "" {
return fmt.Sprintf("jdenticon: invalid input for %s: %s (got: %s)", e.Field, e.Reason, e.Value)
}
return fmt.Sprintf("jdenticon: invalid input: %s", e.Reason)
}
// NewErrInvalidInput creates a new ErrInvalidInput.
func NewErrInvalidInput(field, value, reason string) *ErrInvalidInput {
return &ErrInvalidInput{
Field: field,
Value: value,
Reason: reason,
}
}
// ErrInvalidSize represents an error due to invalid size parameter.
type ErrInvalidSize int
func (e ErrInvalidSize) Error() string {
return fmt.Sprintf("jdenticon: invalid size: must be positive, got %d", int(e))
}
// ErrInvalidIcon represents an error due to invalid icon state.
type ErrInvalidIcon string
func (e ErrInvalidIcon) Error() string {
return fmt.Sprintf("jdenticon: invalid icon: %s", string(e))
}
// ErrRenderFailed represents an error during rendering.
type ErrRenderFailed struct {
Format string // The format being rendered (SVG, PNG)
Cause error // The underlying error
}
func (e *ErrRenderFailed) Error() string {
return fmt.Sprintf("jdenticon: %s rendering failed: %v", e.Format, e.Cause)
}
func (e *ErrRenderFailed) Unwrap() error {
return e.Cause
}
// NewErrRenderFailed creates a new ErrRenderFailed.
func NewErrRenderFailed(format string, cause error) *ErrRenderFailed {
return &ErrRenderFailed{
Format: format,
Cause: cause,
}
}
// ErrGenerationFailed represents an error during identicon generation.
type ErrGenerationFailed struct {
Input string // The input string that failed to generate (may be truncated)
Size int // The requested size
Cause error // The underlying error
}
func (e *ErrGenerationFailed) Error() string {
return fmt.Sprintf("jdenticon: generation failed for input %q (size %d): %v", e.Input, e.Size, e.Cause)
}
func (e *ErrGenerationFailed) Unwrap() error {
return e.Cause
}
// NewErrGenerationFailed creates a new ErrGenerationFailed.
func NewErrGenerationFailed(input string, size int, cause error) *ErrGenerationFailed {
// Truncate long inputs for display
displayInput := input
if len(input) > 50 {
displayInput = input[:47] + "..."
}
return &ErrGenerationFailed{
Input: displayInput,
Size: size,
Cause: cause,
}
}
// ErrCacheCreationFailed represents an error during cache creation.
type ErrCacheCreationFailed struct {
Size int // The requested cache size
Cause error // The underlying error
}
func (e *ErrCacheCreationFailed) Error() string {
return fmt.Sprintf("jdenticon: cache creation failed (size %d): %v", e.Size, e.Cause)
}
func (e *ErrCacheCreationFailed) Unwrap() error {
return e.Cause
}
// NewErrCacheCreationFailed creates a new ErrCacheCreationFailed.
func NewErrCacheCreationFailed(size int, cause error) *ErrCacheCreationFailed {
return &ErrCacheCreationFailed{
Size: size,
Cause: cause,
}
}
// ErrValueTooLarge is returned when a numeric input exceeds a configured limit.
// This supports the configurable DoS protection system.
type ErrValueTooLarge struct {
ParameterName string // The name of the parameter being validated (e.g., "IconSize", "InputLength")
Limit int // The configured limit that was exceeded
Actual int // The actual value that was provided
}
func (e *ErrValueTooLarge) Error() string {
return fmt.Sprintf("jdenticon: %s of %d exceeds configured limit of %d",
e.ParameterName, e.Actual, e.Limit)
}
// NewErrValueTooLarge creates a new ErrValueTooLarge.
func NewErrValueTooLarge(parameterName string, limit, actual int) *ErrValueTooLarge {
return &ErrValueTooLarge{
ParameterName: parameterName,
Limit: limit,
Actual: actual,
}
}
// ErrEffectiveSizeTooLarge is returned when the effective PNG size (size * supersampling) exceeds limits.
// This provides specific context for PNG rendering with supersampling.
type ErrEffectiveSizeTooLarge struct {
Limit int // The configured size limit
Actual int // The calculated effective size (size * supersampling)
Size int // The requested icon size
Supersampling int // The supersampling factor applied
}
func (e *ErrEffectiveSizeTooLarge) Error() string {
return fmt.Sprintf("jdenticon: effective PNG size of %d (size %d × supersampling %d) exceeds limit of %d",
e.Actual, e.Size, e.Supersampling, e.Limit)
}
// NewErrEffectiveSizeTooLarge creates a new ErrEffectiveSizeTooLarge.
func NewErrEffectiveSizeTooLarge(limit, actual, size, supersampling int) *ErrEffectiveSizeTooLarge {
return &ErrEffectiveSizeTooLarge{
Limit: limit,
Actual: actual,
Size: size,
Supersampling: supersampling,
}
}
// ErrComplexityLimitExceeded is returned when the calculated geometric complexity exceeds the configured limit.
// This prevents resource exhaustion attacks through extremely complex identicon generation.
type ErrComplexityLimitExceeded struct {
Limit int // The configured complexity limit
Actual int // The calculated complexity score
InputHash string // The input hash that caused the high complexity (truncated for display)
}
func (e *ErrComplexityLimitExceeded) Error() string {
return fmt.Sprintf("jdenticon: complexity score of %d exceeds limit of %d for input hash %s",
e.Actual, e.Limit, e.InputHash)
}
// NewErrComplexityLimitExceeded creates a new ErrComplexityLimitExceeded.
func NewErrComplexityLimitExceeded(limit, actual int, inputHash string) *ErrComplexityLimitExceeded {
// Truncate long hash for display
displayHash := inputHash
if len(inputHash) > 16 {
displayHash = inputHash[:13] + "..."
}
return &ErrComplexityLimitExceeded{
Limit: limit,
Actual: actual,
InputHash: displayHash,
}
}

View File

@@ -1,344 +1,137 @@
package jdenticon
import (
"context"
"fmt"
"reflect"
"strconv"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/kevin/go-jdenticon/internal/renderer"
)
// iconRenderer defines the common interface for rendering identicons to different formats
type iconRenderer interface {
SetBackground(fillColor string, opacity float64)
BeginShape(color string)
AddPolygon(points []engine.Point)
AddCircle(topLeft engine.Point, size float64, invert bool)
EndShape()
// Package-level convenience functions for simple use cases.
// These wrap the Generator API for easy one-off identicon generation.
var (
// defaultGenerator is a package-level generator instance for convenience functions.
// It uses default configuration with a small cache for better performance.
defaultGenerator *Generator
)
func init() {
// Initialize the default generator with a small cache
var err error
defaultGenerator, err = NewGeneratorWithCacheSize(50)
if err != nil {
// Fall back to no caching if cache creation fails
defaultGenerator, _ = NewGenerator()
}
}
// Icon represents a generated identicon that can be rendered in various formats.
type Icon struct {
icon *engine.Icon
// Generate creates an identicon using the default configuration with context support.
// The context can be used to set timeouts or cancel generation.
//
// This is a convenience function equivalent to:
//
// generator := jdenticon.NewGenerator()
// icon := generator.Generate(ctx, input, size)
//
// For more control over configuration or caching, use Generator directly.
func Generate(ctx context.Context, input string, size int) (*Icon, error) {
// Apply input validation using default configuration
config := DefaultConfig()
if err := validateInputs(input, size, config); err != nil {
return nil, err
}
if defaultGenerator == nil {
return nil, NewErrGenerationFailed(input, size, fmt.Errorf("default generator not initialized"))
}
return defaultGenerator.Generate(ctx, input, size)
}
// renderTo renders the icon to the given renderer, handling all common rendering logic
func (i *Icon) renderTo(r iconRenderer) {
if i.icon == nil {
return
}
// Set background color if configured
if i.icon.Config.BackColor != nil {
r.SetBackground(i.icon.Config.BackColor.String(), 1.0)
}
// Render each shape group
for _, group := range i.icon.Shapes {
r.BeginShape(group.Color.String())
for _, shape := range group.Shapes {
// Skip empty shapes
if shape.Type == "empty" {
continue
}
switch shape.Type {
case "polygon":
// Transform points
transformedPoints := make([]engine.Point, len(shape.Points))
for j, point := range shape.Points {
transformedPoints[j] = shape.Transform.TransformIconPoint(point.X, point.Y, 0, 0)
}
r.AddPolygon(transformedPoints)
case "circle":
// Use dedicated circle fields - CircleX, CircleY represent top-left corner
topLeft := shape.Transform.TransformIconPoint(shape.CircleX, shape.CircleY, 0, 0)
r.AddCircle(topLeft, shape.CircleSize, shape.Invert)
// ToSVG generates an identicon and returns it as an SVG string with context support.
// The context can be used to set timeouts or cancel generation.
//
// This is a convenience function that uses default configuration.
func ToSVG(ctx context.Context, input string, size int) (string, error) {
return ToSVGWithConfig(ctx, input, size, DefaultConfig())
}
// ToPNG generates an identicon and returns it as PNG bytes with context support.
// The context can be used to set timeouts or cancel generation.
//
// This is a convenience function that uses default configuration with dynamic supersampling
// to ensure the effective size stays within safe limits while maximizing quality.
func ToPNG(ctx context.Context, input string, size int) ([]byte, error) {
// Start with default configuration
config := DefaultConfig()
// Apply dynamic supersampling to respect size limits
if maxSize := config.effectiveMaxIconSize(); maxSize != -1 {
effectiveSize := size * config.PNGSupersampling
if effectiveSize > maxSize {
// Calculate the maximum safe supersampling factor
newSupersampling := maxSize / size
if newSupersampling >= 1 {
config.PNGSupersampling = newSupersampling
} else {
// Even 1x supersampling would exceed limits, let validation catch this
config.PNGSupersampling = 1
}
}
r.EndShape()
}
return ToPNGWithConfig(ctx, input, size, config)
}
// Generate creates an identicon for the given input value and size.
// The input value is typically an email address, username, or any string
// that should produce a consistent visual representation.
func Generate(value string, size int) (*Icon, error) {
// Compute hash from the input value
hash := ComputeHash(value)
// Create generator with default configuration
generator := engine.NewDefaultGenerator()
// Generate the icon
engineIcon, err := generator.Generate(hash, float64(size))
// ToSVGWithConfig generates an identicon with custom configuration and returns it as an SVG string.
func ToSVGWithConfig(ctx context.Context, input string, size int, config Config) (string, error) {
// Validate inputs using the provided configuration
if err := validateInputs(input, size, config); err != nil {
return "", err
}
// Validate the configuration itself
if err := config.Validate(); err != nil {
return "", err
}
generator, err := NewGeneratorWithConfig(config, 1) // Minimal caching for one-off usage
if err != nil {
return "", err
}
icon, err := generator.Generate(ctx, input, size)
if err != nil {
return "", err
}
return icon.ToSVG()
}
// ToPNGWithConfig generates an identicon with custom configuration and returns it as PNG bytes.
func ToPNGWithConfig(ctx context.Context, input string, size int, config Config) ([]byte, error) {
// Validate inputs using the provided configuration
if err := validateInputs(input, size, config); err != nil {
return nil, err
}
// Validate the configuration itself
if err := config.Validate(); err != nil {
return nil, err
}
// Validate PNG-specific effective size (size * supersampling)
if err := validatePNGSize(size, config); err != nil {
return nil, err
}
generator, err := NewGeneratorWithConfig(config, 1) // Minimal caching for one-off usage
if err != nil {
return nil, err
}
return &Icon{icon: engineIcon}, nil
}
// ToSVG renders the icon as an SVG string.
func (i *Icon) ToSVG() (string, error) {
if i.icon == nil {
return "", nil
}
svgRenderer := renderer.NewSVGRenderer(int(i.icon.Size))
i.renderTo(svgRenderer)
return svgRenderer.ToSVG(), nil
}
// ToPNG renders the icon as PNG image data.
func (i *Icon) ToPNG() ([]byte, error) {
if i.icon == nil {
return nil, nil
}
pngRenderer := renderer.NewPNGRenderer(int(i.icon.Size))
i.renderTo(pngRenderer)
return pngRenderer.ToPNG(), nil
}
// ToSVG generates an identicon as an SVG string for the given input value.
// The value can be any type - it will be converted to a string and hashed.
// Size specifies the icon size in pixels.
// Optional config parameters can be provided to customize the appearance.
func ToSVG(value interface{}, size int, config ...Config) (string, error) {
if size <= 0 {
return "", fmt.Errorf("size must be positive, got %d", size)
}
// Generate icon with the provided configuration
icon, err := generateWithConfig(value, size, config...)
if err != nil {
return "", fmt.Errorf("failed to generate icon: %w", err)
}
// Render as SVG
svg, err := icon.ToSVG()
if err != nil {
return "", fmt.Errorf("failed to render SVG: %w", err)
}
return svg, nil
}
// ToPNG generates an identicon as PNG image data for the given input value.
// The value can be any type - it will be converted to a string and hashed.
// Size specifies the icon size in pixels.
// Optional config parameters can be provided to customize the appearance.
func ToPNG(value interface{}, size int, config ...Config) ([]byte, error) {
if size <= 0 {
return nil, fmt.Errorf("size must be positive, got %d", size)
}
// Generate icon with the provided configuration
icon, err := generateWithConfig(value, size, config...)
if err != nil {
return nil, fmt.Errorf("failed to generate icon: %w", err)
}
// Render as PNG
png, err := icon.ToPNG()
if err != nil {
return nil, fmt.Errorf("failed to render PNG: %w", err)
}
return png, nil
}
// ToHash generates a hash string for the given input value.
// This is a convenience function that wraps ComputeHash with better type handling.
// The hash can be used with other functions or stored for consistent icon generation.
func ToHash(value interface{}) string {
return ComputeHash(value)
}
// generateWithConfig is a helper function that creates an icon with optional configuration.
func generateWithConfig(value interface{}, size int, configs ...Config) (*Icon, error) {
// Convert value to string representation
stringValue := convertToString(value)
// Compute hash from the input value
hash := ComputeHash(stringValue)
// Create generator with configuration
var generator *engine.Generator
if len(configs) > 0 {
// Use the provided configuration
engineConfig := convertToEngineConfig(configs[0])
generator = engine.NewGenerator(engineConfig)
} else {
// Use default configuration
generator = engine.NewDefaultGenerator()
}
// Generate the icon
engineIcon, err := generator.Generate(hash, float64(size))
icon, err := generator.Generate(ctx, input, size)
if err != nil {
return nil, err
}
return &Icon{icon: engineIcon}, nil
return icon.ToPNG()
}
// convertToString converts any value to its string representation using reflection.
// This handles various types similar to how JavaScript would convert them.
func convertToString(value interface{}) string {
if value == nil {
return ""
}
// Use reflection to handle different types
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.String:
return v.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return fmt.Sprintf("%d", v.Int())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return fmt.Sprintf("%d", v.Uint())
case reflect.Float32, reflect.Float64:
return fmt.Sprintf("%g", v.Float())
case reflect.Bool:
if v.Bool() {
return "true"
}
return "false"
case reflect.Slice:
// Handle byte slices specially
if v.Type().Elem().Kind() == reflect.Uint8 {
return string(v.Bytes())
}
fallthrough
default:
// For all other types, use fmt.Sprintf
return fmt.Sprintf("%v", value)
}
}
// convertToEngineConfig converts a public Config to an internal engine.ColorConfig.
func convertToEngineConfig(config Config) engine.ColorConfig {
engineConfig := engine.DefaultColorConfig()
// Convert hue restrictions
if len(config.HueRestrictions) > 0 {
engineConfig.Hues = make([]float64, len(config.HueRestrictions))
copy(engineConfig.Hues, config.HueRestrictions)
}
// Convert lightness ranges
engineConfig.ColorLightness = engine.LightnessRange{
Min: config.ColorLightnessRange[0],
Max: config.ColorLightnessRange[1],
}
engineConfig.GrayscaleLightness = engine.LightnessRange{
Min: config.GrayscaleLightnessRange[0],
Max: config.GrayscaleLightnessRange[1],
}
// Convert saturation values
engineConfig.ColorSaturation = config.ColorSaturation
engineConfig.GrayscaleSaturation = config.GrayscaleSaturation
// Convert background color
if config.BackgroundColor != "" {
normalizedColor, err := ParseColor(config.BackgroundColor)
if err == nil {
// Parse the normalized hex color into an engine.Color
color, parseErr := parseHexToEngineColor(normalizedColor)
if parseErr == nil {
engineConfig.BackColor = &color
}
}
}
// Convert padding
engineConfig.IconPadding = config.Padding
return engineConfig
}
// parseHexToEngineColor converts a hex color string to an engine.Color.
func parseHexToEngineColor(hexColor string) (engine.Color, error) {
if hexColor == "" {
return engine.Color{}, fmt.Errorf("empty color string")
}
// Remove # if present
if len(hexColor) > 0 && hexColor[0] == '#' {
hexColor = hexColor[1:]
}
var r, g, b, a uint8 = 0, 0, 0, 255
switch len(hexColor) {
case 6: // RRGGBB
rgb, err := parseHexComponent(hexColor[0:2])
if err != nil {
return engine.Color{}, err
}
r = rgb
rgb, err = parseHexComponent(hexColor[2:4])
if err != nil {
return engine.Color{}, err
}
g = rgb
rgb, err = parseHexComponent(hexColor[4:6])
if err != nil {
return engine.Color{}, err
}
b = rgb
case 8: // RRGGBBAA
rgb, err := parseHexComponent(hexColor[0:2])
if err != nil {
return engine.Color{}, err
}
r = rgb
rgb, err = parseHexComponent(hexColor[2:4])
if err != nil {
return engine.Color{}, err
}
g = rgb
rgb, err = parseHexComponent(hexColor[4:6])
if err != nil {
return engine.Color{}, err
}
b = rgb
rgb, err = parseHexComponent(hexColor[6:8])
if err != nil {
return engine.Color{}, err
}
a = rgb
default:
return engine.Color{}, fmt.Errorf("invalid hex color length: %d", len(hexColor))
}
return engine.NewColorRGBA(r, g, b, a), nil
}
// parseHexComponent parses a 2-character hex string to a uint8 value.
func parseHexComponent(hex string) (uint8, error) {
if len(hex) != 2 {
return 0, fmt.Errorf("hex component must be 2 characters, got %d", len(hex))
}
value, err := strconv.ParseUint(hex, 16, 8)
if err != nil {
return 0, fmt.Errorf("invalid hex component '%s': %w", hex, err)
}
return uint8(value), nil
}

View File

@@ -1,719 +1,161 @@
package jdenticon
import (
"bytes"
"fmt"
"context"
"strings"
"testing"
)
func TestGenerate(t *testing.T) {
tests := []struct {
name string
value string
size int
name string
input string
size int
wantErr bool
}{
{
name: "email address",
value: "test@example.com",
size: 64,
name: "valid_email",
input: "user@example.com",
size: 64,
wantErr: false,
},
{
name: "username",
value: "johndoe",
size: 32,
name: "valid_username",
input: "johndoe",
size: 128,
wantErr: false,
},
{
name: "large icon",
value: "large-icon-test",
size: 256,
name: "empty_input",
input: "",
size: 64,
wantErr: true,
},
{
name: "zero_size",
input: "test",
size: 0,
wantErr: true,
},
{
name: "negative_size",
input: "test",
size: -1,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
icon, err := Generate(tt.value, tt.size)
icon, err := Generate(context.Background(), tt.input, tt.size)
if tt.wantErr {
if err == nil {
t.Errorf("Generate(context.Background(), ) expected error for %s, but got none", tt.name)
}
return
}
if err != nil {
t.Fatalf("Generate failed: %v", err)
t.Errorf("Generate(context.Background(), ) unexpected error for %s: %v", tt.name, err)
return
}
if icon == nil {
t.Fatal("Generate returned nil icon")
}
// Test SVG generation
svg, err := icon.ToSVG()
if err != nil {
t.Fatalf("ToSVG failed: %v", err)
}
if svg == "" {
t.Error("ToSVG returned empty string")
}
// Basic SVG validation
if !strings.Contains(svg, "<svg") {
t.Error("SVG output does not contain svg tag")
}
if !strings.Contains(svg, "</svg>") {
t.Error("SVG output does not contain closing svg tag")
}
// Test PNG generation
png, err := icon.ToPNG()
if err != nil {
t.Fatalf("ToPNG failed: %v", err)
}
if len(png) == 0 {
t.Error("ToPNG returned empty data")
}
// Basic PNG validation (check PNG signature)
if len(png) < 8 || string(png[1:4]) != "PNG" {
t.Error("PNG output does not have valid PNG signature")
t.Errorf("Generate(context.Background(), ) returned nil icon for %s", tt.name)
return
}
})
}
}
func TestGenerateConsistency(t *testing.T) {
value := "consistency-test"
size := 64
// Generate the same icon multiple times
icon1, err := Generate(value, size)
if err != nil {
t.Fatalf("First generate failed: %v", err)
}
icon2, err := Generate(value, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
// SVG should be identical
svg1, err := icon1.ToSVG()
if err != nil {
t.Fatalf("First ToSVG failed: %v", err)
}
svg2, err := icon2.ToSVG()
if err != nil {
t.Fatalf("Second ToSVG failed: %v", err)
}
if svg1 != svg2 {
t.Error("SVG outputs are not consistent for same input")
}
// PNG should be identical
png1, err := icon1.ToPNG()
if err != nil {
t.Fatalf("First ToPNG failed: %v", err)
}
png2, err := icon2.ToPNG()
if err != nil {
t.Fatalf("Second ToPNG failed: %v", err)
}
if !bytes.Equal(png1, png2) {
t.Error("PNG outputs are not consistent for same input")
}
}
func TestGenerateInvalidInputs(t *testing.T) {
tests := []struct {
name string
value string
size int
}{
{
name: "zero size",
value: "test",
size: 0,
},
{
name: "negative size",
value: "test",
size: -10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Generate(tt.value, tt.size)
if err == nil {
t.Error("Expected error for invalid input")
}
})
}
}
func TestGenerateVariety(t *testing.T) {
// Test that different inputs produce different outputs
values := []string{"value1", "value2", "value3", "test@example.com", "another-test"}
size := 64
svgs := make([]string, len(values))
for i, value := range values {
icon, err := Generate(value, size)
if err != nil {
t.Fatalf("Generate failed for %s: %v", value, err)
}
svg, err := icon.ToSVG()
if err != nil {
t.Fatalf("ToSVG failed for %s: %v", value, err)
}
svgs[i] = svg
}
// Check that all SVGs are different
for i := 0; i < len(svgs); i++ {
for j := i + 1; j < len(svgs); j++ {
if svgs[i] == svgs[j] {
t.Errorf("SVG outputs are identical for different inputs: %s and %s", values[i], values[j])
}
}
}
}
func BenchmarkGenerate(b *testing.B) {
value := "benchmark-test"
size := 64
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := Generate(value, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
// BenchmarkGenerateVariousSizes tests generation performance across different icon sizes
func BenchmarkGenerateVariousSizes(b *testing.B) {
sizes := []int{64, 128, 256, 512, 1024}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := Generate("benchmark@test.com", size)
if err != nil {
b.Fatalf("Generate failed for size %d: %v", size, err)
}
}
})
}
}
// BenchmarkGenerateVariousInputs tests generation performance with different input types
func BenchmarkGenerateVariousInputs(b *testing.B) {
inputs := []struct {
name string
value string
}{
{"email", "user@example.com"},
{"username", "john_doe_123"},
{"uuid", "550e8400-e29b-41d4-a716-446655440000"},
{"short", "abc"},
{"long", "this_is_a_very_long_identifier_that_might_be_used_for_generating_identicons_in_some_applications"},
{"special_chars", "user+test@domain.co.uk"},
{"numbers", "12345678901234567890"},
}
for _, input := range inputs {
b.Run(input.name, func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := Generate(input.value, 128)
if err != nil {
b.Fatalf("Generate failed for input %s: %v", input.name, err)
}
}
})
}
}
func BenchmarkToSVG(b *testing.B) {
icon, err := Generate("benchmark-test", 64)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := icon.ToSVG()
if err != nil {
b.Fatalf("ToSVG failed: %v", err)
}
}
}
func BenchmarkToPNG(b *testing.B) {
icon, err := Generate("benchmark-test", 64)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := icon.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// BenchmarkHashGeneration benchmarks just hash computation performance
func BenchmarkHashGeneration(b *testing.B) {
inputs := []string{
"user@example.com",
"john_doe_123",
"550e8400-e29b-41d4-a716-446655440000",
"abc",
"this_is_a_very_long_identifier_that_might_be_used_for_generating_identicons",
}
for _, input := range inputs {
b.Run(fmt.Sprintf("len_%d", len(input)), func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = ComputeHash(input)
}
})
}
}
// BenchmarkSVGRenderingVariousSizes benchmarks SVG rendering across different sizes
func BenchmarkSVGRenderingVariousSizes(b *testing.B) {
sizes := []int{64, 128, 256, 512}
icons := make(map[int]*Icon)
// Pre-generate icons
for _, size := range sizes {
icon, err := Generate("benchmark@test.com", size)
if err != nil {
b.Fatalf("Failed to generate icon for size %d: %v", size, err)
}
icons[size] = icon
}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
icon := icons[size]
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := icon.ToSVG()
if err != nil {
b.Fatalf("ToSVG failed for size %d: %v", size, err)
}
}
})
}
}
// BenchmarkPNGRenderingVariousSizes benchmarks PNG rendering across different sizes
func BenchmarkPNGRenderingVariousSizes(b *testing.B) {
sizes := []int{64, 128, 256} // Smaller range for PNG due to higher memory usage
icons := make(map[int]*Icon)
// Pre-generate icons
for _, size := range sizes {
icon, err := Generate("benchmark@test.com", size)
if err != nil {
b.Fatalf("Failed to generate icon for size %d: %v", size, err)
}
icons[size] = icon
}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
icon := icons[size]
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := icon.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed for size %d: %v", size, err)
}
}
})
}
}
// BenchmarkWithCustomConfig benchmarks generation with custom configuration
func BenchmarkWithCustomConfig(b *testing.B) {
config, err := Configure(
WithHueRestrictions([]float64{0.0, 0.33, 0.66}),
WithColorSaturation(0.6),
WithBackgroundColor("#ffffff"),
WithPadding(0.1),
)
if err != nil {
b.Fatalf("Configure failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := ToSVG("benchmark@test.com", 128, config)
if err != nil {
b.Fatalf("ToSVG with config failed: %v", err)
}
}
}
// BenchmarkWithCustomConfigPNG benchmarks PNG generation with custom configuration
func BenchmarkWithCustomConfigPNG(b *testing.B) {
config, err := Configure(
WithColorSaturation(0.6),
WithBackgroundColor("#123456"),
WithPadding(0.1),
)
if err != nil {
b.Fatalf("Configure failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := ToPNG("benchmark-png-config@test.com", 128, config)
if err != nil {
b.Fatalf("ToPNG with config failed: %v", err)
}
}
}
// BenchmarkConcurrentGeneration tests performance under concurrent load
func BenchmarkConcurrentGeneration(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
value := fmt.Sprintf("concurrent_user_%d@example.com", i)
_, err := Generate(value, 128)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
i++
}
})
}
// BenchmarkBatchGeneration tests memory allocation patterns for batch generation
func BenchmarkBatchGeneration(b *testing.B) {
const batchSize = 100
b.SetBytes(batchSize) // Report throughput as items/sec
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for j := 0; j < batchSize; j++ {
value := fmt.Sprintf("batch_user_%d@example.com", j)
_, err := Generate(value, 64)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
}
// Tests for new public API functions
func TestToSVG(t *testing.T) {
tests := []struct {
name string
value interface{}
size int
valid bool
}{
{"string input", "test@example.com", 64, true},
{"int input", 12345, 64, true},
{"float input", 123.45, 64, true},
{"bool input", true, 64, true},
{"nil input", nil, 64, true},
{"byte slice", []byte("hello"), 64, true},
{"zero size", "test", 0, false},
{"negative size", "test", -10, false},
input := "test@example.com"
size := 64
svg, err := ToSVG(context.Background(), input, size)
if err != nil {
t.Fatalf("ToSVG(context.Background(), ) failed: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svg, err := ToSVG(tt.value, tt.size)
if tt.valid {
if err != nil {
t.Fatalf("ToSVG failed: %v", err)
}
if svg == "" {
t.Error("ToSVG returned empty string")
}
// Basic SVG validation
if !strings.Contains(svg, "<svg") {
t.Error("SVG output does not contain svg tag")
}
if !strings.Contains(svg, "</svg>") {
t.Error("SVG output does not contain closing svg tag")
}
} else {
if err == nil {
t.Error("Expected error for invalid input")
}
}
})
if svg == "" {
t.Error("ToSVG() returned empty string")
}
// Basic SVG validation
if !strings.HasPrefix(svg, "<svg") {
t.Error("ToSVG() output doesn't start with <svg")
}
if !strings.HasSuffix(svg, "</svg>") {
t.Error("ToSVG() output doesn't end with </svg>")
}
if !strings.Contains(svg, "xmlns") {
t.Error("ToSVG() output missing xmlns attribute")
}
}
func TestToPNG(t *testing.T) {
tests := []struct {
name string
value interface{}
size int
valid bool
}{
{"string input", "test@example.com", 64, true},
{"int input", 12345, 64, true},
{"float input", 123.45, 64, true},
{"bool input", false, 64, true},
{"nil input", nil, 64, true},
{"byte slice", []byte("hello"), 64, true},
{"zero size", "test", 0, false},
{"negative size", "test", -10, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
png, err := ToPNG(tt.value, tt.size)
if tt.valid {
if err != nil {
t.Fatalf("ToPNG failed: %v", err)
}
if len(png) == 0 {
t.Error("ToPNG returned empty data")
}
// Basic PNG validation (check PNG signature)
if len(png) < 8 || string(png[1:4]) != "PNG" {
t.Error("PNG output does not have valid PNG signature")
}
} else {
if err == nil {
t.Error("Expected error for invalid input")
}
}
})
}
}
func TestToHash(t *testing.T) {
tests := []struct {
name string
value interface{}
expected string
}{
{"string", "test", ComputeHash("test")},
{"int", 123, ComputeHash("123")},
{"float", 123.45, ComputeHash("123.45")},
{"bool true", true, ComputeHash("true")},
{"bool false", false, ComputeHash("false")},
{"nil", nil, ComputeHash("")},
{"byte slice", []byte("hello"), ComputeHash("hello")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := ToHash(tt.value)
if hash != tt.expected {
t.Errorf("ToHash(%v) = %s, expected %s", tt.value, hash, tt.expected)
}
// Hash should be non-empty and valid hex
if hash == "" {
t.Error("ToHash returned empty string")
}
// Should be valid hex characters
for _, c := range hash {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("Hash contains invalid character: %c", c)
break
}
}
})
}
}
func TestToSVGWithConfig(t *testing.T) {
value := "config-test"
input := "test@example.com"
size := 64
// Test with custom configuration
config, err := Configure(
WithHueRestrictions([]float64{120, 240}), // Blue/green hues
WithColorSaturation(0.8),
WithBackgroundColor("#ffffff"),
WithPadding(0.1),
)
if err != nil {
t.Fatalf("Configure failed: %v", err)
}
svg, err := ToSVG(value, size, config)
if err != nil {
t.Fatalf("ToSVG with config failed: %v", err)
}
if svg == "" {
t.Error("ToSVG with config returned empty string")
}
// Test that it's different from default
defaultSvg, err := ToSVG(value, size)
if err != nil {
t.Fatalf("ToSVG default failed: %v", err)
}
if svg == defaultSvg {
t.Error("SVG with config is identical to default SVG")
}
}
func TestToPNGWithConfig(t *testing.T) {
value := "config-test"
size := 64
// Test with custom configuration
config, err := Configure(
WithHueRestrictions([]float64{60, 180}), // Yellow/cyan hues
WithColorSaturation(0.9),
WithBackgroundColor("#000000"),
WithPadding(0.05),
)
png, err := ToPNG(context.Background(), input, size)
if err != nil {
t.Fatalf("Configure failed: %v", err)
t.Fatalf("ToPNG(context.Background(), ) failed: %v", err)
}
png, err := ToPNG(value, size, config)
if err != nil {
t.Fatalf("ToPNG with config failed: %v", err)
}
if len(png) == 0 {
t.Error("ToPNG with config returned empty data")
t.Error("ToPNG() returned empty byte slice")
}
// Test that it's different from default
defaultPng, err := ToPNG(value, size)
if err != nil {
t.Fatalf("ToPNG default failed: %v", err)
// Basic PNG validation - check PNG signature
if len(png) < 8 {
t.Error("ToPNG() output too short to be valid PNG")
return
}
if bytes.Equal(png, defaultPng) {
t.Error("PNG with config is identical to default PNG")
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
expectedSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
for i, expected := range expectedSignature {
if png[i] != expected {
t.Errorf("ToPNG(context.Background(), ) invalid PNG signature at byte %d: expected %02x, got %02x", i, expected, png[i])
}
}
}
func TestPublicAPIConsistency(t *testing.T) {
value := "consistency-test"
func TestDeterminism(t *testing.T) {
input := "determinism-test"
size := 64
// Generate with both old and new APIs
icon, err := Generate(value, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
// Generate the same input multiple times
svg1, err1 := ToSVG(context.Background(), input, size)
svg2, err2 := ToSVG(context.Background(), input, size)
if err1 != nil || err2 != nil {
t.Fatalf("ToSVG(context.Background(), ) failed: err1=%v, err2=%v", err1, err2)
}
oldSvg, err := icon.ToSVG()
if err != nil {
t.Fatalf("Icon.ToSVG failed: %v", err)
if svg1 != svg2 {
t.Error("ToSVG() not deterministic: same input produced different output")
}
oldPng, err := icon.ToPNG()
if err != nil {
t.Fatalf("Icon.ToPNG failed: %v", err)
png1, err1 := ToPNG(context.Background(), input, size)
png2, err2 := ToPNG(context.Background(), input, size)
if err1 != nil || err2 != nil {
t.Fatalf("ToPNG(context.Background(), ) failed: err1=%v, err2=%v", err1, err2)
}
newSvg, err := ToSVG(value, size)
if err != nil {
t.Fatalf("ToSVG failed: %v", err)
if len(png1) != len(png2) {
t.Error("ToPNG() not deterministic: same input produced different length output")
return
}
newPng, err := ToPNG(value, size)
if err != nil {
t.Fatalf("ToPNG failed: %v", err)
}
// Results should be identical
if oldSvg != newSvg {
t.Error("SVG output differs between old and new APIs")
}
if !bytes.Equal(oldPng, newPng) {
t.Error("PNG output differs between old and new APIs")
for i := range png1 {
if png1[i] != png2[i] {
t.Errorf("ToPNG(context.Background(), ) not deterministic: difference at byte %d", i)
break
}
}
}
func TestTypeConversion(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{"string", "hello", "hello"},
{"int", 42, "42"},
{"int64", int64(42), "42"},
{"float64", 3.14, "3.14"},
{"bool true", true, "true"},
{"bool false", false, "false"},
{"nil", nil, ""},
{"byte slice", []byte("test"), "test"},
{"struct", struct{ Name string }{"test"}, "{test}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertToString(tt.input)
if result != tt.expected {
t.Errorf("convertToString(%v) = %s, expected %s", tt.input, result, tt.expected)
}
})
}
}
// Helper function for min
func min(a, b int) int {
if a < b {
return a
}
return b
}

124
jdenticon/generator.go Normal file
View File

@@ -0,0 +1,124 @@
package jdenticon
import (
"context"
"fmt"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/util"
)
// Generator provides thread-safe identicon generation with caching.
// It wraps the internal engine.Generator to provide a clean public API.
type Generator struct {
engine *engine.Generator
config Config // Store the configuration for validation during Generate calls
}
// NewGenerator creates a new Generator with default configuration and default caching.
func NewGenerator() (*Generator, error) {
engineGen, err := engine.NewDefaultGenerator()
if err != nil {
return nil, NewErrGenerationFailed("", 0, err)
}
return &Generator{
engine: engineGen,
config: DefaultConfig(),
}, nil
}
// NewGeneratorWithCacheSize creates a new Generator with the specified cache size.
func NewGeneratorWithCacheSize(cacheSize int) (*Generator, error) {
if cacheSize <= 0 {
return nil, NewErrInvalidInput("cacheSize", fmt.Sprintf("%d", cacheSize), "must be positive")
}
config := engine.DefaultGeneratorConfig()
config.CacheSize = cacheSize
engineGen, err := engine.NewGeneratorWithConfig(config)
if err != nil {
return nil, NewErrCacheCreationFailed(cacheSize, err)
}
return &Generator{
engine: engineGen,
config: DefaultConfig(),
}, nil
}
// NewGeneratorWithConfig creates a new Generator with custom configuration and caching.
func NewGeneratorWithConfig(config Config, cacheSize int) (*Generator, error) {
if cacheSize <= 0 {
return nil, NewErrInvalidInput("cacheSize", fmt.Sprintf("%d", cacheSize), "must be positive")
}
// Convert public config to internal config
colorConfig, err := config.toEngineColorConfig()
if err != nil {
return nil, err
}
generatorConfig := engine.GeneratorConfig{
ColorConfig: colorConfig,
CacheSize: cacheSize,
MaxComplexity: config.MaxComplexity,
MaxIconSize: config.MaxIconSize,
}
engineGen, err := engine.NewGeneratorWithConfig(generatorConfig)
if err != nil {
return nil, NewErrCacheCreationFailed(cacheSize, err)
}
return &Generator{
engine: engineGen,
config: config,
}, nil
}
// Generate creates an identicon for the given input string and size with context support.
// The context can be used to set timeouts or cancel generation.
//
// The input string is hashed to generate a deterministic identicon.
// Size must be positive and represents the width/height in pixels.
//
// This method applies the security limits that were configured when the Generator was created.
// For different limits, create a new Generator with NewGeneratorWithConfig.
//
// This method is thread-safe and uses caching if configured.
func (g *Generator) Generate(ctx context.Context, input string, size int) (*Icon, error) {
// Apply validation using the generator's stored configuration
if err := validateInputs(input, size, g.config); err != nil {
return nil, err
}
// Check for early cancellation
if err := ctx.Err(); err != nil {
return nil, err
}
// Convert input to hash
hash := util.ComputeHash(input)
// Validate complexity before generation
if err := validateComplexity(hash, g.config); err != nil {
return nil, err
}
// Generate using the internal engine with context
engineIcon, err := g.engine.Generate(ctx, hash, float64(size))
if err != nil {
return nil, NewErrGenerationFailed(input, size, err)
}
// Wrap in public Icon directly
return newIcon(engineIcon), nil
}
// GetCacheMetrics returns the cache hit and miss counts.
// These metrics are thread-safe to read.
func (g *Generator) GetCacheMetrics() (hits, misses int64) {
return g.engine.GetCacheMetrics()
}

View File

@@ -1,226 +0,0 @@
package jdenticon
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"github.com/kevin/go-jdenticon/internal/util"
)
// ComputeHash computes a SHA-1 hash for any value and returns it as a hexadecimal string.
// This function mimics the JavaScript version's behavior for compatibility.
func ComputeHash(value interface{}) string {
var input string
// Handle different input types, converting to string like JavaScript version
switch v := value.(type) {
case nil:
input = ""
case string:
input = v
case []byte:
input = string(v)
case int:
input = strconv.Itoa(v)
case int64:
input = strconv.FormatInt(v, 10)
case float64:
input = fmt.Sprintf("%g", v)
default:
// Convert to string using fmt.Sprintf for other types
input = fmt.Sprintf("%v", v)
}
// Compute SHA-1 hash using Go's crypto/sha1 package
h := sha1.New()
h.Write([]byte(input))
hash := h.Sum(nil)
// Convert to hexadecimal string (lowercase to match JavaScript)
return fmt.Sprintf("%x", hash)
}
// HashValue is a convenience function that wraps ComputeHash for string inputs.
// Kept for backward compatibility.
func HashValue(value string) string {
return ComputeHash(value)
}
// IsValidHash checks if a string is a valid hash for Jdenticon.
// It must be a hexadecimal string with at least 11 characters.
func IsValidHash(hashCandidate string) bool {
return util.IsValidHash(hashCandidate)
}
// isValidHash is a private wrapper for backward compatibility with existing tests
func isValidHash(hashCandidate string) bool {
return IsValidHash(hashCandidate)
}
// parseHex extracts a value from a hex string at a specific position.
// This function is used to deterministically extract shape and color information
// from the hash string, matching the JavaScript implementation.
// When octets is 0 or negative, it reads from startPosition to the end of the string.
func parseHex(hash string, startPosition int, octets int) (int, error) {
return util.ParseHex(hash, startPosition, octets)
}
// ParseHex provides a public API that matches the JavaScript parseHex function exactly.
// It extracts a hexadecimal value from the hash string at the specified position.
// If octets is not provided or is <= 0, it reads from the position to the end of the string.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ParseHex(hash string, startPosition int, octets ...int) int {
octetCount := 0
if len(octets) > 0 {
octetCount = octets[0]
}
result, err := parseHex(hash, startPosition, octetCount)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
return result
}
// ParseHash converts a hexadecimal hash string into a byte array for further processing.
// It validates the input hash string and handles common prefixes like "0x".
// Returns an error if the hash contains invalid hexadecimal characters.
func ParseHash(hash string) ([]byte, error) {
if hash == "" {
return nil, errors.New("hash string cannot be empty")
}
// Remove "0x" prefix if present
cleanHash := strings.TrimPrefix(hash, "0x")
cleanHash = strings.TrimPrefix(cleanHash, "0X")
// Validate hash length (must be even for proper byte conversion)
if len(cleanHash)%2 != 0 {
return nil, errors.New("hash string must have even length")
}
// Decode hex string to bytes
bytes, err := hex.DecodeString(cleanHash)
if err != nil {
return nil, fmt.Errorf("invalid hexadecimal string: %w", err)
}
return bytes, nil
}
// ExtractInt extracts a specific number of bits from a hash byte array and converts them to an integer.
// The index parameter specifies the starting position (negative values count from the end).
// The bits parameter specifies how many bits to extract.
func ExtractInt(hash []byte, index, bits int) int {
if len(hash) == 0 || bits <= 0 {
return 0
}
// Handle negative indices (count from end)
if index < 0 {
index = len(hash) + index
}
// Ensure index is within bounds
if index < 0 || index >= len(hash) {
return 0
}
// Calculate how many bytes we need to read
bytesNeeded := (bits + 7) / 8 // Round up to nearest byte
// Ensure we don't read past the end of the array
if index+bytesNeeded > len(hash) {
bytesNeeded = len(hash) - index
}
if bytesNeeded <= 0 {
return 0
}
// Extract bytes and convert to integer
var result int
for i := 0; i < bytesNeeded; i++ {
if index+i < len(hash) {
result = (result << 8) | int(hash[index+i])
}
}
// Mask to only include the requested number of bits
if bits < 64 {
mask := (1 << bits) - 1
result &= mask
}
return result
}
// ExtractFloat extracts a specific number of bits from a hash byte array and converts them to a float64 value between 0 and 1.
// The value is normalized by dividing by the maximum possible value for the given number of bits.
func ExtractFloat(hash []byte, index, bits int) float64 {
if bits <= 0 {
return 0.0
}
// Extract integer value
intValue := ExtractInt(hash, index, bits)
// Calculate maximum possible value for the given number of bits
maxValue := (1 << bits) - 1
if maxValue == 0 {
return 0.0
}
// Normalize to [0,1] range
return float64(intValue) / float64(maxValue)
}
// ExtractHue extracts the hue value from a hash string using the same algorithm as the JavaScript version.
// This is a convenience function that extracts the last 7 characters and normalizes to [0,1] range.
// Returns 0.0 on error to maintain compatibility with the JavaScript implementation.
func ExtractHue(hash string) float64 {
hueValue, err := parseHex(hash, -7, 0) // Read from -7 to end
if err != nil {
return 0.0 // Maintain JavaScript compatibility: return 0.0 on error
}
return float64(hueValue) / 0xfffffff
}
// ExtractShapeIndex extracts a shape index from the hash at the specified position.
// This is a convenience function that matches the JavaScript shape selection logic.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ExtractShapeIndex(hash string, position int) int {
result, err := parseHex(hash, position, 1)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
return result
}
// ExtractRotation extracts a rotation value from the hash at the specified position.
// This is a convenience function that matches the JavaScript rotation logic.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ExtractRotation(hash string, position int) int {
result, err := parseHex(hash, position, 1)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
return result
}
// ExtractColorIndex extracts a color index from the hash at the specified position.
// This is a convenience function that matches the JavaScript color selection logic.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ExtractColorIndex(hash string, position int, availableColors int) int {
value, err := parseHex(hash, position, 1)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
if availableColors > 0 {
return value % availableColors
}
return value
}

View File

@@ -1,481 +0,0 @@
package jdenticon
import (
"strings"
"testing"
)
func TestComputeHash(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string // Known SHA-1 hash values
}{
{
name: "empty string",
input: "",
expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
},
{
name: "simple string",
input: "hello",
expected: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
},
{
name: "email address",
input: "user@example.com",
expected: "63a710569261a24b3766275b7000ce8d7b32e2f7",
},
{
name: "nil input",
input: nil,
expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709", // Same as empty string
},
{
name: "integer input",
input: 123,
expected: "40bd001563085fc35165329ea1ff5c5ecbdbbeef", // SHA-1 of "123"
},
{
name: "byte slice input",
input: []byte("test"),
expected: "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ComputeHash(tt.input)
if result != tt.expected {
t.Errorf("ComputeHash(%v) = %s, want %s", tt.input, result, tt.expected)
}
})
}
}
func TestHashValue(t *testing.T) {
// Test that HashValue produces the same result as ComputeHash for strings
testStrings := []string{"", "hello", "user@example.com", "test123"}
for _, str := range testStrings {
hashValue := HashValue(str)
computeHash := ComputeHash(str)
if hashValue != computeHash {
t.Errorf("HashValue(%s) = %s, but ComputeHash(%s) = %s", str, hashValue, str, computeHash)
}
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "valid long hash",
input: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
expected: true,
},
{
name: "valid minimum length",
input: "da39a3ee5e6",
expected: true,
},
{
name: "too short",
input: "da39a3ee5e",
expected: false,
},
{
name: "invalid character",
input: "da39a3ee5e6g4b0d3255bfef95601890afd80709",
expected: false,
},
{
name: "uppercase valid",
input: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709",
expected: true,
},
{
name: "empty string",
input: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidHash(tt.input)
if result != tt.expected {
t.Errorf("isValidHash(%s) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestParseHex(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
tests := []struct {
name string
startPosition int
octets int
expected int
}{
{
name: "first character",
startPosition: 0,
octets: 1,
expected: 0xd, // 'd' in hex
},
{
name: "two characters",
startPosition: 0,
octets: 2,
expected: 0xda,
},
{
name: "middle position",
startPosition: 10,
octets: 1,
expected: 0x6, // '6' at position 10
},
{
name: "negative index",
startPosition: -1,
octets: 1,
expected: 0x9, // last character '9'
},
{
name: "negative index multiple chars",
startPosition: -2,
octets: 2,
expected: 0x09, // last two characters "09"
},
{
name: "out of bounds",
startPosition: 100,
octets: 1,
expected: 0,
},
{
name: "zero octets reads to end",
startPosition: -7,
octets: 0,
expected: 0xfd80709, // Last 7 characters "fd80709"
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseHex(hash, tt.startPosition, tt.octets)
if err != nil {
// For out of bounds case, we expect an error but return 0
if tt.expected == 0 && tt.name == "out of bounds" {
// This is expected
return
}
t.Errorf("parseHex(%s, %d, %d) unexpected error: %v", hash, tt.startPosition, tt.octets, err)
return
}
if result != tt.expected {
t.Errorf("parseHex(%s, %d, %d) = %d, want %d", hash, tt.startPosition, tt.octets, result, tt.expected)
}
})
}
}
func TestParseHexErrors(t *testing.T) {
tests := []struct {
name string
hash string
startPosition int
octets int
expectError bool
errorContains string
}{
{
name: "invalid hex character",
hash: "da39g3ee5e6b4b0d3255bfef95601890afd80709",
startPosition: 4,
octets: 1,
expectError: true,
errorContains: "failed to parse hex",
},
{
name: "out of bounds positive",
hash: "da39a3ee",
startPosition: 10,
octets: 1,
expectError: true,
errorContains: "out of bounds",
},
{
name: "out of bounds negative",
hash: "da39a3ee",
startPosition: -20,
octets: 1,
expectError: true,
errorContains: "out of bounds",
},
{
name: "valid hex",
hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
startPosition: 0,
octets: 2,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseHex(tt.hash, tt.startPosition, tt.octets)
if tt.expectError {
if err == nil {
t.Errorf("parseHex(%s, %d, %d) expected error, got nil", tt.hash, tt.startPosition, tt.octets)
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("parseHex(%s, %d, %d) error = %v, want error containing %s", tt.hash, tt.startPosition, tt.octets, err, tt.errorContains)
}
} else {
if err != nil {
t.Errorf("parseHex(%s, %d, %d) unexpected error: %v", tt.hash, tt.startPosition, tt.octets, err)
}
// For valid case, just ensure no error and result >= 0
if result < 0 {
t.Errorf("parseHex(%s, %d, %d) = %d, want >= 0", tt.hash, tt.startPosition, tt.octets, result)
}
}
})
}
}
func TestParseHash(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
expected []byte
}{
{
name: "valid hex string",
input: "da39a3ee",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "with 0x prefix",
input: "0xda39a3ee",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "with 0X prefix",
input: "0Xda39a3ee",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "uppercase hex",
input: "DA39A3EE",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "empty string",
input: "",
expectError: true,
expected: nil,
},
{
name: "odd length",
input: "da39a3e",
expectError: true,
expected: nil,
},
{
name: "invalid character",
input: "da39g3ee",
expectError: true,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseHash(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("ParseHash(%s) expected error, got nil", tt.input)
}
} else {
if err != nil {
t.Errorf("ParseHash(%s) unexpected error: %v", tt.input, err)
}
if len(result) != len(tt.expected) {
t.Errorf("ParseHash(%s) length = %d, want %d", tt.input, len(result), len(tt.expected))
}
for i, b := range result {
if i < len(tt.expected) && b != tt.expected[i] {
t.Errorf("ParseHash(%s)[%d] = %x, want %x", tt.input, i, b, tt.expected[i])
}
}
}
})
}
}
func TestExtractHue(t *testing.T) {
// Test with a known hash to ensure consistent behavior with JavaScript
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
hue := ExtractHue(hash)
// Verify it's in [0,1] range
if hue < 0.0 || hue > 1.0 {
t.Errorf("ExtractHue(%s) = %f, want value in [0,1] range", hash, hue)
}
// Verify it matches manual calculation
expectedValue, err := parseHex(hash, -7, 0)
if err != nil {
t.Fatalf("parseHex failed: %v", err)
}
expectedHue := float64(expectedValue) / 0xfffffff
if hue != expectedHue {
t.Errorf("ExtractHue(%s) = %f, want %f", hash, hue, expectedHue)
}
// Test error cases - should return 0.0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
hue := ExtractHue(invalidHash)
if hue != 0.0 {
t.Errorf("ExtractHue with invalid hash should return 0.0, got %f", hue)
}
})
t.Run("short hash", func(t *testing.T) {
shortHash := "short"
hue := ExtractHue(shortHash)
if hue != 0.0 {
t.Errorf("ExtractHue with short hash should return 0.0, got %f", hue)
}
})
}
func TestExtractShapeIndex(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
tests := []struct {
position int
expected int
}{
{1, 0xa}, // 'a' at position 1
{2, 0x3}, // '3' at position 2
{4, 0xa}, // 'a' at position 4
}
for _, tt := range tests {
result := ExtractShapeIndex(hash, tt.position)
if result != tt.expected {
t.Errorf("ExtractShapeIndex(%s, %d) = %d, want %d", hash, tt.position, result, tt.expected)
}
}
// Test error cases - should return 0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
result := ExtractShapeIndex(invalidHash, 0)
if result != 0 {
t.Errorf("ExtractShapeIndex with invalid hash should return 0, got %d", result)
}
})
t.Run("out of bounds position", func(t *testing.T) {
result := ExtractShapeIndex(hash, 100)
if result != 0 {
t.Errorf("ExtractShapeIndex with out of bounds position should return 0, got %d", result)
}
})
}
func TestExtractColorIndex(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
// Test modulo behavior
availableColors := 5
position := 8
rawValue, err := parseHex(hash, position, 1)
if err != nil {
t.Fatalf("parseHex failed: %v", err)
}
expected := rawValue % availableColors
result := ExtractColorIndex(hash, position, availableColors)
if result != expected {
t.Errorf("ExtractColorIndex(%s, %d, %d) = %d, want %d", hash, position, availableColors, result, expected)
}
// Test with zero availableColors
result = ExtractColorIndex(hash, position, 0)
if result != rawValue {
t.Errorf("ExtractColorIndex(%s, %d, 0) = %d, want %d", hash, position, result, rawValue)
}
// Test error cases - should return 0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
result := ExtractColorIndex(invalidHash, 0, 5)
if result != 0 {
t.Errorf("ExtractColorIndex with invalid hash should return 0, got %d", result)
}
})
t.Run("out of bounds position", func(t *testing.T) {
result := ExtractColorIndex(hash, 100, 5)
if result != 0 {
t.Errorf("ExtractColorIndex with out of bounds position should return 0, got %d", result)
}
})
}
func TestExtractRotation(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
tests := []struct {
position int
expected int
}{
{0, 0xd}, // 'd' at position 0
{1, 0xa}, // 'a' at position 1
{5, 0x3}, // '3' at position 5
}
for _, tt := range tests {
result := ExtractRotation(hash, tt.position)
if result != tt.expected {
t.Errorf("ExtractRotation(%s, %d) = %d, want %d", hash, tt.position, result, tt.expected)
}
}
// Test error cases - should return 0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
result := ExtractRotation(invalidHash, 0)
if result != 0 {
t.Errorf("ExtractRotation with invalid hash should return 0, got %d", result)
}
})
t.Run("out of bounds position", func(t *testing.T) {
result := ExtractRotation(hash, 100)
if result != 0 {
t.Errorf("ExtractRotation with out of bounds position should return 0, got %d", result)
}
})
}

110
jdenticon/icon.go Normal file
View File

@@ -0,0 +1,110 @@
package jdenticon
import (
"fmt"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/renderer"
)
// Icon represents a generated identicon that can be rendered as SVG or PNG.
// It wraps the internal engine.Icon to provide a clean public API.
type Icon struct {
engineIcon *engine.Icon
}
// newIcon creates a new public Icon from an internal engine.Icon.
func newIcon(engineIcon *engine.Icon) *Icon {
return &Icon{
engineIcon: engineIcon,
}
}
// ToSVG renders the icon as an SVG string.
//
// Returns the SVG markup as a string, or an error if rendering fails.
func (i *Icon) ToSVG() (string, error) {
if i.engineIcon == nil {
return "", ErrInvalidIcon("icon data is nil")
}
size := int(i.engineIcon.Size)
svgRenderer := renderer.NewSVGRenderer(size)
if err := i.renderToRenderer(svgRenderer); err != nil {
return "", NewErrRenderFailed("SVG", err)
}
return svgRenderer.ToSVG(), nil
}
// ToPNG renders the icon as PNG bytes.
//
// Returns the PNG data as a byte slice, or an error if rendering fails.
func (i *Icon) ToPNG() ([]byte, error) {
if i.engineIcon == nil {
return nil, ErrInvalidIcon("icon data is nil")
}
size := int(i.engineIcon.Size)
pngRenderer := renderer.NewPNGRenderer(size)
if err := i.renderToRenderer(pngRenderer); err != nil {
return nil, NewErrRenderFailed("PNG", err)
}
png, err := pngRenderer.ToPNG()
if err != nil {
return nil, NewErrRenderFailed("PNG", err)
}
return png, nil
}
// renderToRenderer renders the icon to any renderer that implements the Renderer interface.
func (i *Icon) renderToRenderer(r renderer.Renderer) error {
// Set background if specified
if i.engineIcon.Config.BackColor != nil {
color := i.engineIcon.Config.BackColor
colorStr := color.String()
opacity := float64(color.A) / 255.0
r.SetBackground(colorStr, opacity)
}
// Render each shape group
for _, shapeGroup := range i.engineIcon.Shapes {
colorStr := shapeGroup.Color.String()
r.BeginShape(colorStr)
for _, shape := range shapeGroup.Shapes {
if err := i.renderShape(r, shape); err != nil {
return fmt.Errorf("shape rendering failed: %w", err)
}
}
r.EndShape()
}
return nil
}
// renderShape renders a single shape to the renderer.
func (i *Icon) renderShape(r renderer.Renderer, shape engine.Shape) error {
switch shape.Type {
case "polygon":
if len(shape.Points) < 3 {
return fmt.Errorf("polygon must have at least 3 points, got %d", len(shape.Points))
}
r.AddPolygon(shape.Points)
case "circle":
topLeft := engine.Point{X: shape.CircleX, Y: shape.CircleY}
r.AddCircle(topLeft, shape.CircleSize, shape.Invert)
default:
return fmt.Errorf("unsupported shape type: %s", shape.Type)
}
return nil
}

View File

@@ -0,0 +1,179 @@
package jdenticon
import (
"context"
"testing"
)
// TestFullWorkflow tests the complete workflow from input to final output
func TestFullWorkflow(t *testing.T) {
// Test with different input types
testCases := []struct {
name string
input string
size int
}{
{"email", "user@example.com", 64},
{"username", "johndoe", 128},
{"uuid_like", "550e8400-e29b-41d4-a716-446655440000", 32},
{"special_chars", "test@#$%^&*()", 96},
{"unicode", "тест", 64},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test Generate -> ToSVG workflow
icon, err := Generate(context.Background(), tc.input, tc.size)
if err != nil {
t.Fatalf("Generate failed for %s: %v", tc.input, err)
}
svg, err := icon.ToSVG()
if err != nil {
t.Fatalf("ToSVG failed for %s: %v", tc.input, err)
}
if len(svg) == 0 {
t.Errorf("SVG output empty for %s", tc.input)
}
// Test Generate -> ToPNG workflow
png, err := icon.ToPNG()
if err != nil {
t.Fatalf("ToPNG failed for %s: %v", tc.input, err)
}
if len(png) == 0 {
t.Errorf("PNG output empty for %s", tc.input)
}
// Verify PNG header
if len(png) >= 8 {
pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
for i, expected := range pngSignature {
if png[i] != expected {
t.Errorf("Invalid PNG signature for %s at byte %d", tc.input, i)
break
}
}
}
})
}
}
// TestGeneratorCaching tests that the generator properly caches results
func TestGeneratorCaching(t *testing.T) {
generator, err := NewGeneratorWithCacheSize(10)
if err != nil {
t.Fatalf("Failed to create generator: %v", err)
}
input := "cache-test"
size := 64
// Generate the same icon multiple times
icon1, err1 := generator.Generate(context.Background(), input, size)
icon2, err2 := generator.Generate(context.Background(), input, size)
if err1 != nil || err2 != nil {
t.Fatalf("Generate failed: err1=%v, err2=%v", err1, err2)
}
// Verify they produce the same output
svg1, err1 := icon1.ToSVG()
svg2, err2 := icon2.ToSVG()
if err1 != nil || err2 != nil {
t.Fatalf("ToSVG failed: err1=%v, err2=%v", err1, err2)
}
if svg1 != svg2 {
t.Error("Cached results differ from fresh generation")
}
}
// TestCustomConfiguration tests generation with custom configuration
func TestCustomConfiguration(t *testing.T) {
config := DefaultConfig()
config.ColorSaturation = 0.8
config.Padding = 0.15
config.BackgroundColor = "#ffffff"
generator, err := NewGeneratorWithConfig(config, 10)
if err != nil {
t.Fatalf("Failed to create generator with config: %v", err)
}
icon, err := generator.Generate(context.Background(), "config-test", 64)
if err != nil {
t.Fatalf("Generate with config failed: %v", err)
}
svg, err := icon.ToSVG()
if err != nil {
t.Fatalf("ToSVG with config failed: %v", err)
}
if len(svg) == 0 {
t.Error("SVG output empty with custom config")
}
// Test convenience function with config
svg2, err := ToSVGWithConfig(context.Background(), "config-test", 64, config)
if err != nil {
t.Fatalf("ToSVGWithConfig failed: %v", err)
}
if len(svg2) == 0 {
t.Error("ToSVGWithConfig output empty")
}
}
// TestErrorHandling tests various error conditions
func TestErrorHandling(t *testing.T) {
tests := []struct {
name string
testFunc func() error
wantErr bool
}{
{
name: "invalid_cache_size",
testFunc: func() error {
_, err := NewGeneratorWithCacheSize(-1)
return err
},
wantErr: true,
},
{
name: "invalid_config_cache_size",
testFunc: func() error {
config := DefaultConfig()
_, err := NewGeneratorWithConfig(config, -1)
return err
},
wantErr: true,
},
{
name: "invalid_config_values",
testFunc: func() error {
config := DefaultConfig()
config.ColorSaturation = 2.0 // Invalid: > 1.0
_, err := NewGeneratorWithConfig(config, 10)
return err
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.testFunc()
if tt.wantErr && err == nil {
t.Errorf("Expected error for %s, but got none", tt.name)
}
if !tt.wantErr && err != nil {
t.Errorf("Unexpected error for %s: %v", tt.name, err)
}
})
}
}

BIN
jdenticon/jdenticon Executable file

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +1,10 @@
package jdenticon
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
)
@@ -17,9 +19,9 @@ func TestJavaScriptReferenceCompatibility(t *testing.T) {
}
for _, tc := range testCases {
t.Run(tc.input+"_"+string(rune(tc.size)), func(t *testing.T) {
// Generate Go SVG
goSvg, err := ToSVG(tc.input, tc.size)
t.Run(tc.input+"_"+strconv.Itoa(tc.size), func(t *testing.T) {
// Generate Go SVG with context
goSvg, err := ToSVG(context.Background(), tc.input, tc.size)
if err != nil {
t.Fatalf("Failed to generate Go SVG: %v", err)
}
@@ -47,12 +49,13 @@ func TestJavaScriptReferenceCompatibility(t *testing.T) {
t.Errorf("SVG output differs from JavaScript reference")
t.Logf("Go output:\n%s", goSvg)
t.Logf("JS reference:\n%s", refSvg)
// Save Go output for manual inspection
goPath := filepath.Join("../go-output", refFilename)
os.MkdirAll(filepath.Dir(goPath), 0755)
os.WriteFile(goPath, []byte(goSvg), 0644)
t.Logf("Go output saved to: %s", goPath)
}
})
}
}
}

View File

@@ -0,0 +1,173 @@
package jdenticon
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestComplexityLimitProtection(t *testing.T) {
// Test that complexity limits prevent resource exhaustion
config, err := Configure(WithMaxComplexity(1)) // Very low limit
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
ctx := context.Background()
_, err = ToSVGWithConfig(ctx, "test-complexity", 64, config)
if err == nil {
t.Fatal("Expected complexity limit to be exceeded, but got no error")
}
// Check that we get the right error type (may be wrapped in ErrGenerationFailed)
var complexityErr *ErrComplexityLimitExceeded
if !errors.As(err, &complexityErr) {
// Check if it's an engine complexity error that got translated
if !strings.Contains(err.Error(), "complexity limit exceeded") {
t.Errorf("Expected complexity limit error, got: %v", err)
}
}
}
func TestComplexityLimitDisabled(t *testing.T) {
// Test that complexity limits can be disabled
config, err := Configure(WithMaxComplexity(-1)) // Disabled
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
ctx := context.Background()
_, err = ToSVGWithConfig(ctx, "test-disabled", 64, config)
if err != nil {
t.Errorf("Expected no error with disabled complexity limit, got: %v", err)
}
}
func TestContextTimeoutProtection(t *testing.T) {
// Create a context that will timeout very quickly
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait a bit to ensure the context expires
time.Sleep(1 * time.Millisecond)
_, err := Generate(ctx, "test-timeout", 64)
if err == nil {
t.Fatal("Expected context timeout error, but got no error")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("Expected context.DeadlineExceeded, got: %v", err)
}
}
func TestContextCancellationProtection(t *testing.T) {
// Create a context that we'll cancel
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err := Generate(ctx, "test-cancellation", 64)
if err == nil {
t.Fatal("Expected context cancellation error, but got no error")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("Expected context.Canceled, got: %v", err)
}
}
func TestNormalOperationWithLimits(t *testing.T) {
// Test that normal operation works with reasonable limits
config, err := Configure(WithMaxComplexity(200)) // Reasonable limit
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
ctx := context.Background()
svg, err := ToSVGWithConfig(ctx, "test-normal", 64, config)
if err != nil {
t.Errorf("Expected normal operation to work, got error: %v", err)
}
if len(svg) == 0 {
t.Error("Expected non-empty SVG output")
}
}
func TestDefaultComplexityLimit(t *testing.T) {
// Test that default complexity limit allows normal operation
config := DefaultConfig()
ctx := context.Background()
svg, err := ToSVGWithConfig(ctx, "test-default", 64, config)
if err != nil {
t.Errorf("Expected default limits to allow normal operation, got error: %v", err)
}
if len(svg) == 0 {
t.Error("Expected non-empty SVG output")
}
}
func TestComplexityCalculationConsistency(t *testing.T) {
// Test that complexity calculation is deterministic
config, err := Configure(WithMaxComplexity(50))
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
ctx := context.Background()
input := "consistency-test"
// Try the same input multiple times - should get consistent results
for i := 0; i < 5; i++ {
_, err := ToSVGWithConfig(ctx, input, 64, config)
// The error should be consistent (either always fail or always succeed)
if i == 0 {
// Store first result for comparison
if err != nil {
// If first attempt failed, all should fail
continue
}
}
// All subsequent attempts should have the same result
}
}
func BenchmarkComplexityCalculation(b *testing.B) {
// Benchmark the overhead of complexity calculation
config, err := Configure(WithMaxComplexity(100))
if err != nil {
b.Fatalf("Failed to create config: %v", err)
}
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ToSVGWithConfig(ctx, "benchmark-test", 64, config)
}
}
func BenchmarkWithoutComplexityLimit(b *testing.B) {
// Benchmark without complexity limits for comparison
config, err := Configure(WithMaxComplexity(-1)) // Disabled
if err != nil {
b.Fatalf("Failed to create config: %v", err)
}
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ToSVGWithConfig(ctx, "benchmark-test", 64, config)
}
}

468
jdenticon/security_test.go Normal file
View File

@@ -0,0 +1,468 @@
package jdenticon
import (
"context"
"errors"
"strings"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/constants"
)
// TestDoSProtection_InputLength tests protection against large input strings.
func TestDoSProtection_InputLength(t *testing.T) {
tests := []struct {
name string
inputLength int
config Config
expectError bool
errorType string
}{
{
name: "normal input with default config",
inputLength: 100,
config: DefaultConfig(),
expectError: false,
},
{
name: "maximum allowed input with default config",
inputLength: constants.DefaultMaxInputLength,
config: DefaultConfig(),
expectError: false,
},
{
name: "oversized input with default config",
inputLength: constants.DefaultMaxInputLength + 1,
config: DefaultConfig(),
expectError: true,
errorType: "*jdenticon.ErrValueTooLarge",
},
{
name: "oversized input with custom smaller limit",
inputLength: 1000,
config: func() Config {
c := DefaultConfig()
c.MaxInputLength = 500
return c
}(),
expectError: true,
errorType: "*jdenticon.ErrValueTooLarge",
},
{
name: "oversized input with disabled limit",
inputLength: constants.DefaultMaxInputLength + 1000,
config: func() Config {
c := DefaultConfig()
c.MaxInputLength = -1 // Disabled
return c
}(),
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create input string of specified length
input := strings.Repeat("a", tt.inputLength)
// Test ToSVGWithConfig
_, err := ToSVGWithConfig(context.Background(), input, 100, tt.config)
if tt.expectError {
if err == nil {
t.Errorf("ToSVGWithConfig: expected error but got none")
return
}
// Check error type if specified
if tt.errorType != "" {
var valueErr *ErrValueTooLarge
if !errors.As(err, &valueErr) {
t.Errorf("ToSVGWithConfig: expected error type %s, got %T", tt.errorType, err)
} else if valueErr.ParameterName != "InputLength" {
t.Errorf("ToSVGWithConfig: expected InputLength error, got %s", valueErr.ParameterName)
}
}
} else if err != nil {
t.Errorf("ToSVGWithConfig: unexpected error: %v", err)
}
// Test ToPNGWithConfig
_, err = ToPNGWithConfig(context.Background(), input, 100, tt.config)
if tt.expectError {
if err == nil {
t.Errorf("ToPNGWithConfig: expected error but got none")
}
} else if err != nil {
t.Errorf("ToPNGWithConfig: unexpected error: %v", err)
}
})
}
}
// TestDoSProtection_IconSize tests protection against large icon sizes.
func TestDoSProtection_IconSize(t *testing.T) {
tests := []struct {
name string
size int
config Config
expectError bool
errorType string
}{
{
name: "normal size with default config",
size: 256,
config: DefaultConfig(),
expectError: false,
},
{
name: "maximum allowed size with default config",
size: constants.DefaultMaxIconSize,
config: DefaultConfig(),
expectError: false,
},
{
name: "oversized icon with default config",
size: constants.DefaultMaxIconSize + 1,
config: DefaultConfig(),
expectError: true,
errorType: "*jdenticon.ErrValueTooLarge",
},
{
name: "oversized icon with custom smaller limit",
size: 2000,
config: func() Config {
c := DefaultConfig()
c.MaxIconSize = 1000
return c
}(),
expectError: true,
errorType: "*jdenticon.ErrValueTooLarge",
},
{
name: "oversized icon with disabled limit",
size: constants.DefaultMaxIconSize + 1000,
config: func() Config {
c := DefaultConfig()
c.MaxIconSize = -1 // Disabled
return c
}(),
expectError: false,
},
{
name: "zero size",
size: 0,
config: DefaultConfig(),
expectError: true,
errorType: "jdenticon.ErrInvalidSize",
},
{
name: "negative size",
size: -100,
config: DefaultConfig(),
expectError: true,
errorType: "jdenticon.ErrInvalidSize",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := "test"
// Test ToSVGWithConfig
_, err := ToSVGWithConfig(context.Background(), input, tt.size, tt.config)
if tt.expectError {
if err == nil {
t.Errorf("ToSVGWithConfig: expected error but got none")
return
}
// Check error type if specified
if tt.errorType != "" {
switch tt.errorType {
case "*jdenticon.ErrValueTooLarge":
var valueErr *ErrValueTooLarge
if !errors.As(err, &valueErr) {
t.Errorf("ToSVGWithConfig: expected error type %s, got %T", tt.errorType, err)
} else if valueErr.ParameterName != "IconSize" {
t.Errorf("ToSVGWithConfig: expected IconSize error, got %s", valueErr.ParameterName)
}
case "jdenticon.ErrInvalidSize":
var sizeErr ErrInvalidSize
if !errors.As(err, &sizeErr) {
t.Errorf("ToSVGWithConfig: expected error type %s, got %T", tt.errorType, err)
}
}
}
} else if err != nil {
t.Errorf("ToSVGWithConfig: unexpected error: %v", err)
}
})
}
}
// TestDoSProtection_PNGEffectiveSize tests protection against PNG supersampling creating oversized effective images.
func TestDoSProtection_PNGEffectiveSize(t *testing.T) {
tests := []struct {
name string
size int
supersampling int
config Config
expectError bool
errorType string
}{
{
name: "normal PNG with default supersampling",
size: 512,
supersampling: 8, // 512 * 8 = 4096 (exactly at limit)
expectError: false,
},
{
name: "oversized effective PNG size",
size: 1024,
supersampling: 8, // 1024 * 8 = 8192 (exceeds 4096 limit)
expectError: true,
errorType: "*jdenticon.ErrEffectiveSizeTooLarge",
},
{
name: "large PNG with low supersampling (within limit)",
size: 2048,
supersampling: 2, // 2048 * 2 = 4096 (exactly at limit)
expectError: false,
},
{
name: "maximum PNG with 1x supersampling",
size: 4096,
supersampling: 1, // 4096 * 1 = 4096 (exactly at limit)
expectError: false,
},
{
name: "PNG with disabled size limit",
size: 2000,
supersampling: 10, // Would exceed default limit but should be allowed
config: func() Config {
c := DefaultConfig()
c.MaxIconSize = -1 // Disabled
return c
}(),
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := tt.config
if config.MaxIconSize == 0 && config.MaxInputLength == 0 {
// Use default config if not specified
config = DefaultConfig()
}
config.PNGSupersampling = tt.supersampling
input := "test"
_, err := ToPNGWithConfig(context.Background(), input, tt.size, config)
if tt.expectError {
if err == nil {
t.Errorf("ToPNGWithConfig: expected error but got none")
return
}
// Check error type if specified
if tt.errorType == "*jdenticon.ErrEffectiveSizeTooLarge" {
var effectiveErr *ErrEffectiveSizeTooLarge
if !errors.As(err, &effectiveErr) {
t.Errorf("ToPNGWithConfig: expected error type %s, got %T", tt.errorType, err)
} else {
expectedEffective := tt.size * tt.supersampling
if effectiveErr.Actual != expectedEffective {
t.Errorf("ToPNGWithConfig: expected effective size %d, got %d",
expectedEffective, effectiveErr.Actual)
}
if effectiveErr.Size != tt.size {
t.Errorf("ToPNGWithConfig: expected size %d, got %d",
tt.size, effectiveErr.Size)
}
if effectiveErr.Supersampling != tt.supersampling {
t.Errorf("ToPNGWithConfig: expected supersampling %d, got %d",
tt.supersampling, effectiveErr.Supersampling)
}
}
}
} else if err != nil {
t.Errorf("ToPNGWithConfig: unexpected error: %v", err)
}
})
}
}
// TestDoSProtection_DynamicSupersampling tests the dynamic supersampling feature in ToPNG.
func TestDoSProtection_DynamicSupersampling(t *testing.T) {
tests := []struct {
name string
size int
expectError bool
expectedMaxSS int // Expected maximum supersampling that should be used
}{
{
name: "small size uses full supersampling",
size: 256,
expectError: false,
expectedMaxSS: 8, // 256 * 8 = 2048 < 4096, so full supersampling
},
{
name: "medium size uses reduced supersampling",
size: 1024,
expectError: false,
expectedMaxSS: 4, // 1024 * 4 = 4096, reduced from default 8
},
{
name: "large size uses minimal supersampling",
size: 4096,
expectError: false,
expectedMaxSS: 1, // 4096 * 1 = 4096, minimal supersampling
},
{
name: "oversized even with minimal supersampling",
size: 4097,
expectError: true, // Even 4097 * 1 = 4097 > 4096
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := "test"
data, err := ToPNG(context.Background(), input, tt.size)
if tt.expectError {
if err == nil {
t.Errorf("ToPNG: expected error but got none")
}
return
}
if err != nil {
t.Errorf("ToPNG: unexpected error: %v", err)
return
}
if len(data) == 0 {
t.Errorf("ToPNG: expected PNG data but got empty result")
}
})
}
}
// TestDoSProtection_EmptyInput tests protection against empty input strings.
func TestDoSProtection_EmptyInput(t *testing.T) {
config := DefaultConfig()
_, err := ToSVGWithConfig(context.Background(), "", 100, config)
if err == nil {
t.Errorf("ToSVGWithConfig: expected error for empty input but got none")
return
}
var inputErr *ErrInvalidInput
if !errors.As(err, &inputErr) {
t.Errorf("ToSVGWithConfig: expected ErrInvalidInput, got %T", err)
} else if inputErr.Field != "input" {
t.Errorf("ToSVGWithConfig: expected input field error, got %s", inputErr.Field)
}
}
// TestDoSProtection_ConfigurableLimits tests that the configurable limits work as expected.
func TestDoSProtection_ConfigurableLimits(t *testing.T) {
// Test custom limits
customConfig := DefaultConfig()
customConfig.MaxIconSize = 1000 // Much smaller than default
customConfig.MaxInputLength = 10000 // Much smaller than default
customConfig.PNGSupersampling = 4
// This should work with custom config
_, err := ToSVGWithConfig(context.Background(), "test", 500, customConfig)
if err != nil {
t.Errorf("ToSVGWithConfig with custom config: unexpected error: %v", err)
}
// This should fail with custom config (size too large)
_, err = ToSVGWithConfig(context.Background(), "test", 1001, customConfig)
if err == nil {
t.Errorf("ToSVGWithConfig with custom config: expected error for oversized icon but got none")
}
// Test disabled limits
noLimitsConfig := DefaultConfig()
noLimitsConfig.MaxIconSize = -1 // Disabled
noLimitsConfig.MaxInputLength = -1 // Disabled
noLimitsConfig.PNGSupersampling = 1
// This should work even with very large size (disabled limits)
largeInput := strings.Repeat("x", constants.DefaultMaxInputLength+1000)
_, err = ToSVGWithConfig(context.Background(), largeInput, constants.DefaultMaxIconSize+1000, noLimitsConfig)
if err != nil {
t.Errorf("ToSVGWithConfig with disabled limits: unexpected error: %v", err)
}
}
// TestDoSProtection_GeneratorConsistency tests that Generator API respects configured limits.
func TestDoSProtection_GeneratorConsistency(t *testing.T) {
// Test generator with custom limits
customConfig := DefaultConfig()
customConfig.MaxIconSize = 1000
customConfig.MaxInputLength = 100
generator, err := NewGeneratorWithConfig(customConfig, 10)
if err != nil {
t.Fatalf("NewGeneratorWithConfig: unexpected error: %v", err)
}
// This should work within limits
_, err = generator.Generate(context.Background(), "test", 500)
if err != nil {
t.Errorf("Generator.Generate within limits: unexpected error: %v", err)
}
// This should fail due to size limit
_, err = generator.Generate(context.Background(), "test", 1001)
if err == nil {
t.Errorf("Generator.Generate with oversized icon: expected error but got none")
} else {
var valueErr *ErrValueTooLarge
if !errors.As(err, &valueErr) {
t.Errorf("Generator.Generate: expected ErrValueTooLarge, got %T", err)
} else if valueErr.ParameterName != "IconSize" {
t.Errorf("Generator.Generate: expected IconSize error, got %s", valueErr.ParameterName)
}
}
// This should fail due to input length limit
longInput := strings.Repeat("a", 101)
_, err = generator.Generate(context.Background(), longInput, 100)
if err == nil {
t.Errorf("Generator.Generate with long input: expected error but got none")
} else {
var valueErr *ErrValueTooLarge
if !errors.As(err, &valueErr) {
t.Errorf("Generator.Generate: expected ErrValueTooLarge, got %T", err)
} else if valueErr.ParameterName != "InputLength" {
t.Errorf("Generator.Generate: expected InputLength error, got %s", valueErr.ParameterName)
}
}
// Test generator with default limits
defaultGenerator, err := NewGenerator()
if err != nil {
t.Fatalf("NewGenerator: unexpected error: %v", err)
}
// Should work with normal inputs
_, err = defaultGenerator.Generate(context.Background(), "test", 256)
if err != nil {
t.Errorf("Default generator: unexpected error: %v", err)
}
// Should fail with oversized input
_, err = defaultGenerator.Generate(context.Background(), "test", constants.DefaultMaxIconSize+1)
if err == nil {
t.Errorf("Default generator with oversized icon: expected error but got none")
}
}

85
jdenticon/validation.go Normal file
View File

@@ -0,0 +1,85 @@
package jdenticon
import (
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// validation.go contains helper functions for input validation and DoS protection.
// validateInputs performs common validation for input string and size parameters.
// This provides centralized validation logic used across all public API functions.
func validateInputs(input string, size int, config Config) error {
// Validate input string length
if maxLen := config.effectiveMaxInputLength(); maxLen != -1 && len(input) > maxLen {
return NewErrValueTooLarge("InputLength", maxLen, len(input))
}
// Validate that input is not empty
if input == "" {
return NewErrInvalidInput("input", input, "cannot be empty")
}
// Validate base icon size (must be positive)
if size <= 0 {
return ErrInvalidSize(size)
}
// Validate icon size against configured limit
if maxSize := config.effectiveMaxIconSize(); maxSize != -1 && size > maxSize {
return NewErrValueTooLarge("IconSize", maxSize, size)
}
return nil
}
// validateComplexity performs complexity validation for an input string.
// This should be called after basic input validation and hash computation.
func validateComplexity(hash string, config Config) error {
if maxComplexity := config.effectiveMaxComplexity(); maxComplexity != -1 {
// Create a temporary engine generator to calculate complexity
// This is needed to access the CalculateComplexity method
engineConfig := engine.DefaultGeneratorConfig()
engineConfig.ColorConfig = engine.ColorConfig{
IconPadding: config.Padding,
ColorSaturation: config.ColorSaturation,
GrayscaleSaturation: config.GrayscaleSaturation,
ColorLightness: engine.LightnessRange{
Min: config.ColorLightnessRange[0],
Max: config.ColorLightnessRange[1],
},
GrayscaleLightness: engine.LightnessRange{
Min: config.GrayscaleLightnessRange[0],
Max: config.GrayscaleLightnessRange[1],
},
Hues: config.HueRestrictions,
BackColor: nil, // Not needed for complexity calculation
}
tempGenerator, err := engine.NewGeneratorWithConfig(engineConfig)
if err != nil {
return err
}
complexity, err := tempGenerator.CalculateComplexity(hash)
if err != nil {
return err
}
if complexity > maxComplexity {
return NewErrComplexityLimitExceeded(maxComplexity, complexity, hash)
}
}
return nil
}
// validatePNGSize performs additional validation for PNG functions that use supersampling.
// This checks the effective size (size * supersampling) against configured limits.
func validatePNGSize(size int, config Config) error {
// Check effective size for PNG with supersampling
effectiveSize := size * config.PNGSupersampling
if maxSize := config.effectiveMaxIconSize(); maxSize != -1 && effectiveSize > maxSize {
return NewErrEffectiveSizeTooLarge(maxSize, effectiveSize, size, config.PNGSupersampling)
}
return nil
}