diff --git a/docker/reference/normalize.go b/docker/reference/normalize.go index 6a86ec64fd..47de5850a2 100644 --- a/docker/reference/normalize.go +++ b/docker/reference/normalize.go @@ -1,17 +1,16 @@ package reference import ( - "errors" "fmt" "strings" "github.com/opencontainers/go-digest" ) -var ( +const ( legacyDefaultDomain = "index.docker.io" defaultDomain = "docker.io" - officialRepoName = "library" + officialRepoPrefix = "library/" defaultTag = "latest" ) @@ -41,7 +40,7 @@ func ParseNormalizedNamed(s string) (Named, error) { remoteName = remainder } if strings.ToLower(remoteName) != remoteName { - return nil, errors.New("invalid reference format: repository name must be lowercase") + return nil, fmt.Errorf("invalid reference format: repository name (%s) must be lowercase", remoteName) } ref, err := Parse(domain + "/" + remainder) @@ -89,7 +88,7 @@ func ParseDockerRef(ref string) (Named, error) { // 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") { + if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost" && strings.ToLower(name[:i]) == name[:i]) { domain, remainder = defaultDomain, name } else { domain, remainder = name[:i], name[i+1:] @@ -97,8 +96,13 @@ func splitDockerDomain(name string) (domain, remainder string) { if domain == legacyDefaultDomain { domain = defaultDomain } + // 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 domain == defaultDomain && !strings.ContainsRune(remainder, '/') { - remainder = officialRepoName + "/" + remainder + remainder = officialRepoPrefix + remainder } return } @@ -118,8 +122,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 diff --git a/docker/reference/normalize_test.go b/docker/reference/normalize_test.go index 08eda0644f..827366fe67 100644 --- a/docker/reference/normalize_test.go +++ b/docker/reference/normalize_test.go @@ -8,6 +8,7 @@ import ( ) func TestValidateReferenceName(t *testing.T) { + t.Parallel() validRepoNames := []string{ "docker/docker", "library/debian", @@ -21,12 +22,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 +47,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 +71,7 @@ func TestValidateReferenceName(t *testing.T) { } func TestValidateRemoteName(t *testing.T) { + t.Parallel() validRepositoryNames := []string{ // Sanity check. "docker/docker", @@ -69,7 +85,7 @@ func TestValidateRemoteName(t *testing.T) { // Allow multiple hyphens as well. "docker---rules/docker", - //Username doc and image name docker being tested. + // Username doc and image name docker being tested. "doc/docker", // single character names are now allowed. @@ -114,8 +130,8 @@ func TestValidateRemoteName(t *testing.T) { // No repository. "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", + // namespace too long + "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,6 +141,7 @@ func TestValidateRemoteName(t *testing.T) { } func TestParseRepositoryInfo(t *testing.T) { + t.Parallel() type tcase struct { RemoteName, FamiliarName, FullName, AmbiguousName, Domain string } @@ -228,6 +245,20 @@ 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 { @@ -264,6 +295,7 @@ func TestParseRepositoryInfo(t *testing.T) { } func TestParseReferenceWithTagAndDigest(t *testing.T) { + t.Parallel() shortRef := "busybox:latest@sha256:86e0e091d0da6bde2456dbb48306f3956bbeb2eae1b5b9a43045843f69fe4aaa" ref, err := ParseNormalizedNamed(shortRef) if err != nil { @@ -285,6 +317,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") } @@ -327,6 +360,7 @@ func equalReference(r1, r2 Reference) bool { } func TestParseAnyReference(t *testing.T) { + t.Parallel() tcases := []struct { Reference string Equivalent string @@ -386,6 +420,10 @@ func TestParseAnyReference(t *testing.T) { Reference: "dbcc1c35ac38df41fd2f5e4130b32ffdb93ebae8b3dbe638c23575912276fc9", Equivalent: "docker.io/library/dbcc1c35ac38df41fd2f5e4130b32ffdb93ebae8b3dbe638c23575912276fc9", }, + { + Reference: "dbcc1", + Equivalent: "docker.io/library/dbcc1", + }, } for _, tcase := range tcases { @@ -413,6 +451,7 @@ func TestParseAnyReference(t *testing.T) { } func TestNormalizedSplitHostname(t *testing.T) { + t.Parallel() testcases := []struct { input string domain string @@ -495,6 +534,7 @@ func TestNormalizedSplitHostname(t *testing.T) { } func TestMatchError(t *testing.T) { + t.Parallel() named, err := ParseAnyReference("foo") if err != nil { t.Fatal(err) @@ -506,6 +546,7 @@ func TestMatchError(t *testing.T) { } func TestMatch(t *testing.T) { + t.Parallel() matchCases := []struct { reference string pattern string @@ -573,6 +614,7 @@ func TestMatch(t *testing.T) { } func TestParseDockerRef(t *testing.T) { + t.Parallel() testcases := []struct { name string input string @@ -636,6 +678,7 @@ func TestParseDockerRef(t *testing.T) { } for _, test := range testcases { t.Run(test.name, func(t *testing.T) { + t.Parallel() normalized, err := ParseDockerRef(test.input) if err != nil { t.Fatal(err) diff --git a/docker/reference/reference.go b/docker/reference/reference.go index b7cd00b0d6..3499fbf8ff 100644 --- a/docker/reference/reference.go +++ b/docker/reference/reference.go @@ -5,7 +5,9 @@ // // reference := name [ ":" tag ] [ "@" digest ] // name := [domain '/'] path-component ['/' path-component]* -// domain := domain-component ['.' domain-component]* [':' port-number] +// 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 := alpha-numeric [separator alpha-numeric]* @@ -175,7 +177,8 @@ func splitDomain(name string) (string, string) { // 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 +// +// Deprecated: Use [Domain] or [Path]. func SplitHostname(named Named) (string, string) { if r, ok := named.(namedRepository); ok { return r.Domain(), r.Path() @@ -320,11 +323,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 7ba9935053..5d6878cfe9 100644 --- a/docker/reference/reference_test.go +++ b/docker/reference/reference_test.go @@ -4,7 +4,6 @@ import ( _ "crypto/sha256" _ "crypto/sha512" "encoding/json" - "strconv" "strings" "testing" @@ -12,6 +11,7 @@ import ( ) func TestReferenceParse(t *testing.T) { + t.Parallel() // referenceTestcases is a unified set of testcases for // testing the parsing of references referenceTestcases := []struct { @@ -102,11 +102,11 @@ func TestReferenceParse(t *testing.T) { 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 - //{ + // 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, @@ -171,73 +171,167 @@ 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, + }, } 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) + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + repo, err := Parse(testcase.input) + if testcase.err != nil { + if err == nil { + t.Errorf("missing expected error: %v", testcase.err) + } else if testcase.err != err { + t.Errorf("mismatched error: got %v, expected %v", err, testcase.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() != testcase.input { + t.Errorf("mismatched repo: got %q, expected %q", repo.String(), testcase.input) } - domain, _ := SplitHostname(named) - if domain != testcase.domain { - failf("unexpected domain: got %q, expected %q", domain, testcase.domain) - } - } 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) + if named, ok := repo.(Named); ok { + if named.Name() != testcase.repository { + t.Errorf("unexpected repository: got %q, expected %q", named.Name(), testcase.repository) + } + domain, _ := SplitHostname(named) + if domain != testcase.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, testcase.domain) } - } else { - failf("expected tagged type, got %T", repo) + } else if testcase.repository != "" || testcase.domain != "" { + t.Errorf("expected named type, got %T", repo) } - } 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) + tagged, ok := repo.(Tagged) + if testcase.tag != "" { + if ok { + if tagged.Tag() != testcase.tag { + t.Errorf("unexpected tag: got %q, expected %q", tagged.Tag(), testcase.tag) + } + } else { + t.Errorf("expected tagged type, got %T", repo) } - } else { - failf("expected digested type, got %T", repo) + } else if ok { + t.Errorf("unexpected tagged type") } - } else if ok { - failf("unexpected digested type") - } + digested, ok := repo.(Digested) + if testcase.digest != "" { + if ok { + if digested.Digest().String() != testcase.digest { + t.Errorf("unexpected digest: got %q, expected %q", digested.Digest().String(), testcase.digest) + } + } else { + t.Errorf("expected digested type, got %T", repo) + } + } else if ok { + t.Errorf("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) { + t.Parallel() testcases := []struct { input string err error @@ -268,19 +362,19 @@ func TestWithNameFailure(t *testing.T) { }, } 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) - } + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + _, err := WithName(testcase.input) + if err == nil { + t.Errorf("no error parsing name. expected: %s", testcase.err) + } + }) } } func TestSplitHostname(t *testing.T) { + t.Parallel() testcases := []struct { input string domain string @@ -318,22 +412,21 @@ func TestSplitHostname(t *testing.T) { }, } 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) - } + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + named, err := WithName(testcase.input) + if err != nil { + t.Errorf("error parsing name: %s", err) + } + domain, name := SplitHostname(named) + if domain != testcase.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, testcase.domain) + } + if name != testcase.name { + t.Errorf("unexpected name: got %q, expected %q", name, testcase.name) + } + }) } } @@ -343,6 +436,7 @@ type serializationType struct { } func TestSerialization(t *testing.T) { + t.Parallel() testcases := []struct { description string input string @@ -374,99 +468,98 @@ func TestSerialization(t *testing.T) { }, } 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) + testcase := testcase + t.Run(testcase.description, func(t *testing.T) { + t.Parallel() + m := map[string]string{ + "Description": testcase.description, + "Field": testcase.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{} - continue - } else if testcase.err != nil { - failf("expected error unmarshaling: %v", testcase.err) - } - - if t.Description != testcase.description { - failf("wrong description, expected %q, got %q", testcase.description, t.Description) - } + if err := json.Unmarshal(b, &st); err != nil { + if testcase.err == nil { + t.Errorf("error unmarshalling: %v", err) + } + if err != testcase.err { + t.Errorf("wrong error, expected %v, got %v", testcase.err, err) + } - ref := t.Field.Reference() + return + } else if testcase.err != nil { + t.Errorf("expected error unmarshalling: %v", testcase.err) + } - if named, ok := ref.(Named); ok { - if named.Name() != testcase.name { - failf("unexpected repository: got %q, expected %q", named.Name(), testcase.name) + if st.Description != testcase.description { + t.Errorf("wrong description, expected %q, got %q", testcase.description, st.Description) } - } 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) + ref := st.Field.Reference() + + if named, ok := ref.(Named); ok { + if named.Name() != testcase.name { + t.Errorf("unexpected repository: got %q, expected %q", named.Name(), testcase.name) } - } else { - failf("expected tagged type, got %T", ref) + } else if testcase.name != "" { + t.Errorf("expected named type, got %T", ref) } - } 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) + tagged, ok := ref.(Tagged) + if testcase.tag != "" { + if ok { + if tagged.Tag() != testcase.tag { + t.Errorf("unexpected tag: got %q, expected %q", tagged.Tag(), testcase.tag) + } + } else { + t.Errorf("expected tagged type, got %T", ref) } - } else { - failf("expected digested type, got %T", ref) + } else if ok { + t.Errorf("unexpected tagged type") } - } else if ok { - failf("unexpected digested type") - } - t = serializationType{ - Description: testcase.description, - Field: AsField(ref), - } + digested, ok := ref.(Digested) + if testcase.digest != "" { + if ok { + if digested.Digest().String() != testcase.digest { + t.Errorf("unexpected digest: got %q, expected %q", digested.Digest().String(), testcase.digest) + } + } else { + t.Errorf("expected digested type, got %T", ref) + } + } else if ok { + t.Errorf("unexpected digested type") + } - b2, err := json.Marshal(t) - if err != nil { - failf("error marshaling serialization type: %v", err) - } + st = serializationType{ + Description: testcase.description, + Field: AsField(ref), + } - if string(b) != string(b2) { - failf("unexpected serialized value: expected %q, got %q", string(b), string(b2)) - } + b2, err := json.Marshal(st) + if err != nil { + t.Errorf("error marshing serialization type: %v", err) + } - // 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") - } + if string(b) != string(b2) { + t.Errorf("unexpected serialized value: expected %q, got %q", string(b), string(b2)) + } + // 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) { + t.Parallel() testcases := []struct { name string digest digest.Digest @@ -501,34 +594,34 @@ func TestWithTag(t *testing.T) { }, } 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) + testcase := testcase + t.Run(testcase.combined, func(t *testing.T) { + t.Parallel() + named, err := WithName(testcase.name) if err != nil { - failf("error adding digest") + t.Errorf("error parsing name: %s", err) + } + if testcase.digest != "" { + canonical, err := WithDigest(named, testcase.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, testcase.tag) + if err != nil { + t.Errorf("WithTag failed: %s", err) + } + if tagged.String() != testcase.combined { + t.Errorf("unexpected: got %q, expected %q", tagged.String(), testcase.combined) + } + }) } } func TestWithDigest(t *testing.T) { + t.Parallel() testcases := []struct { name string digest digest.Digest @@ -558,33 +651,33 @@ func TestWithDigest(t *testing.T) { }, } 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) + testcase := testcase + t.Run(testcase.combined, func(t *testing.T) { + t.Parallel() + named, err := WithName(testcase.name) + if err != nil { + t.Errorf("error parsing name: %s", err) + } + if testcase.tag != "" { + tagged, err := WithTag(named, testcase.tag) + if err != nil { + t.Errorf("error adding tag") + } + named = tagged + } + digested, err := WithDigest(named, testcase.digest) if err != nil { - failf("error adding tag") + t.Errorf("WithDigest failed: %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 digested.String() != testcase.combined { + t.Errorf("unexpected: got %q, expected %q", digested.String(), testcase.combined) + } + }) } } func TestParseNamed(t *testing.T) { + t.Parallel() testcases := []struct { input string domain string @@ -629,31 +722,30 @@ func TestParseNamed(t *testing.T) { }, } 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 - } + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + named, err := ParseNamed(testcase.input) + if err != nil && testcase.err == nil { + t.Errorf("error parsing name: %s", err) + return + } else if err == nil && testcase.err != nil { + t.Errorf("parsing succeeded: expected error %v", testcase.err) + return + } else if err != testcase.err { + t.Errorf("unexpected error %v, expected %v", err, testcase.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) - } + domain, name := SplitHostname(named) + if domain != testcase.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, testcase.domain) + } + if name != testcase.name { + t.Errorf("unexpected name: got %q, expected %q", name, testcase.name) + } + }) } } diff --git a/docker/reference/regexp.go b/docker/reference/regexp.go index 7860349320..42f86b8be1 100644 --- a/docker/reference/regexp.go +++ b/docker/reference/regexp.go @@ -3,141 +3,167 @@ package reference import "regexp" var ( - // alphaNumericRegexp defines the alpha numeric atom, typically a + // alphaNumeric defines the alpha numeric atom, typically a // component of names. This only allows lower case characters and digits. - alphaNumericRegexp = match(`[a-z0-9]+`) + alphaNumeric = `[a-z0-9]+` - // separatorRegexp defines the separators allowed to be embedded in name + // separator defines the separators allowed to be embedded in name // components. This allow one period, one or two underscore and multiple - // dashes. - separatorRegexp = match(`(?:[._]|__|[-]*)`) - - // nameComponentRegexp restricts registry path component names to start + // 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 = `(?:[._]|__|[-]*)` + + // 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. - nameComponentRegexp = expression( - alphaNumericRegexp, - optional(repeated(separatorRegexp, alphaNumericRegexp))) - - // domainComponentRegexp restricts the registry domain component of a - // repository name to start with a component as defined by DomainRegexp - // and followed by an optional port. - domainComponentRegexp = match(`(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])`) + nameComponent = expression( + alphaNumeric, + optional(repeated(separator, alphaNumeric))) + + // 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])` + + // 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 = expression( + literal(`[`), `(?:[a-fA-F0-9:]+)`, literal(`]`), + ) + + // 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. This includes IPv4 addresses on decimal format. + domainName = expression( + domainNameComponent, + optional(repeated(literal(`.`), 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 + `)` + + // allowed by the URI Host subcomponent on rfc3986 to ensure backwards + // compatibility with Docker image names. + domain = expression( + host, + optional(literal(`:`), `[0-9]+`)) // DomainRegexp 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 = expression( - domainComponentRegexp, - optional(repeated(literal(`.`), domainComponentRegexp)), - optional(literal(`:`), match(`[0-9]+`))) + DomainRegexp = regexp.MustCompile(domain) + tag = `[\w][\w.-]{0,127}` // TagRegexp matches valid tag names. From docker/docker:graph/tags.go. - TagRegexp = match(`[\w][\w.-]{0,127}`) + TagRegexp = regexp.MustCompile(tag) + anchoredTag = anchored(tag) // anchoredTagRegexp matches valid tag names, anchored at the start and // end of the matched string. - anchoredTagRegexp = anchored(TagRegexp) + anchoredTagRegexp = regexp.MustCompile(anchoredTag) + digestPat = `[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}` // DigestRegexp matches valid digests. - DigestRegexp = match(`[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}`) + DigestRegexp = regexp.MustCompile(digestPat) + anchoredDigest = anchored(digestPat) // anchoredDigestRegexp matches valid digests, anchored at the start and // end of the matched string. - anchoredDigestRegexp = anchored(DigestRegexp) + anchoredDigestRegexp = regexp.MustCompile(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 = expression( - optional(DomainRegexp, literal(`/`)), - nameComponentRegexp, - optional(repeated(literal(`/`), nameComponentRegexp))) + NameRegexp = regexp.MustCompile(namePat) + anchoredName = anchored( + optional(capture(domain), literal(`/`)), + capture(nameComponent, + optional(repeated(literal(`/`), nameComponent)))) // anchoredNameRegexp is used to parse a name value, capturing the // domain and trailing components. - anchoredNameRegexp = anchored( - optional(capture(DomainRegexp), literal(`/`)), - capture(nameComponentRegexp, - optional(repeated(literal(`/`), nameComponentRegexp)))) + anchoredNameRegexp = regexp.MustCompile(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 = anchored(capture(NameRegexp), - optional(literal(":"), capture(TagRegexp)), - optional(literal("@"), capture(DigestRegexp))) + ReferenceRegexp = regexp.MustCompile(referencePat) + identifier = `([a-f0-9]{64})` // 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 = match(`([a-f0-9]{64})`) - - // 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 = match(`([a-f0-9]{6,64})`) + IdentifierRegexp = regexp.MustCompile(identifier) + anchoredIdentifier = anchored(identifier) // anchoredIdentifierRegexp is used to check or match an // identifier value, anchored at start and end of string. - anchoredIdentifierRegexp = anchored(IdentifierRegexp) - - // anchoredShortIdentifierRegexp is used to check if a value - // is a possible identifier prefix, anchored at start and end - // of string. - anchoredShortIdentifierRegexp = anchored(ShortIdentifierRegexp) + anchoredIdentifierRegexp = regexp.MustCompile(anchoredIdentifier) ) -// match compiles the string to a regular expression. -var match = regexp.MustCompile - // literal compiles s into a literal regular expression, escaping any regexp // reserved characters. -func literal(s string) *regexp.Regexp { - re := match(regexp.QuoteMeta(s)) +func literal(s string) string { + re := regexp.MustCompile(regexp.QuoteMeta(s)) if _, complete := re.LiteralPrefix(); !complete { panic("must be a literal") } - return re + return re.String() } // expression defines a full expression, where each regular expression must // follow the previous. -func expression(res ...*regexp.Regexp) *regexp.Regexp { +func expression(res ...string) string { var s string for _, re := range res { - s += re.String() + s += re } - return match(s) + return s } // optional wraps the expression in a non-capturing group and makes the // production optional. -func optional(res ...*regexp.Regexp) *regexp.Regexp { - return match(group(expression(res...)).String() + `?`) +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 ...*regexp.Regexp) *regexp.Regexp { - return match(group(expression(res...)).String() + `+`) +func repeated(res ...string) string { + return group(expression(res...)) + `+` } // group wraps the regexp in a non-capturing group. -func group(res ...*regexp.Regexp) *regexp.Regexp { - return match(`(?:` + expression(res...).String() + `)`) +func group(res ...string) string { + return `(?:` + expression(res...) + `)` } // capture wraps the expression in a capturing group. -func capture(res ...*regexp.Regexp) *regexp.Regexp { - return match(`(` + expression(res...).String() + `)`) +func capture(res ...string) string { + return `(` + expression(res...) + `)` } // anchored anchors the regular expression by adding start and end delimiters. -func anchored(res ...*regexp.Regexp) *regexp.Regexp { - return match(`^` + expression(res...).String() + `$`) +func anchored(res ...string) string { + return `^` + expression(res...) + `$` } diff --git a/docker/reference/regexp_test.go b/docker/reference/regexp_test.go index 48f721943b..5d6878cfe9 100644 --- a/docker/reference/regexp_test.go +++ b/docker/reference/regexp_test.go @@ -1,556 +1,751 @@ package reference import ( - "regexp" + _ "crypto/sha256" + _ "crypto/sha512" + "encoding/json" "strings" "testing" -) - -type regexpMatch struct { - input string - match bool - subs []string -} -func checkRegexp(t *testing.T, r *regexp.Regexp, m regexpMatch) { - matches := r.FindStringSubmatch(m.input) - if m.match && matches != nil { - if len(matches) != (r.NumSubexp()+1) || matches[0] != m.input { - t.Fatalf("Bad match result %#v for %q", matches, m.input) - } - if len(matches) < (len(m.subs) + 1) { - t.Errorf("Expected %d sub matches, only have %d for %q", len(m.subs), len(matches)-1, m.input) - } - for i := range m.subs { - if m.subs[i] != matches[i+1] { - t.Errorf("Unexpected submatch %d: %q, expected %q for %q", i+1, matches[i+1], m.subs[i], m.input) - } - } - } else if m.match { - t.Errorf("Expected match for %q", m.input) - } else if matches != nil { - t.Errorf("Unexpected match for %q", m.input) - } -} + "github.com/opencontainers/go-digest" +) -func TestDomainRegexp(t *testing.T) { - hostcases := []regexpMatch{ - { - input: "test.com", - match: true, - }, - { - input: "test.com:10304", - match: true, - }, - { - input: "test.com:http", - match: false, - }, - { - input: "localhost", - match: true, - }, - { - input: "localhost:8080", - match: true, - }, +func TestReferenceParse(t *testing.T) { + t.Parallel() + // referenceTestcases is a unified set of testcases for + // testing the parsing of references + referenceTestcases := []struct { + // input is the repository name or name component testcase + input string + // err is the error expected from Parse, or nil + err error + // repository is the string representation for the reference + repository string + // domain is the domain expected in the reference + domain string + // tag is the tag for the reference + tag string + // digest is the digest for the reference (enforces digest reference) + digest string + }{ { - input: "a", - match: true, + input: "test_com", + repository: "test_com", }, { - input: "a.b", - match: true, + input: "test.com:tag", + repository: "test.com", + tag: "tag", }, { - input: "ab.cd.com", - match: true, + input: "test.com:5000", + repository: "test.com", + tag: "5000", }, { - input: "a-b.com", - match: true, + input: "test.com/repo:tag", + domain: "test.com", + repository: "test.com/repo", + tag: "tag", }, { - input: "-ab.com", - match: false, + input: "test:5000/repo", + domain: "test:5000", + repository: "test:5000/repo", }, { - input: "ab-.com", - match: false, + input: "test:5000/repo:tag", + domain: "test:5000", + repository: "test:5000/repo", + tag: "tag", }, { - input: "ab.c-om", - match: true, + input: "test:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + domain: "test:5000", + repository: "test:5000/repo", + digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }, { - input: "ab.-com", - match: false, + input: "test:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + domain: "test:5000", + repository: "test:5000/repo", + tag: "tag", + digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }, { - input: "ab.com-", - match: false, + input: "test:5000/repo", + domain: "test:5000", + repository: "test:5000/repo", }, - { - input: "0101.com", - match: true, // TODO(dmcgowan): valid if this should be allowed - }, - { - input: "001a.com", - match: true, - }, - { - input: "b.gbc.io:443", - match: true, - }, - { - input: "b.gbc.io", - match: true, - }, - { - input: "xn--n3h.com", // ☃.com in punycode - match: true, - }, - { - input: "Asdf.com", // uppercase character - match: true, - }, - } - r := regexp.MustCompile(`^` + DomainRegexp.String() + `$`) - for i := range hostcases { - checkRegexp(t, r, hostcases[i]) - } -} - -func TestFullNameRegexp(t *testing.T) { - if anchoredNameRegexp.NumSubexp() != 2 { - t.Fatalf("anchored name regexp should have two submatches: %v, %v != 2", - anchoredNameRegexp, anchoredNameRegexp.NumSubexp()) - } - - testcases := []regexpMatch{ { input: "", - match: false, + err: ErrNameEmpty, }, { - input: "short", - match: true, - subs: []string{"", "short"}, + input: ":justtag", + err: ErrReferenceInvalidFormat, }, { - input: "simple/name", - match: true, - subs: []string{"simple", "name"}, + input: "@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + err: ErrReferenceInvalidFormat, }, { - input: "library/ubuntu", - match: true, - subs: []string{"library", "ubuntu"}, + input: "repo@sha256:ffffffffffffffffffffffffffffffffff", + err: digest.ErrDigestInvalidLength, }, { - input: "docker/stevvooe/app", - match: true, - subs: []string{"docker", "stevvooe/app"}, + input: "validname@invaliddigest:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + err: digest.ErrDigestUnsupported, }, { - input: "aa/aa/aa/aa/aa/aa/aa/aa/aa/bb/bb/bb/bb/bb/bb", - match: true, - subs: []string{"aa", "aa/aa/aa/aa/aa/aa/aa/aa/bb/bb/bb/bb/bb/bb"}, + input: "Uppercase:tag", + err: ErrNameContainsUppercase, }, + // FIXME "Uppercase" is incorrectly handled as a domain-name here, therefore passes. + // See https://github.com/distribution/distribution/pull/1778, and https://github.com/docker/docker/pull/20175 + // { + // input: "Uppercase/lowercase:tag", + // err: ErrNameContainsUppercase, + // }, { - input: "aa/aa/bb/bb/bb", - match: true, - subs: []string{"aa", "aa/bb/bb/bb"}, + input: "test:5000/Uppercase/lowercase:tag", + err: ErrNameContainsUppercase, }, { - input: "a/a/a/a", - match: true, - subs: []string{"a", "a/a/a"}, + input: "lowercase:Uppercase", + repository: "lowercase", + tag: "Uppercase", }, { - input: "a/a/a/a/", - match: false, + input: strings.Repeat("a/", 128) + "a:tag", + err: ErrNameTooLong, }, { - input: "a//a/a", - match: false, - }, - { - input: "a", - match: true, - subs: []string{"", "a"}, - }, - { - input: "a/aa", - match: true, - subs: []string{"a", "aa"}, - }, - { - input: "a/aa/a", - match: true, - subs: []string{"a", "aa/a"}, - }, - { - input: "foo.com", - match: true, - subs: []string{"", "foo.com"}, - }, - { - input: "foo.com/", - match: false, - }, - { - input: "foo.com:8080/bar", - match: true, - subs: []string{"foo.com:8080", "bar"}, - }, - { - input: "foo.com:http/bar", - match: false, - }, - { - input: "foo.com/bar", - match: true, - subs: []string{"foo.com", "bar"}, - }, - { - input: "foo.com/bar/baz", - match: true, - subs: []string{"foo.com", "bar/baz"}, - }, - { - input: "localhost:8080/bar", - match: true, - subs: []string{"localhost:8080", "bar"}, - }, - { - input: "sub-dom1.foo.com/bar/baz/quux", - match: true, - subs: []string{"sub-dom1.foo.com", "bar/baz/quux"}, - }, - { - input: "blog.foo.com/bar/baz", - match: true, - subs: []string{"blog.foo.com", "bar/baz"}, - }, - { - input: "a^a", - match: false, + input: strings.Repeat("a/", 127) + "a:tag-puts-this-over-max", + domain: "a", + repository: strings.Repeat("a/", 127) + "a", + tag: "tag-puts-this-over-max", }, { input: "aa/asdf$$^/aa", - match: false, + err: ErrReferenceInvalidFormat, }, { - input: "asdf$$^/aa", - match: false, + input: "sub-dom1.foo.com/bar/baz/quux", + domain: "sub-dom1.foo.com", + repository: "sub-dom1.foo.com/bar/baz/quux", }, { - input: "aa-a/a", - match: true, - subs: []string{"aa-a", "a"}, + input: "sub-dom1.foo.com/bar/baz/quux:some-long-tag", + domain: "sub-dom1.foo.com", + repository: "sub-dom1.foo.com/bar/baz/quux", + tag: "some-long-tag", }, { - input: strings.Repeat("a/", 128) + "a", - match: true, - subs: []string{"a", strings.Repeat("a/", 127) + "a"}, + input: "b.gcr.io/test.example.com/my-app:test.example.com", + domain: "b.gcr.io", + repository: "b.gcr.io/test.example.com/my-app", + tag: "test.example.com", }, { - input: "a-/a/a/a", - match: false, + input: "xn--n3h.com/myimage:xn--n3h.com", // ☃.com in punycode + domain: "xn--n3h.com", + repository: "xn--n3h.com/myimage", + tag: "xn--n3h.com", }, { - input: "foo.com/a-/a/a", - match: false, + input: "xn--7o8h.com/myimage:xn--7o8h.com@sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // 🐳.com in punycode + domain: "xn--7o8h.com", + repository: "xn--7o8h.com/myimage", + tag: "xn--7o8h.com", + digest: "sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }, { - input: "-foo/bar", - match: false, + input: "foo_bar.com:8080", + repository: "foo_bar.com", + tag: "8080", }, { - input: "foo/bar-", - match: false, + input: "foo/foo_bar.com:8080", + domain: "foo", + repository: "foo/foo_bar.com", + tag: "8080", }, { - input: "foo-/bar", - match: false, + input: "192.168.1.1", + repository: "192.168.1.1", }, { - input: "foo/-bar", - match: false, + input: "192.168.1.1:tag", + repository: "192.168.1.1", + tag: "tag", }, { - input: "_foo/bar", - match: false, + input: "192.168.1.1:5000", + repository: "192.168.1.1", + tag: "5000", }, { - input: "foo_bar", - match: true, - subs: []string{"", "foo_bar"}, + input: "192.168.1.1/repo", + domain: "192.168.1.1", + repository: "192.168.1.1/repo", }, { - input: "foo_bar.com", - match: true, - subs: []string{"", "foo_bar.com"}, + input: "192.168.1.1:5000/repo", + domain: "192.168.1.1:5000", + repository: "192.168.1.1:5000/repo", }, { - input: "foo_bar.com:8080", - match: false, + input: "192.168.1.1:5000/repo:5050", + domain: "192.168.1.1:5000", + repository: "192.168.1.1:5000/repo", + tag: "5050", }, { - input: "foo_bar.com:8080/app", - match: false, + input: "[2001:db8::1]", + err: ErrReferenceInvalidFormat, }, { - input: "foo.com/foo_bar", - match: true, - subs: []string{"foo.com", "foo_bar"}, + input: "[2001:db8::1]:5000", + err: ErrReferenceInvalidFormat, }, { - input: "____/____", - match: false, + input: "[2001:db8::1]:tag", + err: ErrReferenceInvalidFormat, }, { - input: "_docker/_docker", - match: false, + input: "[2001:db8::1]/repo", + domain: "[2001:db8::1]", + repository: "[2001:db8::1]/repo", }, { - input: "docker_/docker_", - match: false, + 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: "b.gcr.io/test.example.com/my-app", - match: true, - subs: []string{"b.gcr.io", "test.example.com/my-app"}, + input: "[2001:db8::1]:5000/repo", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", }, { - input: "xn--n3h.com/myimage", // ☃.com in punycode - match: true, - subs: []string{"xn--n3h.com", "myimage"}, + input: "[2001:db8::1]:5000/repo:tag", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", + tag: "tag", }, { - input: "xn--7o8h.com/myimage", // 🐳.com in punycode - match: true, - subs: []string{"xn--7o8h.com", "myimage"}, + input: "[2001:db8::1]:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + domain: "[2001:db8::1]:5000", + repository: "[2001:db8::1]:5000/repo", + digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }, { - input: "example.com/xn--7o8h.com/myimage", // 🐳.com in punycode - match: true, - subs: []string{"example.com", "xn--7o8h.com/myimage"}, + 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: "example.com/some_separator__underscore/myimage", - match: true, - subs: []string{"example.com", "some_separator__underscore/myimage"}, + input: "[2001:db8::]:5000/repo", + domain: "[2001:db8::]:5000", + repository: "[2001:db8::]:5000/repo", }, { - input: "example.com/__underscore/myimage", - match: false, + input: "[::1]:5000/repo", + domain: "[::1]:5000", + repository: "[::1]:5000/repo", }, { - input: "example.com/..dots/myimage", - match: false, + input: "[fe80::1%eth0]:5000/repo", + err: ErrReferenceInvalidFormat, }, { - input: "example.com/.dots/myimage", - match: false, - }, - { - input: "example.com/nodouble..dots/myimage", - match: false, + input: "[fe80::1%@invalidzone]:5000/repo", + err: ErrReferenceInvalidFormat, }, + } + for _, testcase := range referenceTestcases { + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + repo, err := Parse(testcase.input) + if testcase.err != nil { + if err == nil { + t.Errorf("missing expected error: %v", testcase.err) + } else if testcase.err != err { + t.Errorf("mismatched error: got %v, expected %v", err, testcase.err) + } + return + } else if err != nil { + t.Errorf("unexpected parse error: %v", err) + return + } + if repo.String() != testcase.input { + t.Errorf("mismatched repo: got %q, expected %q", repo.String(), testcase.input) + } + + if named, ok := repo.(Named); ok { + if named.Name() != testcase.repository { + t.Errorf("unexpected repository: got %q, expected %q", named.Name(), testcase.repository) + } + domain, _ := SplitHostname(named) + if domain != testcase.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, testcase.domain) + } + } else if testcase.repository != "" || testcase.domain != "" { + t.Errorf("expected named type, got %T", repo) + } + + tagged, ok := repo.(Tagged) + if testcase.tag != "" { + if ok { + if tagged.Tag() != testcase.tag { + t.Errorf("unexpected tag: got %q, expected %q", tagged.Tag(), testcase.tag) + } + } else { + t.Errorf("expected tagged type, got %T", repo) + } + } else if ok { + t.Errorf("unexpected tagged type") + } + + digested, ok := repo.(Digested) + if testcase.digest != "" { + if ok { + if digested.Digest().String() != testcase.digest { + t.Errorf("unexpected digest: got %q, expected %q", digested.Digest().String(), testcase.digest) + } + } else { + t.Errorf("expected digested type, got %T", repo) + } + } else if ok { + t.Errorf("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) { + t.Parallel() + testcases := []struct { + input string + err error + }{ { - input: "example.com/nodouble..dots/myimage", - match: false, + input: "", + err: ErrNameEmpty, }, { - input: "docker./docker", - match: false, + input: ":justtag", + err: ErrReferenceInvalidFormat, }, { - input: ".docker/docker", - match: false, + input: "@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + err: ErrReferenceInvalidFormat, }, { - input: "docker-/docker", - match: false, + input: "validname@invaliddigest:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + err: ErrReferenceInvalidFormat, }, { - input: "-docker/docker", - match: false, + input: strings.Repeat("a/", 128) + "a:tag", + err: ErrNameTooLong, }, { - input: "do..cker/docker", - match: false, + input: "aa/asdf$$^/aa", + err: ErrReferenceInvalidFormat, }, + } + for _, testcase := range testcases { + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + _, err := WithName(testcase.input) + if err == nil { + t.Errorf("no error parsing name. expected: %s", testcase.err) + } + }) + } +} + +func TestSplitHostname(t *testing.T) { + t.Parallel() + testcases := []struct { + input string + domain string + name string + }{ { - input: "do__cker:8080/docker", - match: false, + input: "test.com/foo", + domain: "test.com", + name: "foo", }, { - input: "do__cker/docker", - match: true, - subs: []string{"", "do__cker/docker"}, + input: "test_com/foo", + domain: "", + name: "test_com/foo", }, { - input: "b.gcr.io/test.example.com/my-app", - match: true, - subs: []string{"b.gcr.io", "test.example.com/my-app"}, + input: "test:8080/foo", + domain: "test:8080", + name: "foo", }, { - input: "registry.io/foo/project--id.module--name.ver---sion--name", - match: true, - subs: []string{"registry.io", "foo/project--id.module--name.ver---sion--name"}, + input: "test.com:8080/foo", + domain: "test.com:8080", + name: "foo", }, { - input: "Asdf.com/foo/bar", // uppercase character in hostname - match: true, + input: "test-com:8080/foo", + domain: "test-com:8080", + name: "foo", }, { - input: "Foo/FarB", // uppercase characters in remote name - match: false, + input: "xn--n3h.com:18080/foo", + domain: "xn--n3h.com:18080", + name: "foo", }, } - for i := range testcases { - checkRegexp(t, anchoredNameRegexp, testcases[i]) + for _, testcase := range testcases { + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + named, err := WithName(testcase.input) + if err != nil { + t.Errorf("error parsing name: %s", err) + } + domain, name := SplitHostname(named) + if domain != testcase.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, testcase.domain) + } + if name != testcase.name { + t.Errorf("unexpected name: got %q, expected %q", name, testcase.name) + } + }) } } -func TestReferenceRegexp(t *testing.T) { - if ReferenceRegexp.NumSubexp() != 3 { - t.Fatalf("anchored name regexp should have three submatches: %v, %v != 3", - ReferenceRegexp, ReferenceRegexp.NumSubexp()) - } +type serializationType struct { + Description string + Field Field +} - testcases := []regexpMatch{ +func TestSerialization(t *testing.T) { + t.Parallel() + testcases := []struct { + description string + input string + name string + tag string + digest string + err error + }{ { - input: "registry.com:8080/myapp:tag", - match: true, - subs: []string{"registry.com:8080/myapp", "tag", ""}, + description: "empty value", + err: ErrNameEmpty, }, { - input: "registry.com:8080/myapp@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912", - match: true, - subs: []string{"registry.com:8080/myapp", "", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"}, + description: "just a name", + input: "example.com:8000/named", + name: "example.com:8000/named", }, { - input: "registry.com:8080/myapp:tag2@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912", - match: true, - subs: []string{"registry.com:8080/myapp", "tag2", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"}, + description: "name with a tag", + input: "example.com:8000/named:tagged", + name: "example.com:8000/named", + tag: "tagged", }, { - input: "registry.com:8080/myapp@sha256:badbadbadbad", - match: false, - }, - { - input: "registry.com:8080/myapp:invalid~tag", - match: false, - }, - { - input: "bad_hostname.com:8080/myapp:tag", - match: false, - }, - { - input:// localhost treated as name, missing tag with 8080 as tag - "localhost:8080@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912", - match: true, - subs: []string{"localhost", "8080", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"}, + description: "name with digest", + input: "other.com/named@sha256:1234567890098765432112345667890098765432112345667890098765432112", + name: "other.com/named", + digest: "sha256:1234567890098765432112345667890098765432112345667890098765432112", }, + } + for _, testcase := range testcases { + testcase := testcase + t.Run(testcase.description, func(t *testing.T) { + t.Parallel() + m := map[string]string{ + "Description": testcase.description, + "Field": testcase.input, + } + 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 testcase.err == nil { + t.Errorf("error unmarshalling: %v", err) + } + if err != testcase.err { + t.Errorf("wrong error, expected %v, got %v", testcase.err, err) + } + + return + } else if testcase.err != nil { + t.Errorf("expected error unmarshalling: %v", testcase.err) + } + + if st.Description != testcase.description { + t.Errorf("wrong description, expected %q, got %q", testcase.description, st.Description) + } + + ref := st.Field.Reference() + + if named, ok := ref.(Named); ok { + if named.Name() != testcase.name { + t.Errorf("unexpected repository: got %q, expected %q", named.Name(), testcase.name) + } + } else if testcase.name != "" { + t.Errorf("expected named type, got %T", ref) + } + + tagged, ok := ref.(Tagged) + if testcase.tag != "" { + if ok { + if tagged.Tag() != testcase.tag { + t.Errorf("unexpected tag: got %q, expected %q", tagged.Tag(), testcase.tag) + } + } else { + t.Errorf("expected tagged type, got %T", ref) + } + } else if ok { + t.Errorf("unexpected tagged type") + } + + digested, ok := ref.(Digested) + if testcase.digest != "" { + if ok { + if digested.Digest().String() != testcase.digest { + t.Errorf("unexpected digest: got %q, expected %q", digested.Digest().String(), testcase.digest) + } + } else { + t.Errorf("expected digested type, got %T", ref) + } + } else if ok { + t.Errorf("unexpected digested type") + } + + st = serializationType{ + Description: testcase.description, + Field: AsField(ref), + } + + b2, err := json.Marshal(st) + if err != nil { + t.Errorf("error marshing serialization type: %v", err) + } + + if string(b) != string(b2) { + t.Errorf("unexpected serialized value: expected %q, got %q", string(b), string(b2)) + } + + // 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) { + t.Parallel() + testcases := []struct { + name string + digest digest.Digest + tag string + combined string + }{ { - input: "localhost:8080/name@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912", - match: true, - subs: []string{"localhost:8080/name", "", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"}, + name: "test.com/foo", + tag: "tag", + combined: "test.com/foo:tag", }, { - input: "localhost:http/name@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912", - match: false, + name: "foo", + tag: "tag2", + combined: "foo:tag2", }, { - // localhost will be treated as an image name without a host - input: "localhost@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912", - match: true, - subs: []string{"localhost", "", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"}, + name: "test.com:8000/foo", + tag: "tag4", + combined: "test.com:8000/foo:tag4", }, { - input: "registry.com:8080/myapp@bad", - match: false, + name: "test.com:8000/foo", + tag: "TAG5", + combined: "test.com:8000/foo:TAG5", }, { - input: "registry.com:8080/myapp@2bad", - match: false, // TODO(dmcgowan): Support this as valid + name: "test.com:8000/foo", + digest: "sha256:1234567890098765432112345667890098765", + tag: "TAG5", + combined: "test.com:8000/foo:TAG5@sha256:1234567890098765432112345667890098765", }, } + for _, testcase := range testcases { + testcase := testcase + t.Run(testcase.combined, func(t *testing.T) { + t.Parallel() + named, err := WithName(testcase.name) + if err != nil { + t.Errorf("error parsing name: %s", err) + } + if testcase.digest != "" { + canonical, err := WithDigest(named, testcase.digest) + if err != nil { + t.Errorf("error adding digest") + } + named = canonical + } - for i := range testcases { - checkRegexp(t, ReferenceRegexp, testcases[i]) + tagged, err := WithTag(named, testcase.tag) + if err != nil { + t.Errorf("WithTag failed: %s", err) + } + if tagged.String() != testcase.combined { + t.Errorf("unexpected: got %q, expected %q", tagged.String(), testcase.combined) + } + }) } - } -func TestIdentifierRegexp(t *testing.T) { - fullCases := []regexpMatch{ - { - input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf9821", - match: true, - }, +func TestWithDigest(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + digest digest.Digest + tag string + combined string + }{ { - input: "7EC43B381E5AEFE6E04EFB0B3F0693FF2A4A50652D64AEC573905F2DB5889A1C", - match: false, + name: "test.com/foo", + digest: "sha256:1234567890098765432112345667890098765", + combined: "test.com/foo@sha256:1234567890098765432112345667890098765", }, { - input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf", - match: false, + name: "foo", + digest: "sha256:1234567890098765432112345667890098765", + combined: "foo@sha256:1234567890098765432112345667890098765", }, { - input: "sha256:da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf9821", - match: false, + name: "test.com:8000/foo", + digest: "sha256:1234567890098765432112345667890098765", + combined: "test.com:8000/foo@sha256:1234567890098765432112345667890098765", }, { - input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf98218482", - match: false, + name: "test.com:8000/foo", + digest: "sha256:1234567890098765432112345667890098765", + tag: "latest", + combined: "test.com:8000/foo:latest@sha256:1234567890098765432112345667890098765", }, } + for _, testcase := range testcases { + testcase := testcase + t.Run(testcase.combined, func(t *testing.T) { + t.Parallel() + named, err := WithName(testcase.name) + if err != nil { + t.Errorf("error parsing name: %s", err) + } + if testcase.tag != "" { + tagged, err := WithTag(named, testcase.tag) + if err != nil { + t.Errorf("error adding tag") + } + named = tagged + } + digested, err := WithDigest(named, testcase.digest) + if err != nil { + t.Errorf("WithDigest failed: %s", err) + } + if digested.String() != testcase.combined { + t.Errorf("unexpected: got %q, expected %q", digested.String(), testcase.combined) + } + }) + } +} - shortCases := []regexpMatch{ +func TestParseNamed(t *testing.T) { + t.Parallel() + testcases := []struct { + input string + domain string + name string + err error + }{ { - input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf9821", - match: true, + input: "test.com/foo", + domain: "test.com", + name: "foo", }, { - input: "7EC43B381E5AEFE6E04EFB0B3F0693FF2A4A50652D64AEC573905F2DB5889A1C", - match: false, + input: "test:8080/foo", + domain: "test:8080", + name: "foo", }, { - input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf", - match: true, + input: "test_com/foo", + err: ErrNameNotCanonical, }, { - input: "sha256:da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf9821", - match: false, + input: "test.com", + err: ErrNameNotCanonical, }, { - input: "da304e823d8ca2b9d863a3c897baeb852ba21ea9a9f1414736394ae7fcaf98218482", - match: false, + input: "foo", + err: ErrNameNotCanonical, }, { - input: "da304", - match: false, + input: "library/foo", + err: ErrNameNotCanonical, }, { - input: "da304e", - match: true, + input: "docker.io/library/foo", + domain: "docker.io", + name: "library/foo", + }, + // Ambiguous case, parser will add "library/" to foo + { + input: "docker.io/foo", + err: ErrNameNotCanonical, }, } + for _, testcase := range testcases { + testcase := testcase + t.Run(testcase.input, func(t *testing.T) { + t.Parallel() + named, err := ParseNamed(testcase.input) + if err != nil && testcase.err == nil { + t.Errorf("error parsing name: %s", err) + return + } else if err == nil && testcase.err != nil { + t.Errorf("parsing succeeded: expected error %v", testcase.err) + return + } else if err != testcase.err { + t.Errorf("unexpected error %v, expected %v", err, testcase.err) + return + } else if err != nil { + return + } - 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 i := range shortCases { - checkRegexp(t, anchoredShortIdentifierRegexp, shortCases[i]) + domain, name := SplitHostname(named) + if domain != testcase.domain { + t.Errorf("unexpected domain: got %q, expected %q", domain, testcase.domain) + } + if name != testcase.name { + t.Errorf("unexpected name: got %q, expected %q", name, testcase.name) + } + }) } }