From 2ba6378db5767f584e175c20dd386a91c7f6ed13 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:23:35 -0800 Subject: [PATCH 1/6] feat: add 'azd extension validate-registry' command for extension registry validation Add a new 'azd extension validate-registry' command that validates extension registry.json files from a local path or URL. This migrates validation logic from the standalone Node.js script in awesome-azd into the CLI. Validates: - Required fields (id, displayName, description, versions) - Extension ID format (dot-separated namespace) - Semver version format - Valid capabilities against known capability types - Platform artifact structure (valid os/arch, required url) - Artifact checksums (warning by default, error with --strict) Supports multiple input formats: - Registry object with 'extensions' array wrapper - Array of extension objects - Single extension object Outputs results as human-readable text (default) or JSON (--output json). Returns exit code 0 on success, non-zero on validation failure. Closes #6896 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 152 ++++++ cli/azd/pkg/extensions/validate_registry.go | 270 ++++++++++ .../pkg/extensions/validate_registry_test.go | 480 ++++++++++++++++++ 3 files changed, 902 insertions(+) create mode 100644 cli/azd/pkg/extensions/validate_registry.go create mode 100644 cli/azd/pkg/extensions/validate_registry_test.go diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 1382f07a8f0..4cd6f2aa2af 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -8,9 +8,12 @@ import ( "errors" "fmt" "io" + "net/http" + "os" "slices" "strings" "text/tabwriter" + "time" "github.com/Masterminds/semver/v3" "github.com/azure/azure-dev/cli/azd/cmd/actions" @@ -90,6 +93,21 @@ func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor FlagsResolver: newExtensionUpgradeFlags, }) + // azd extension validate-registry + group.Add("validate-registry", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "validate-registry ", + Short: "Validate an extension registry.json file.", + Long: "Validate an extension registry.json file from a local path or URL.\n\n" + + "Checks required fields, valid capabilities, semver version format,\n" + + "platform artifact structure, and extension ID format.", + }, + OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, + DefaultFormat: output.NoneFormat, + ActionResolver: newExtensionValidateRegistryAction, + FlagsResolver: newExtensionValidateRegistryFlags, + }) + sourceGroup := group.Add("source", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ Use: "source", @@ -1473,3 +1491,137 @@ func validateVersionCompatibility( } return nil } + +// azd extension validate-registry +type extensionValidateRegistryFlags struct { + strict bool +} + +func newExtensionValidateRegistryFlags(cmd *cobra.Command) *extensionValidateRegistryFlags { + flags := &extensionValidateRegistryFlags{} + cmd.Flags().BoolVar(&flags.strict, "strict", false, "Enable strict validation (treat warnings as errors)") + return flags +} + +type extensionValidateRegistryAction struct { + args []string + flags *extensionValidateRegistryFlags + console input.Console + formatter output.Formatter + writer io.Writer +} + +func newExtensionValidateRegistryAction( + args []string, + flags *extensionValidateRegistryFlags, + console input.Console, + formatter output.Formatter, + writer io.Writer, +) actions.Action { + return &extensionValidateRegistryAction{ + args: args, + flags: flags, + console: console, + formatter: formatter, + writer: writer, + } +} + +func (a *extensionValidateRegistryAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if len(a.args) == 0 { + return nil, fmt.Errorf("must specify a path or URL to a registry.json file") + } + if len(a.args) > 1 { + return nil, fmt.Errorf("cannot specify multiple registry files") + } + + source := a.args[0] + data, err := readRegistrySource(ctx, source) + if err != nil { + return nil, fmt.Errorf("failed to read registry: %w", err) + } + + result, err := extensions.ValidateRegistryJSON(data, a.flags.strict) + if err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + if a.formatter.Kind() == output.JsonFormat { + if err := a.formatter.Format(result, a.writer, nil); err != nil { + return nil, err + } + } else { + displayValidationResult(a.console, ctx, result) + } + + if !result.Valid { + return nil, fmt.Errorf("validation failed: one or more extensions have errors") + } + + return nil, nil +} + +// readRegistrySource reads registry.json content from a local file path or URL. +func readRegistrySource(ctx context.Context, source string) ([]byte, error) { + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + return fetchRegistryURL(ctx, source) + } + return os.ReadFile(source) +} + +// fetchRegistryURL fetches registry.json content from a URL. +func fetchRegistryURL(ctx context.Context, url string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + return io.ReadAll(resp.Body) +} + +func displayValidationResult(console input.Console, ctx context.Context, result *extensions.RegistryValidationResult) { + for _, ext := range result.Extensions { + id := ext.Id + if id == "" { + id = "(unknown)" + } + + if ext.Valid { + console.Message(ctx, fmt.Sprintf(" %s %s", output.WithSuccessFormat("✓"), id)) + } else { + console.Message(ctx, fmt.Sprintf(" %s %s", output.WithErrorFormat("✗"), id)) + } + + if ext.LatestVersion != "" { + console.Message(ctx, fmt.Sprintf(" Version: %s", ext.LatestVersion)) + } + + for _, issue := range ext.Issues { + if issue.Severity == extensions.ValidationError { + console.Message(ctx, fmt.Sprintf(" %s %s", + output.WithErrorFormat("ERROR:"), issue.Message)) + } else { + console.Message(ctx, fmt.Sprintf(" %s %s", + output.WithWarningFormat("WARNING:"), issue.Message)) + } + } + } + + console.Message(ctx, "") + if result.Valid { + console.Message(ctx, output.WithSuccessFormat("Registry validation passed.")) + } else { + console.Message(ctx, output.WithErrorFormat("Registry validation failed.")) + } +} diff --git a/cli/azd/pkg/extensions/validate_registry.go b/cli/azd/pkg/extensions/validate_registry.go new file mode 100644 index 00000000000..3dbf656c261 --- /dev/null +++ b/cli/azd/pkg/extensions/validate_registry.go @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// ValidPlatforms defines the valid os/arch combinations for extension artifacts. +var ValidPlatforms = []string{ + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64", +} + +// ValidCapabilities defines the valid capability types for extensions. +var ValidCapabilities = []CapabilityType{ + CustomCommandCapability, + LifecycleEventsCapability, + McpServerCapability, + ServiceTargetProviderCapability, + FrameworkServiceProviderCapability, + MetadataCapability, +} + +// semverRegex validates strict semver format: MAJOR.MINOR.PATCH with optional pre-release suffix. +var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$`) + +// extensionIdRegex validates extension ID format: dot-separated segments, each alphanumeric with hyphens. +var extensionIdRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$`) + +// ValidationSeverity represents the severity of a validation error. +type ValidationSeverity string + +const ( + // ValidationError is a validation error that prevents the registry from being valid. + ValidationError ValidationSeverity = "error" + // ValidationWarning is a validation warning that does not prevent the registry from being valid. + ValidationWarning ValidationSeverity = "warning" +) + +// ValidationIssue represents a single validation finding. +type ValidationIssue struct { + // Severity is the severity of the validation issue (error or warning). + Severity ValidationSeverity `json:"severity"` + // Message describes the validation issue. + Message string `json:"message"` +} + +// ExtensionValidationResult represents the validation result for a single extension. +type ExtensionValidationResult struct { + // Id is the extension ID (may be empty if missing). + Id string `json:"id"` + // DisplayName is the extension display name. + DisplayName string `json:"displayName"` + // LatestVersion is the latest version string found. + LatestVersion string `json:"latestVersion"` + // Capabilities lists the capabilities of the latest version. + Capabilities []CapabilityType `json:"capabilities"` + // Platforms lists the platforms of the latest version. + Platforms []string `json:"platforms"` + // Issues contains all validation issues found. + Issues []ValidationIssue `json:"issues"` + // Valid is true if no errors were found. + Valid bool `json:"valid"` +} + +// RegistryValidationResult represents the validation result for an entire registry file. +type RegistryValidationResult struct { + // Extensions contains validation results for each extension. + Extensions []ExtensionValidationResult `json:"extensions"` + // Valid is true if all extensions are valid. + Valid bool `json:"valid"` +} + +// ValidateRegistryJSON validates the raw JSON bytes of a registry.json file. +func ValidateRegistryJSON(data []byte, strict bool) (*RegistryValidationResult, error) { + var registry Registry + + // Determine the JSON structure and parse accordingly + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + + trimmed := strings.TrimSpace(string(raw)) + if len(trimmed) > 0 && trimmed[0] == '[' { + // Array of extensions + var extensions []*ExtensionMetadata + if err := json.Unmarshal(data, &extensions); err != nil { + return nil, fmt.Errorf("invalid registry format: failed to parse as extension array: %w", err) + } + registry.Extensions = extensions + } else { + // Try as Registry object (with "extensions" wrapper) + if err := json.Unmarshal(data, ®istry); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + + // If no "extensions" field, try as a single extension + if registry.Extensions == nil { + var single ExtensionMetadata + if err := json.Unmarshal(data, &single); err != nil { + return nil, fmt.Errorf("invalid registry format: expected object with 'extensions' array, "+ + "an array of extensions, or a single extension object: %w", err) + } + registry.Extensions = []*ExtensionMetadata{&single} + } + } + + if len(registry.Extensions) == 0 { + return nil, fmt.Errorf("registry contains no extensions") + } + + result := &RegistryValidationResult{ + Valid: true, + } + + for _, ext := range registry.Extensions { + extResult := validateExtension(ext, strict) + result.Extensions = append(result.Extensions, extResult) + if !extResult.Valid { + result.Valid = false + } + } + + return result, nil +} + +// validateExtension validates a single extension metadata entry. +func validateExtension(ext *ExtensionMetadata, strict bool) ExtensionValidationResult { + result := ExtensionValidationResult{ + Id: ext.Id, + DisplayName: ext.DisplayName, + Valid: true, + } + + // Required fields + if ext.Id == "" { + result.addError("missing or empty required field 'id'") + } else if !extensionIdRegex.MatchString(ext.Id) { + result.addError(fmt.Sprintf("invalid extension ID format '%s': must be dot-separated segments "+ + "(e.g. 'publisher.extension' or 'publisher.category.extension')", ext.Id)) + } + + if ext.DisplayName == "" { + result.addError("missing or empty required field 'displayName'") + } + + if ext.Description == "" { + result.addError("missing or empty required field 'description'") + } + + if len(ext.Versions) == 0 { + result.addError("missing or empty required field 'versions'") + return result + } + + // Validate each version + for i, ver := range ext.Versions { + validateVersion(&result, i, &ver, strict) + } + + // Set latest version info from the last version entry + latestIdx := len(ext.Versions) - 1 + latestVer := ext.Versions[latestIdx] + result.LatestVersion = latestVer.Version + result.Capabilities = latestVer.Capabilities + if latestVer.Artifacts != nil { + for platform := range latestVer.Artifacts { + result.Platforms = append(result.Platforms, platform) + } + } + + return result +} + +// validateVersion validates a single version entry within an extension. +func validateVersion(result *ExtensionValidationResult, index int, ver *ExtensionVersion, strict bool) { + prefix := fmt.Sprintf("versions[%d]", index) + + // Validate semver format + if ver.Version == "" { + result.addError(fmt.Sprintf("%s: missing required field 'version'", prefix)) + } else if !semverRegex.MatchString(ver.Version) { + result.addError(fmt.Sprintf("%s: invalid semver format '%s' "+ + "(expected MAJOR.MINOR.PATCH with optional pre-release suffix)", prefix, ver.Version)) + } + + // Validate capabilities + for _, cap := range ver.Capabilities { + if !isValidCapability(cap) { + result.addError(fmt.Sprintf("%s: unknown capability '%s' (valid: %s)", + prefix, cap, strings.Join(capabilityStrings(), ", "))) + } + } + + // Validate artifacts + if ver.Artifacts != nil { + for platform, artifact := range ver.Artifacts { + artifactPrefix := fmt.Sprintf("%s.artifacts[%s]", prefix, platform) + + if !isValidPlatform(platform) { + result.addError(fmt.Sprintf("%s: unknown platform '%s' (valid: %s)", + artifactPrefix, platform, strings.Join(ValidPlatforms, ", "))) + } + + if artifact.URL == "" { + result.addError(fmt.Sprintf("%s: missing required field 'url'", artifactPrefix)) + } + + if artifact.Checksum.Value == "" { + if strict { + result.addError(fmt.Sprintf("%s: missing required checksum", artifactPrefix)) + } else { + result.addWarning(fmt.Sprintf("%s: missing checksum (recommended for integrity verification)", + artifactPrefix)) + } + } + } + } +} + +func (r *ExtensionValidationResult) addError(msg string) { + r.Issues = append(r.Issues, ValidationIssue{ + Severity: ValidationError, + Message: msg, + }) + r.Valid = false +} + +func (r *ExtensionValidationResult) addWarning(msg string) { + r.Issues = append(r.Issues, ValidationIssue{ + Severity: ValidationWarning, + Message: msg, + }) +} + +func isValidCapability(cap CapabilityType) bool { + for _, valid := range ValidCapabilities { + if cap == valid { + return true + } + } + return false +} + +func isValidPlatform(platform string) bool { + for _, valid := range ValidPlatforms { + if platform == valid { + return true + } + } + return false +} + +func capabilityStrings() []string { + result := make([]string, len(ValidCapabilities)) + for i, cap := range ValidCapabilities { + result[i] = string(cap) + } + return result +} diff --git a/cli/azd/pkg/extensions/validate_registry_test.go b/cli/azd/pkg/extensions/validate_registry_test.go new file mode 100644 index 00000000000..aac0dd93cea --- /dev/null +++ b/cli/azd/pkg/extensions/validate_registry_test.go @@ -0,0 +1,480 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateRegistryJSON_ValidRegistry(t *testing.T) { + registry := Registry{ + Extensions: []*ExtensionMetadata{ + { + Id: "publisher.extension", + DisplayName: "Test Extension", + Description: "A test extension", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Capabilities: []CapabilityType{CustomCommandCapability}, + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext-linux-amd64", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc123"}, + }, + "darwin/arm64": { + URL: "https://example.com/ext-darwin-arm64", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "def456"}, + }, + }, + }, + }, + }, + }, + } + + data, err := json.Marshal(registry) + require.NoError(t, err) + + result, err := ValidateRegistryJSON(data, false) + require.NoError(t, err) + require.True(t, result.Valid) + require.Len(t, result.Extensions, 1) + require.True(t, result.Extensions[0].Valid) + require.Empty(t, result.Extensions[0].Issues) +} + +func TestValidateRegistryJSON_MissingRequiredFields(t *testing.T) { + tests := []struct { + name string + ext ExtensionMetadata + expected []string + }{ + { + name: "missing id", + ext: ExtensionMetadata{DisplayName: "Test", Description: "Test", Versions: []ExtensionVersion{{Version: "1.0.0"}}}, + expected: []string{"missing or empty required field 'id'"}, + }, + { + name: "missing displayName", + ext: ExtensionMetadata{Id: "pub.ext", Description: "Test", Versions: []ExtensionVersion{{Version: "1.0.0"}}}, + expected: []string{"missing or empty required field 'displayName'"}, + }, + { + name: "missing description", + ext: ExtensionMetadata{Id: "pub.ext", DisplayName: "Test", Versions: []ExtensionVersion{{Version: "1.0.0"}}}, + expected: []string{"missing or empty required field 'description'"}, + }, + { + name: "missing versions", + ext: ExtensionMetadata{Id: "pub.ext", DisplayName: "Test", Description: "Test"}, + expected: []string{"missing or empty required field 'versions'"}, + }, + { + name: "empty versions", + ext: ExtensionMetadata{Id: "pub.ext", DisplayName: "Test", Description: "Test", Versions: []ExtensionVersion{}}, + expected: []string{"missing or empty required field 'versions'"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := Registry{Extensions: []*ExtensionMetadata{&tt.ext}} + data, err := json.Marshal(registry) + require.NoError(t, err) + + result, err := ValidateRegistryJSON(data, false) + require.NoError(t, err) + require.False(t, result.Valid) + require.False(t, result.Extensions[0].Valid) + + for _, expectedMsg := range tt.expected { + found := false + for _, issue := range result.Extensions[0].Issues { + if issue.Message == expectedMsg && issue.Severity == ValidationError { + found = true + break + } + } + require.True(t, found, "expected error message not found: %s", expectedMsg) + } + }) + } +} + +func TestValidateRegistryJSON_InvalidExtensionId(t *testing.T) { + tests := []struct { + name string + id string + valid bool + }{ + {"valid two segments", "publisher.extension", true}, + {"valid three segments", "publisher.category.extension", true}, + {"valid with hyphens", "my-publisher.my-extension", true}, + {"invalid single segment", "extension", false}, + {"invalid starts with dot", ".extension", false}, + {"invalid ends with dot", "extension.", false}, + {"invalid double dots", "publisher..extension", false}, + {"invalid special chars", "publisher.ext@nsion", false}, + {"invalid spaces", "publisher.ext ension", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &ExtensionMetadata{ + Id: tt.id, + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{{Version: "1.0.0"}}, + } + + result := validateExtension(ext, false) + if tt.valid { + hasIdError := false + for _, issue := range result.Issues { + if issue.Severity == ValidationError && + (issue.Message == "missing or empty required field 'id'" || + len(issue.Message) > 20 && issue.Message[:20] == "invalid extension ID") { + hasIdError = true + } + } + require.False(t, hasIdError, "unexpected ID validation error for '%s'", tt.id) + } else { + require.False(t, result.Valid, "expected validation to fail for ID '%s'", tt.id) + } + }) + } +} + +func TestValidateRegistryJSON_InvalidSemver(t *testing.T) { + tests := []struct { + name string + version string + valid bool + }{ + {"valid basic", "1.0.0", true}, + {"valid with prerelease", "1.0.0-beta.1", true}, + {"valid with alpha", "2.3.4-alpha", true}, + {"invalid missing patch", "1.0", false}, + {"invalid with v prefix", "v1.0.0", false}, + {"invalid with build metadata", "1.0.0+build", false}, + {"invalid text", "latest", false}, + {"empty version", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{{Version: tt.version}}, + } + + result := validateExtension(ext, false) + hasVersionError := false + for _, issue := range result.Issues { + if issue.Severity == ValidationError && + (contains(issue.Message, "semver") || contains(issue.Message, "'version'")) { + hasVersionError = true + } + } + + if tt.valid { + require.False(t, hasVersionError, "unexpected version error for '%s'", tt.version) + } else { + require.True(t, hasVersionError, "expected version error for '%s'", tt.version) + } + }) + } +} + +func TestValidateRegistryJSON_InvalidCapabilities(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Capabilities: []CapabilityType{"custom-commands", "invalid-capability"}, + }, + }, + } + + result := validateExtension(ext, false) + require.False(t, result.Valid) + + found := false + for _, issue := range result.Issues { + if issue.Severity == ValidationError && contains(issue.Message, "unknown capability 'invalid-capability'") { + found = true + } + } + require.True(t, found, "expected unknown capability error") +} + +func TestValidateRegistryJSON_InvalidPlatforms(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc"}, + }, + "freebsd/amd64": { + URL: "https://example.com/ext", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc"}, + }, + }, + }, + }, + } + + result := validateExtension(ext, false) + require.False(t, result.Valid) + + found := false + for _, issue := range result.Issues { + if issue.Severity == ValidationError && contains(issue.Message, "unknown platform 'freebsd/amd64'") { + found = true + } + } + require.True(t, found, "expected unknown platform error") +} + +func TestValidateRegistryJSON_MissingArtifactURL(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc"}, + }, + }, + }, + }, + } + + result := validateExtension(ext, false) + require.False(t, result.Valid) + + found := false + for _, issue := range result.Issues { + if issue.Severity == ValidationError && contains(issue.Message, "missing required field 'url'") { + found = true + } + } + require.True(t, found, "expected missing URL error") +} + +func TestValidateRegistryJSON_MissingChecksum_NonStrict(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext", + }, + }, + }, + }, + } + + result := validateExtension(ext, false) + require.True(t, result.Valid, "missing checksum should be a warning in non-strict mode") + + found := false + for _, issue := range result.Issues { + if issue.Severity == ValidationWarning && contains(issue.Message, "missing checksum") { + found = true + } + } + require.True(t, found, "expected missing checksum warning") +} + +func TestValidateRegistryJSON_MissingChecksum_Strict(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext", + }, + }, + }, + }, + } + + result := validateExtension(ext, true) + require.False(t, result.Valid, "missing checksum should be an error in strict mode") + + found := false + for _, issue := range result.Issues { + if issue.Severity == ValidationError && contains(issue.Message, "missing required checksum") { + found = true + } + } + require.True(t, found, "expected missing checksum error in strict mode") +} + +func TestValidateRegistryJSON_SingleExtensionFormat(t *testing.T) { + // Test that a single extension (not wrapped in registry) can be validated + ext := ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{{Version: "1.0.0"}}, + } + + data, err := json.Marshal(ext) + require.NoError(t, err) + + result, err := ValidateRegistryJSON(data, false) + require.NoError(t, err) + require.True(t, result.Valid) + require.Len(t, result.Extensions, 1) +} + +func TestValidateRegistryJSON_ArrayFormat(t *testing.T) { + // Test that an array of extensions (not wrapped in registry) can be validated + exts := []*ExtensionMetadata{ + { + Id: "pub.ext1", + DisplayName: "Test 1", + Description: "Test 1", + Versions: []ExtensionVersion{{Version: "1.0.0"}}, + }, + { + Id: "pub.ext2", + DisplayName: "Test 2", + Description: "Test 2", + Versions: []ExtensionVersion{{Version: "2.0.0"}}, + }, + } + + data, err := json.Marshal(exts) + require.NoError(t, err) + + result, err := ValidateRegistryJSON(data, false) + require.NoError(t, err) + require.True(t, result.Valid) + require.Len(t, result.Extensions, 2) +} + +func TestValidateRegistryJSON_InvalidJSON(t *testing.T) { + _, err := ValidateRegistryJSON([]byte("not json"), false) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid JSON") +} + +func TestValidateRegistryJSON_MultipleVersions(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + {Version: "1.0.0", Capabilities: []CapabilityType{CustomCommandCapability}}, + {Version: "1.1.0", Capabilities: []CapabilityType{CustomCommandCapability, McpServerCapability}}, + {Version: "2.0.0-beta.1", Capabilities: []CapabilityType{CustomCommandCapability}}, + }, + } + + result := validateExtension(ext, false) + require.True(t, result.Valid) + require.Equal(t, "2.0.0-beta.1", result.LatestVersion) +} + +func TestValidateRegistryJSON_AllValidCapabilities(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Capabilities: []CapabilityType{ + CustomCommandCapability, + LifecycleEventsCapability, + McpServerCapability, + ServiceTargetProviderCapability, + FrameworkServiceProviderCapability, + MetadataCapability, + }, + }, + }, + } + + result := validateExtension(ext, false) + require.True(t, result.Valid) + require.Empty(t, errorsOnly(result.Issues)) +} + +func TestValidateRegistryJSON_AllValidPlatforms(t *testing.T) { + artifacts := map[string]ExtensionArtifact{} + for _, p := range ValidPlatforms { + artifacts[p] = ExtensionArtifact{ + URL: "https://example.com/" + p, + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc"}, + } + } + + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + {Version: "1.0.0", Artifacts: artifacts}, + }, + } + + result := validateExtension(ext, false) + require.True(t, result.Valid) + require.Empty(t, errorsOnly(result.Issues)) +} + +// Helper functions + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func errorsOnly(issues []ValidationIssue) []ValidationIssue { + var result []ValidationIssue + for _, issue := range issues { + if issue.Severity == ValidationError { + result = append(result, issue) + } + } + return result +} From fd3c3bf050a5f2eaec1257084a1e8bfd36ed9743 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:00:32 -0800 Subject: [PATCH 2/6] fix: address PR review feedback for extension source validate - Move command from 'azd extension validate-registry' to 'azd extension source validate' - Add deprecated hidden alias for backward compatibility - Use SourceManager to resolve sources (leverages existing registry reading code) - Use semver package instead of regex for version validation - Use semver-based latest version detection instead of last-element - Restrict extension ID regex to lowercase only - Add checksum algorithm validation (sha256, sha512) - Require artifacts or dependencies for each version - Guard against nil extension entries - Update --strict flag help text to 'require checksums' - Remove custom HTTP/file reading code in favor of SourceManager - Update and expand test coverage (22 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 140 +++++++------ cli/azd/pkg/extensions/validate_registry.go | 122 ++++++++--- .../pkg/extensions/validate_registry_test.go | 192 ++++++++++++++---- 3 files changed, 323 insertions(+), 131 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 4cd6f2aa2af..4bbe59b8fff 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -8,12 +8,9 @@ import ( "errors" "fmt" "io" - "net/http" - "os" "slices" "strings" "text/tabwriter" - "time" "github.com/Masterminds/semver/v3" "github.com/azure/azure-dev/cli/azd/cmd/actions" @@ -96,16 +93,15 @@ func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor // azd extension validate-registry group.Add("validate-registry", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ - Use: "validate-registry ", - Short: "Validate an extension registry.json file.", - Long: "Validate an extension registry.json file from a local path or URL.\n\n" + - "Checks required fields, valid capabilities, semver version format,\n" + - "platform artifact structure, and extension ID format.", + Use: "validate-registry ", + Short: "Validate an extension registry.json file.", + Deprecated: "Use 'azd extension source validate' instead.", + Hidden: true, }, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, - ActionResolver: newExtensionValidateRegistryAction, - FlagsResolver: newExtensionValidateRegistryFlags, + ActionResolver: newExtensionSourceValidateAction, + FlagsResolver: newExtensionSourceValidateFlags, }) sourceGroup := group.Add("source", &actions.ActionDescriptorOptions{ @@ -146,6 +142,22 @@ func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor DefaultFormat: output.NoneFormat, }) + // azd extension source validate + sourceGroup.Add("validate", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "validate ", + Short: "Validate an extension source's registry.json file.", + Long: "Validate an extension source's registry.json file.\n\n" + + "Accepts a source name (from 'azd extension source list'), a local file path,\n" + + "or a URL. Checks required fields, valid capabilities, semver version format,\n" + + "platform artifact structure, and extension ID format.", + }, + OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, + DefaultFormat: output.NoneFormat, + ActionResolver: newExtensionSourceValidateAction, + FlagsResolver: newExtensionSourceValidateFlags, + }) + return group } @@ -1492,60 +1504,85 @@ func validateVersionCompatibility( return nil } -// azd extension validate-registry -type extensionValidateRegistryFlags struct { +// azd extension source validate +type extensionSourceValidateFlags struct { strict bool } -func newExtensionValidateRegistryFlags(cmd *cobra.Command) *extensionValidateRegistryFlags { - flags := &extensionValidateRegistryFlags{} - cmd.Flags().BoolVar(&flags.strict, "strict", false, "Enable strict validation (treat warnings as errors)") +func newExtensionSourceValidateFlags(cmd *cobra.Command) *extensionSourceValidateFlags { + flags := &extensionSourceValidateFlags{} + cmd.Flags().BoolVar(&flags.strict, "strict", false, "Enable strict validation (require checksums)") return flags } -type extensionValidateRegistryAction struct { - args []string - flags *extensionValidateRegistryFlags - console input.Console - formatter output.Formatter - writer io.Writer +type extensionSourceValidateAction struct { + args []string + flags *extensionSourceValidateFlags + console input.Console + formatter output.Formatter + writer io.Writer + sourceManager *extensions.SourceManager } -func newExtensionValidateRegistryAction( +func newExtensionSourceValidateAction( args []string, - flags *extensionValidateRegistryFlags, + flags *extensionSourceValidateFlags, console input.Console, formatter output.Formatter, writer io.Writer, + sourceManager *extensions.SourceManager, ) actions.Action { - return &extensionValidateRegistryAction{ - args: args, - flags: flags, - console: console, - formatter: formatter, - writer: writer, + return &extensionSourceValidateAction{ + args: args, + flags: flags, + console: console, + formatter: formatter, + writer: writer, + sourceManager: sourceManager, } } -func (a *extensionValidateRegistryAction) Run(ctx context.Context) (*actions.ActionResult, error) { +func (a *extensionSourceValidateAction) Run(ctx context.Context) (*actions.ActionResult, error) { if len(a.args) == 0 { - return nil, fmt.Errorf("must specify a path or URL to a registry.json file") + return nil, fmt.Errorf("must specify a source name, file path, or URL") } if len(a.args) > 1 { - return nil, fmt.Errorf("cannot specify multiple registry files") + return nil, fmt.Errorf("cannot specify multiple sources") + } + + arg := a.args[0] + + // Resolve the source: try as named source first, then as direct path/URL + sourceConfig, err := a.sourceManager.Get(ctx, arg) + if err != nil { + // Not a named source — auto-detect type from the argument + kind := extensions.SourceKindFile + if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") { + kind = extensions.SourceKindUrl + } + sourceConfig = &extensions.SourceConfig{ + Name: "validate", + Type: kind, + Location: arg, + } } - source := a.args[0] - data, err := readRegistrySource(ctx, source) + source, err := a.sourceManager.CreateSource(ctx, sourceConfig) if err != nil { - return nil, fmt.Errorf("failed to read registry: %w", err) + return nil, fmt.Errorf("failed to load source: %w", err) } - result, err := extensions.ValidateRegistryJSON(data, a.flags.strict) + extensionList, err := source.ListExtensions(ctx) if err != nil { - return nil, fmt.Errorf("validation failed: %w", err) + return nil, fmt.Errorf("failed to list extensions: %w", err) } + if len(extensionList) == 0 { + return nil, fmt.Errorf("source contains no extensions") + } + + result := extensions.ValidateExtensions(extensionList, a.flags.strict) + if a.formatter.Kind() == output.JsonFormat { if err := a.formatter.Format(result, a.writer, nil); err != nil { return nil, err @@ -1561,35 +1598,6 @@ func (a *extensionValidateRegistryAction) Run(ctx context.Context) (*actions.Act return nil, nil } -// readRegistrySource reads registry.json content from a local file path or URL. -func readRegistrySource(ctx context.Context, source string) ([]byte, error) { - if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { - return fetchRegistryURL(ctx, source) - } - return os.ReadFile(source) -} - -// fetchRegistryURL fetches registry.json content from a URL. -func fetchRegistryURL(ctx context.Context, url string) ([]byte, error) { - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) - } - - return io.ReadAll(resp.Body) -} - func displayValidationResult(console input.Console, ctx context.Context, result *extensions.RegistryValidationResult) { for _, ext := range result.Extensions { id := ext.Id diff --git a/cli/azd/pkg/extensions/validate_registry.go b/cli/azd/pkg/extensions/validate_registry.go index 3dbf656c261..bdba9727fd8 100644 --- a/cli/azd/pkg/extensions/validate_registry.go +++ b/cli/azd/pkg/extensions/validate_registry.go @@ -8,6 +8,8 @@ import ( "fmt" "regexp" "strings" + + "github.com/Masterminds/semver/v3" ) // ValidPlatforms defines the valid os/arch combinations for extension artifacts. @@ -30,11 +32,11 @@ var ValidCapabilities = []CapabilityType{ MetadataCapability, } -// semverRegex validates strict semver format: MAJOR.MINOR.PATCH with optional pre-release suffix. -var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$`) +// validChecksumAlgorithms defines the supported checksum algorithms. +var validChecksumAlgorithms = []string{"sha256", "sha512"} -// extensionIdRegex validates extension ID format: dot-separated segments, each alphanumeric with hyphens. -var extensionIdRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$`) +// extensionIdRegex validates extension ID format: dot-separated lowercase segments with hyphens. +var extensionIdRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) // ValidationSeverity represents the severity of a validation error. type ValidationSeverity string @@ -80,6 +82,31 @@ type RegistryValidationResult struct { Valid bool `json:"valid"` } +// ValidateExtensions validates a slice of parsed extension metadata. +func ValidateExtensions(exts []*ExtensionMetadata, strict bool) *RegistryValidationResult { + result := &RegistryValidationResult{ + Valid: true, + } + + for _, ext := range exts { + var extResult ExtensionValidationResult + if ext == nil { + extResult = ExtensionValidationResult{ + Issues: []ValidationIssue{{Severity: ValidationError, Message: "null extension entry"}}, + Valid: false, + } + } else { + extResult = validateExtension(ext, strict) + } + result.Extensions = append(result.Extensions, extResult) + if !extResult.Valid { + result.Valid = false + } + } + + return result +} + // ValidateRegistryJSON validates the raw JSON bytes of a registry.json file. func ValidateRegistryJSON(data []byte, strict bool) (*RegistryValidationResult, error) { var registry Registry @@ -119,19 +146,7 @@ func ValidateRegistryJSON(data []byte, strict bool) (*RegistryValidationResult, return nil, fmt.Errorf("registry contains no extensions") } - result := &RegistryValidationResult{ - Valid: true, - } - - for _, ext := range registry.Extensions { - extResult := validateExtension(ext, strict) - result.Extensions = append(result.Extensions, extResult) - if !extResult.Valid { - result.Valid = false - } - } - - return result, nil + return ValidateExtensions(registry.Extensions, strict), nil } // validateExtension validates a single extension metadata entry. @@ -146,7 +161,7 @@ func validateExtension(ext *ExtensionMetadata, strict bool) ExtensionValidationR if ext.Id == "" { result.addError("missing or empty required field 'id'") } else if !extensionIdRegex.MatchString(ext.Id) { - result.addError(fmt.Sprintf("invalid extension ID format '%s': must be dot-separated segments "+ + result.addError(fmt.Sprintf("invalid extension ID format '%s': must be dot-separated lowercase segments "+ "(e.g. 'publisher.extension' or 'publisher.category.extension')", ext.Id)) } @@ -168,28 +183,54 @@ func validateExtension(ext *ExtensionMetadata, strict bool) ExtensionValidationR validateVersion(&result, i, &ver, strict) } - // Set latest version info from the last version entry - latestIdx := len(ext.Versions) - 1 - latestVer := ext.Versions[latestIdx] - result.LatestVersion = latestVer.Version - result.Capabilities = latestVer.Capabilities - if latestVer.Artifacts != nil { - for platform := range latestVer.Artifacts { - result.Platforms = append(result.Platforms, platform) + // Find latest version using semver ordering + latestVer := findLatestVersion(ext.Versions) + if latestVer != nil { + result.LatestVersion = latestVer.Version + result.Capabilities = latestVer.Capabilities + if latestVer.Artifacts != nil { + for platform := range latestVer.Artifacts { + result.Platforms = append(result.Platforms, platform) + } } } return result } +// findLatestVersion finds the latest version using semver ordering, preferring stable over pre-release. +func findLatestVersion(versions []ExtensionVersion) *ExtensionVersion { + var latest *ExtensionVersion + var latestSemver *semver.Version + + for i := range versions { + v, err := semver.NewVersion(versions[i].Version) + if err != nil { + continue + } + + if latestSemver == nil || v.GreaterThan(latestSemver) { + latest = &versions[i] + latestSemver = v + } + } + + // If no valid semver found, fall back to last element + if latest == nil && len(versions) > 0 { + latest = &versions[len(versions)-1] + } + + return latest +} + // validateVersion validates a single version entry within an extension. func validateVersion(result *ExtensionValidationResult, index int, ver *ExtensionVersion, strict bool) { prefix := fmt.Sprintf("versions[%d]", index) - // Validate semver format + // Validate semver format using the semver package if ver.Version == "" { result.addError(fmt.Sprintf("%s: missing required field 'version'", prefix)) - } else if !semverRegex.MatchString(ver.Version) { + } else if _, err := semver.StrictNewVersion(ver.Version); err != nil { result.addError(fmt.Sprintf("%s: invalid semver format '%s' "+ "(expected MAJOR.MINOR.PATCH with optional pre-release suffix)", prefix, ver.Version)) } @@ -202,8 +243,15 @@ func validateVersion(result *ExtensionValidationResult, index int, ver *Extensio } } + // Enforce that each version has at least one artifact or dependency + hasArtifacts := len(ver.Artifacts) > 0 + hasDependencies := len(ver.Dependencies) > 0 + if !hasArtifacts && !hasDependencies { + result.addError(fmt.Sprintf("%s: version must define at least one artifact or dependency", prefix)) + } + // Validate artifacts - if ver.Artifacts != nil { + if hasArtifacts { for platform, artifact := range ver.Artifacts { artifactPrefix := fmt.Sprintf("%s.artifacts[%s]", prefix, platform) @@ -223,6 +271,13 @@ func validateVersion(result *ExtensionValidationResult, index int, ver *Extensio result.addWarning(fmt.Sprintf("%s: missing checksum (recommended for integrity verification)", artifactPrefix)) } + } else if artifact.Checksum.Algorithm == "" { + result.addError(fmt.Sprintf("%s: checksum value present but missing algorithm "+ + "(supported: %s)", artifactPrefix, strings.Join(validChecksumAlgorithms, ", "))) + } else if !isValidChecksumAlgorithm(artifact.Checksum.Algorithm) { + result.addError(fmt.Sprintf("%s: unsupported checksum algorithm '%s' "+ + "(supported: %s)", artifactPrefix, artifact.Checksum.Algorithm, + strings.Join(validChecksumAlgorithms, ", "))) } } } @@ -261,6 +316,15 @@ func isValidPlatform(platform string) bool { return false } +func isValidChecksumAlgorithm(alg string) bool { + for _, valid := range validChecksumAlgorithms { + if alg == valid { + return true + } + } + return false +} + func capabilityStrings() []string { result := make([]string, len(ValidCapabilities)) for i, cap := range ValidCapabilities { diff --git a/cli/azd/pkg/extensions/validate_registry_test.go b/cli/azd/pkg/extensions/validate_registry_test.go index aac0dd93cea..d55692ae23d 100644 --- a/cli/azd/pkg/extensions/validate_registry_test.go +++ b/cli/azd/pkg/extensions/validate_registry_test.go @@ -10,6 +10,16 @@ import ( "github.com/stretchr/testify/require" ) +// validArtifacts creates a minimal valid artifact set for tests. +func validArtifacts() map[string]ExtensionArtifact { + return map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext-linux-amd64", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc123"}, + }, + } +} + func TestValidateRegistryJSON_ValidRegistry(t *testing.T) { registry := Registry{ Extensions: []*ExtensionMetadata{ @@ -55,18 +65,27 @@ func TestValidateRegistryJSON_MissingRequiredFields(t *testing.T) { expected []string }{ { - name: "missing id", - ext: ExtensionMetadata{DisplayName: "Test", Description: "Test", Versions: []ExtensionVersion{{Version: "1.0.0"}}}, + name: "missing id", + ext: ExtensionMetadata{ + DisplayName: "Test", Description: "Test", + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, + }, expected: []string{"missing or empty required field 'id'"}, }, { - name: "missing displayName", - ext: ExtensionMetadata{Id: "pub.ext", Description: "Test", Versions: []ExtensionVersion{{Version: "1.0.0"}}}, + name: "missing displayName", + ext: ExtensionMetadata{ + Id: "pub.ext", Description: "Test", + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, + }, expected: []string{"missing or empty required field 'displayName'"}, }, { - name: "missing description", - ext: ExtensionMetadata{Id: "pub.ext", DisplayName: "Test", Versions: []ExtensionVersion{{Version: "1.0.0"}}}, + name: "missing description", + ext: ExtensionMetadata{ + Id: "pub.ext", DisplayName: "Test", + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, + }, expected: []string{"missing or empty required field 'description'"}, }, { @@ -121,6 +140,7 @@ func TestValidateRegistryJSON_InvalidExtensionId(t *testing.T) { {"invalid double dots", "publisher..extension", false}, {"invalid special chars", "publisher.ext@nsion", false}, {"invalid spaces", "publisher.ext ension", false}, + {"invalid uppercase", "Publisher.Extension", false}, } for _, tt := range tests { @@ -129,7 +149,7 @@ func TestValidateRegistryJSON_InvalidExtensionId(t *testing.T) { Id: tt.id, DisplayName: "Test", Description: "Test", - Versions: []ExtensionVersion{{Version: "1.0.0"}}, + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, } result := validateExtension(ext, false) @@ -159,9 +179,9 @@ func TestValidateRegistryJSON_InvalidSemver(t *testing.T) { {"valid basic", "1.0.0", true}, {"valid with prerelease", "1.0.0-beta.1", true}, {"valid with alpha", "2.3.4-alpha", true}, + {"valid with build metadata", "1.0.0+build", true}, {"invalid missing patch", "1.0", false}, {"invalid with v prefix", "v1.0.0", false}, - {"invalid with build metadata", "1.0.0+build", false}, {"invalid text", "latest", false}, {"empty version", "", false}, } @@ -172,14 +192,14 @@ func TestValidateRegistryJSON_InvalidSemver(t *testing.T) { Id: "pub.ext", DisplayName: "Test", Description: "Test", - Versions: []ExtensionVersion{{Version: tt.version}}, + Versions: []ExtensionVersion{{Version: tt.version, Artifacts: validArtifacts()}}, } result := validateExtension(ext, false) hasVersionError := false for _, issue := range result.Issues { if issue.Severity == ValidationError && - (contains(issue.Message, "semver") || contains(issue.Message, "'version'")) { + (containsStr(issue.Message, "semver") || containsStr(issue.Message, "'version'")) { hasVersionError = true } } @@ -202,6 +222,7 @@ func TestValidateRegistryJSON_InvalidCapabilities(t *testing.T) { { Version: "1.0.0", Capabilities: []CapabilityType{"custom-commands", "invalid-capability"}, + Artifacts: validArtifacts(), }, }, } @@ -211,7 +232,7 @@ func TestValidateRegistryJSON_InvalidCapabilities(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && contains(issue.Message, "unknown capability 'invalid-capability'") { + if issue.Severity == ValidationError && containsStr(issue.Message, "unknown capability 'invalid-capability'") { found = true } } @@ -245,7 +266,7 @@ func TestValidateRegistryJSON_InvalidPlatforms(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && contains(issue.Message, "unknown platform 'freebsd/amd64'") { + if issue.Severity == ValidationError && containsStr(issue.Message, "unknown platform 'freebsd/amd64'") { found = true } } @@ -274,7 +295,7 @@ func TestValidateRegistryJSON_MissingArtifactURL(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && contains(issue.Message, "missing required field 'url'") { + if issue.Severity == ValidationError && containsStr(issue.Message, "missing required field 'url'") { found = true } } @@ -303,7 +324,7 @@ func TestValidateRegistryJSON_MissingChecksum_NonStrict(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationWarning && contains(issue.Message, "missing checksum") { + if issue.Severity == ValidationWarning && containsStr(issue.Message, "missing checksum") { found = true } } @@ -332,20 +353,128 @@ func TestValidateRegistryJSON_MissingChecksum_Strict(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && contains(issue.Message, "missing required checksum") { + if issue.Severity == ValidationError && containsStr(issue.Message, "missing required checksum") { found = true } } require.True(t, found, "expected missing checksum error in strict mode") } +func TestValidateRegistryJSON_ChecksumAlgorithmValidation(t *testing.T) { + t.Run("missing algorithm with value", func(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext", + Checksum: ExtensionChecksum{Value: "abc123"}, + }, + }, + }, + }, + } + + result := validateExtension(ext, false) + require.False(t, result.Valid) + found := false + for _, issue := range result.Issues { + if containsStr(issue.Message, "missing algorithm") { + found = true + } + } + require.True(t, found, "expected missing algorithm error") + }) + + t.Run("unsupported algorithm", func(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext", + Checksum: ExtensionChecksum{Algorithm: "md5", Value: "abc123"}, + }, + }, + }, + }, + } + + result := validateExtension(ext, false) + require.False(t, result.Valid) + found := false + for _, issue := range result.Issues { + if containsStr(issue.Message, "unsupported checksum algorithm") { + found = true + } + } + require.True(t, found, "expected unsupported algorithm error") + }) +} + +func TestValidateRegistryJSON_RequireArtifactsOrDependencies(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + {Version: "1.0.0"}, + }, + } + + result := validateExtension(ext, false) + require.False(t, result.Valid) + + found := false + for _, issue := range result.Issues { + if containsStr(issue.Message, "must define at least one artifact or dependency") { + found = true + } + } + require.True(t, found, "expected artifacts/dependencies requirement error") +} + +func TestValidateRegistryJSON_DependenciesOnly(t *testing.T) { + ext := &ExtensionMetadata{ + Id: "pub.ext", + DisplayName: "Test", + Description: "Test", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "other.ext", Version: ">=1.0.0"}}, + }, + }, + } + + result := validateExtension(ext, false) + for _, issue := range result.Issues { + require.False(t, containsStr(issue.Message, "must define at least one artifact or dependency"), + "extension with dependencies should not require artifacts") + } +} + +func TestValidateRegistryJSON_NilExtensionEntry(t *testing.T) { + result := ValidateExtensions([]*ExtensionMetadata{nil}, false) + require.False(t, result.Valid) + require.Len(t, result.Extensions, 1) + require.False(t, result.Extensions[0].Valid) +} + func TestValidateRegistryJSON_SingleExtensionFormat(t *testing.T) { - // Test that a single extension (not wrapped in registry) can be validated ext := ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", Description: "Test", - Versions: []ExtensionVersion{{Version: "1.0.0"}}, + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, } data, err := json.Marshal(ext) @@ -358,19 +487,14 @@ func TestValidateRegistryJSON_SingleExtensionFormat(t *testing.T) { } func TestValidateRegistryJSON_ArrayFormat(t *testing.T) { - // Test that an array of extensions (not wrapped in registry) can be validated exts := []*ExtensionMetadata{ { - Id: "pub.ext1", - DisplayName: "Test 1", - Description: "Test 1", - Versions: []ExtensionVersion{{Version: "1.0.0"}}, + Id: "pub.ext1", DisplayName: "Test 1", Description: "Test 1", + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, }, { - Id: "pub.ext2", - DisplayName: "Test 2", - Description: "Test 2", - Versions: []ExtensionVersion{{Version: "2.0.0"}}, + Id: "pub.ext2", DisplayName: "Test 2", Description: "Test 2", + Versions: []ExtensionVersion{{Version: "2.0.0", Artifacts: validArtifacts()}}, }, } @@ -389,21 +513,20 @@ func TestValidateRegistryJSON_InvalidJSON(t *testing.T) { require.Contains(t, err.Error(), "invalid JSON") } -func TestValidateRegistryJSON_MultipleVersions(t *testing.T) { +func TestValidateRegistryJSON_LatestVersionUseSemver(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", Description: "Test", Versions: []ExtensionVersion{ - {Version: "1.0.0", Capabilities: []CapabilityType{CustomCommandCapability}}, - {Version: "1.1.0", Capabilities: []CapabilityType{CustomCommandCapability, McpServerCapability}}, - {Version: "2.0.0-beta.1", Capabilities: []CapabilityType{CustomCommandCapability}}, + {Version: "2.0.0", Artifacts: validArtifacts()}, + {Version: "1.0.0", Artifacts: validArtifacts()}, + {Version: "1.5.0", Artifacts: validArtifacts()}, }, } result := validateExtension(ext, false) - require.True(t, result.Valid) - require.Equal(t, "2.0.0-beta.1", result.LatestVersion) + require.Equal(t, "2.0.0", result.LatestVersion, "should pick highest semver, not last element") } func TestValidateRegistryJSON_AllValidCapabilities(t *testing.T) { @@ -422,6 +545,7 @@ func TestValidateRegistryJSON_AllValidCapabilities(t *testing.T) { FrameworkServiceProviderCapability, MetadataCapability, }, + Artifacts: validArtifacts(), }, }, } @@ -456,10 +580,6 @@ func TestValidateRegistryJSON_AllValidPlatforms(t *testing.T) { // Helper functions -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) -} - func containsStr(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { From ecdf7adfef54e7557c5307222b05a09a79d021b3 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:09:52 -0800 Subject: [PATCH 3/6] fix: break long test line to satisfy linter (125 char max) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/extensions/validate_registry_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/extensions/validate_registry_test.go b/cli/azd/pkg/extensions/validate_registry_test.go index d55692ae23d..24d9789d601 100644 --- a/cli/azd/pkg/extensions/validate_registry_test.go +++ b/cli/azd/pkg/extensions/validate_registry_test.go @@ -94,8 +94,11 @@ func TestValidateRegistryJSON_MissingRequiredFields(t *testing.T) { expected: []string{"missing or empty required field 'versions'"}, }, { - name: "empty versions", - ext: ExtensionMetadata{Id: "pub.ext", DisplayName: "Test", Description: "Test", Versions: []ExtensionVersion{}}, + name: "empty versions", + ext: ExtensionMetadata{ + Id: "pub.ext", DisplayName: "Test", Description: "Test", + Versions: []ExtensionVersion{}, + }, expected: []string{"missing or empty required field 'versions'"}, }, } From 5eb143ab95d7f33d5455315409ffabae754efeb1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:53:13 -0800 Subject: [PATCH 4/6] fix: remove deprecated validate-registry command alias The command now lives exclusively at azd extension source validate . No backwards compatibility alias needed since this is a new command. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 4bbe59b8fff..e2907376893 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -90,20 +90,6 @@ func extensionActions(root *actions.ActionDescriptor) *actions.ActionDescriptor FlagsResolver: newExtensionUpgradeFlags, }) - // azd extension validate-registry - group.Add("validate-registry", &actions.ActionDescriptorOptions{ - Command: &cobra.Command{ - Use: "validate-registry ", - Short: "Validate an extension registry.json file.", - Deprecated: "Use 'azd extension source validate' instead.", - Hidden: true, - }, - OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, - DefaultFormat: output.NoneFormat, - ActionResolver: newExtensionSourceValidateAction, - FlagsResolver: newExtensionSourceValidateFlags, - }) - sourceGroup := group.Add("source", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ Use: "source", From 7eeb5f5e83260549848fefa56a9ffb54c217ed28 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:44:55 -0800 Subject: [PATCH 5/6] fix: improve validation command error handling, snapshots, and test cleanup - Add errors.Is(err, ErrSourceNotFound) guard in extension source validate - Regenerate CLI snapshots for validate subcommand - Remove unused ValidateRegistryJSON function - Use semver.StrictNewVersion consistently in findLatestVersion - Replace custom containsStr with strings.Contains in tests - Refactor tests to call ValidateExtensions directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 3 + ...stUsage-azd-extension-source-validate.snap | 19 +++ .../TestUsage-azd-extension-source.snap | 7 +- cli/azd/pkg/extensions/validate_registry.go | 47 +----- .../pkg/extensions/validate_registry_test.go | 156 ++++++------------ 5 files changed, 74 insertions(+), 158 deletions(-) create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-extension-source-validate.snap diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index e2907376893..31d76d94dbd 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -1540,6 +1540,9 @@ func (a *extensionSourceValidateAction) Run(ctx context.Context) (*actions.Actio // Resolve the source: try as named source first, then as direct path/URL sourceConfig, err := a.sourceManager.Get(ctx, arg) + if err != nil && !errors.Is(err, extensions.ErrSourceNotFound) { + return nil, fmt.Errorf("failed to get source %q: %w", arg, err) + } if err != nil { // Not a named source — auto-detect type from the argument kind := extensions.SourceKindFile diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-source-validate.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-validate.snap new file mode 100644 index 00000000000..5f71c440d23 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-source-validate.snap @@ -0,0 +1,19 @@ + +Validate an extension source's registry.json file. + +Usage + azd extension source validate [flags] + +Flags + --strict : Enable strict validation (require checksums) + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd extension source validate in your web browser. + -h, --help : Gets help for validate. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap index 004f1b55b7c..71f0d64973c 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-source.snap @@ -5,9 +5,10 @@ Usage azd extension source [command] Available Commands - add : Add an extension source with the specified name - list : List extension sources - remove : Remove an extension source with the specified name + add : Add an extension source with the specified name + list : List extension sources + remove : Remove an extension source with the specified name + validate : Validate an extension source's registry.json file. Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/pkg/extensions/validate_registry.go b/cli/azd/pkg/extensions/validate_registry.go index bdba9727fd8..b58910c50e5 100644 --- a/cli/azd/pkg/extensions/validate_registry.go +++ b/cli/azd/pkg/extensions/validate_registry.go @@ -4,7 +4,6 @@ package extensions import ( - "encoding/json" "fmt" "regexp" "strings" @@ -107,48 +106,6 @@ func ValidateExtensions(exts []*ExtensionMetadata, strict bool) *RegistryValidat return result } -// ValidateRegistryJSON validates the raw JSON bytes of a registry.json file. -func ValidateRegistryJSON(data []byte, strict bool) (*RegistryValidationResult, error) { - var registry Registry - - // Determine the JSON structure and parse accordingly - var raw json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - - trimmed := strings.TrimSpace(string(raw)) - if len(trimmed) > 0 && trimmed[0] == '[' { - // Array of extensions - var extensions []*ExtensionMetadata - if err := json.Unmarshal(data, &extensions); err != nil { - return nil, fmt.Errorf("invalid registry format: failed to parse as extension array: %w", err) - } - registry.Extensions = extensions - } else { - // Try as Registry object (with "extensions" wrapper) - if err := json.Unmarshal(data, ®istry); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - - // If no "extensions" field, try as a single extension - if registry.Extensions == nil { - var single ExtensionMetadata - if err := json.Unmarshal(data, &single); err != nil { - return nil, fmt.Errorf("invalid registry format: expected object with 'extensions' array, "+ - "an array of extensions, or a single extension object: %w", err) - } - registry.Extensions = []*ExtensionMetadata{&single} - } - } - - if len(registry.Extensions) == 0 { - return nil, fmt.Errorf("registry contains no extensions") - } - - return ValidateExtensions(registry.Extensions, strict), nil -} - // validateExtension validates a single extension metadata entry. func validateExtension(ext *ExtensionMetadata, strict bool) ExtensionValidationResult { result := ExtensionValidationResult{ @@ -198,13 +155,13 @@ func validateExtension(ext *ExtensionMetadata, strict bool) ExtensionValidationR return result } -// findLatestVersion finds the latest version using semver ordering, preferring stable over pre-release. +// findLatestVersion finds the latest version using strict semver ordering. func findLatestVersion(versions []ExtensionVersion) *ExtensionVersion { var latest *ExtensionVersion var latestSemver *semver.Version for i := range versions { - v, err := semver.NewVersion(versions[i].Version) + v, err := semver.StrictNewVersion(versions[i].Version) if err != nil { continue } diff --git a/cli/azd/pkg/extensions/validate_registry_test.go b/cli/azd/pkg/extensions/validate_registry_test.go index 24d9789d601..e407a17261d 100644 --- a/cli/azd/pkg/extensions/validate_registry_test.go +++ b/cli/azd/pkg/extensions/validate_registry_test.go @@ -4,7 +4,7 @@ package extensions import ( - "encoding/json" + "strings" "testing" "github.com/stretchr/testify/require" @@ -20,26 +20,24 @@ func validArtifacts() map[string]ExtensionArtifact { } } -func TestValidateRegistryJSON_ValidRegistry(t *testing.T) { - registry := Registry{ - Extensions: []*ExtensionMetadata{ - { - Id: "publisher.extension", - DisplayName: "Test Extension", - Description: "A test extension", - Versions: []ExtensionVersion{ - { - Version: "1.0.0", - Capabilities: []CapabilityType{CustomCommandCapability}, - Artifacts: map[string]ExtensionArtifact{ - "linux/amd64": { - URL: "https://example.com/ext-linux-amd64", - Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc123"}, - }, - "darwin/arm64": { - URL: "https://example.com/ext-darwin-arm64", - Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "def456"}, - }, +func TestValidateExtensions_ValidRegistry(t *testing.T) { + exts := []*ExtensionMetadata{ + { + Id: "publisher.extension", + DisplayName: "Test Extension", + Description: "A test extension", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Capabilities: []CapabilityType{CustomCommandCapability}, + Artifacts: map[string]ExtensionArtifact{ + "linux/amd64": { + URL: "https://example.com/ext-linux-amd64", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "abc123"}, + }, + "darwin/arm64": { + URL: "https://example.com/ext-darwin-arm64", + Checksum: ExtensionChecksum{Algorithm: "sha256", Value: "def456"}, }, }, }, @@ -47,18 +45,14 @@ func TestValidateRegistryJSON_ValidRegistry(t *testing.T) { }, } - data, err := json.Marshal(registry) - require.NoError(t, err) - - result, err := ValidateRegistryJSON(data, false) - require.NoError(t, err) + result := ValidateExtensions(exts, false) require.True(t, result.Valid) require.Len(t, result.Extensions, 1) require.True(t, result.Extensions[0].Valid) require.Empty(t, result.Extensions[0].Issues) } -func TestValidateRegistryJSON_MissingRequiredFields(t *testing.T) { +func TestValidateExtensions_MissingRequiredFields(t *testing.T) { tests := []struct { name string ext ExtensionMetadata @@ -105,12 +99,7 @@ func TestValidateRegistryJSON_MissingRequiredFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - registry := Registry{Extensions: []*ExtensionMetadata{&tt.ext}} - data, err := json.Marshal(registry) - require.NoError(t, err) - - result, err := ValidateRegistryJSON(data, false) - require.NoError(t, err) + result := ValidateExtensions([]*ExtensionMetadata{&tt.ext}, false) require.False(t, result.Valid) require.False(t, result.Extensions[0].Valid) @@ -128,7 +117,7 @@ func TestValidateRegistryJSON_MissingRequiredFields(t *testing.T) { } } -func TestValidateRegistryJSON_InvalidExtensionId(t *testing.T) { +func TestValidateExtension_InvalidExtensionId(t *testing.T) { tests := []struct { name string id string @@ -173,7 +162,7 @@ func TestValidateRegistryJSON_InvalidExtensionId(t *testing.T) { } } -func TestValidateRegistryJSON_InvalidSemver(t *testing.T) { +func TestValidateExtension_InvalidSemver(t *testing.T) { tests := []struct { name string version string @@ -202,7 +191,7 @@ func TestValidateRegistryJSON_InvalidSemver(t *testing.T) { hasVersionError := false for _, issue := range result.Issues { if issue.Severity == ValidationError && - (containsStr(issue.Message, "semver") || containsStr(issue.Message, "'version'")) { + (strings.Contains(issue.Message, "semver") || strings.Contains(issue.Message, "'version'")) { hasVersionError = true } } @@ -216,7 +205,7 @@ func TestValidateRegistryJSON_InvalidSemver(t *testing.T) { } } -func TestValidateRegistryJSON_InvalidCapabilities(t *testing.T) { +func TestValidateExtension_InvalidCapabilities(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -235,14 +224,14 @@ func TestValidateRegistryJSON_InvalidCapabilities(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && containsStr(issue.Message, "unknown capability 'invalid-capability'") { + if issue.Severity == ValidationError && strings.Contains(issue.Message, "unknown capability 'invalid-capability'") { found = true } } require.True(t, found, "expected unknown capability error") } -func TestValidateRegistryJSON_InvalidPlatforms(t *testing.T) { +func TestValidateExtension_InvalidPlatforms(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -269,14 +258,14 @@ func TestValidateRegistryJSON_InvalidPlatforms(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && containsStr(issue.Message, "unknown platform 'freebsd/amd64'") { + if issue.Severity == ValidationError && strings.Contains(issue.Message, "unknown platform 'freebsd/amd64'") { found = true } } require.True(t, found, "expected unknown platform error") } -func TestValidateRegistryJSON_MissingArtifactURL(t *testing.T) { +func TestValidateExtension_MissingArtifactURL(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -298,14 +287,14 @@ func TestValidateRegistryJSON_MissingArtifactURL(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && containsStr(issue.Message, "missing required field 'url'") { + if issue.Severity == ValidationError && strings.Contains(issue.Message, "missing required field 'url'") { found = true } } require.True(t, found, "expected missing URL error") } -func TestValidateRegistryJSON_MissingChecksum_NonStrict(t *testing.T) { +func TestValidateExtension_MissingChecksum_NonStrict(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -327,14 +316,14 @@ func TestValidateRegistryJSON_MissingChecksum_NonStrict(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationWarning && containsStr(issue.Message, "missing checksum") { + if issue.Severity == ValidationWarning && strings.Contains(issue.Message, "missing checksum") { found = true } } require.True(t, found, "expected missing checksum warning") } -func TestValidateRegistryJSON_MissingChecksum_Strict(t *testing.T) { +func TestValidateExtension_MissingChecksum_Strict(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -356,14 +345,14 @@ func TestValidateRegistryJSON_MissingChecksum_Strict(t *testing.T) { found := false for _, issue := range result.Issues { - if issue.Severity == ValidationError && containsStr(issue.Message, "missing required checksum") { + if issue.Severity == ValidationError && strings.Contains(issue.Message, "missing required checksum") { found = true } } require.True(t, found, "expected missing checksum error in strict mode") } -func TestValidateRegistryJSON_ChecksumAlgorithmValidation(t *testing.T) { +func TestValidateExtension_ChecksumAlgorithmValidation(t *testing.T) { t.Run("missing algorithm with value", func(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", @@ -386,7 +375,7 @@ func TestValidateRegistryJSON_ChecksumAlgorithmValidation(t *testing.T) { require.False(t, result.Valid) found := false for _, issue := range result.Issues { - if containsStr(issue.Message, "missing algorithm") { + if strings.Contains(issue.Message, "missing algorithm") { found = true } } @@ -415,7 +404,7 @@ func TestValidateRegistryJSON_ChecksumAlgorithmValidation(t *testing.T) { require.False(t, result.Valid) found := false for _, issue := range result.Issues { - if containsStr(issue.Message, "unsupported checksum algorithm") { + if strings.Contains(issue.Message, "unsupported checksum algorithm") { found = true } } @@ -423,7 +412,7 @@ func TestValidateRegistryJSON_ChecksumAlgorithmValidation(t *testing.T) { }) } -func TestValidateRegistryJSON_RequireArtifactsOrDependencies(t *testing.T) { +func TestValidateExtension_RequireArtifactsOrDependencies(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -438,14 +427,14 @@ func TestValidateRegistryJSON_RequireArtifactsOrDependencies(t *testing.T) { found := false for _, issue := range result.Issues { - if containsStr(issue.Message, "must define at least one artifact or dependency") { + if strings.Contains(issue.Message, "must define at least one artifact or dependency") { found = true } } require.True(t, found, "expected artifacts/dependencies requirement error") } -func TestValidateRegistryJSON_DependenciesOnly(t *testing.T) { +func TestValidateExtension_DependenciesOnly(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -460,63 +449,19 @@ func TestValidateRegistryJSON_DependenciesOnly(t *testing.T) { result := validateExtension(ext, false) for _, issue := range result.Issues { - require.False(t, containsStr(issue.Message, "must define at least one artifact or dependency"), + require.False(t, strings.Contains(issue.Message, "must define at least one artifact or dependency"), "extension with dependencies should not require artifacts") } } -func TestValidateRegistryJSON_NilExtensionEntry(t *testing.T) { +func TestValidateExtensions_NilExtensionEntry(t *testing.T) { result := ValidateExtensions([]*ExtensionMetadata{nil}, false) require.False(t, result.Valid) require.Len(t, result.Extensions, 1) require.False(t, result.Extensions[0].Valid) } -func TestValidateRegistryJSON_SingleExtensionFormat(t *testing.T) { - ext := ExtensionMetadata{ - Id: "pub.ext", - DisplayName: "Test", - Description: "Test", - Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, - } - - data, err := json.Marshal(ext) - require.NoError(t, err) - - result, err := ValidateRegistryJSON(data, false) - require.NoError(t, err) - require.True(t, result.Valid) - require.Len(t, result.Extensions, 1) -} - -func TestValidateRegistryJSON_ArrayFormat(t *testing.T) { - exts := []*ExtensionMetadata{ - { - Id: "pub.ext1", DisplayName: "Test 1", Description: "Test 1", - Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: validArtifacts()}}, - }, - { - Id: "pub.ext2", DisplayName: "Test 2", Description: "Test 2", - Versions: []ExtensionVersion{{Version: "2.0.0", Artifacts: validArtifacts()}}, - }, - } - - data, err := json.Marshal(exts) - require.NoError(t, err) - - result, err := ValidateRegistryJSON(data, false) - require.NoError(t, err) - require.True(t, result.Valid) - require.Len(t, result.Extensions, 2) -} - -func TestValidateRegistryJSON_InvalidJSON(t *testing.T) { - _, err := ValidateRegistryJSON([]byte("not json"), false) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid JSON") -} - -func TestValidateRegistryJSON_LatestVersionUseSemver(t *testing.T) { +func TestValidateExtension_LatestVersionUseSemver(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -532,7 +477,7 @@ func TestValidateRegistryJSON_LatestVersionUseSemver(t *testing.T) { require.Equal(t, "2.0.0", result.LatestVersion, "should pick highest semver, not last element") } -func TestValidateRegistryJSON_AllValidCapabilities(t *testing.T) { +func TestValidateExtension_AllValidCapabilities(t *testing.T) { ext := &ExtensionMetadata{ Id: "pub.ext", DisplayName: "Test", @@ -558,7 +503,7 @@ func TestValidateRegistryJSON_AllValidCapabilities(t *testing.T) { require.Empty(t, errorsOnly(result.Issues)) } -func TestValidateRegistryJSON_AllValidPlatforms(t *testing.T) { +func TestValidateExtension_AllValidPlatforms(t *testing.T) { artifacts := map[string]ExtensionArtifact{} for _, p := range ValidPlatforms { artifacts[p] = ExtensionArtifact{ @@ -583,15 +528,6 @@ func TestValidateRegistryJSON_AllValidPlatforms(t *testing.T) { // Helper functions -func containsStr(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - func errorsOnly(issues []ValidationIssue) []ValidationIssue { var result []ValidationIssue for _, issue := range issues { From e45e6fab935a069c1d0db8fe523bb45de39facf1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:03:33 -0800 Subject: [PATCH 6/6] fix: update figspec snapshot for extension source validate command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/testdata/TestFigSpec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index a962a87fe44..bd165bb370f 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1845,6 +1845,19 @@ const completionSpec: Fig.Spec = { name: 'name', }, }, + { + name: ['validate'], + description: 'Validate an extension source\'s registry.json file.', + options: [ + { + name: ['--strict'], + description: 'Enable strict validation (require checksums)', + }, + ], + args: { + name: 'name-or-path-or-url', + }, + }, ], }, { @@ -3274,6 +3287,10 @@ const completionSpec: Fig.Spec = { name: ['remove'], description: 'Remove an extension source with the specified name', }, + { + name: ['validate'], + description: 'Validate an extension source\'s registry.json file.', + }, ], }, {