diff --git a/docs/registry.md b/docs/registry.md index e50fb44..228869d 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -180,7 +180,12 @@ descriptor size without hashing the body a second time: - `CopyBlobSized` — exact-size and no-trailing-data enforcement for a body the transport already digest-verified -- `ParseRef` splits registry-relative `repo[:tag]` strings at the first colon, - defaulting a missing tag to `latest`; it does not validate the input +- `ParseRef` splits a registry-relative `repo@digest` at the `@` and a + `repo[:tag]` at the first colon, defaulting a missing tag to `latest`; the + digest comes back in the tag position, and every `Registry` method that takes + a reference fetches by digest when the reference contains a colon; it does + not validate the input - `IsRelativeRef` validates that grammar before `ParseRef` is used on external - input, rejecting URLs, registry ports, digests, and empty tags + input: a lowercase repo path with a tag, or with a `sha256` / `sha512` digest + of the right length; it rejects URLs, registry ports, a tag and a digest + together, and empty tags diff --git a/ociutil/ociutil.go b/ociutil/ociutil.go index a5dbdf6..5df786c 100644 --- a/ociutil/ociutil.go +++ b/ociutil/ociutil.go @@ -12,8 +12,9 @@ import ( ) var ( - relRepo = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$`) - relTag = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$`) + relRepo = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$`) + relTag = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$`) + relDigest = regexp.MustCompile(`^(?:sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$`) ) // BlobSizeChecker enforces exact size and no trailing data on a digest-verified body. @@ -75,16 +76,22 @@ func CopyBlobSized(dst io.Writer, body io.Reader, digest string, size int64) err return err } -// ParseRef splits a registry-relative "repo[:tag]" at its first colon, defaulting the tag to "latest". +// ParseRef splits a registry-relative "repo@digest" at the @ and "repo[:tag]" at its first colon, defaulting the tag to "latest". func ParseRef(ref string) (string, string) { + if name, digest, ok := strings.Cut(ref, "@"); ok && name != "" { + return name, digest + } if name, tag, ok := strings.Cut(ref, ":"); ok && name != "" { return name, tag } return ref, "latest" } -// IsRelativeRef reports whether ref is a registry-relative repo[:tag], the only form ParseRef splits correctly. +// IsRelativeRef reports whether ref is a registry-relative repo[:tag] or repo@digest, the only forms ParseRef splits correctly. func IsRelativeRef(ref string) bool { - repo, tag := ParseRef(ref) - return relRepo.MatchString(repo) && relTag.MatchString(tag) + repo, reference := ParseRef(ref) + if strings.Contains(ref, "@") { + return relRepo.MatchString(repo) && relDigest.MatchString(reference) + } + return relRepo.MatchString(repo) && relTag.MatchString(reference) } diff --git a/ociutil/ociutil_test.go b/ociutil/ociutil_test.go index 2e20c93..6d0606f 100644 --- a/ociutil/ociutil_test.go +++ b/ociutil/ociutil_test.go @@ -9,6 +9,8 @@ import ( "testing/iotest" ) +const hex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + func TestParseRef(t *testing.T) { tests := []struct { ref string @@ -21,6 +23,7 @@ func TestParseRef(t *testing.T) { {"ghcr.io/cocoonstack/cocoon/ubuntu:24.04", "ghcr.io/cocoonstack/cocoon/ubuntu", "24.04"}, {":tag", ":tag", "latest"}, {"", "", "latest"}, + {"ns/repo@sha256:" + hex64, "ns/repo", "sha256:" + hex64}, } for _, tt := range tests { t.Run(tt.ref, func(t *testing.T) { @@ -42,10 +45,17 @@ func TestIsRelativeRef(t *testing.T) { {"ns/repo:v1.2-rc", true}, {"ghcr.io/cocoonstack/cocoon/ubuntu:24.04", true}, {"snap_shot-x:latest", true}, + {"ns/repo@sha256:" + hex64, true}, + {"ns/repo@sha512:" + hex64 + hex64, true}, {"registry:5000/repo:tag", false}, {"registry:5000/repo", false}, {"repo@sha256:deadbeef", false}, + {"repo@sha256:" + strings.ToUpper(hex64), false}, + {"repo@md5:" + hex64, false}, + {"registry:5000/repo@sha256:" + hex64, false}, + {"repo:v1@sha256:" + hex64, false}, + {"Repo@sha256:" + hex64, false}, {"https://cloud-images.ubuntu.com/noble.img", false}, {"Repo:tag", false}, {"repo:", false},