diff --git a/docker/reference/README.md b/docker/reference/README.md index 3c4d74eb4..563c9ffda 100644 --- a/docker/reference/README.md +++ b/docker/reference/README.md @@ -1,2 +1 @@ -This is a copy of github.com/docker/distribution/reference as of commit 3226863cbcba6dbc2f6c83a37b28126c934af3f8, -except that ParseAnyReferenceWithSet has been removed to drop the dependency on github.com/docker/distribution/digestset. \ No newline at end of file +This is a copy of github.com/docker/distribution/reference as of commit 8c942b0459dfdcc5b6685581dd0a5a470f615bff. \ No newline at end of file diff --git a/docker/reference/helpers.go b/docker/reference/helpers.go index 978df7eab..d10c7ef83 100644 --- a/docker/reference/helpers.go +++ b/docker/reference/helpers.go @@ -32,7 +32,7 @@ func FamiliarString(ref Reference) string { } // FamiliarMatch reports whether ref matches the specified pattern. -// See https://godoc.org/path#Match for supported patterns. +// See [path.Match] for supported patterns. func FamiliarMatch(pattern string, ref Reference) (bool, error) { matched, err := path.Match(pattern, FamiliarString(ref)) if namedRef, isNamed := ref.(Named); isNamed && !matched { diff --git a/docker/reference/normalize.go b/docker/reference/normalize.go index d3f47d210..4979eec73 100644 --- a/docker/reference/normalize.go +++ b/docker/reference/normalize.go @@ -1,18 +1,42 @@ package reference import ( - "errors" "fmt" "strings" "github.com/opencontainers/go-digest" ) -var ( +const ( + // legacyDefaultDomain is the legacy domain for Docker Hub (which was + // originally named "the Docker Index"). This domain is still used for + // authentication and image search, which were part of the "v1" Docker + // registry specification. + // + // This domain will continue to be supported, but there are plans to consolidate + // legacy domains to new "canonical" domains. Once those domains are decided + // on, we must update the normalization functions, but preserve compatibility + // with existing installs, clients, and user configuration. legacyDefaultDomain = "index.docker.io" - defaultDomain = "docker.io" - officialRepoName = "library" - defaultTag = "latest" + + // defaultDomain is the default domain used for images on Docker Hub. + // It is used to normalize "familiar" names to canonical names, for example, + // to convert "ubuntu" to "docker.io/library/ubuntu:latest". + // + // Note that actual domain of Docker Hub's registry is registry-1.docker.io. + // This domain will continue to be supported, but there are plans to consolidate + // legacy domains to new "canonical" domains. Once those domains are decided + // on, we must update the normalization functions, but preserve compatibility + // with existing installs, clients, and user configuration. + defaultDomain = "docker.io" + + // officialRepoPrefix is the namespace used for official images on Docker Hub. + // It is used to normalize "familiar" names to canonical names, for example, + // to convert "ubuntu" to "docker.io/library/ubuntu:latest". + officialRepoPrefix = "library/" + + // defaultTag is the default tag if no tag is provided. + defaultTag = "latest" ) // normalizedNamed represents a name which has been @@ -25,23 +49,33 @@ type normalizedNamed interface { Familiar() Named } -// ParseNormalizedNamed parses a string into a named reference -// transforming a familiar name from Docker UI to a fully -// qualified reference. If the value may be an identifier -// use ParseAnyReference. +// ParseNormalizedNamed parses a string into a [Named] reference transforming a +// familiar name from Docker UI to a fully qualified reference. If the value may +// be an identifier, use [ParseAnyReference] instead. It returns a nil reference +// if an error occurs. +// +// An error is returned when parsing a reference that contains a digest with an +// algorithm that is not registered. Implementations must register the algorithm +// by importing the appropriate implementation. +// +// For example, to register the sha256 algorithm implementation from Go's stdlib: +// +// import ( +// _ "crypto/sha256" +// ) func ParseNormalizedNamed(s string) (Named, error) { if ok := anchoredIdentifierRegexp.MatchString(s); ok { return nil, fmt.Errorf("invalid repository name (%s), cannot specify 64-byte hexadecimal strings", s) } domain, remainder := splitDockerDomain(s) - var remoteName string + var remote string if tagSep := strings.IndexRune(remainder, ':'); tagSep > -1 { - remoteName = remainder[:tagSep] + remote = remainder[:tagSep] } else { - remoteName = remainder + remote = remainder } - if strings.ToLower(remoteName) != remoteName { - return nil, errors.New("invalid reference format: repository name must be lowercase") + if strings.ToLower(remote) != remote { + return nil, fmt.Errorf("invalid reference format: repository name (%s) must be lowercase", remote) } ref, err := Parse(domain + "/" + remainder) @@ -55,52 +89,105 @@ func ParseNormalizedNamed(s string) (Named, error) { return named, nil } -// ParseDockerRef normalizes the image reference following the docker convention. This is added -// mainly for backward compatibility. -// The reference returned can only be either tagged or digested. For reference contains both tag -// and digest, the function returns digested reference, e.g. docker.io/library/busybox:latest@ -// sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa will be returned as -// docker.io/library/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa. +// namedTaggedDigested is a reference that has both a tag and a digest. +type namedTaggedDigested interface { + NamedTagged + Digested +} + +// ParseDockerRef normalizes the image reference following the docker convention, +// which allows for references to contain both a tag and a digest. It returns a +// reference that is either tagged or digested. For references containing both +// a tag and a digest, it returns a digested reference. For example, the following +// reference: +// +// docker.io/library/busybox:latest@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa +// +// Is returned as a digested reference (with the ":latest" tag removed): +// +// docker.io/library/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa +// +// References that are already "tagged" or "digested" are returned unmodified: +// +// // Already a digested reference +// docker.io/library/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa +// +// // Already a named reference +// docker.io/library/busybox:latest +// +// An error is returned when parsing a reference that contains a digest with an +// algorithm that is not registered. Implementations must register the algorithm +// by importing the appropriate implementation. +// +// For example, to register the sha256 algorithm implementation from Go's stdlib: +// +// import ( +// _ "crypto/sha256" +// ) func ParseDockerRef(ref string) (Named, error) { named, err := ParseNormalizedNamed(ref) if err != nil { return nil, err } - if _, ok := named.(NamedTagged); ok { - if canonical, ok := named.(Canonical); ok { - // The reference is both tagged and digested, only - // return digested. - newNamed, err := WithName(canonical.Name()) - if err != nil { - return nil, err - } - newCanonical, err := WithDigest(newNamed, canonical.Digest()) - if err != nil { - return nil, err - } - return newCanonical, nil + if canonical, ok := named.(namedTaggedDigested); ok { + // The reference is both tagged and digested; only return digested. + newNamed, err := WithName(canonical.Name()) + if err != nil { + return nil, err } + return WithDigest(newNamed, canonical.Digest()) } return TagNameOnly(named), nil } -// splitDockerDomain splits a repository name to domain and remotename string. +// splitDockerDomain splits a repository name to domain and remote-name. // If no valid domain is found, the default domain is used. Repository name // needs to be already validated before. -func splitDockerDomain(name string) (domain, remainder string) { - i := strings.IndexRune(name, '/') - if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost") { - domain, remainder = defaultDomain, name - } else { - domain, remainder = name[:i], name[i+1:] +func splitDockerDomain(name string) (domain, remoteName string) { + maybeDomain, maybeRemoteName, ok := strings.Cut(name, "/") + if !ok { + // Fast-path for single element ("familiar" names), such as "ubuntu" + // or "ubuntu:latest". Familiar names must be handled separately, to + // prevent them from being handled as "hostname:port". + // + // Canonicalize them as "docker.io/library/name[:tag]" + + // FIXME(thaJeztah): account for bare "localhost" or "example.com" names, which SHOULD be considered a domain. + return defaultDomain, officialRepoPrefix + name } - if domain == legacyDefaultDomain { - domain = defaultDomain + + switch { + case maybeDomain == localhost: + // localhost is a reserved namespace and always considered a domain. + domain, remoteName = maybeDomain, maybeRemoteName + case maybeDomain == legacyDefaultDomain: + // canonicalize the Docker Hub and legacy "Docker Index" domains. + domain, remoteName = defaultDomain, maybeRemoteName + case strings.ContainsAny(maybeDomain, ".:"): + // Likely a domain or IP-address: + // + // - contains a "." (e.g., "example.com" or "127.0.0.1") + // - contains a ":" (e.g., "example:5000", "::1", or "[::1]:5000") + domain, remoteName = maybeDomain, maybeRemoteName + case strings.ToLower(maybeDomain) != maybeDomain: + // Uppercase namespaces are not allowed, so if the first element + // is not lowercase, we assume it to be a domain-name. + domain, remoteName = maybeDomain, maybeRemoteName + default: + // None of the above: it's not a domain, so use the default, and + // use the name input the remote-name. + domain, remoteName = defaultDomain, name } - if domain == defaultDomain && !strings.ContainsRune(remainder, '/') { - remainder = officialRepoName + "/" + remainder + + if domain == defaultDomain && !strings.ContainsRune(remoteName, '/') { + // Canonicalize "familiar" names, but only on Docker Hub, not + // on other domains: + // + // "docker.io/ubuntu[:tag]" => "docker.io/library/ubuntu[:tag]" + remoteName = officialRepoPrefix + remoteName } - return + + return domain, remoteName } // familiarizeName returns a shortened version of the name familiar @@ -118,8 +205,15 @@ func familiarizeName(named namedRepository) repository { if repo.domain == defaultDomain { repo.domain = "" // Handle official repositories which have the pattern "library/" - if split := strings.Split(repo.path, "/"); len(split) == 2 && split[0] == officialRepoName { - repo.path = split[1] + if strings.HasPrefix(repo.path, officialRepoPrefix) { + // TODO(thaJeztah): this check may be too strict, as it assumes the + // "library/" namespace does not have nested namespaces. While this + // is true (currently), technically it would be possible for Docker + // Hub to use those (e.g. "library/distros/ubuntu:latest"). + // See https://github.com/distribution/distribution/pull/3769#issuecomment-1302031785. + if remainder := strings.TrimPrefix(repo.path, officialRepoPrefix); !strings.ContainsRune(remainder, '/') { + repo.path = remainder + } } } return repo @@ -169,6 +263,16 @@ func TagNameOnly(ref Named) Named { // ParseAnyReference parses a reference string as a possible identifier, // full digest, or familiar name. +// +// An error is returned when parsing a reference that contains a digest with an +// algorithm that is not registered. Implementations must register the algorithm +// by importing the appropriate implementation. +// +// For example, to register the sha256 algorithm implementation from Go's stdlib: +// +// import ( +// _ "crypto/sha256" +// ) func ParseAnyReference(ref string) (Reference, error) { if ok := anchoredIdentifierRegexp.MatchString(ref); ok { return digestReference("sha256:" + ref), nil diff --git a/docker/reference/normalize_test.go b/docker/reference/normalize_test.go index a21c800e1..129aaf110 100644 --- a/docker/reference/normalize_test.go +++ b/docker/reference/normalize_test.go @@ -8,10 +8,15 @@ import ( ) func TestValidateReferenceName(t *testing.T) { + t.Parallel() validRepoNames := []string{ "docker/docker", "library/debian", "debian", + "localhost/library/debian", + "localhost/debian", + "LOCALDOMAIN/library/debian", + "LOCALDOMAIN/debian", "docker.io/docker/docker", "docker.io/library/debian", "docker.io/debian", @@ -21,12 +26,21 @@ func TestValidateReferenceName(t *testing.T) { "127.0.0.1:5000/docker/docker", "127.0.0.1:5000/library/debian", "127.0.0.1:5000/debian", + "192.168.0.1", + "192.168.0.1:80", + "192.168.0.1:8/debian", + "192.168.0.2:25000/debian", "thisisthesongthatneverendsitgoesonandonandonthisisthesongthatnev", + "[fc00::1]:5000/docker", + "[fc00::1]:5000/docker/docker", + "[fc00:1:2:3:4:5:6:7]:5000/library/debian", // This test case was moved from invalid to valid since it is valid input // when specified with a hostname, it removes the ambiguity from about // whether the value is an identifier or repository name "docker.io/1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a9f1e3d5d7c9b1a3f5e7d9c1b3a", + "Docker/docker", + "DOCKER/docker", } invalidRepoNames := []string{ "https://github.com/docker/docker", @@ -37,6 +51,11 @@ func TestValidateReferenceName(t *testing.T) { "docker///docker", "docker.io/docker/Docker", "docker.io/docker///docker", + "[fc00::1]", + "[fc00::1]:5000", + "fc00::1:5000/debian", + "[fe80::1%eth0]:5000/debian", + "[2001:db8:3:4::192.0.2.33]:5000/debian", "1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a9f1e3d5d7c9b1a3f5e7d9c1b3a", } @@ -56,6 +75,7 @@ func TestValidateReferenceName(t *testing.T) { } func TestValidateRemoteName(t *testing.T) { + t.Parallel() validRepositoryNames := []string{ // Sanity check. "docker/docker", @@ -115,7 +135,7 @@ func TestValidateRemoteName(t *testing.T) { "docker/", // namespace too long - "this_is_not_a_valid_namespace_because_its_length_is_greater_than_255_this_is_not_a_valid_namespace_because_its_length_is_greater_than_255_this_is_not_a_valid_namespace_because_its_length_is_greater_than_255_this_is_not_a_valid_namespace_because_its_length_is_greater_than_255/docker", + "this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255_this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255_this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255_this_is_not_a_valid_namespace_because_its_lenth_is_greater_than_255/docker", } for _, repositoryName := range invalidRepositoryNames { if _, err := ParseNormalizedNamed(repositoryName); err == nil { @@ -125,11 +145,40 @@ func TestValidateRemoteName(t *testing.T) { } func TestParseRepositoryInfo(t *testing.T) { + t.Parallel() type tcase struct { RemoteName, FamiliarName, FullName, AmbiguousName, Domain string } - tcases := []tcase{ + tests := []tcase{ + { + RemoteName: "fooo", + FamiliarName: "localhost/fooo", + FullName: "localhost/fooo", + AmbiguousName: "localhost/fooo", + Domain: "localhost", + }, + { + RemoteName: "fooo/bar", + FamiliarName: "localhost/fooo/bar", + FullName: "localhost/fooo/bar", + AmbiguousName: "localhost/fooo/bar", + Domain: "localhost", + }, + { + RemoteName: "fooo", + FamiliarName: "LOCALDOMAIN/fooo", + FullName: "LOCALDOMAIN/fooo", + AmbiguousName: "LOCALDOMAIN/fooo", + Domain: "LOCALDOMAIN", + }, + { + RemoteName: "fooo/bar", + FamiliarName: "LOCALDOMAIN/fooo/bar", + FullName: "LOCALDOMAIN/fooo/bar", + AmbiguousName: "LOCALDOMAIN/fooo/bar", + Domain: "LOCALDOMAIN", + }, { RemoteName: "fooo/bar", FamiliarName: "fooo/bar", @@ -228,41 +277,64 @@ func TestParseRepositoryInfo(t *testing.T) { AmbiguousName: "", Domain: "docker.io", }, + { + RemoteName: "bar", + FamiliarName: "Foo/bar", + FullName: "Foo/bar", + AmbiguousName: "", + Domain: "Foo", + }, + { + RemoteName: "bar", + FamiliarName: "FOO/bar", + FullName: "FOO/bar", + AmbiguousName: "", + Domain: "FOO", + }, } - for _, tcase := range tcases { - refStrings := []string{tcase.FamiliarName, tcase.FullName} - if tcase.AmbiguousName != "" { - refStrings = append(refStrings, tcase.AmbiguousName) + for i, tc := range tests { + tc := tc + refStrings := []string{tc.FamiliarName, tc.FullName} + if tc.AmbiguousName != "" { + refStrings = append(refStrings, tc.AmbiguousName) } - var refs []Named for _, r := range refStrings { - named, err := ParseNormalizedNamed(r) - if err != nil { - t.Fatal(err) - } - refs = append(refs, named) - } - - for _, r := range refs { - if expected, actual := tcase.FamiliarName, FamiliarName(r); expected != actual { - t.Fatalf("Invalid normalized reference for %q. Expected %q, got %q", r, expected, actual) - } - if expected, actual := tcase.FullName, r.String(); expected != actual { - t.Fatalf("Invalid canonical reference for %q. Expected %q, got %q", r, expected, actual) - } - if expected, actual := tcase.Domain, Domain(r); expected != actual { - t.Fatalf("Invalid domain for %q. Expected %q, got %q", r, expected, actual) - } - if expected, actual := tcase.RemoteName, Path(r); expected != actual { - t.Fatalf("Invalid remoteName for %q. Expected %q, got %q", r, expected, actual) - } + r := r + t.Run(strconv.Itoa(i)+"/"+r, func(t *testing.T) { + t.Parallel() + named, err := ParseNormalizedNamed(r) + if err != nil { + t.Fatalf("ref=%s: %v", r, err) + } + t.Run("FamiliarName", func(t *testing.T) { + if expected, actual := tc.FamiliarName, FamiliarName(named); expected != actual { + t.Errorf("Invalid familiar name for %q. Expected %q, got %q", named, expected, actual) + } + }) + t.Run("FullName", func(t *testing.T) { + if expected, actual := tc.FullName, named.String(); expected != actual { + t.Errorf("Invalid canonical reference for %q. Expected %q, got %q", named, expected, actual) + } + }) + t.Run("Domain", func(t *testing.T) { + if expected, actual := tc.Domain, Domain(named); expected != actual { + t.Errorf("Invalid domain for %q. Expected %q, got %q", named, expected, actual) + } + }) + t.Run("RemoteName", func(t *testing.T) { + if expected, actual := tc.RemoteName, Path(named); expected != actual { + t.Errorf("Invalid remoteName for %q. Expected %q, got %q", named, expected, actual) + } + }) + }) } } } func TestParseReferenceWithTagAndDigest(t *testing.T) { + t.Parallel() shortRef := "busybox:latest@sha256:86e0e091d0da6bde2456dbb48306f3956bbeb2eae1b5b9a43045843f69fe4aaa" ref, err := ParseNormalizedNamed(shortRef) if err != nil { @@ -284,6 +356,7 @@ func TestParseReferenceWithTagAndDigest(t *testing.T) { } func TestInvalidReferenceComponents(t *testing.T) { + t.Parallel() if _, err := ParseNormalizedNamed("-foo"); err == nil { t.Fatal("Expected WithName to detect invalid name") } @@ -326,7 +399,8 @@ func equalReference(r1, r2 Reference) bool { } func TestParseAnyReference(t *testing.T) { - tcases := []struct { + t.Parallel() + tests := []struct { Reference string Equivalent string Expected Reference @@ -385,115 +459,124 @@ func TestParseAnyReference(t *testing.T) { Reference: "dbcc1c35ac38df41fd2f5e4130b32ffdb93ebae8b3dbe638c23575912276fc9", Equivalent: "docker.io/library/dbcc1c35ac38df41fd2f5e4130b32ffdb93ebae8b3dbe638c23575912276fc9", }, + { + Reference: "dbcc1", + Equivalent: "docker.io/library/dbcc1", + }, } - for _, tcase := range tcases { - var ref Reference - var err error - ref, err = ParseAnyReference(tcase.Reference) - if err != nil { - t.Fatalf("Error parsing reference %s: %v", tcase.Reference, err) - } - if ref.String() != tcase.Equivalent { - t.Fatalf("Unexpected string: %s, expected %s", ref.String(), tcase.Equivalent) - } - - expected := tcase.Expected - if expected == nil { - expected, err = Parse(tcase.Equivalent) + for _, tc := range tests { + tc := tc + t.Run(tc.Reference, func(t *testing.T) { + t.Parallel() + var ref Reference + var err error + ref, err = ParseAnyReference(tc.Reference) if err != nil { - t.Fatalf("Error parsing reference %s: %v", tcase.Equivalent, err) + t.Fatalf("Error parsing reference %s: %v", tc.Reference, err) } - } - if !equalReference(ref, expected) { - t.Errorf("Unexpected reference %#v, expected %#v", ref, expected) - } + if ref.String() != tc.Equivalent { + t.Fatalf("Unexpected string: %s, expected %s", ref.String(), tc.Equivalent) + } + + expected := tc.Expected + if expected == nil { + expected, err = Parse(tc.Equivalent) + if err != nil { + t.Fatalf("Error parsing reference %s: %v", tc.Equivalent, err) + } + } + if !equalReference(ref, expected) { + t.Errorf("Unexpected reference %#v, expected %#v", ref, expected) + } + }) } } func TestNormalizedSplitHostname(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { input string domain string - name string + path string }{ { input: "test.com/foo", domain: "test.com", - name: "foo", + path: "foo", }, { input: "test_com/foo", domain: "docker.io", - name: "test_com/foo", + path: "test_com/foo", }, { input: "docker/migrator", domain: "docker.io", - name: "docker/migrator", + path: "docker/migrator", }, { input: "test.com:8080/foo", domain: "test.com:8080", - name: "foo", + path: "foo", }, { input: "test-com:8080/foo", domain: "test-com:8080", - name: "foo", + path: "foo", }, { input: "foo", domain: "docker.io", - name: "library/foo", + path: "library/foo", }, { input: "xn--n3h.com/foo", domain: "xn--n3h.com", - name: "foo", + path: "foo", }, { input: "xn--n3h.com:18080/foo", domain: "xn--n3h.com:18080", - name: "foo", + path: "foo", }, { input: "docker.io/foo", domain: "docker.io", - name: "library/foo", + path: "library/foo", }, { input: "docker.io/library/foo", domain: "docker.io", - name: "library/foo", + path: "library/foo", }, { input: "docker.io/library/foo/bar", domain: "docker.io", - name: "library/foo/bar", + path: "library/foo/bar", }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.input)+": "+format, v...) - t.Fail() - } + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + named, err := ParseNormalizedNamed(tc.input) + if err != nil { + t.Errorf("error parsing name: %s", err) + } - named, err := ParseNormalizedNamed(testcase.input) - if err != nil { - failf("error parsing name: %s", err) - } - domain, name := SplitHostname(named) - if domain != testcase.domain { - failf("unexpected domain: got %q, expected %q", domain, testcase.domain) - } - if name != testcase.name { - failf("unexpected name: got %q, expected %q", name, testcase.name) - } + if domain := Domain(named); domain != tc.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, tc.domain) + } + if path := Path(named); path != tc.path { + t.Errorf("unexpected name: got %q, expected %q", path, tc.path) + } + }) } } func TestMatchError(t *testing.T) { + t.Parallel() named, err := ParseAnyReference("foo") if err != nil { t.Fatal(err) @@ -505,7 +588,8 @@ func TestMatchError(t *testing.T) { } func TestMatch(t *testing.T) { - matchCases := []struct { + t.Parallel() + tests := []struct { reference string pattern string expected bool @@ -556,23 +640,28 @@ func TestMatch(t *testing.T) { expected: true, }, } - for _, c := range matchCases { - named, err := ParseAnyReference(c.reference) - if err != nil { - t.Fatal(err) - } - actual, err := FamiliarMatch(c.pattern, named) - if err != nil { - t.Fatal(err) - } - if actual != c.expected { - t.Fatalf("expected %s match %s to be %v, was %v", c.reference, c.pattern, c.expected, actual) - } + for _, tc := range tests { + tc := tc + t.Run(tc.reference, func(t *testing.T) { + t.Parallel() + named, err := ParseAnyReference(tc.reference) + if err != nil { + t.Fatal(err) + } + actual, err := FamiliarMatch(tc.pattern, named) + if err != nil { + t.Fatal(err) + } + if actual != tc.expected { + t.Fatalf("expected %s match %s to be %v, was %v", tc.reference, tc.pattern, tc.expected, actual) + } + }) } } func TestParseDockerRef(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { name string input string expected string @@ -633,15 +722,17 @@ func TestParseDockerRef(t *testing.T) { expected: "gcr.io/library/busybox@sha256:e6693c20186f837fc393390135d8a598a96a833917917789d63766cab6c59582", }, } - for _, test := range testcases { - t.Run(test.name, func(t *testing.T) { - normalized, err := ParseDockerRef(test.input) + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + normalized, err := ParseDockerRef(tc.input) if err != nil { t.Fatal(err) } output := normalized.String() - if output != test.expected { - t.Fatalf("expected %q to be parsed as %v, got %v", test.input, test.expected, output) + if output != tc.expected { + t.Fatalf("expected %q to be parsed as %v, got %v", tc.input, tc.expected, output) } _, err = Parse(output) if err != nil { diff --git a/docker/reference/reference.go b/docker/reference/reference.go index 6c5484c06..14d5b466d 100644 --- a/docker/reference/reference.go +++ b/docker/reference/reference.go @@ -1,15 +1,18 @@ // Package reference provides a general type to represent any way of referencing images within the registry. // Its main purpose is to abstract tags and digests (content-addressable hash). // -// Grammar +// # Grammar // // reference := name [ ":" tag ] [ "@" digest ] -// name := [domain '/'] path-component ['/' path-component]* -// domain := domain-component ['.' domain-component]* [':' port-number] +// name := [domain '/'] remote-name +// domain := host [':' port-number] +// host := domain-name | IPv4address | \[ IPv6address \] ; rfc3986 appendix-A +// domain-name := domain-component ['.' domain-component]* // domain-component := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ // port-number := /[0-9]+/ -// path-component := alphanumeric [separator alphanumeric]* -// alphanumeric := /[a-z0-9]+/ +// path-component := alpha-numeric [separator alpha-numeric]* +// path (or "remote-name") := path-component ['/' path-component]* +// alpha-numeric := /[a-z0-9]+/ // separator := /[_.]|__|[-]*/ // // tag := /[\w][\w.-]{0,127}/ @@ -21,7 +24,29 @@ // digest-hex := /[0-9a-fA-F]{32,}/ ; At least 128 bit digest value // // identifier := /[a-f0-9]{64}/ -// short-identifier := /[a-f0-9]{6,64}/ +// +// # Supported algorithms +// +// Implementations must register the algorithms they want to support for digests +// by importing the appropriate implementation in a non-test file. +// +// For example, to register the sha256 algorithm implementation from Go's stdlib: +// +// import ( +// _ "crypto/sha256" +// ) +// +// Even though digests may be assemblable as a string, always verify your input +// with [digest.Parse] or use [digest.Digest.Validate] when accepting untrusted +// input. While there are measures to avoid common problems, this ensures you +// have valid digests in the rest of your application. +// +// While alternative encodings of hash values (digests) are possible (for example, +// base64), this package deals exclusively with hex-encoded digests. Refer to +// the [OCI image specification] for algorithms that are defined as part of the +// specification. +// +// [OCI image specification]: https://github.com/opencontainers/image-spec/blob/v1.0.2/descriptor.md#registered-algorithms package reference import ( @@ -33,8 +58,13 @@ import ( ) const ( + // RepositoryNameTotalLengthMax is the maximum total number of characters in a repository name. + RepositoryNameTotalLengthMax = 255 + // NameTotalLengthMax is the maximum total number of characters in a repository name. - NameTotalLengthMax = 255 + // + // Deprecated: use [RepositoryNameTotalLengthMax] instead. + NameTotalLengthMax = RepositoryNameTotalLengthMax ) var ( @@ -53,8 +83,8 @@ var ( // ErrNameEmpty is returned for empty, invalid repository names. ErrNameEmpty = errors.New("repository name must have at least one component") - // ErrNameTooLong is returned when a repository name is longer than NameTotalLengthMax. - ErrNameTooLong = fmt.Errorf("repository name must not be more than %v characters", NameTotalLengthMax) + // ErrNameTooLong is returned when a repository name is longer than RepositoryNameTotalLengthMax. + ErrNameTooLong = fmt.Errorf("repository name must not be more than %v characters", RepositoryNameTotalLengthMax) // ErrNameNotCanonical is returned when a name is not canonical. ErrNameNotCanonical = errors.New("repository name must be canonical") @@ -95,6 +125,16 @@ func (f Field) MarshalText() (p []byte, err error) { // UnmarshalText parses text bytes by invoking the // reference parser to ensure the appropriately // typed reference object is wrapped by field. +// +// An error is returned when unmarshaling a reference that contains a digest with an +// algorithm that is not registered. Implementations must register the algorithm +// by importing the appropriate implementation. +// +// For example, to register the sha256 algorithm implementation from Go's stdlib: +// +// import ( +// _ "crypto/sha256" +// ) func (f *Field) UnmarshalText(p []byte) error { r, err := Parse(string(p)) if err != nil { @@ -145,7 +185,7 @@ type namedRepository interface { Path() string } -// Domain returns the domain part of the Named reference +// Domain returns the domain part of the [Named] reference. func Domain(named Named) string { if r, ok := named.(namedRepository); ok { return r.Domain() @@ -154,7 +194,7 @@ func Domain(named Named) string { return domain } -// Path returns the name without the domain part of the Named reference +// Path returns the name without the domain part of the [Named] reference. func Path(named Named) (name string) { if r, ok := named.(namedRepository); ok { return r.Path() @@ -163,6 +203,9 @@ func Path(named Named) (name string) { return path } +// splitDomain splits a named reference into a hostname and path string. +// If no valid hostname is found, the hostname is empty and the full value +// is returned as name func splitDomain(name string) (string, string) { match := anchoredNameRegexp.FindStringSubmatch(name) if len(match) != 3 { @@ -171,21 +214,19 @@ func splitDomain(name string) (string, string) { return match[1], match[2] } -// SplitHostname splits a named reference into a -// hostname and name string. If no valid hostname is -// found, the hostname is empty and the full value -// is returned as name -// Deprecated: Use Domain or Path -func SplitHostname(named Named) (string, string) { - if r, ok := named.(namedRepository); ok { - return r.Domain(), r.Path() - } - return splitDomain(named.Name()) -} - -// Parse parses s and returns a syntactically valid Reference. -// If an error was encountered it is returned, along with a nil Reference. -// NOTE: Parse will not handle short digests. +// Parse parses s and returns a syntactically valid [Reference]. It returns a +// nil Reference if an error occurs. When parsing a reference that contains a +// digest, an error is returned if the digest's algorithm that is not registered. +// +// An error is returned when parsing a reference that contains a digest with an +// algorithm that is not registered. Implementations must register the algorithm +// by importing the appropriate implementation. +// +// For example, to register the sha256 algorithm implementation from Go's stdlib: +// +// import ( +// _ "crypto/sha256" +// ) func Parse(s string) (Reference, error) { matches := ReferenceRegexp.FindStringSubmatch(s) if matches == nil { @@ -198,10 +239,6 @@ func Parse(s string) (Reference, error) { return nil, ErrReferenceInvalidFormat } - if len(matches[1]) > NameTotalLengthMax { - return nil, ErrNameTooLong - } - var repo repository nameMatch := anchoredNameRegexp.FindStringSubmatch(matches[1]) @@ -213,6 +250,10 @@ func Parse(s string) (Reference, error) { repo.path = matches[1] } + if len(repo.path) > RepositoryNameTotalLengthMax { + return nil, ErrNameTooLong + } + ref := reference{ namedRepository: repo, tag: matches[2], @@ -237,7 +278,6 @@ func Parse(s string) (Reference, error) { // the Named interface. The reference must have a name and be in the canonical // form, otherwise an error is returned. // If an error was encountered it is returned, along with a nil Reference. -// NOTE: ParseNamed will not handle short digests. func ParseNamed(s string) (Named, error) { named, err := ParseNormalizedNamed(s) if err != nil { @@ -252,14 +292,15 @@ func ParseNamed(s string) (Named, error) { // WithName returns a named object representing the given string. If the input // is invalid ErrReferenceInvalidFormat will be returned. func WithName(name string) (Named, error) { - if len(name) > NameTotalLengthMax { - return nil, ErrNameTooLong - } - match := anchoredNameRegexp.FindStringSubmatch(name) if match == nil || len(match) != 3 { return nil, ErrReferenceInvalidFormat } + + if len(match[2]) > RepositoryNameTotalLengthMax { + return nil, ErrNameTooLong + } + return repository{ domain: match[1], path: match[2], @@ -320,11 +361,13 @@ func WithDigest(name Named, digest digest.Digest) (Canonical, error) { // TrimNamed removes any tag or digest from the named reference. func TrimNamed(ref Named) Named { - domain, path := SplitHostname(ref) - return repository{ - domain: domain, - path: path, + repo := repository{} + if r, ok := ref.(namedRepository); ok { + repo.domain, repo.path = r.Domain(), r.Path() + } else { + repo.domain, repo.path = splitDomain(ref.Name()) } + return repo } func getBestReferenceType(ref reference) Reference { diff --git a/docker/reference/reference_test.go b/docker/reference/reference_test.go index ce1a11ddc..ac46e93b0 100644 --- a/docker/reference/reference_test.go +++ b/docker/reference/reference_test.go @@ -1,10 +1,9 @@ package reference import ( - _ "crypto/sha256" - _ "crypto/sha512" + _ "crypto/sha256" // make sure the sha256 algorithm is registered, as it's used in tests. "encoding/json" - "strconv" + "errors" "strings" "testing" @@ -12,9 +11,10 @@ import ( ) func TestReferenceParse(t *testing.T) { - // referenceTestcases is a unified set of testcases for + t.Parallel() + // tests is a unified set of testcases for // testing the parsing of references - referenceTestcases := []struct { + tests := []struct { // input is the repository name or name component testcase input string // err is the error expected from Parse, or nil @@ -97,16 +97,21 @@ func TestReferenceParse(t *testing.T) { input: "validname@invaliddigest:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", err: digest.ErrDigestUnsupported, }, + { + // sha512 is valid, but not registered by default ("crypto/sha512" is not imported) + input: "validname@sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + err: digest.ErrDigestUnsupported, + }, { input: "Uppercase:tag", err: ErrNameContainsUppercase, }, // FIXME "Uppercase" is incorrectly handled as a domain-name here, therefore passes. - // See https://github.com/docker/distribution/pull/1778, and https://github.com/docker/docker/pull/20175 - // { - // input: "Uppercase/lowercase:tag", - // err: ErrNameContainsUppercase, - // }, + // See https://github.com/distribution/distribution/pull/1778, and https://github.com/docker/docker/pull/20175 + // { + // input: "Uppercase/lowercase:tag", + // err: ErrNameContainsUppercase, + // }, { input: "test:5000/Uppercase/lowercase:tag", err: ErrNameContainsUppercase, @@ -117,7 +122,7 @@ func TestReferenceParse(t *testing.T) { tag: "Uppercase", }, { - input: strings.Repeat("a/", 128) + "a:tag", + input: "domain/" + strings.Repeat("a", 256) + ":tag", err: ErrNameTooLong, }, { @@ -154,11 +159,11 @@ func TestReferenceParse(t *testing.T) { tag: "xn--n3h.com", }, { - input: "xn--7o8h.com/myimage:xn--7o8h.com@sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // 🐳.com in punycode + input: "xn--7o8h.com/myimage:xn--7o8h.com@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // 🐳.com in punycode domain: "xn--7o8h.com", repository: "xn--7o8h.com/myimage", tag: "xn--7o8h.com", - digest: "sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }, { input: "foo_bar.com:8080", @@ -171,79 +176,179 @@ func TestReferenceParse(t *testing.T) { repository: "foo/foo_bar.com", tag: "8080", }, + { + input: "192.168.1.1", + repository: "192.168.1.1", + }, + { + input: "192.168.1.1:tag", + repository: "192.168.1.1", + tag: "tag", + }, + { + input: "192.168.1.1:5000", + repository: "192.168.1.1", + tag: "5000", + }, + { + input: "192.168.1.1/repo", + domain: "192.168.1.1", + repository: "192.168.1.1/repo", + }, + { + input: "192.168.1.1:5000/repo", + domain: "192.168.1.1:5000", + repository: "192.168.1.1:5000/repo", + }, + { + input: "192.168.1.1:5000/repo:5050", + domain: "192.168.1.1:5000", + repository: "192.168.1.1:5000/repo", + tag: "5050", + }, + { + input: "[2001:db8::1]", + err: ErrReferenceInvalidFormat, + }, + { + input: "[2001:db8::1]:5000", + err: ErrReferenceInvalidFormat, + }, + { + input: "[2001:db8::1]:tag", + err: ErrReferenceInvalidFormat, + }, + { + input: "[2001:db8::1]/repo", + domain: "[2001:db8::1]", + repository: "[2001:db8::1]/repo", + }, + { + input: "[2001:db8:1:2:3:4:5:6]/repo:tag", + domain: "[2001:db8:1:2:3:4:5:6]", + repository: "[2001:db8:1:2:3:4:5:6]/repo", + tag: "tag", + }, + { + input: "[2001:db8::1]:5000/repo", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", + }, + { + input: "[2001:db8::1]:5000/repo:tag", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", + tag: "tag", + }, + { + input: "[2001:db8::1]:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", + digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + { + input: "[2001:db8::1]:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", + tag: "tag", + digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + { + input: "[2001:db8::]:5000/repo", + domain: "[2001:db8::]:5000", + repository: "[2001:db8::]:5000/repo", + }, + { + input: "[::1]:5000/repo", + domain: "[::1]:5000", + repository: "[::1]:5000/repo", + }, + { + input: "[fe80::1%eth0]:5000/repo", + err: ErrReferenceInvalidFormat, + }, + { + input: "[fe80::1%@invalidzone]:5000/repo", + err: ErrReferenceInvalidFormat, + }, + { + input: "example.com/" + strings.Repeat("a", 255) + ":tag", + domain: "example.com", + repository: "example.com/" + strings.Repeat("a", 255), + tag: "tag", + }, } - for _, testcase := range referenceTestcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.input)+": "+format, v...) - t.Fail() - } - - repo, err := Parse(testcase.input) - if testcase.err != nil { - if err == nil { - failf("missing expected error: %v", testcase.err) - } else if testcase.err != err { - failf("mismatched error: got %v, expected %v", err, testcase.err) + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + repo, err := Parse(tc.input) + if tc.err != nil { + if err == nil { + t.Errorf("missing expected error: %v", tc.err) + } else if tc.err != err { + t.Errorf("mismatched error: got %v, expected %v", err, tc.err) + } + return + } else if err != nil { + t.Errorf("unexpected parse error: %v", err) + return } - continue - } else if err != nil { - failf("unexpected parse error: %v", err) - continue - } - if repo.String() != testcase.input { - failf("mismatched repo: got %q, expected %q", repo.String(), testcase.input) - } - - if named, ok := repo.(Named); ok { - if named.Name() != testcase.repository { - failf("unexpected repository: got %q, expected %q", named.Name(), testcase.repository) + if repo.String() != tc.input { + t.Errorf("mismatched repo: got %q, expected %q", repo.String(), tc.input) } - domain, _ := SplitHostname(named) - if domain != testcase.domain { - failf("unexpected domain: got %q, expected %q", domain, testcase.domain) + + if named, ok := repo.(Named); ok { + if named.Name() != tc.repository { + t.Errorf("unexpected repository: got %q, expected %q", named.Name(), tc.repository) + } + if domain := Domain(named); domain != tc.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, tc.domain) + } + } else if tc.repository != "" || tc.domain != "" { + t.Errorf("expected named type, got %T", repo) } - } else if testcase.repository != "" || testcase.domain != "" { - failf("expected named type, got %T", repo) - } - tagged, ok := repo.(Tagged) - if testcase.tag != "" { - if ok { - if tagged.Tag() != testcase.tag { - failf("unexpected tag: got %q, expected %q", tagged.Tag(), testcase.tag) + tagged, ok := repo.(Tagged) + if tc.tag != "" { + if ok { + if tagged.Tag() != tc.tag { + t.Errorf("unexpected tag: got %q, expected %q", tagged.Tag(), tc.tag) + } + } else { + t.Errorf("expected tagged type, got %T", repo) } - } else { - failf("expected tagged type, got %T", repo) + } else if ok { + t.Errorf("unexpected tagged type") } - } else if ok { - failf("unexpected tagged type") - } - digested, ok := repo.(Digested) - if testcase.digest != "" { - if ok { - if digested.Digest().String() != testcase.digest { - failf("unexpected digest: got %q, expected %q", digested.Digest().String(), testcase.digest) + digested, ok := repo.(Digested) + if tc.digest != "" { + if ok { + if digested.Digest().String() != tc.digest { + t.Errorf("unexpected digest: got %q, expected %q", digested.Digest().String(), tc.digest) + } + } else { + t.Errorf("expected digested type, got %T", repo) } - } else { - failf("expected digested type, got %T", repo) + } else if ok { + t.Errorf("unexpected digested type") } - } else if ok { - failf("unexpected digested type") - } + }) } } // TestWithNameFailure tests cases where WithName should fail. Cases where it // should succeed are covered by TestSplitHostname, below. func TestWithNameFailure(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { input string err error }{ { input: "", - err: ErrNameEmpty, + err: ErrReferenceInvalidFormat, }, { input: ":justtag", @@ -258,7 +363,11 @@ func TestWithNameFailure(t *testing.T) { err: ErrReferenceInvalidFormat, }, { - input: strings.Repeat("a/", 128) + "a:tag", + input: "example.com/repo:tag", + err: ErrReferenceInvalidFormat, + }, + { + input: "example.com/" + strings.Repeat("a", 256), err: ErrNameTooLong, }, { @@ -266,73 +375,71 @@ func TestWithNameFailure(t *testing.T) { err: ErrReferenceInvalidFormat, }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.input)+": "+format, v...) - t.Fail() - } - - _, err := WithName(testcase.input) - if err == nil { - failf("no error parsing name. expected: %s", testcase.err) - } + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + _, err := WithName(tc.input) + if !errors.Is(err, tc.err) { + t.Errorf("unexpected error parsing name. expected: %s, got: %s", tc.err, err) + } + }) } } -func TestSplitHostname(t *testing.T) { - testcases := []struct { +func TestDomainAndPath(t *testing.T) { + t.Parallel() + tests := []struct { input string domain string - name string + path string }{ { input: "test.com/foo", domain: "test.com", - name: "foo", + path: "foo", }, { input: "test_com/foo", domain: "", - name: "test_com/foo", + path: "test_com/foo", }, { input: "test:8080/foo", domain: "test:8080", - name: "foo", + path: "foo", }, { input: "test.com:8080/foo", domain: "test.com:8080", - name: "foo", + path: "foo", }, { input: "test-com:8080/foo", domain: "test-com:8080", - name: "foo", + path: "foo", }, { input: "xn--n3h.com:18080/foo", domain: "xn--n3h.com:18080", - name: "foo", + path: "foo", }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.input)+": "+format, v...) - t.Fail() - } - - named, err := WithName(testcase.input) - if err != nil { - failf("error parsing name: %s", err) - } - domain, name := SplitHostname(named) - if domain != testcase.domain { - failf("unexpected domain: got %q, expected %q", domain, testcase.domain) - } - if name != testcase.name { - failf("unexpected name: got %q, expected %q", name, testcase.name) - } + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + named, err := WithName(tc.input) + if err != nil { + t.Errorf("error parsing name: %s", err) + } + if domain := Domain(named); domain != tc.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, tc.domain) + } + if path := Path(named); path != tc.path { + t.Errorf("unexpected name: got %q, expected %q", path, tc.path) + } + }) } } @@ -342,7 +449,8 @@ type serializationType struct { } func TestSerialization(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { description string input string name string @@ -372,100 +480,100 @@ func TestSerialization(t *testing.T) { digest: "sha256:1234567890098765432112345667890098765432112345667890098765432112", }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.input)+": "+format, v...) - t.Fail() - } - - m := map[string]string{ - "Description": testcase.description, - "Field": testcase.input, - } - b, err := json.Marshal(m) - if err != nil { - failf("error marshaling: %v", err) - } - t := serializationType{} - - if err := json.Unmarshal(b, &t); err != nil { - if testcase.err == nil { - failf("error unmarshaling: %v", err) + for _, tc := range tests { + tc := tc + t.Run(tc.description, func(t *testing.T) { + t.Parallel() + m := map[string]string{ + "Description": tc.description, + "Field": tc.input, } - if err != testcase.err { - failf("wrong error, expected %v, got %v", testcase.err, err) + b, err := json.Marshal(m) + if err != nil { + t.Errorf("error marshalling: %v", err) } + st := serializationType{} + + if err := json.Unmarshal(b, &st); err != nil { + if tc.err == nil { + t.Errorf("error unmarshalling: %v", err) + } + if err != tc.err { + t.Errorf("wrong error, expected %v, got %v", tc.err, err) + } - continue - } else if testcase.err != nil { - failf("expected error unmarshaling: %v", testcase.err) - } + return + } else if tc.err != nil { + t.Errorf("expected error unmarshalling: %v", tc.err) + } - if t.Description != testcase.description { - failf("wrong description, expected %q, got %q", testcase.description, t.Description) - } + if st.Description != tc.description { + t.Errorf("wrong description, expected %q, got %q", tc.description, st.Description) + } - ref := t.Field.Reference() + ref := st.Field.Reference() - if named, ok := ref.(Named); ok { - if named.Name() != testcase.name { - failf("unexpected repository: got %q, expected %q", named.Name(), testcase.name) + if named, ok := ref.(Named); ok { + if named.Name() != tc.name { + t.Errorf("unexpected repository: got %q, expected %q", named.Name(), tc.name) + } + } else if tc.name != "" { + t.Errorf("expected named type, got %T", ref) } - } else if testcase.name != "" { - failf("expected named type, got %T", ref) - } - tagged, ok := ref.(Tagged) - if testcase.tag != "" { - if ok { - if tagged.Tag() != testcase.tag { - failf("unexpected tag: got %q, expected %q", tagged.Tag(), testcase.tag) + tagged, ok := ref.(Tagged) + if tc.tag != "" { + if ok { + if tagged.Tag() != tc.tag { + t.Errorf("unexpected tag: got %q, expected %q", tagged.Tag(), tc.tag) + } + } else { + t.Errorf("expected tagged type, got %T", ref) } - } else { - failf("expected tagged type, got %T", ref) + } else if ok { + t.Errorf("unexpected tagged type") } - } else if ok { - failf("unexpected tagged type") - } - digested, ok := ref.(Digested) - if testcase.digest != "" { - if ok { - if digested.Digest().String() != testcase.digest { - failf("unexpected digest: got %q, expected %q", digested.Digest().String(), testcase.digest) + digested, ok := ref.(Digested) + if tc.digest != "" { + if ok { + if digested.Digest().String() != tc.digest { + t.Errorf("unexpected digest: got %q, expected %q", digested.Digest().String(), tc.digest) + } + } else { + t.Errorf("expected digested type, got %T", ref) } - } else { - failf("expected digested type, got %T", ref) + } else if ok { + t.Errorf("unexpected digested type") } - } else if ok { - failf("unexpected digested type") - } - t = serializationType{ - Description: testcase.description, - Field: AsField(ref), - } + st = serializationType{ + Description: tc.description, + Field: AsField(ref), + } - b2, err := json.Marshal(t) - if err != nil { - failf("error marshaling serialization type: %v", err) - } + b2, err := json.Marshal(st) + if err != nil { + t.Errorf("error marshing serialization type: %v", err) + } - if string(b) != string(b2) { - failf("unexpected serialized value: expected %q, got %q", string(b), string(b2)) - } + if string(b) != string(b2) { + t.Errorf("unexpected serialized value: expected %q, got %q", string(b), string(b2)) + } - // Ensure t.Field is not implementing "Reference" directly, getting - // around the Reference type system - var fieldInterface interface{} = t.Field - if _, ok := fieldInterface.(Reference); ok { - failf("field should not implement Reference interface") - } + // Ensure st.Field is not implementing "Reference" directly, getting + // around the Reference type system + var fieldInterface interface{} = st.Field + if _, ok := fieldInterface.(Reference); ok { + t.Errorf("field should not implement Reference interface") + } + }) } } func TestWithTag(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { name string digest digest.Digest tag string @@ -498,36 +606,36 @@ func TestWithTag(t *testing.T) { combined: "test.com:8000/foo:TAG5@sha256:1234567890098765432112345667890098765", }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.name)+": "+format, v...) - t.Fail() - } - - named, err := WithName(testcase.name) - if err != nil { - failf("error parsing name: %s", err) - } - if testcase.digest != "" { - canonical, err := WithDigest(named, testcase.digest) + for _, tc := range tests { + tc := tc + t.Run(tc.combined, func(t *testing.T) { + t.Parallel() + named, err := WithName(tc.name) if err != nil { - failf("error adding digest") + t.Errorf("error parsing name: %s", err) + } + if tc.digest != "" { + canonical, err := WithDigest(named, tc.digest) + if err != nil { + t.Errorf("error adding digest") + } + named = canonical } - named = canonical - } - tagged, err := WithTag(named, testcase.tag) - if err != nil { - failf("WithTag failed: %s", err) - } - if tagged.String() != testcase.combined { - failf("unexpected: got %q, expected %q", tagged.String(), testcase.combined) - } + tagged, err := WithTag(named, tc.tag) + if err != nil { + t.Errorf("WithTag failed: %s", err) + } + if tagged.String() != tc.combined { + t.Errorf("unexpected: got %q, expected %q", tagged.String(), tc.combined) + } + }) } } func TestWithDigest(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { name string digest digest.Digest tag string @@ -555,49 +663,49 @@ func TestWithDigest(t *testing.T) { combined: "test.com:8000/foo:latest@sha256:1234567890098765432112345667890098765", }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.name)+": "+format, v...) - t.Fail() - } - - named, err := WithName(testcase.name) - if err != nil { - failf("error parsing name: %s", err) - } - if testcase.tag != "" { - tagged, err := WithTag(named, testcase.tag) + for _, tc := range tests { + tc := tc + t.Run(tc.combined, func(t *testing.T) { + t.Parallel() + named, err := WithName(tc.name) if err != nil { - failf("error adding tag") + t.Errorf("error parsing name: %s", err) } - named = tagged - } - digested, err := WithDigest(named, testcase.digest) - if err != nil { - failf("WithDigest failed: %s", err) - } - if digested.String() != testcase.combined { - failf("unexpected: got %q, expected %q", digested.String(), testcase.combined) - } + if tc.tag != "" { + tagged, err := WithTag(named, tc.tag) + if err != nil { + t.Errorf("error adding tag") + } + named = tagged + } + digested, err := WithDigest(named, tc.digest) + if err != nil { + t.Errorf("WithDigest failed: %s", err) + } + if digested.String() != tc.combined { + t.Errorf("unexpected: got %q, expected %q", digested.String(), tc.combined) + } + }) } } func TestParseNamed(t *testing.T) { - testcases := []struct { + t.Parallel() + tests := []struct { input string domain string - name string + path string err error }{ { input: "test.com/foo", domain: "test.com", - name: "foo", + path: "foo", }, { input: "test:8080/foo", domain: "test:8080", - name: "foo", + path: "foo", }, { input: "test_com/foo", @@ -618,7 +726,7 @@ func TestParseNamed(t *testing.T) { { input: "docker.io/library/foo", domain: "docker.io", - name: "library/foo", + path: "library/foo", }, // Ambiguous case, parser will add "library/" to foo { @@ -626,32 +734,30 @@ func TestParseNamed(t *testing.T) { err: ErrNameNotCanonical, }, } - for _, testcase := range testcases { - failf := func(format string, v ...interface{}) { - t.Logf(strconv.Quote(testcase.input)+": "+format, v...) - t.Fail() - } - - named, err := ParseNamed(testcase.input) - if err != nil && testcase.err == nil { - failf("error parsing name: %s", err) - continue - } else if err == nil && testcase.err != nil { - failf("parsing succeeded: expected error %v", testcase.err) - continue - } else if err != testcase.err { - failf("unexpected error %v, expected %v", err, testcase.err) - continue - } else if err != nil { - continue - } + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + named, err := ParseNamed(tc.input) + if err != nil && tc.err == nil { + t.Errorf("error parsing name: %s", err) + return + } else if err == nil && tc.err != nil { + t.Errorf("parsing succeeded: expected error %v", tc.err) + return + } else if err != tc.err { + t.Errorf("unexpected error %v, expected %v", err, tc.err) + return + } else if err != nil { + return + } - domain, name := SplitHostname(named) - if domain != testcase.domain { - failf("unexpected domain: got %q, expected %q", domain, testcase.domain) - } - if name != testcase.name { - failf("unexpected name: got %q, expected %q", name, testcase.name) - } + if domain := Domain(named); domain != tc.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, tc.domain) + } + if path := Path(named); path != tc.path { + t.Errorf("unexpected name: got %q, expected %q", path, tc.path) + } + }) } } diff --git a/docker/reference/regexp-additions.go b/docker/reference/regexp-additions.go deleted file mode 100644 index 7b15871f7..000000000 --- a/docker/reference/regexp-additions.go +++ /dev/null @@ -1,6 +0,0 @@ -package reference - -// Return true if the specified string fully matches `IdentifierRegexp`. -func IsFullIdentifier(s string) bool { - return anchoredIdentifierRegexp.MatchString(s) -} diff --git a/docker/reference/regexp.go b/docker/reference/regexp.go index 76ba5c2d5..65bc49d79 100644 --- a/docker/reference/regexp.go +++ b/docker/reference/regexp.go @@ -3,154 +3,161 @@ package reference import ( "regexp" "strings" - - storageRegexp "github.com/containers/storage/pkg/regexp" ) +// DigestRegexp matches well-formed digests, including algorithm (e.g. "sha256:"). +var DigestRegexp = regexp.MustCompile(digestPat) + +// DomainRegexp matches hostname or IP-addresses, optionally including a port +// number. It defines the structure of potential domain components that may be +// part of image names. This is purposely a subset of what is allowed by DNS to +// ensure backwards compatibility with Docker image names. It may be a subset of +// DNS domain name, an IPv4 address in decimal format, or an IPv6 address between +// square brackets (excluding zone identifiers as defined by [RFC 6874] or special +// addresses such as IPv4-Mapped). +// +// [RFC 6874]: https://www.rfc-editor.org/rfc/rfc6874. +var DomainRegexp = regexp.MustCompile(domainAndPort) + +// IdentifierRegexp is the format for string identifier used as a +// content addressable identifier using sha256. These identifiers +// are like digests without the algorithm, since sha256 is used. +var IdentifierRegexp = regexp.MustCompile(identifier) + +// NameRegexp is the format for the name component of references, including +// an optional domain and port, but without tag or digest suffix. +var NameRegexp = regexp.MustCompile(namePat) + +// ReferenceRegexp is the full supported format of a reference. The regexp +// is anchored and has capturing groups for name, tag, and digest +// components. +var ReferenceRegexp = regexp.MustCompile(referencePat) + +// TagRegexp matches valid tag names. From [docker/docker:graph/tags.go]. +// +// [docker/docker:graph/tags.go]: https://github.com/moby/moby/blob/v1.6.0/graph/tags.go#L26-L28 +var TagRegexp = regexp.MustCompile(tag) + const ( - // alphaNumeric defines the alpha numeric atom, typically a + // alphanumeric defines the alphanumeric atom, typically a // component of names. This only allows lower case characters and digits. - alphaNumeric = `[a-z0-9]+` + alphanumeric = `[a-z0-9]+` // separator defines the separators allowed to be embedded in name - // components. This allow one period, one or two underscore and multiple + // components. This allows one period, one or two underscore and multiple // dashes. Repeated dashes and underscores are intentionally treated // differently. In order to support valid hostnames as name components, // supporting repeated dash was added. Additionally double underscore is // now allowed as a separator to loosen the restriction for previously // supported names. - separator = `(?:[._]|__|[-]*)` + separator = `(?:[._]|__|[-]+)` + + // localhost is treated as a special value for domain-name. Any other + // domain-name without a "." or a ":port" are considered a path component. + localhost = `localhost` - // repository name to start with a component as defined by DomainRegexp - // and followed by an optional port. - domainComponent = `(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])` + // domainNameComponent restricts the registry domain component of a + // repository name to start with a component as defined by DomainRegexp. + domainNameComponent = `(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])` - // The string counterpart for TagRegexp. + // optionalPort matches an optional port-number including the port separator + // (e.g. ":80"). + optionalPort = `(?::[0-9]+)?` + + // tag matches valid tag names. From docker/docker:graph/tags.go. tag = `[\w][\w.-]{0,127}` - // The string counterpart for DigestRegexp. + // digestPat matches well-formed digests, including algorithm (e.g. "sha256:"). + // + // TODO(thaJeztah): this should follow the same rules as https://pkg.go.dev/github.com/opencontainers/go-digest@v1.0.0#DigestRegexp + // so that go-digest defines the canonical format. Note that the go-digest is + // more relaxed: + // - it allows multiple algorithms (e.g. "sha256+b64:") to allow + // future expansion of supported algorithms. + // - it allows the "" value to use urlsafe base64 encoding as defined + // in [rfc4648, section 5]. + // + // [rfc4648, section 5]: https://www.rfc-editor.org/rfc/rfc4648#section-5. digestPat = `[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}` - // The string counterpart for IdentifierRegexp. + // identifier is the format for a content addressable identifier using sha256. + // These identifiers are like digests without the algorithm, since sha256 is used. identifier = `([a-f0-9]{64})` - // The string counterpart for ShortIdentifierRegexp. - shortIdentifier = `([a-f0-9]{6,64})` + // ipv6address are enclosed between square brackets and may be represented + // in many ways, see rfc5952. Only IPv6 in compressed or uncompressed format + // are allowed, IPv6 zone identifiers (rfc6874) or Special addresses such as + // IPv4-Mapped are deliberately excluded. + ipv6address = `\[(?:[a-fA-F0-9:]+)\]` ) var ( - // nameComponent restricts registry path component names to start - // with at least one letter or number, with following parts able to be - // separated by one period, one or two underscore and multiple dashes. - nameComponent = expression( - alphaNumeric, - optional(repeated(separator, alphaNumeric))) - - domain = expression( - domainComponent, - optional(repeated(literal(`.`), domainComponent)), - optional(literal(`:`), `[0-9]+`)) - // DomainRegexp defines the structure of potential domain components + // domainName defines the structure of potential domain components // that may be part of image names. This is purposely a subset of what is // allowed by DNS to ensure backwards compatibility with Docker image - // names. - DomainRegexp = re(domain) + // names. This includes IPv4 addresses on decimal format. + domainName = domainNameComponent + anyTimes(`\.`+domainNameComponent) + + // host defines the structure of potential domains based on the URI + // Host subcomponent on rfc3986. It may be a subset of DNS domain name, + // or an IPv4 address in decimal format, or an IPv6 address between square + // brackets (excluding zone identifiers as defined by rfc6874 or special + // addresses such as IPv4-Mapped). + host = `(?:` + domainName + `|` + ipv6address + `)` - // TagRegexp matches valid tag names. From docker/docker:graph/tags.go. - TagRegexp = re(tag) + // allowed by the URI Host subcomponent on rfc3986 to ensure backwards + // compatibility with Docker image names. + domainAndPort = host + optionalPort - anchoredTag = anchored(tag) // anchoredTagRegexp matches valid tag names, anchored at the start and // end of the matched string. - anchoredTagRegexp = storageRegexp.Delayed(anchoredTag) + anchoredTagRegexp = regexp.MustCompile(anchored(tag)) - // DigestRegexp matches valid digests. - DigestRegexp = re(digestPat) - - anchoredDigest = anchored(digestPat) // anchoredDigestRegexp matches valid digests, anchored at the start and // end of the matched string. - anchoredDigestRegexp = storageRegexp.Delayed(anchoredDigest) - - namePat = expression( - optional(domain, literal(`/`)), - nameComponent, - optional(repeated(literal(`/`), nameComponent))) - // NameRegexp is the format for the name component of references. The - // regexp has capturing groups for the domain and name part omitting - // the separating forward slash from either. - NameRegexp = re(namePat) - - anchoredName = anchored( - optional(capture(domain), literal(`/`)), - capture(nameComponent, - optional(repeated(literal(`/`), nameComponent)))) + anchoredDigestRegexp = regexp.MustCompile(anchored(digestPat)) + + // pathComponent restricts path-components to start with an alphanumeric + // character, with following parts able to be separated by a separator + // (one period, one or two underscore and multiple dashes). + pathComponent = alphanumeric + anyTimes(separator+alphanumeric) + + // remoteName matches the remote-name of a repository. It consists of one + // or more forward slash (/) delimited path-components: + // + // pathComponent[[/pathComponent] ...] // e.g., "library/ubuntu" + remoteName = pathComponent + anyTimes(`/`+pathComponent) + namePat = optional(domainAndPort+`/`) + remoteName + // anchoredNameRegexp is used to parse a name value, capturing the // domain and trailing components. - anchoredNameRegexp = storageRegexp.Delayed(anchoredName) - - referencePat = anchored(capture(namePat), - optional(literal(":"), capture(tag)), - optional(literal("@"), capture(digestPat))) - // ReferenceRegexp is the full supported format of a reference. The regexp - // is anchored and has capturing groups for name, tag, and digest - // components. - ReferenceRegexp = re(referencePat) - - // IdentifierRegexp is the format for string identifier used as a - // content addressable identifier using sha256. These identifiers - // are like digests without the algorithm, since sha256 is used. - IdentifierRegexp = re(identifier) - - // ShortIdentifierRegexp is the format used to represent a prefix - // of an identifier. A prefix may be used to match a sha256 identifier - // within a list of trusted identifiers. - ShortIdentifierRegexp = re(shortIdentifier) - - anchoredIdentifier = anchored(identifier) + anchoredNameRegexp = regexp.MustCompile(anchored(optional(capture(domainAndPort), `/`), capture(remoteName))) + + referencePat = anchored(capture(namePat), optional(`:`, capture(tag)), optional(`@`, capture(digestPat))) + // anchoredIdentifierRegexp is used to check or match an // identifier value, anchored at start and end of string. - anchoredIdentifierRegexp = storageRegexp.Delayed(anchoredIdentifier) + anchoredIdentifierRegexp = regexp.MustCompile(anchored(identifier)) ) -// re compiles the string to a regular expression. -var re = regexp.MustCompile - -// literal compiles s into a literal regular expression, escaping any regexp -// reserved characters. -func literal(s string) string { - return regexp.QuoteMeta(s) -} - -// expression defines a full expression, where each regular expression must -// follow the previous. -func expression(res ...string) string { - return strings.Join(res, "") -} - // optional wraps the expression in a non-capturing group and makes the // production optional. func optional(res ...string) string { - return group(expression(res...)) + `?` -} - -// repeated wraps the regexp in a non-capturing group to get one or more -// matches. -func repeated(res ...string) string { - return group(expression(res...)) + `+` + return `(?:` + strings.Join(res, "") + `)?` } -// group wraps the regexp in a non-capturing group. -func group(res ...string) string { - return `(?:` + expression(res...) + `)` +// anyTimes wraps the expression in a non-capturing group that can occur +// any number of times. +func anyTimes(res ...string) string { + return `(?:` + strings.Join(res, "") + `)*` } // capture wraps the expression in a capturing group. func capture(res ...string) string { - return `(` + expression(res...) + `)` + return `(` + strings.Join(res, "") + `)` } // anchored anchors the regular expression by adding start and end delimiters. func anchored(res ...string) string { - return `^` + expression(res...) + `$` + return `^` + strings.Join(res, "") + `$` } diff --git a/docker/reference/regexp_bench_test.go b/docker/reference/regexp_bench_test.go new file mode 100644 index 000000000..12cb6077c --- /dev/null +++ b/docker/reference/regexp_bench_test.go @@ -0,0 +1,270 @@ +package reference + +import ( + "strings" + "testing" +) + +func BenchmarkParse(b *testing.B) { + tests := []regexpMatch{ + { + input: "", + match: false, + }, + { + input: "short", + match: true, + }, + { + input: "simple/name", + match: true, + }, + { + input: "library/ubuntu", + match: true, + }, + { + input: "docker/stevvooe/app", + match: true, + }, + { + input: "aa/aa/aa/aa/aa/aa/aa/aa/aa/bb/bb/bb/bb/bb/bb", + match: true, + }, + { + input: "aa/aa/bb/bb/bb", + match: true, + }, + { + input: "a/a/a/a", + match: true, + }, + { + input: "a/a/a/a/", + match: false, + }, + { + input: "a//a/a", + match: false, + }, + { + input: "a", + match: true, + }, + { + input: "a/aa", + match: true, + }, + { + input: "a/aa/a", + match: true, + }, + { + input: "foo.com", + match: true, + }, + { + input: "foo.com/", + match: false, + }, + { + input: "foo.com:8080/bar", + match: true, + }, + { + input: "foo.com:http/bar", + match: false, + }, + { + input: "foo.com/bar", + match: true, + }, + { + input: "foo.com/bar/baz", + match: true, + }, + { + input: "localhost:8080/bar", + match: true, + }, + { + input: "sub-dom1.foo.com/bar/baz/quux", + match: true, + }, + { + input: "blog.foo.com/bar/baz", + match: true, + }, + { + input: "a^a", + match: false, + }, + { + input: "aa/asdf$$^/aa", + match: false, + }, + { + input: "asdf$$^/aa", + match: false, + }, + { + input: "aa-a/a", + match: true, + }, + { + input: strings.Repeat("a/", 128) + "a", + match: true, + }, + { + input: "a-/a/a/a", + match: false, + }, + { + input: "foo.com/a-/a/a", + match: false, + }, + { + input: "-foo/bar", + match: false, + }, + { + input: "foo/bar-", + match: false, + }, + { + input: "foo-/bar", + match: false, + }, + { + input: "foo/-bar", + match: false, + }, + { + input: "_foo/bar", + match: false, + }, + { + input: "foo_bar", + match: true, + }, + { + input: "foo_bar.com", + match: true, + }, + { + input: "foo_bar.com:8080", + match: false, + }, + { + input: "foo_bar.com:8080/app", + match: false, + }, + { + input: "foo.com/foo_bar", + match: true, + }, + { + input: "____/____", + match: false, + }, + { + input: "_docker/_docker", + match: false, + }, + { + input: "docker_/docker_", + match: false, + }, + { + input: "b.gcr.io/test.example.com/my-app", + match: true, + }, + { + input: "xn--n3h.com/myimage", // ☃.com in punycode + match: true, + }, + { + input: "xn--7o8h.com/myimage", // 🐳.com in punycode + match: true, + }, + { + input: "example.com/xn--7o8h.com/myimage", // 🐳.com in punycode + match: true, + }, + { + input: "example.com/some_separator__underscore/myimage", + match: true, + }, + { + input: "example.com/__underscore/myimage", + match: false, + }, + { + input: "example.com/..dots/myimage", + match: false, + }, + { + input: "example.com/.dots/myimage", + match: false, + }, + { + input: "example.com/nodouble..dots/myimage", + match: false, + }, + { + input: "example.com/nodouble..dots/myimage", + match: false, + }, + { + input: "docker./docker", + match: false, + }, + { + input: ".docker/docker", + match: false, + }, + { + input: "docker-/docker", + match: false, + }, + { + input: "-docker/docker", + match: false, + }, + { + input: "do..cker/docker", + match: false, + }, + { + input: "do__cker:8080/docker", + match: false, + }, + { + input: "do__cker/docker", + match: true, + }, + { + input: "b.gcr.io/test.example.com/my-app", + match: true, + }, + { + input: "registry.io/foo/project--id.module--name.ver---sion--name", + match: true, + }, + { + input: "Asdf.com/foo/bar", // uppercase character in hostname + match: true, + }, + { + input: "Foo/FarB", // uppercase characters in remote name + match: false, + }, + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + for _, tc := range tests { + _, _ = Parse(tc.input) + } + } +} diff --git a/docker/reference/regexp_test.go b/docker/reference/regexp_test.go index 289e559fd..ca4680d31 100644 --- a/docker/reference/regexp_test.go +++ b/docker/reference/regexp_test.go @@ -11,12 +11,9 @@ type regexpMatch struct { match bool subs []string } -type Regex interface { - FindStringSubmatch(s string) []string - NumSubexp() int -} -func checkRegexp(t *testing.T, r Regex, m regexpMatch) { +func checkRegexp(t *testing.T, r *regexp.Regexp, m regexpMatch) { + t.Helper() matches := r.FindStringSubmatch(m.input) if m.match && matches != nil { if len(matches) != (r.NumSubexp()+1) || matches[0] != m.input { @@ -38,7 +35,11 @@ func checkRegexp(t *testing.T, r Regex, m regexpMatch) { } func TestDomainRegexp(t *testing.T) { - hostcases := []regexpMatch{ + t.Parallel() + tests := []struct { + input string + match bool + }{ { input: "test.com", match: true, @@ -119,20 +120,68 @@ func TestDomainRegexp(t *testing.T) { input: "Asdf.com", // uppercase character match: true, }, + { + input: "192.168.1.1:75050", // ipv4 + match: true, + }, + { + input: "192.168.1.1:750050", // port with more than 5 digits, it will fail on validation + match: true, + }, + { + input: "[fd00:1:2::3]:75050", // ipv6 compressed + match: true, + }, + { + input: "[fd00:1:2::3]75050", // ipv6 wrong port separator + match: false, + }, + { + input: "[fd00:1:2::3]::75050", // ipv6 wrong port separator + match: false, + }, + { + input: "[fd00:1:2::3%eth0]:75050", // ipv6 with zone + match: false, + }, + { + input: "[fd00123123123]:75050", // ipv6 wrong format, will fail in validation + match: true, + }, + { + input: "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:75050", // ipv6 long format + match: true, + }, + { + input: "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:750505", // ipv6 long format and invalid port, it will fail in validation + match: true, + }, + { + input: "fd00:1:2::3:75050", // bad ipv6 without square brackets + match: false, + }, } r := regexp.MustCompile(`^` + DomainRegexp.String() + `$`) - for i := range hostcases { - checkRegexp(t, r, hostcases[i]) + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + match := r.MatchString(tc.input) + if match != tc.match { + t.Errorf("Expected match=%t, got %t", tc.match, match) + } + }) } } func TestFullNameRegexp(t *testing.T) { + t.Parallel() if anchoredNameRegexp.NumSubexp() != 2 { t.Fatalf("anchored name regexp should have two submatches: %v, %v != 2", - anchoredNameRegexp.String(), anchoredNameRegexp.NumSubexp()) + anchoredNameRegexp, anchoredNameRegexp.NumSubexp()) } - testcases := []regexpMatch{ + tests := []regexpMatch{ { input: "", match: false, @@ -416,18 +465,23 @@ func TestFullNameRegexp(t *testing.T) { match: false, }, } - for i := range testcases { - checkRegexp(t, anchoredNameRegexp, testcases[i]) + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + checkRegexp(t, anchoredNameRegexp, tc) + }) } } func TestReferenceRegexp(t *testing.T) { + t.Parallel() if ReferenceRegexp.NumSubexp() != 3 { t.Fatalf("anchored name regexp should have three submatches: %v, %v != 3", ReferenceRegexp, ReferenceRegexp.NumSubexp()) } - testcases := []regexpMatch{ + tests := []regexpMatch{ { input: "registry.com:8080/myapp:tag", match: true, @@ -486,14 +540,21 @@ func TestReferenceRegexp(t *testing.T) { }, } - for i := range testcases { - checkRegexp(t, ReferenceRegexp, testcases[i]) + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + checkRegexp(t, ReferenceRegexp, tc) + }) } - } func TestIdentifierRegexp(t *testing.T) { - fullCases := []regexpMatch{ + t.Parallel() + tests := []struct { + input string + match bool + }{ { input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf9821", match: true, @@ -515,11 +576,14 @@ func TestIdentifierRegexp(t *testing.T) { match: false, }, } - - for i := range fullCases { - checkRegexp(t, anchoredIdentifierRegexp, fullCases[i]) - if IsFullIdentifier(fullCases[i].input) != fullCases[i].match { - t.Errorf("Expected match for %q to be %v", fullCases[i].input, fullCases[i].match) - } + for _, tc := range tests { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + match := anchoredIdentifierRegexp.MatchString(tc.input) + if match != tc.match { + t.Errorf("Expected match=%t, got %t", tc.match, match) + } + }) } } diff --git a/docker/reference/sort.go b/docker/reference/sort.go new file mode 100644 index 000000000..416c37b07 --- /dev/null +++ b/docker/reference/sort.go @@ -0,0 +1,75 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package reference + +import ( + "sort" +) + +// Sort sorts string references preferring higher information references. +// +// The precedence is as follows: +// +// 1. [Named] + [Tagged] + [Digested] (e.g., "docker.io/library/busybox:latest@sha256:") +// 2. [Named] + [Tagged] (e.g., "docker.io/library/busybox:latest") +// 3. [Named] + [Digested] (e.g., "docker.io/library/busybo@sha256:") +// 4. [Named] (e.g., "docker.io/library/busybox") +// 5. [Digested] (e.g., "docker.io@sha256:") +// 6. Parse error +func Sort(references []string) []string { + var prefs []Reference + var bad []string + + for _, ref := range references { + pref, err := ParseAnyReference(ref) + if err != nil { + bad = append(bad, ref) + } else { + prefs = append(prefs, pref) + } + } + sort.Slice(prefs, func(a, b int) bool { + ar := refRank(prefs[a]) + br := refRank(prefs[b]) + if ar == br { + return prefs[a].String() < prefs[b].String() + } + return ar < br + }) + sort.Strings(bad) + var refs []string + for _, pref := range prefs { + refs = append(refs, pref.String()) + } + return append(refs, bad...) +} + +func refRank(ref Reference) uint8 { + if _, ok := ref.(Named); ok { + if _, ok = ref.(Tagged); ok { + if _, ok = ref.(Digested); ok { + return 1 + } + return 2 + } + if _, ok = ref.(Digested); ok { + return 3 + } + return 4 + } + return 5 +} diff --git a/docker/reference/sort_test.go b/docker/reference/sort_test.go new file mode 100644 index 000000000..be0729046 --- /dev/null +++ b/docker/reference/sort_test.go @@ -0,0 +1,84 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package reference + +import ( + "io" + "math/rand" + "testing" + + "github.com/opencontainers/go-digest" +) + +func TestReferenceSorting(t *testing.T) { + t.Parallel() + digested := func(seed int64) string { + b, err := io.ReadAll(io.LimitReader(rand.New(rand.NewSource(seed)), 64)) + if err != nil { + panic(err) + } + return digest.FromBytes(b).String() + } + // Add z. prefix to string sort after "sha256:" + r1 := func(name, tag string, seed int64) string { + return "z.containerd.io/" + name + ":" + tag + "@" + digested(seed) + } + r2 := func(name, tag string) string { + return "z.containerd.io/" + name + ":" + tag + } + r3 := func(name string, seed int64) string { + return "z.containerd.io/" + name + "@" + digested(seed) + } + + for i, tc := range []struct { + unsorted []string + expected []string + }{ + { + unsorted: []string{r2("name", "latest"), r3("name", 1), r1("name", "latest", 1)}, + expected: []string{r1("name", "latest", 1), r2("name", "latest"), r3("name", 1)}, + }, + { + unsorted: []string{"can't parse this:latest", r3("name", 1), r2("name", "latest")}, + expected: []string{r2("name", "latest"), r3("name", 1), "can't parse this:latest"}, + }, + { + unsorted: []string{digested(1), r3("name", 1), r2("name", "latest")}, + expected: []string{r2("name", "latest"), r3("name", 1), digested(1)}, + }, + { + unsorted: []string{r2("name", "tag2"), r2("name", "tag3"), r2("name", "tag1")}, + expected: []string{r2("name", "tag1"), r2("name", "tag2"), r2("name", "tag3")}, + }, + { + unsorted: []string{r2("name-2", "tag"), r2("name-3", "tag"), r2("name-1", "tag")}, + expected: []string{r2("name-1", "tag"), r2("name-2", "tag"), r2("name-3", "tag")}, + }, + } { + sorted := Sort(tc.unsorted) + if len(sorted) != len(tc.expected) { + t.Errorf("[%d]: Mismatched sized, got %d, expected %d", i, len(sorted), len(tc.expected)) + continue + } + for j := range sorted { + if sorted[j] != tc.expected[j] { + t.Errorf("[%d]: Wrong value at %d, got %q, expected %q", i, j, sorted[j], tc.expected[j]) + break + } + } + } +}