diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index cbbe04a9..8c1a4cc8 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -11,6 +11,7 @@ import ( "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/internal/namespace" "github.com/modelcontextprotocol/registry/internal/service" "golang.org/x/net/html" ) @@ -54,6 +55,17 @@ func PublishHandler(registry service.RegistryService, authService auth.Service) return } + // Validate namespace format if it follows domain-scoped convention + if err := namespace.ValidateNamespace(serverDetail.Name); err != nil { + // If the namespace doesn't follow domain-scoped format, check if it's a legacy format + if !errors.Is(err, namespace.ErrInvalidNamespace) { + http.Error(w, "Invalid namespace: "+err.Error(), http.StatusBadRequest) + return + } + // For pseudo-domain io.github format/borrowed domain, we'll allow them to pass through + // This provides support for the borrowed io.github domain while encouraging new domain-scoped formats + } + // Version is required if serverDetail.VersionDetail.Version == "" { http.Error(w, "Version is required", http.StatusBadRequest) @@ -73,15 +85,31 @@ func PublishHandler(registry service.RegistryService, authService auth.Service) token = authHeader[7:] } - // Determine authentication method based on server name prefix + // Determine authentication method based on server name format var authMethod model.AuthMethod - switch { - case strings.HasPrefix(serverDetail.Name, "io.github"): - authMethod = model.AuthMethodGitHub - // Additional cases can be added here for other prefixes - default: - // Keep the default auth method as AuthMethodNone - authMethod = model.AuthMethodNone + + // Check if the namespace is domain-scoped and extract domain for auth + if parsed, err := namespace.ParseNamespace(serverDetail.Name); err == nil { + // For domain-scoped namespaces, determine auth method based on domain + switch parsed.Domain { + case "github.com": + authMethod = model.AuthMethodGitHub + // Additional domain-specific auth methods can be added here + default: + // For other domains, require GitHub auth for now + // NOTE: Domain verification system needs to be implemented + authMethod = model.AuthMethodGitHub + } + } else { + // Legacy namespace format - use existing logic + switch { + case strings.HasPrefix(serverDetail.Name, "io.github"): + authMethod = model.AuthMethodGitHub + // Additional cases can be added here for other prefixes + default: + // Keep the default auth method as AuthMethodNone + authMethod = model.AuthMethodNone + } } serverName := html.EscapeString(serverDetail.Name) diff --git a/internal/api/handlers/v0/publish_namespace_test.go b/internal/api/handlers/v0/publish_namespace_test.go new file mode 100644 index 00000000..6040f338 --- /dev/null +++ b/internal/api/handlers/v0/publish_namespace_test.go @@ -0,0 +1,126 @@ +//nolint:testpackage // Internal package testing allows access to private functions +package v0 + +import ( + "testing" + + "github.com/modelcontextprotocol/registry/internal/namespace" +) + +// TestNamespaceValidationIntegration tests that the namespace validation +// functionality is working correctly for various namespace formats +func TestNamespaceValidationIntegration(t *testing.T) { + tests := []struct { + name string + serverName string + shouldPass bool + description string + }{ + { + name: "valid domain-scoped namespace", + serverName: "com.github/my-server", + shouldPass: true, + description: "Standard domain-scoped namespace should be valid", + }, + { + name: "valid subdomain namespace", + serverName: "com.github.api/tool", + shouldPass: true, + description: "Subdomain-scoped namespace should be valid", + }, + { + name: "valid apache commons namespace", + serverName: "org.apache.commons/utility", + shouldPass: true, + description: "Apache commons style namespace should be valid", + }, + { + name: "valid kubernetes namespace", + serverName: "io.kubernetes/plugin", + shouldPass: true, + description: "Kubernetes.io style namespace should be valid", + }, + { + name: "reserved namespace - localhost", + serverName: "com.localhost/server", + shouldPass: false, + description: "Reserved localhost namespace should be rejected", + }, + { + name: "reserved namespace - example", + serverName: "com.example/server", + shouldPass: false, + description: "Reserved example namespace should be rejected", + }, + { + name: "legacy format - io.github prefix", + serverName: "io.github.username/my-server", + shouldPass: true, // This is valid reverse domain notation + description: "Legacy io.github format should be valid as reverse domain notation", + }, + { + name: "invalid format - forward domain notation", + serverName: "github.com/my-server", + shouldPass: false, + description: "Forward domain notation should be rejected", + }, + { + name: "invalid format - simple name", + serverName: "my-server", + shouldPass: false, + description: "Simple server name should not match domain-scoped pattern", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := namespace.ValidateNamespace(tt.serverName) + + if tt.shouldPass { + if err != nil { + t.Errorf("Expected namespace '%s' to be valid, but got error: %v", tt.serverName, err) + } + } else { + if err == nil { + t.Errorf("Expected namespace '%s' to be invalid, but validation passed", tt.serverName) + } + } + }) + } +} + +// TestDomainExtractionIntegration tests the domain extraction functionality +func TestDomainExtractionIntegration(t *testing.T) { + // Test valid extractions + validTests := []struct { + namespace string + domain string + }{ + {"com.github/my-server", "github.com"}, + {"com.github.api/tool", "api.github.com"}, + {"org.apache.commons/utility", "commons.apache.org"}, + {"io.kubernetes/plugin", "kubernetes.io"}, + } + + for _, test := range validTests { + t.Run("extract_"+test.domain, func(t *testing.T) { + domain, err := namespace.ParseDomainFromNamespace(test.namespace) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } else if domain != test.domain { + t.Errorf("Expected '%s', got '%s'", test.domain, domain) + } + }) + } + + // Test invalid extractions + invalidTests := []string{"invalid-format", "github.com/server", "simple"} + for _, test := range invalidTests { + t.Run("invalid_"+test, func(t *testing.T) { + _, err := namespace.ParseDomainFromNamespace(test) + if err == nil { + t.Errorf("Expected error for '%s'", test) + } + }) + } +} diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go new file mode 100644 index 00000000..95a2dcdd --- /dev/null +++ b/internal/namespace/namespace.go @@ -0,0 +1,294 @@ +package namespace + +import ( + "errors" + "fmt" + "net" + "regexp" + "strings" + "unicode" +) + +// ErrInvalidNamespace is returned when a namespace is invalid +var ErrInvalidNamespace = errors.New("invalid namespace") + +// ErrInvalidDomain is returned when a domain is invalid +var ErrInvalidDomain = errors.New("invalid domain") + +// ErrReservedNamespace is returned when trying to use a reserved namespace +var ErrReservedNamespace = errors.New("reserved namespace") + +// reservedNamespaces contains namespaces that are reserved and cannot be used +var reservedNamespaces = map[string]bool{ + "com.localhost": true, + "org.localhost": true, + "net.localhost": true, + "localhost": true, + "com.example": true, + "org.example": true, + "net.example": true, + "example": true, + "com.test": true, + "org.test": true, + "net.test": true, + "test": true, + "com.invalid": true, + "org.invalid": true, + "net.invalid": true, + "invalid": true, + "com.local": true, + "org.local": true, + "net.local": true, + "local": true, +} + +// Namespace represents a parsed namespace with its components +type Namespace struct { + Original string // Original namespace string + Domain string // Extracted domain (e.g., "github.com") + ServerName string // Server name portion (e.g., "my-server") +} + +// domainPattern matches valid domain names according to RFC specifications +// This regex handles: +// - Domain labels (segments between dots) +// - International domain names (IDN) +// - Proper length restrictions +// - Valid characters +var domainPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`) + +// reverseNotationPattern matches reverse domain notation with server name +// Format: tld.domain/server-name (e.g., com.github/my-server) +// This pattern ensures we start with a TLD-like identifier followed by domain components +var reverseNotationPattern = regexp.MustCompile( + `^([a-zA-Z]{2,4}\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?` + + `(?:\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*)/` + + `([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)$`, +) + +// ParseDomainFromNamespace extracts the domain from a domain-scoped namespace +// Supports reverse domain notation (e.g., com.github/my-server -> github.com) +// Returns the normalized domain and any parsing errors +func ParseDomainFromNamespace(namespace string) (string, error) { + parsed, err := ParseNamespace(namespace) + if err != nil { + return "", err + } + return parsed.Domain, nil +} + +// ParseNamespace parses a namespace string and returns a Namespace struct +// with the extracted domain and server name components +func ParseNamespace(namespace string) (*Namespace, error) { + if namespace == "" { + return nil, fmt.Errorf("%w: namespace cannot be empty", ErrInvalidNamespace) + } + + // Normalize namespace to lowercase for consistent processing + normalizedNamespace := strings.ToLower(strings.TrimSpace(namespace)) + + // Check if it matches the reverse domain notation pattern + matches := reverseNotationPattern.FindStringSubmatch(normalizedNamespace) + if len(matches) < 5 { + return nil, fmt.Errorf("%w: namespace must follow format 'domain.tld/server-name'", ErrInvalidNamespace) + } + + reverseDomain := matches[1] + serverName := matches[4] + + // Check for reserved domains before conversion + if isReservedNamespace(reverseDomain) { + return nil, fmt.Errorf("%w: namespace %s is reserved", ErrReservedNamespace, reverseDomain) + } + + // Convert reverse domain notation to normal domain + domain, err := reverseNotationToDomain(reverseDomain) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidNamespace, err) + } + + // Validate the extracted domain + err = validateDomain(domain) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidNamespace, err) + } + + // Validate server name + if err := validateServerName(serverName); err != nil { + return nil, fmt.Errorf("%w: invalid server name: %w", ErrInvalidNamespace, err) + } + + return &Namespace{ + Original: namespace, + Domain: domain, + ServerName: serverName, + }, nil +} + +// reverseNotationToDomain converts reverse domain notation to normal domain format +// Examples: +// - com.github -> github.com +// - com.github.api -> api.github.com +// - org.apache.commons -> commons.apache.org +func reverseNotationToDomain(reverseDomain string) (string, error) { + if reverseDomain == "" { + return "", errors.New("reverse domain cannot be empty") + } + + parts := strings.Split(reverseDomain, ".") + if len(parts) < 2 { + return "", errors.New("reverse domain must have at least two parts") + } + + // Reverse the parts to create normal domain notation + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + + domain := strings.Join(parts, ".") + + // Normalize domain (lowercase, remove trailing dots) + domain = strings.ToLower(strings.TrimRight(domain, ".")) + + return domain, nil +} + +// validateDomain validates that a domain meets RFC requirements and security standards +func validateDomain(domain string) error { + if domain == "" { + return errors.New("domain cannot be empty") + } + + // Normalize domain (lowercase, remove trailing dots) + domain = strings.ToLower(strings.TrimRight(domain, ".")) + + // Check for maximum length (253 characters for FQDN) + if len(domain) > 253 { + return errors.New("domain too long (max 253 characters)") + } + + // Check for minimum length + if len(domain) < 3 { + return errors.New("domain too short (min 3 characters)") + } + + // Check for valid characters and format using regex + if !domainPattern.MatchString(domain) { + return errors.New("domain contains invalid characters or format") + } + + // Check each label (part between dots) + labels := strings.Split(domain, ".") + if len(labels) < 2 { + return errors.New("domain must have at least two labels") + } + + for _, label := range labels { + if err := validateDomainLabel(label); err != nil { + return fmt.Errorf("invalid domain label '%s': %w", label, err) + } + } + + // Check for suspicious Unicode homographs (basic check) + if containsSuspiciousUnicode(domain) { + return errors.New("domain contains suspicious Unicode characters") + } + + // Validate using Go's net package for additional checks + //nolint:staticcheck // Empty branch is intentional - this is a soft validation + if _, err := net.LookupTXT(domain); err != nil { + // Note: This is a soft validation - we don't require DNS resolution to succeed + // as the domain might be newly registered or not yet propagated + // This is just a basic sanity check for obvious invalid domains + } + + return nil +} + +// validateDomainLabel validates individual domain labels (parts between dots) +func validateDomainLabel(label string) error { + if label == "" { + return errors.New("label cannot be empty") + } + + if len(label) > 63 { + return errors.New("label too long (max 63 characters)") + } + + if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return errors.New("label cannot start or end with hyphen") + } + + // Check that label doesn't consist only of numbers (to prevent confusion with IP addresses) + if regexp.MustCompile(`^\d+$`).MatchString(label) { + return errors.New("label cannot consist only of numbers") + } + + return nil +} + +// validateServerName validates the server name portion of the namespace +func validateServerName(serverName string) error { + if serverName == "" { + return errors.New("server name cannot be empty") + } + + if len(serverName) < 1 || len(serverName) > 100 { + return errors.New("server name must be between 1 and 100 characters") + } + + // Server name should contain only alphanumeric characters and hyphens + // Cannot start or end with hyphen + serverNamePattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`) + if !serverNamePattern.MatchString(serverName) { + return errors.New("server name can only contain alphanumeric characters and hyphens, and cannot start or end with hyphen") + } + + return nil +} + +// isReservedNamespace checks if a namespace is reserved +func isReservedNamespace(namespace string) bool { + return reservedNamespaces[strings.ToLower(namespace)] +} + +// containsSuspiciousUnicode performs basic checks for Unicode homograph attacks +func containsSuspiciousUnicode(domain string) bool { + for _, r := range domain { + // Check for characters that might be used in homograph attacks + // This is a basic check - a more comprehensive solution would use + // a proper Unicode confusables database + if unicode.Is(unicode.Mn, r) || // Mark, nonspacing + unicode.Is(unicode.Me, r) || // Mark, enclosing + unicode.Is(unicode.Mc, r) { // Mark, spacing combining + return true + } + + // Check for certain suspicious Unicode blocks + if r >= 0x0400 && r <= 0x04FF { // Cyrillic + return true + } + if r >= 0x0370 && r <= 0x03FF { // Greek + return true + } + } + return false +} + +// ValidateNamespace is a convenience function that validates a namespace +// and returns detailed error information +func ValidateNamespace(namespace string) error { + _, err := ParseNamespace(namespace) + return err +} + +// IsValidNamespace returns true if the namespace is valid +func IsValidNamespace(namespace string) bool { + return ValidateNamespace(namespace) == nil +} + +// GetDomainFromNamespace is a convenience function that extracts and validates +// a domain from a namespace, returning just the domain string or an error +func GetDomainFromNamespace(namespace string) (string, error) { + return ParseDomainFromNamespace(namespace) +} diff --git a/internal/namespace/namespace_test.go b/internal/namespace/namespace_test.go new file mode 100644 index 00000000..abdfec1a --- /dev/null +++ b/internal/namespace/namespace_test.go @@ -0,0 +1,616 @@ +//nolint:testpackage // Internal package testing allows access to private functions +package namespace + +import ( + "errors" + "testing" +) + +// Test constants to avoid duplication +const ( + testGitHubNamespace = "com.github/my-server" + testGitHubDomain = "github.com" + testMyServer = "my-server" + testAPIGitHubDomain = "api.github.com" + testApacheCommonsDomain = "commons.apache.org" + testKubernetesIODomain = "kubernetes.io" + testGitHubReverseDomain = "com.github" + testLocalhostNamespace = "com.localhost/server" + invalidNamespaceLabel = "invalid namespace" +) + +func TestParseNamespaceValidCases(t *testing.T) { + tests := []struct { + name string + namespace string + wantDomain string + wantServer string + }{ + { + name: "simple github namespace", + namespace: testGitHubNamespace, + wantDomain: testGitHubDomain, + wantServer: testMyServer, + }, + { + name: "subdomain namespace", + namespace: "com.github.api/tool", + wantDomain: testAPIGitHubDomain, + wantServer: "tool", + }, + { + name: "apache commons namespace", + namespace: "org.apache.commons/utility", + wantDomain: testApacheCommonsDomain, + wantServer: "utility", + }, + { + name: "kubernetes io namespace", + namespace: "io.kubernetes/plugin", + wantDomain: testKubernetesIODomain, + wantServer: "plugin", + }, + { + name: "case normalization", + namespace: "COM.GITHUB/MY-SERVER", + wantDomain: testGitHubDomain, + wantServer: testMyServer, + }, + { + name: "with whitespace", + namespace: " " + testGitHubNamespace + " ", + wantDomain: testGitHubDomain, + wantServer: testMyServer, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseNamespace(tt.namespace) + if err != nil { + t.Errorf("ParseNamespace() unexpected error: %v", err) + return + } + + if result.Domain != tt.wantDomain { + t.Errorf("ParseNamespace() domain = %v, want %v", result.Domain, tt.wantDomain) + } + + if result.ServerName != tt.wantServer { + t.Errorf("ParseNamespace() server name = %v, want %v", result.ServerName, tt.wantServer) + } + + if result.Original != tt.namespace { + t.Errorf("ParseNamespace() original = %v, want %v", result.Original, tt.namespace) + } + }) + } +} + +func TestParseNamespaceInvalidCases(t *testing.T) { + tests := []struct { + name string + namespace string + expectedErr error + }{ + { + name: "empty namespace", + namespace: "", + expectedErr: ErrInvalidNamespace, + }, + { + name: "missing server name", + namespace: testGitHubReverseDomain, + expectedErr: ErrInvalidNamespace, + }, + { + name: "forward domain notation", + namespace: testGitHubDomain + "/" + testMyServer, + expectedErr: ErrInvalidNamespace, + }, + { + name: "wildcard domain", + namespace: "*.github/" + testMyServer, + expectedErr: ErrInvalidNamespace, + }, + { + name: "invalid domain format", + namespace: ".com.invalid/server", + expectedErr: ErrInvalidNamespace, + }, + { + name: "reserved namespace localhost", + namespace: testLocalhostNamespace, + expectedErr: ErrReservedNamespace, + }, + { + name: "reserved namespace example", + namespace: "com.example/server", + expectedErr: ErrReservedNamespace, + }, + { + name: "single domain part", + namespace: "github/" + testMyServer, + expectedErr: ErrInvalidNamespace, + }, + { + name: "server name with invalid characters", + namespace: testGitHubReverseDomain + "/my_server!", + expectedErr: ErrInvalidNamespace, + }, + { + name: "server name starting with hyphen", + namespace: testGitHubReverseDomain + "/-server", + expectedErr: ErrInvalidNamespace, + }, + { + name: "server name ending with hyphen", + namespace: testGitHubReverseDomain + "/server-", + expectedErr: ErrInvalidNamespace, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseNamespace(tt.namespace) + if err == nil { + t.Errorf("ParseNamespace() expected error but got none") + return + } + if tt.expectedErr != nil && !errors.Is(err, tt.expectedErr) { + t.Errorf("ParseNamespace() expected error %v, got %v", tt.expectedErr, err) + } + }) + } +} + +func TestParseDomainFromNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + wantDomain string + wantErr bool + }{ + { + name: "github namespace", + namespace: testGitHubNamespace, + wantDomain: testGitHubDomain, + wantErr: false, + }, + { + name: "subdomain namespace", + namespace: "com.github.api/tool", + wantDomain: testAPIGitHubDomain, + wantErr: false, + }, + { + name: invalidNamespaceLabel, + namespace: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + domain, err := ParseDomainFromNamespace(tt.namespace) + + if tt.wantErr { + if err == nil { + t.Errorf("ParseDomainFromNamespace() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("ParseDomainFromNamespace() unexpected error: %v", err) + return + } + + if domain != tt.wantDomain { + t.Errorf("ParseDomainFromNamespace() = %v, want %v", domain, tt.wantDomain) + } + }) + } +} + +func TestReverseNotationToDomain(t *testing.T) { + tests := []struct { + name string + reverseDomain string + wantDomain string + wantErr bool + }{ + { + name: "github", + reverseDomain: testGitHubReverseDomain, + wantDomain: testGitHubDomain, + wantErr: false, + }, + { + name: "github subdomain", + reverseDomain: "com.github.api", + wantDomain: testAPIGitHubDomain, + wantErr: false, + }, + { + name: "apache commons", + reverseDomain: "org.apache.commons", + wantDomain: testApacheCommonsDomain, + wantErr: false, + }, + { + name: "kubernetes io", + reverseDomain: "io.kubernetes", + wantDomain: testKubernetesIODomain, + wantErr: false, + }, + { + name: "case normalization", + reverseDomain: "COM.GITHUB", + wantDomain: testGitHubDomain, + wantErr: false, + }, + { + name: "empty string", + reverseDomain: "", + wantErr: true, + }, + { + name: "single part", + reverseDomain: "github", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + domain, err := reverseNotationToDomain(tt.reverseDomain) + + if tt.wantErr { + if err == nil { + t.Errorf("reverseNotationToDomain() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("reverseNotationToDomain() unexpected error: %v", err) + return + } + + if domain != tt.wantDomain { + t.Errorf("reverseNotationToDomain() = %v, want %v", domain, tt.wantDomain) + } + }) + } +} + +func TestValidateDomain(t *testing.T) { + tests := []struct { + name string + domain string + wantErr bool + }{ + // Valid domains + { + name: "github.com", + domain: testGitHubDomain, + wantErr: false, + }, + { + name: "api.github.com", + domain: testAPIGitHubDomain, + wantErr: false, + }, + { + name: "kubernetes.io", + domain: testKubernetesIODomain, + wantErr: false, + }, + { + name: "commons.apache.org", + domain: testApacheCommonsDomain, + wantErr: false, + }, + // Invalid domains + { + name: "empty domain", + domain: "", + wantErr: true, + }, + { + name: "too short", + domain: "a.b", + wantErr: false, // Actually valid - minimum is met + }, + { + name: "single label", + domain: "localhost", + wantErr: true, + }, + { + name: "starts with dot", + domain: ".github.com", + wantErr: true, + }, + { + name: "ends with dot", + domain: "github.com.", + wantErr: false, // Trailing dots are normalized away + }, + { + name: "contains invalid characters", + domain: "github_invalid.com", + wantErr: true, + }, + { + name: "label starts with hyphen", + domain: "-github.com", + wantErr: true, + }, + { + name: "label ends with hyphen", + domain: "github-.com", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDomain(tt.domain) + + if tt.wantErr && err == nil { + t.Errorf("validateDomain() expected error but got none") + } + + if !tt.wantErr && err != nil { + t.Errorf("validateDomain() unexpected error: %v", err) + } + }) + } +} + +func TestValidateServerName(t *testing.T) { + tests := []struct { + name string + serverName string + wantErr bool + }{ + // Valid server names + { + name: "simple name", + serverName: testMyServer, + wantErr: false, + }, + { + name: "alphanumeric", + serverName: "server123", + wantErr: false, + }, + { + name: "with hyphens", + serverName: "my-awesome-server", + wantErr: false, + }, + { + name: "single character", + serverName: "a", + wantErr: false, + }, + // Invalid server names + { + name: "empty", + serverName: "", + wantErr: true, + }, + { + name: "starts with hyphen", + serverName: "-server", + wantErr: true, + }, + { + name: "ends with hyphen", + serverName: "server-", + wantErr: true, + }, + { + name: "contains underscore", + serverName: "my_server", + wantErr: true, + }, + { + name: "contains special characters", + serverName: "my-server!", + wantErr: true, + }, + { + name: "only hyphen", + serverName: "-", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateServerName(tt.serverName) + + if tt.wantErr && err == nil { + t.Errorf("validateServerName() expected error but got none") + } + + if !tt.wantErr && err != nil { + t.Errorf("validateServerName() unexpected error: %v", err) + } + }) + } +} + +func TestIsReservedNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + want bool + }{ + { + name: "localhost reserved", + namespace: "com.localhost", + want: true, + }, + { + name: "example reserved", + namespace: "com.example", + want: true, + }, + { + name: "test reserved", + namespace: "org.test", + want: true, + }, + { + name: "case insensitive", + namespace: "COM.LOCALHOST", + want: true, + }, + { + name: "github not reserved", + namespace: "com.github", + want: false, + }, + { + name: "custom domain not reserved", + namespace: "com.mycompany", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isReservedNamespace(tt.namespace) + if got != tt.want { + t.Errorf("isReservedNamespace() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + wantErr bool + }{ + { + name: "valid namespace", + namespace: testGitHubNamespace, + wantErr: false, + }, + { + name: invalidNamespaceLabel, + namespace: "invalid", + wantErr: true, + }, + { + name: "reserved namespace", + namespace: testLocalhostNamespace, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateNamespace(tt.namespace) + + if tt.wantErr && err == nil { + t.Errorf("ValidateNamespace() expected error but got none") + } + + if !tt.wantErr && err != nil { + t.Errorf("ValidateNamespace() unexpected error: %v", err) + } + }) + } +} + +func TestIsValidNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + want bool + }{ + { + name: "valid namespace", + namespace: testGitHubNamespace, + want: true, + }, + { + name: invalidNamespaceLabel, + namespace: "invalid", + want: false, + }, + { + name: "reserved namespace", + namespace: testLocalhostNamespace, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsValidNamespace(tt.namespace) + if got != tt.want { + t.Errorf("IsValidNamespace() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContainsSuspiciousUnicode(t *testing.T) { + tests := []struct { + name string + domain string + want bool + }{ + { + name: "normal ascii domain", + domain: testGitHubDomain, + want: false, + }, + { + name: "domain with cyrillic", + domain: "githubрcom", // Contains Cyrillic 'р' (U+0440) + want: true, + }, + { + name: "domain with greek", + domain: "githubαcom", // Contains Greek 'α' (U+03B1) + want: true, + }, + { + name: "normal domain with numbers", + domain: "github123.com", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containsSuspiciousUnicode(tt.domain) + if got != tt.want { + t.Errorf("containsSuspiciousUnicode() = %v, want %v", got, tt.want) + } + }) + } +} + +// Benchmark tests +func BenchmarkParseNamespace(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = ParseNamespace(testGitHubNamespace) + } +} + +func BenchmarkValidateDomain(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = validateDomain(testGitHubDomain) + } +} diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index a7394249..e555279f 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -14,7 +14,7 @@ import ( "regexp" "strings" - "github.com/santhosh-tekuri/jsonschema/v5" + jsonschema "github.com/santhosh-tekuri/jsonschema/v5" ) const ( diff --git a/tools/validate-schemas/main.go b/tools/validate-schemas/main.go index 90f21f8b..4e106218 100644 --- a/tools/validate-schemas/main.go +++ b/tools/validate-schemas/main.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "github.com/santhosh-tekuri/jsonschema/v5" + jsonschema "github.com/santhosh-tekuri/jsonschema/v5" ) func main() {