From 8675d71e8b49c3fd4e261425b654bfabefe661df Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Sat, 11 Oct 2025 18:44:38 +0300 Subject: [PATCH] Use distribution/reference for parsing OCI references Signed-off-by: Radoslav Dimitrov --- go.mod | 2 + go.sum | 4 + internal/validators/registries/npm.go | 2 +- internal/validators/registries/oci.go | 2 +- .../validators/registries/oci_ref_parser.go | 102 ++++++------------ 5 files changed, 38 insertions(+), 74 deletions(-) diff --git a/go.mod b/go.mod index 3563101d..ff1197e0 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -34,6 +35,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect diff --git a/go.sum b/go.sum index a3967194..90ac021b 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNm github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -44,6 +46,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= diff --git a/internal/validators/registries/npm.go b/internal/validators/registries/npm.go index 458040c7..7c665a99 100644 --- a/internal/validators/registries/npm.go +++ b/internal/validators/registries/npm.go @@ -43,7 +43,7 @@ func ValidateNPM(ctx context.Context, pkg model.Package, serverName string) erro // Validate that MCPB-specific fields are not present if pkg.FileSHA256 != "" { - return fmt.Errorf("NPM packages must not have 'fileSha256' field - this is only for MCPB packages") + return fmt.Errorf("NPM packages must not have 'fileSha256' field") } // Validate that the registry base URL matches NPM exactly diff --git a/internal/validators/registries/oci.go b/internal/validators/registries/oci.go index fb8a92c0..4db302ca 100644 --- a/internal/validators/registries/oci.go +++ b/internal/validators/registries/oci.go @@ -96,7 +96,7 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro return fmt.Errorf("OCI packages must not have 'version' field - include version in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')") } if pkg.FileSHA256 != "" { - return fmt.Errorf("OCI packages must not have 'fileSha256' field - this is only for MCPB packages") + return fmt.Errorf("OCI packages must not have 'fileSha256' field") } // Parse the canonical OCI reference from the identifier diff --git a/internal/validators/registries/oci_ref_parser.go b/internal/validators/registries/oci_ref_parser.go index c0e41d21..7b6a1bf0 100644 --- a/internal/validators/registries/oci_ref_parser.go +++ b/internal/validators/registries/oci_ref_parser.go @@ -2,13 +2,12 @@ package registries import ( "fmt" - "regexp" "strings" + + "github.com/distribution/reference" ) const ( - // defaultOCIRegistry is the default registry when none is specified - defaultOCIRegistry = "docker.io" // defaultOCINamespace is the default namespace for official images defaultOCINamespace = "library" ) @@ -22,7 +21,7 @@ type OCIReference struct { Digest string // e.g., "sha256:abc..." (optional) } -// ParseOCIReference parses a canonical OCI image reference. +// ParseOCIReference parses a canonical OCI image reference using github.com/distribution/reference. // Supported formats: // - registry/namespace/image:tag // - registry/namespace/image@digest @@ -34,84 +33,43 @@ func ParseOCIReference(ref string) (*OCIReference, error) { return nil, fmt.Errorf("OCI reference cannot be empty") } - result := &OCIReference{} - - // Split by @ to separate digest - var mainPart string - if idx := strings.Index(ref, "@"); idx >= 0 { - mainPart = ref[:idx] - result.Digest = ref[idx+1:] - - // Validate digest format - if !strings.HasPrefix(result.Digest, "sha256:") { - return nil, fmt.Errorf("invalid digest format: must start with 'sha256:'") - } - digestPattern := regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`) - if !digestPattern.MatchString(result.Digest) { - return nil, fmt.Errorf("invalid digest format: must be sha256 followed by 64 hex characters") - } - } else { - mainPart = ref + // Parse using distribution/reference - normalizes short forms to canonical + named, err := reference.ParseNormalizedNamed(ref) + if err != nil { + return nil, fmt.Errorf("invalid OCI reference format: %w", err) } - // Split by : to separate tag - var pathPart string - if idx := strings.LastIndex(mainPart, ":"); idx >= 0 { - // Check if this looks like a registry with port (e.g., localhost:5000) - // or a tag. If there's a / after the :, it's likely a port. - if idx > 0 && !strings.Contains(mainPart[idx:], "/") { - pathPart = mainPart[:idx] - result.Tag = mainPart[idx+1:] - } else { - pathPart = mainPart - } - } else { - pathPart = mainPart - } + result := &OCIReference{} + + // Extract registry (domain) + result.Registry = reference.Domain(named) - // Parse the path (registry/namespace/image or namespace/image or image) - parts := strings.Split(pathPart, "/") + // Extract path (namespace/image or just image) + path := reference.Path(named) + parts := strings.Split(path, "/") - switch len(parts) { - case 1: - // Just image name: "postgres:16" -> docker.io/library/postgres:16 - result.Registry = defaultOCIRegistry + // Parse namespace and image from path + if len(parts) == 1 { + // Single part: library/image (docker.io default namespace) result.Namespace = defaultOCINamespace result.Image = parts[0] + } else { + // Multiple parts: namespace/image or org/team/image + result.Namespace = strings.Join(parts[:len(parts)-1], "/") + result.Image = parts[len(parts)-1] + } - case 2: - // namespace/image: "owner/repo:tag" -> docker.io/owner/repo:tag - // OR registry/image: "ghcr.io/image" -> ghcr.io/library/image - // Heuristic: if first part looks like a domain (contains . or :), treat as registry - if strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":") { - result.Registry = parts[0] - result.Namespace = defaultOCINamespace - result.Image = parts[1] - } else { - result.Registry = defaultOCIRegistry - result.Namespace = parts[0] - result.Image = parts[1] - } - - case 3: - // registry/namespace/image: "ghcr.io/owner/repo:tag" - result.Registry = parts[0] - result.Namespace = parts[1] - result.Image = parts[2] + // Extract tag if present + if tagged, ok := named.(reference.Tagged); ok { + result.Tag = tagged.Tag() + } - default: - // More than 3 parts could be multi-level namespace (e.g., ghcr.io/org/team/repo) - // Take first as registry, last as image, everything in between as namespace - if len(parts) > 3 { - result.Registry = parts[0] - result.Namespace = strings.Join(parts[1:len(parts)-1], "/") - result.Image = parts[len(parts)-1] - } else { - return nil, fmt.Errorf("invalid OCI reference format: %s", ref) - } + // Extract digest if present + if digested, ok := named.(reference.Digested); ok { + result.Digest = digested.Digest().String() } - // Validate we have either a tag or digest + // Validate we have either a tag or digest (required for MCP registry) if result.Tag == "" && result.Digest == "" { return nil, fmt.Errorf("OCI reference must include either a tag or digest: %s", ref) }