Update to provide small and clear interfaces

Signed-off-by: Derek McGowan <derek@mcgstyle.net> (github: dmcgowan)
This commit is contained in:
Derek McGowan 2015-09-08 16:00:48 -07:00
parent f8c09b6a7d
commit 31a448a628
13 changed files with 901 additions and 648 deletions

View file

@ -7,11 +7,13 @@
//
// // repository.go
// repository := hostname ['/' component]+
// hostname := component [':' port-number]
// hostname := hostcomponent [':' port-number]
// component := alpha-numeric [separator alpha-numeric]*
// hostcomponent := [hostpart '.']* hostpart
// alpha-numeric := /[a-zA-Z0-9]+/
// separator := /[._-]/
// separator := /[_-]/
// port-number := /[0-9]+/
// hostpart := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/
//
// // tag.go
// tag := /[\w][\w.-]{0,127}/
@ -20,167 +22,224 @@
// digest := digest-algorithm ":" digest-hex
// digest-algorithm := digest-algorithm-component [ digest-algorithm-separator digest-algorithm-component ]
// digest-algorithm-separator := /[+.-_]/
// digest-algorithm-component := /[A-Za-z]/ /[A-Za-z0-9]*/
// digest-hex := /[A-Za-z0-9_-]+/ ; supports hex bytes or url safe base64
// digest-algorithm-component := /[A-Za-z][A-Za-z0-9]*/
// digest-hex := /[0-9a-fA-F]{32,}/ ; Atleast 128 bit digest value
package reference
import (
"errors"
"regexp"
"fmt"
"github.com/docker/distribution/digest"
)
// ErrReferenceInvalidFormat represents an error while trying to parse a string as a reference.
var ErrReferenceInvalidFormat = errors.New("invalid reference format")
const (
// NameTotalLengthMax is the maximum total number of characters in a repository name.
NameTotalLengthMax = 255
)
// Reference abstracts types that reference images in a certain way.
var (
// ErrReferenceInvalidFormat represents an error while trying to parse a string as a reference.
ErrReferenceInvalidFormat = errors.New("invalid reference format")
// 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
// RepositoryNameTotalLengthMax
ErrNameTooLong = fmt.Errorf("repository name must not be more than %v characters", NameTotalLengthMax)
)
// Reference is an opaque object reference identifier that may include
// modifiers such as a hostname, name, tag, and digest.
type Reference interface {
// Repository returns the repository part of a reference
Repository() Repository
// String returns the entire reference, including the repository part
// String returns the full reference
String() string
}
func parseHostname(s string) (hostname, tail string) {
tail = s
i := regexp.MustCompile(`^` + RepositoryNameHostnameRegexp.String()).FindStringIndex(s)
if i == nil {
return
}
return s[:i[1]], s[i[1]:]
// Named is an object with a full name
type Named interface {
Name() string
}
func parseRepositoryName(s string) (repo, tail string) {
tail = s
i := regexp.MustCompile(`^/(?:` + RepositoryNameComponentRegexp.String() + `/)*` + RepositoryNameComponentRegexp.String()).FindStringIndex(s)
if i == nil {
return
}
return s[:i[1]], s[i[1]:]
// Tagged is an object which has a tag
type Tagged interface {
Tag() string
}
func parseTag(s string) (tag Tag, tail string) {
tail = s
if len(s) == 0 || s[0] != ':' {
return
}
tag, err := NewTag(s[1:])
if err != nil {
return
}
tail = s[len(tag)+1:]
return
// Digested is an object which has a digest
// in which it can be referenced by
type Digested interface {
Digest() digest.Digest
}
func parseDigest(s string) (dgst digest.Digest, tail string) {
tail = s
if len(s) == 0 || s[0] != '@' {
return
// Canonical reference is an object with a fully unique
// name including a name with hostname and digest
type Canonical interface {
Reference
Named
Digested
}
// 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
func SplitHostname(named Named) (string, string) {
name := named.Name()
match := anchoredNameRegexp.FindStringSubmatch(name)
if match == nil || len(match) != 3 {
return "", name
}
dgst, err := digest.ParseDigest(s[1:])
if err != nil {
return
}
tail = s[len(dgst)+1:]
return
return match[1], match[2]
}
// 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.
func Parse(s string) (Reference, error) {
hostname, s := parseHostname(s)
name, s := parseRepositoryName(s)
repository := Repository{Hostname: hostname, Name: name}
if err := repository.Validate(); err != nil {
matches := ReferenceRegexp.FindStringSubmatch(s)
if matches == nil {
if s == "" {
return nil, ErrNameEmpty
}
// TODO(dmcgowan): Provide more specific and helpful error
return nil, ErrReferenceInvalidFormat
}
if len(matches[1]) > NameTotalLengthMax {
return nil, ErrNameTooLong
}
ref := reference{
name: matches[1],
tag: matches[2],
}
if matches[3] != "" {
var err error
ref.digest, err = digest.ParseDigest(matches[3])
if err != nil {
return nil, err
}
tag, s := parseTag(s)
dgst, s := parseDigest(s)
if len(s) > 0 {
return nil, ErrReferenceInvalidFormat
}
if dgst != "" {
return DigestReference{repository: repository, digest: dgst, tag: tag}, nil
r := getBestReferenceType(ref)
if r == nil {
return nil, ErrNameEmpty
}
if tag != "" {
return TagReference{repository: repository, tag: tag}, nil
}
return nil, ErrReferenceInvalidFormat
return r, nil
}
// DigestReference represents a reference of the form `repository@sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef`.
// Implements the Reference interface.
type DigestReference struct {
repository Repository
// ParseNamed parses the input string and returns a named
// object representing the given string. If the input is
// invalid ErrReferenceInvalidFormat will be returned.
func ParseNamed(name string) (Named, error) {
if !anchoredNameRegexp.MatchString(name) {
return nil, ErrReferenceInvalidFormat
}
return repository(name), nil
}
func getBestReferenceType(ref reference) Reference {
if ref.name == "" {
// Allow digest only references
if ref.digest != "" {
return digestReference(ref.digest)
}
return nil
}
if ref.tag == "" {
if ref.digest != "" {
return canonicalReference{
name: ref.name,
digest: ref.digest,
}
}
return repository(ref.name)
}
if ref.digest == "" {
return taggedReference{
name: ref.name,
tag: ref.tag,
}
}
return ref
}
type reference struct {
name string
tag string
digest digest.Digest
tag Tag
}
// Repository returns the repository part.
func (r DigestReference) Repository() Repository { return r.repository }
// String returns the full string reference.
func (r DigestReference) String() string {
return r.repository.String() + "@" + string(r.digest)
func (r reference) String() string {
return r.name + ":" + r.tag + "@" + r.digest.String()
}
// NewDigestReference returns an initialized DigestReference.
func NewDigestReference(canonicalRepository string, digest digest.Digest, optionalTag Tag) (DigestReference, error) {
ref := DigestReference{}
repo, err := NewRepository(canonicalRepository)
if err != nil {
return ref, err
}
ref.repository = repo
if err := digest.Validate(); err != nil {
return ref, err
}
ref.digest = digest
if len(optionalTag) > 0 {
if err := optionalTag.Validate(); err != nil {
return ref, err
}
ref.tag = optionalTag
}
return ref, err
func (r reference) Name() string {
return r.name
}
// TagReference represents a reference of the form `repository:tag`.
// Implements the Reference interface.
type TagReference struct {
repository Repository
tag Tag
func (r reference) Tag() string {
return r.tag
}
// Repository returns the repository part.
func (r TagReference) Repository() Repository { return r.repository }
// String returns the full string reference.
func (r TagReference) String() string {
return r.repository.String() + ":" + string(r.tag)
func (r reference) Digest() digest.Digest {
return r.digest
}
// NewTagReference returns an initialized TagReference.
func NewTagReference(canonicalRepository string, tagName string) (TagReference, error) {
ref := TagReference{}
type repository string
repo, err := NewRepository(canonicalRepository)
if err != nil {
return ref, err
}
ref.repository = repo
tag, err := NewTag(tagName)
if err != nil {
return ref, err
}
ref.tag = tag
return ref, err
func (r repository) String() string {
return string(r)
}
func (r repository) Name() string {
return string(r)
}
type digestReference digest.Digest
func (d digestReference) String() string {
return d.String()
}
func (d digestReference) Digest() digest.Digest {
return digest.Digest(d)
}
type taggedReference struct {
name string
tag string
}
func (t taggedReference) String() string {
return t.name + ":" + t.tag
}
func (t taggedReference) Name() string {
return t.name
}
func (t taggedReference) Tag() string {
return t.tag
}
type canonicalReference struct {
name string
digest digest.Digest
}
func (c canonicalReference) String() string {
return c.name + "@" + c.digest.String()
}
func (c canonicalReference) Name() string {
return c.name
}
func (c canonicalReference) Digest() digest.Digest {
return c.digest
}

View file

@ -1,56 +1,267 @@
package reference
/*
var refRegex = regexp.MustCompile(`^([a-z0-9]+(?:[-._][a-z0-9]+)*(?::[0-9]+(?:/[a-z0-9]+(?:[-._][a-z0-9]+)*)+|(?:/[a-z0-9]+(?:[-._][a-z0-9]+)*)+)?)(:[\w][\w.-]{0,127})?(@` + digest.DigestRegexp.String() + `)?$`)
import (
"strconv"
"strings"
"testing"
func getRepo(s string) string {
matches := refRegex.FindStringSubmatch(s)
if len(matches) == 0 {
return ""
"github.com/docker/distribution/digest"
)
func TestReferenceParse(t *testing.T) {
// 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
// hostname is the hostname expected in the reference
hostname string
// tag is the tag for the reference
tag string
// digest is the digest for the reference (enforces digest reference)
digest string
}{
{
input: "test_com",
repository: "test_com",
},
{
input: "test.com:tag",
repository: "test.com",
tag: "tag",
},
{
input: "test.com:5000",
repository: "test.com",
tag: "5000",
},
{
input: "test.com/repo:tag",
hostname: "test.com",
repository: "test.com/repo",
tag: "tag",
},
{
input: "test:5000/repo",
hostname: "test:5000",
repository: "test:5000/repo",
},
{
input: "test:5000/repo:tag",
hostname: "test:5000",
repository: "test:5000/repo",
tag: "tag",
},
{
input: "test:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
hostname: "test:5000",
repository: "test:5000/repo",
digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
},
{
input: "test:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
hostname: "test:5000",
repository: "test:5000/repo",
tag: "tag",
digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
},
{
input: "test:5000/repo",
hostname: "test:5000",
repository: "test:5000/repo",
},
{
input: "",
err: ErrNameEmpty,
},
{
input: ":justtag",
err: ErrReferenceInvalidFormat,
},
{
input: "@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
err: ErrReferenceInvalidFormat,
},
{
input: "validname@invaliddigest:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
err: digest.ErrDigestUnsupported,
},
{
input: strings.Repeat("a/", 128) + "a:tag",
err: ErrNameTooLong,
},
{
input: strings.Repeat("a/", 127) + "a:tag-puts-this-over-max",
hostname: "a",
repository: strings.Repeat("a/", 127) + "a",
tag: "tag-puts-this-over-max",
},
{
input: "aa/asdf$$^/aa",
err: ErrReferenceInvalidFormat,
},
{
input: "sub-dom1.foo.com/bar/baz/quux",
hostname: "sub-dom1.foo.com",
repository: "sub-dom1.foo.com/bar/baz/quux",
},
{
input: "sub-dom1.foo.com/bar/baz/quux:some-long-tag",
hostname: "sub-dom1.foo.com",
repository: "sub-dom1.foo.com/bar/baz/quux",
tag: "some-long-tag",
},
{
input: "b.gcr.io/test.example.com/my-app:test.example.com",
hostname: "b.gcr.io",
repository: "b.gcr.io/test.example.com/my-app",
tag: "test.example.com",
},
{
input: "xn--n3h.com/myimage:xn--n3h.com", // ☃.com in punycode
hostname: "xn--n3h.com",
repository: "xn--n3h.com/myimage",
tag: "xn--n3h.com",
},
{
input: "xn--7o8h.com/myimage:xn--7o8h.com@sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // 🐳.com in punycode
hostname: "xn--7o8h.com",
repository: "xn--7o8h.com/myimage",
tag: "xn--7o8h.com",
digest: "sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
},
{
input: "foo_bar.com:8080",
repository: "foo_bar.com",
tag: "8080",
},
{
input: "foo/foo_bar.com:8080",
hostname: "foo",
repository: "foo/foo_bar.com",
tag: "8080",
},
}
for _, testcase := range referenceTestcases {
failf := func(format string, v ...interface{}) {
t.Logf(strconv.Quote(testcase.input)+": "+format, v...)
t.Fail()
}
return matches[1]
}
func testRepository(prefix string) error {
for _, s := range []string{
prefix + `@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff`,
prefix + `:frozen@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff`,
prefix + `:latest`,
prefix,
} {
expected := getRepo(s)
ref, err := Parse(s)
if err != nil {
if expected == "" {
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)
}
continue
} else if err != nil {
failf("unexpected parse error: %v", err)
continue
}
return err
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)
}
hostname, _ := SplitHostname(named)
if hostname != testcase.hostname {
failf("unexpected hostname: got %q, expected %q", hostname, testcase.hostname)
}
} else if testcase.repository != "" || testcase.hostname != "" {
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)
}
} else {
failf("expected tagged 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)
}
} else {
failf("expected digested type, got %T", repo)
}
} else if ok {
failf("unexpected digested type")
}
if repo := ref.Repository(); repo.String() != expected {
return fmt.Errorf("repository string: expected %q, got: %q", expected, repo)
}
if refStr := ref.String(); refStr != s {
return fmt.Errorf("reference string: expected %q, got: %q", s, refStr)
}
}
return nil
}
func TestSimpleRepository(t *testing.T) {
if err := testRepository(`busybox`); err != nil {
t.Fatal(err)
}
}
func TestUrlRepository(t *testing.T) {
if err := testRepository(`docker.io/library/busybox`); err != nil {
t.Fatal(err)
func TestSplitHostname(t *testing.T) {
testcases := []struct {
input string
hostname string
name string
}{
{
input: "test.com/foo",
hostname: "test.com",
name: "foo",
},
{
input: "test_com/foo",
hostname: "",
name: "test_com/foo",
},
{
input: "test:8080/foo",
hostname: "test:8080",
name: "foo",
},
{
input: "test.com:8080/foo",
hostname: "test.com:8080",
name: "foo",
},
{
input: "test-com:8080/foo",
hostname: "test-com:8080",
name: "foo",
},
{
input: "xn--n3h.com:18080/foo",
hostname: "xn--n3h.com:18080",
name: "foo",
},
}
for _, testcase := range testcases {
failf := func(format string, v ...interface{}) {
t.Logf(strconv.Quote(testcase.input)+": "+format, v...)
t.Fail()
}
}
func TestPort(t *testing.T) {
if err := testRepository(`busybox:1234`); err != nil {
t.Fatal(err)
named, err := ParseNamed(testcase.input)
if err != nil {
failf("error parsing name: %s", err)
}
hostname, name := SplitHostname(named)
if hostname != testcase.hostname {
failf("unexpected hostname: got %q, expected %q", hostname, testcase.hostname)
}
if name != testcase.name {
failf("unexpected name: got %q, expected %q", name, testcase.name)
}
}
}
*/

37
reference/regexp.go Normal file
View file

@ -0,0 +1,37 @@
package reference
import "regexp"
var (
// nameComponentRegexp restricts registry path component names to
// start with at least one letter or number, with following parts able to
// be separated by one period, dash or underscore.
nameComponentRegexp = regexp.MustCompile(`[a-zA-Z0-9]+(?:[._-][a-z0-9]+)*`)
nameRegexp = regexp.MustCompile(`(?:` + nameComponentRegexp.String() + `/)*` + nameComponentRegexp.String())
hostnameComponentRegexp = regexp.MustCompile(`(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])`)
// hostnameComponentRegexp restricts the registry hostname component of a repository name to
// start with a component as defined by hostnameRegexp and followed by an optional port.
hostnameRegexp = regexp.MustCompile(`(?:` + hostnameComponentRegexp.String() + `\.)*` + hostnameComponentRegexp.String() + `(?::[0-9]+)?`)
// TagRegexp matches valid tag names. From docker/docker:graph/tags.go.
TagRegexp = regexp.MustCompile(`[\w][\w.-]{0,127}`)
// anchoredTagRegexp matches valid tag names, anchored at the start and
// end of the matched string.
anchoredTagRegexp = regexp.MustCompile(`^` + TagRegexp.String() + `$`)
// NameRegexp is the format for the name component of references. The
// regexp has capturing groups for the hostname and name part omitting
// the seperating forward slash from either.
NameRegexp = regexp.MustCompile(`(?:` + hostnameRegexp.String() + `/)?` + nameRegexp.String())
// ReferenceRegexp is the full supported format of a reference. The
// regexp has capturing groups for name, tag, and digest components.
ReferenceRegexp = regexp.MustCompile(`^((?:` + hostnameRegexp.String() + `/)?` + nameRegexp.String() + `)(?:[:](` + TagRegexp.String() + `))?(?:[@]([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$`)
// anchoredNameRegexp is used to parse a name value, capturing hostname
anchoredNameRegexp = regexp.MustCompile(`^(?:(` + hostnameRegexp.String() + `)/)?(` + nameRegexp.String() + `)$`)
)

398
reference/regexp_test.go Normal file
View file

@ -0,0 +1,398 @@
package reference
import (
"regexp"
"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", matches)
}
if len(matches) < (len(m.subs) + 1) {
t.Errorf("Expected %d sub matches, only have %d", len(m.subs), len(matches)-1)
}
for i := range m.subs {
if m.subs[i] != matches[i+1] {
t.Errorf("Unexpected submatch %d: %q, expected %q", i+1, matches[i+1], m.subs[i])
}
}
} else if m.match {
t.Errorf("Expected match for %q", m.input)
} else if matches != nil {
t.Errorf("Unexpected match for %q", m.input)
}
}
func TestHostRegexp(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,
},
{
input: "a",
match: true,
},
{
input: "a.b",
match: true,
},
{
input: "ab.cd.com",
match: true,
},
{
input: "a-b.com",
match: true,
},
{
input: "-ab.com",
match: false,
},
{
input: "ab-.com",
match: false,
},
{
input: "ab.c-om",
match: true,
},
{
input: "ab.-com",
match: false,
},
{
input: "ab.com-",
match: false,
},
{
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,
},
}
r := regexp.MustCompile(`^` + hostnameRegexp.String() + `$`)
for i := range hostcases {
checkRegexp(t, r, hostcases[i])
}
}
func TestFullNameRegexp(t *testing.T) {
testcases := []regexpMatch{
{
input: "",
match: false,
},
{
input: "short",
match: true,
subs: []string{"", "short"},
},
{
input: "simple/name",
match: true,
subs: []string{"simple", "name"},
},
{
input: "library/ubuntu",
match: true,
subs: []string{"library", "ubuntu"},
},
{
input: "docker/stevvooe/app",
match: true,
subs: []string{"docker", "stevvooe/app"},
},
{
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: "aa/aa/bb/bb/bb",
match: true,
subs: []string{"aa", "aa/bb/bb/bb"},
},
{
input: "a/a/a/a",
match: true,
subs: []string{"a", "a/a/a"},
},
{
input: "a/a/a/a/",
match: false,
},
{
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: "aa/asdf$$^/aa",
match: false,
},
{
input: "asdf$$^/aa",
match: false,
},
{
input: "aa-a/a",
match: true,
subs: []string{"aa-a", "a"},
},
{
input: strings.Repeat("a/", 128) + "a",
match: true,
subs: []string{"a", strings.Repeat("a/", 127) + "a"},
},
{
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,
subs: []string{"", "foo_bar"},
},
{
input: "foo_bar.com",
match: true,
subs: []string{"", "foo_bar.com"},
},
{
input: "foo_bar.com:8080",
match: false,
},
{
input: "foo_bar.com:8080/app",
match: false,
},
{
input: "foo.com/foo_bar",
match: true,
subs: []string{"foo.com", "foo_bar"},
},
{
input: "____/____",
match: false,
},
{
input: "_docker/_docker",
match: false,
},
{
input: "docker_/docker_",
match: false,
},
{
input: "b.gcr.io/test.example.com/my-app",
match: true,
subs: []string{"b.gcr.io", "test.example.com/my-app"},
},
{
input: "xn--n3h.com/myimage", // ☃.com in punycode
match: true,
subs: []string{"xn--n3h.com", "myimage"},
},
{
input: "xn--7o8h.com/myimage", // 🐳.com in punycode
match: true,
subs: []string{"xn--7o8h.com", "myimage"},
},
}
for i := range testcases {
checkRegexp(t, anchoredNameRegexp, testcases[i])
}
}
func TestReferenceRegexp(t *testing.T) {
testcases := []regexpMatch{
{
input: "registry.com:8080/myapp:tag",
match: true,
subs: []string{"registry.com:8080/myapp", "tag", ""},
},
{
input: "registry.com:8080/myapp@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912",
match: true,
subs: []string{"registry.com:8080/myapp", "", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"},
},
{
input: "registry.com:8080/myapp:tag2@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912",
match: true,
subs: []string{"registry.com:8080/myapp", "tag2", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"},
},
{
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"},
},
{
input: "localhost:8080/name@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912",
match: true,
subs: []string{"localhost:8080/name", "", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"},
},
{
input: "localhost:http/name@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912",
match: false,
},
{
// localhost will be treated as an image name without a host
input: "localhost@sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912",
match: true,
subs: []string{"localhost", "", "sha256:be178c0543eb17f5f3043021c9e5fcf30285e557a4fc309cce97ff9ca6182912"},
},
{
input: "registry.com:8080/myapp@bad",
match: false,
},
{
input: "registry.com:8080/myapp@2bad",
match: false, // TODO(dmcgowan): Support this as valid
},
}
for i := range testcases {
checkRegexp(t, ReferenceRegexp, testcases[i])
}
}

View file

@ -1,136 +0,0 @@
package reference
import (
"errors"
"fmt"
"regexp"
"strings"
)
const (
// RepositoryNameTotalLengthMax is the maximum total number of characters in a repository name.
RepositoryNameTotalLengthMax = 255
)
// RepositoryNameComponentRegexp restricts registry path component names to
// start with at least one letter or number, with following parts able to
// be separated by one period, dash or underscore.
var RepositoryNameComponentRegexp = regexp.MustCompile(`[a-zA-Z0-9]+(?:[._-][a-z0-9]+)*`)
// RepositoryNameComponentAnchoredRegexp is the version of
// RepositoryNameComponentRegexp which must completely match the content
var RepositoryNameComponentAnchoredRegexp = regexp.MustCompile(`^` + RepositoryNameComponentRegexp.String() + `$`)
// RepositoryNameHostnameRegexp restricts the registry hostname component of a repository name to
// start with a component as defined by RepositoryNameComponentRegexp and followed by an optional port.
var RepositoryNameHostnameRegexp = regexp.MustCompile(RepositoryNameComponentRegexp.String() + `(?::[0-9]+)?`)
// RepositoryNameHostnameAnchoredRegexp is the version of
// RepositoryNameHostnameRegexp which must completely match the content.
var RepositoryNameHostnameAnchoredRegexp = regexp.MustCompile(`^` + RepositoryNameHostnameRegexp.String() + `$`)
// RepositoryNameRegexp builds on RepositoryNameComponentRegexp to allow
// multiple path components, separated by a forward slash.
var RepositoryNameRegexp = regexp.MustCompile(`(?:` + RepositoryNameHostnameRegexp.String() + `/)?(?:` + RepositoryNameComponentRegexp.String() + `/)*` + RepositoryNameComponentRegexp.String())
var (
// ErrRepositoryNameEmpty is returned for empty, invalid repository names.
ErrRepositoryNameEmpty = errors.New("repository name must have at least one component")
// ErrRepositoryNameMissingHostname is returned when a repository name
// does not start with a hostname
ErrRepositoryNameMissingHostname = errors.New("repository name must start with a hostname")
// ErrRepositoryNameHostnameInvalid is returned when a repository name
// does not match RepositoryNameHostnameRegexp
ErrRepositoryNameHostnameInvalid = fmt.Errorf("repository name must match %q", RepositoryNameHostnameRegexp.String())
// ErrRepositoryNameLong is returned when a repository name is longer than
// RepositoryNameTotalLengthMax
ErrRepositoryNameLong = fmt.Errorf("repository name must not be more than %v characters", RepositoryNameTotalLengthMax)
// ErrRepositoryNameComponentInvalid is returned when a repository name does
// not match RepositoryNameComponentRegexp
ErrRepositoryNameComponentInvalid = fmt.Errorf("repository name component must match %q", RepositoryNameComponentRegexp.String())
)
// Repository represents a reference to a Repository.
type Repository struct {
// Hostname refers to the registry hostname where the repository resides.
Hostname string
// Name is a slash (`/`) separated list of string components.
Name string
}
// String returns the string representation of a repository.
func (r Repository) String() string {
// Hostname is not supposed to be empty, but let's be nice.
if len(r.Hostname) == 0 {
return r.Name
}
return r.Hostname + "/" + r.Name
}
// Validate ensures the repository name is valid for use in the
// registry. This function accepts a superset of what might be accepted by
// docker core or docker hub. If the name does not pass validation, an error,
// describing the conditions, is returned.
//
// Effectively, the name should comply with the following grammar:
//
// repository := hostname ['/' component]+
// hostname := component [':' port-number]
// component := alpha-numeric [separator alpha-numeric]*
// alpha-numeric := /[a-zA-Z0-9]+/
// separator := /[._-]/
// port-number := /[0-9]+/
//
// The result of the production should be limited to 255 characters.
func (r Repository) Validate() error {
n := len(r.String())
switch {
case n == 0:
return ErrRepositoryNameEmpty
case n > RepositoryNameTotalLengthMax:
return ErrRepositoryNameLong
case len(r.Hostname) <= 0:
return ErrRepositoryNameMissingHostname
case !RepositoryNameHostnameAnchoredRegexp.MatchString(r.Hostname):
return ErrRepositoryNameHostnameInvalid
}
components := r.Name
for {
var component string
sep := strings.Index(components, "/")
if sep >= 0 {
component = components[:sep]
components = components[sep+1:]
} else { // if no more slashes
component = components
components = ""
}
if !RepositoryNameComponentAnchoredRegexp.MatchString(component) {
return ErrRepositoryNameComponentInvalid
}
if sep < 0 {
return nil
}
}
}
// NewRepository returns a valid Repository from an input string representing
// the canonical form of a repository name.
// If the validation fails, an error is returned.
func NewRepository(canonicalName string) (repo Repository, err error) {
if len(canonicalName) == 0 {
return repo, ErrRepositoryNameEmpty
}
i := strings.Index(canonicalName, "/")
if i <= 0 {
return repo, ErrRepositoryNameMissingHostname
}
repo.Hostname = canonicalName[:i]
repo.Name = canonicalName[i+1:]
return repo, repo.Validate()
}

View file

@ -1,286 +0,0 @@
package reference
import (
"regexp"
"strconv"
"strings"
"testing"
)
var (
// regexpTestcases is a unified set of testcases for
// TestValidateRepositoryName and TestRepositoryNameRegexp.
// Some of them are valid inputs for one and not the other.
regexpTestcases = []struct {
// input is the repository name or name component testcase
input string
// err is the error expected from ValidateRepositoryName, or nil
err error
// invalid should be true if the testcase is *not* expected to
// match RepositoryNameRegexp
invalid bool
}{
{
input: "",
err: ErrRepositoryNameEmpty,
invalid: true,
},
{
input: "short",
err: ErrRepositoryNameMissingHostname,
},
{
input: "simple/name",
},
{
input: "library/ubuntu",
},
{
input: "docker/stevvooe/app",
},
{
input: "aa/aa/aa/aa/aa/aa/aa/aa/aa/bb/bb/bb/bb/bb/bb",
},
{
input: "aa/aa/bb/bb/bb",
},
{
input: "a/a/a/b/b",
},
{
input: "a/a/a/a/",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "a//a/a",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "a",
err: ErrRepositoryNameMissingHostname,
},
{
input: "a/aa",
},
{
input: "aa/a",
},
{
input: "a/aa/a",
},
{
input: "foo.com/",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "foo.com:8080/bar",
},
{
input: "foo.com/bar",
},
{
input: "foo.com/bar/baz",
},
{
input: "foo.com/bar/baz/quux",
},
{
input: "blog.foo.com/bar/baz",
},
{
input: "asdf",
err: ErrRepositoryNameMissingHostname,
},
{
input: "aa/asdf$$^/aa",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "asdf$$^/aa",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "aa-a/aa",
},
{
input: "aa/aa",
},
{
input: "a-a/a-a",
},
{
input: "a",
err: ErrRepositoryNameMissingHostname,
},
{
input: "a/image",
},
{
input: "a-/a/a/a",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "a/a-/a/a/a",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
// total length = 255
input: "a/" + strings.Repeat("a", 253),
},
{
// total length = 256
input: "b/" + strings.Repeat("a", 254),
err: ErrRepositoryNameLong,
},
{
input: "-foo/bar",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "foo/bar-",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "foo-/bar",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "foo/-bar",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "_foo/bar",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "foo/bar_",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "____/____",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "_docker/_docker",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "docker_/docker_",
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "do__cker/docker",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "docker./docker",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: ".docker/docker",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "do..cker/docker",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "docker-/docker",
err: ErrRepositoryNameComponentInvalid,
invalid: true,
},
{
input: "-docker/docker",
err: ErrRepositoryNameComponentInvalid,
},
{
input: "xn--n3h.com/myimage", // http://☃.com in punycode
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "xn--7o8h.com/myimage", // http://🐳.com in punycode
err: ErrRepositoryNameHostnameInvalid,
invalid: true,
},
{
input: "b.gcr.io/test.example.com/my-app", // embedded domain component
},
{
input: "xn--n3h.com/myimage", // http://☃.com in punycode
},
{
input: "xn--7o8h.com/myimage", // http://🐳.com in punycode
},
{
input: "registry.io/foo/project--id.module--name.ver---sion--name", // image with hostname
},
}
)
// TestValidateRepositoryName tests the ValidateRepositoryName function,
// which uses RepositoryNameComponentAnchoredRegexp for validation
func TestValidateRepositoryName(t *testing.T) {
for _, testcase := range regexpTestcases {
failf := func(format string, v ...interface{}) {
t.Logf(strconv.Quote(testcase.input)+": "+format, v...)
t.Fail()
}
if _, err := NewRepository(testcase.input); err != testcase.err {
if testcase.err != nil {
if err != nil {
failf("unexpected error for invalid repository: got %v, expected %v", err, testcase.err)
} else {
failf("expected invalid repository: %v", testcase.err)
}
} else {
if err != nil {
// Wrong error returned.
failf("unexpected error validating repository name: %v, expected %v", err, testcase.err)
} else {
failf("unexpected error validating repository name: %v", err)
}
}
}
}
}
func TestRepositoryNameRegexp(t *testing.T) {
AnchoredRepositoryNameRegexp := regexp.MustCompile(`^` + RepositoryNameRegexp.String() + `$`)
for _, testcase := range regexpTestcases {
failf := func(format string, v ...interface{}) {
t.Logf(strconv.Quote(testcase.input)+": "+format, v...)
t.Fail()
}
matches := AnchoredRepositoryNameRegexp.MatchString(testcase.input)
if matches == testcase.invalid {
if testcase.invalid {
failf("expected invalid repository name %s", testcase.input)
} else {
failf("expected valid repository name %s", testcase.input)
}
}
}
}

View file

@ -1,38 +0,0 @@
package reference
import (
"fmt"
"regexp"
)
var (
// TagRegexp matches valid tag names. From docker/docker:graph/tags.go.
TagRegexp = regexp.MustCompile(`[\w][\w.-]{0,127}`)
// TagAnchoredRegexp matches valid tag names, anchored at the start and
// end of the matched string.
TagAnchoredRegexp = regexp.MustCompile(`^` + TagRegexp.String() + `$`)
// ErrTagInvalid is returned when a tag does not match TagAnchoredRegexp.
ErrTagInvalid = fmt.Errorf("tag name must match %q", TagRegexp.String())
)
// Tag represents an image's tag name.
type Tag string
// NewTag returns a valid Tag from an input string s.
// If the validation fails, an error is returned.
func NewTag(s string) (Tag, error) {
tag := Tag(s)
return tag, tag.Validate()
}
// Validate returns ErrTagInvalid if tag does not match TagAnchoredRegexp.
//
// tag := [\w][\w.-]{0,127}
func (tag Tag) Validate() error {
if !TagAnchoredRegexp.MatchString(string(tag)) {
return ErrTagInvalid
}
return nil
}

View file

@ -13,7 +13,7 @@ var (
nameParameterDescriptor = ParameterDescriptor{
Name: "name",
Type: "string",
Format: reference.RepositoryNameRegexp.String(),
Format: reference.NameRegexp.String(),
Required: true,
Description: `Name of the target repository.`,
}
@ -390,7 +390,7 @@ var routeDescriptors = []RouteDescriptor{
},
{
Name: RouteNameTags,
Path: "/v2/{name:" + reference.RepositoryNameRegexp.String() + "}/tags/list",
Path: "/v2/{name:" + reference.NameRegexp.String() + "}/tags/list",
Entity: "Tags",
Description: "Retrieve information about tags.",
Methods: []MethodDescriptor{
@ -518,7 +518,7 @@ var routeDescriptors = []RouteDescriptor{
},
{
Name: RouteNameManifest,
Path: "/v2/{name:" + reference.RepositoryNameRegexp.String() + "}/manifests/{reference:" + reference.TagRegexp.String() + "|" + digest.DigestRegexp.String() + "}",
Path: "/v2/{name:" + reference.NameRegexp.String() + "}/manifests/{reference:" + reference.TagRegexp.String() + "|" + digest.DigestRegexp.String() + "}",
Entity: "Manifest",
Description: "Create, update, delete and retrieve manifests.",
Methods: []MethodDescriptor{
@ -783,7 +783,7 @@ var routeDescriptors = []RouteDescriptor{
{
Name: RouteNameBlob,
Path: "/v2/{name:" + reference.RepositoryNameRegexp.String() + "}/blobs/{digest:" + digest.DigestRegexp.String() + "}",
Path: "/v2/{name:" + reference.NameRegexp.String() + "}/blobs/{digest:" + digest.DigestRegexp.String() + "}",
Entity: "Blob",
Description: "Operations on blobs identified by `name` and `digest`. Used to fetch or delete layers by digest.",
Methods: []MethodDescriptor{
@ -1007,7 +1007,7 @@ var routeDescriptors = []RouteDescriptor{
{
Name: RouteNameBlobUpload,
Path: "/v2/{name:" + reference.RepositoryNameRegexp.String() + "}/blobs/uploads/",
Path: "/v2/{name:" + reference.NameRegexp.String() + "}/blobs/uploads/",
Entity: "Initiate Blob Upload",
Description: "Initiate a blob upload. This endpoint can be used to create resumable uploads or monolithic uploads.",
Methods: []MethodDescriptor{
@ -1129,7 +1129,7 @@ var routeDescriptors = []RouteDescriptor{
{
Name: RouteNameBlobUploadChunk,
Path: "/v2/{name:" + reference.RepositoryNameRegexp.String() + "}/blobs/uploads/{uuid:[a-zA-Z0-9-_.=]+}",
Path: "/v2/{name:" + reference.NameRegexp.String() + "}/blobs/uploads/{uuid:[a-zA-Z0-9-_.=]+}",
Entity: "Blob Upload",
Description: "Interact with blob uploads. Clients should never assemble URLs for this endpoint and should only take it through the `Location` header on related API requests. The `Location` header and its parameters should be preserved by clients, using the latest value returned via upload related API calls.",
Methods: []MethodDescriptor{

View file

@ -170,6 +170,14 @@ func TestRouter(t *testing.T) {
"name": "foo/bar/manifests",
},
},
{
RouteName: RouteNameManifest,
RequestURI: "/v2/locahost:8080/foo/bar/baz/manifests/tag",
Vars: map[string]string{
"name": "locahost:8080/foo/bar/baz",
"reference": "tag",
},
},
}
checkTestRouter(t, testCases, "", true)

View file

@ -97,9 +97,9 @@ func (r *registry) Repositories(ctx context.Context, entries []string, last stri
return numFilled, returnErr
}
// NewRepository creates a new Repository for the given canonical repository name and base URL.
func NewRepository(ctx context.Context, canonicalName, baseURL string, transport http.RoundTripper) (distribution.Repository, error) {
if _, err := reference.NewRepository(canonicalName); err != nil {
// NewRepository creates a new Repository for the given repository name and base URL.
func NewRepository(ctx context.Context, name, baseURL string, transport http.RoundTripper) (distribution.Repository, error) {
if _, err := reference.ParseNamed(name); err != nil {
return nil, err
}
@ -116,7 +116,7 @@ func NewRepository(ctx context.Context, canonicalName, baseURL string, transport
return &repository{
client: client,
ub: ub,
name: canonicalName,
name: name,
context: ctx,
}, nil
}

View file

@ -25,8 +25,8 @@ func NewInMemoryBlobDescriptorCacheProvider() cache.BlobDescriptorCacheProvider
}
}
func (imbdcp *inMemoryBlobDescriptorCacheProvider) RepositoryScoped(canonicalName string) (distribution.BlobDescriptorService, error) {
if _, err := reference.NewRepository(canonicalName); err != nil {
func (imbdcp *inMemoryBlobDescriptorCacheProvider) RepositoryScoped(repo string) (distribution.BlobDescriptorService, error) {
if _, err := reference.ParseNamed(repo); err != nil {
return nil, err
}
@ -34,9 +34,9 @@ func (imbdcp *inMemoryBlobDescriptorCacheProvider) RepositoryScoped(canonicalNam
defer imbdcp.mu.RUnlock()
return &repositoryScopedInMemoryBlobDescriptorCache{
repo: canonicalName,
repo: repo,
parent: imbdcp,
repository: imbdcp.repositories[canonicalName],
repository: imbdcp.repositories[repo],
}, nil
}

View file

@ -40,13 +40,13 @@ func NewRedisBlobDescriptorCacheProvider(pool *redis.Pool) cache.BlobDescriptorC
}
// RepositoryScoped returns the scoped cache.
func (rbds *redisBlobDescriptorService) RepositoryScoped(canonicalName string) (distribution.BlobDescriptorService, error) {
if _, err := reference.NewRepository(canonicalName); err != nil {
func (rbds *redisBlobDescriptorService) RepositoryScoped(repo string) (distribution.BlobDescriptorService, error) {
if _, err := reference.ParseNamed(repo); err != nil {
return nil, err
}
return &repositoryScopedRedisBlobDescriptorService{
repo: canonicalName,
repo: repo,
upstream: rbds,
}, nil
}

View file

@ -108,7 +108,7 @@ func (reg *registry) Scope() distribution.Scope {
// Instances should not be shared between goroutines but are cheap to
// allocate. In general, they should be request scoped.
func (reg *registry) Repository(ctx context.Context, canonicalName string) (distribution.Repository, error) {
if _, err := reference.NewRepository(canonicalName); err != nil {
if _, err := reference.ParseNamed(canonicalName); err != nil {
return nil, distribution.ErrRepositoryNameInvalid{
Name: canonicalName,
Reason: err,