diff --git a/src/image-reference.ts b/src/image-reference.ts new file mode 100644 index 0000000..50de243 --- /dev/null +++ b/src/image-reference.ts @@ -0,0 +1,218 @@ +import { disposeEmitNodes } from "typescript"; + +// Grammar +// +// reference := name [ ":" tag ] [ "@" digest ] +// name := [domain '/'] path-component ['/' path-component]* +// domain := domain-component ['.' domain-component]* [':' port-number] +// 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]* +// alpha-numeric := /[a-z0-9]+/ +// separator := /[_.]|__|[-]*/ +// +// tag := /[\w][\w.-]{0,127}/ +// +// 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 := /[0-9a-fA-F]{32,}/ ; At least 128 bit digest value +// +// identifier := /[a-f0-9]{64}/ +// short-identifier := /[a-f0-9]{6,64}/ + +// Ref: https://github.com/docker/distribution/blob/master/reference/reference.go +// Ref: https://github.com/docker/distribution/blob/master/reference/regexp.go +// Ref: https://github.com/moby/moby/blob/master/image/spec/v1.2.md + +// NameTotalLengthMax is the maximum total number of characters in a repository name. +const NameTotalLengthMax = 255 + +function match(s: string|RegExp): RegExp { + if (s instanceof RegExp) { + return s; + } + return new RegExp(s); +} + +function quoteMeta(s: string): string { + return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +} + +// literal compiles s into a literal regular expression, escaping any regexp +// reserved characters. +function literal(s: string): RegExp { + return match(quoteMeta(s)); +} + +// expression defines a full expression, where each regular expression must +// follow the previous. +function expression(...res: RegExp[]): RegExp { + let s = ''; + for (const re of res) { + s += re.source; + } + return match(s); +} + +// optional wraps the expression in a non-capturing group and makes the +// production optional. +function optional(...res: RegExp[]): RegExp { + return match(group(expression(...res)).source + '?'); + +} + +// repeated wraps the regexp in a non-capturing group to get one or more +// matches. +function repeated(...res: RegExp[]): RegExp { + return match(group(expression(...res)).source + '+'); +} + +// capture wraps the expression in a capturing group. +function capture(...res: RegExp[]): RegExp { + return match(`(` + expression(...res).source + `)`) +} + +// anchored anchors the regular expression by adding start and end delimiters. +function anchored(...res: RegExp[]): RegExp { + return match(`^` + expression(...res).source + `$`) +} + +// group wraps the regexp in a non-capturing group. +function group(...res: RegExp[]): RegExp { + return match(`(?:${expression(...res).source})`); +} + +// alphaNumericRe defines the alpha numeric atom, typically a component of +// names. This only allows lower case characters and digits. +const alphaNumericRegexp = match(/[a-z0-9]+/); + +// separatorRegexp defines the separators allowed to be embedded in name components. +// This allow one period, one or two underscore and multiple dashes. +const separatorRegexp = match(/(?:[._]|__|[-]*)/); + +// 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, one or two underscore and multiple dashes. +const 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. +const domainComponentRegexp = match(/(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-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. +const DomainRegexp = expression( + domainComponentRegexp, + optional(repeated(literal(`.`), domainComponentRegexp)), + optional(literal(`:`), match(/[0-9]+/))) + +// TagRegexp matches valid tag names. From docker/docker:graph/tags.go. +const TagRegexp = match(/[\w][\w.-]{0,127}/) + +// anchoredTagRegexp matches valid tag names, anchored at the start and +// end of the matched string. +const anchoredTagRegexp = anchored(TagRegexp) + +// DigestRegexp matches valid digests. +const DigestRegexp = match(/[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][0-9a-fA-F]{32,}/) + +// anchoredDigestRegexp matches valid digests, anchored at the start and +// end of the matched string. +const anchoredDigestRegexp = anchored(DigestRegexp) + +// 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. +const NameRegexp = expression( + optional(DomainRegexp, literal(`/`)), + nameComponentRegexp, + optional(repeated(literal(`/`), nameComponentRegexp))) + +// anchoredNameRegexp is used to parse a name value, capturing the +// domain and trailing components. +const anchoredNameRegexp = anchored( + optional(capture(DomainRegexp), literal(`/`)), + capture(nameComponentRegexp, + optional(repeated(literal(`/`), nameComponentRegexp)))) + +// ReferenceRegexp is the full supported format of a reference. The regexp +// is anchored and has capturing groups for name, tag, and digest +// components. +const ReferenceRegexp = anchored(capture(NameRegexp), + optional(literal(":"), capture(TagRegexp)), + optional(literal("@"), capture(DigestRegexp))) + +// 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. +const 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. +const ShortIdentifierRegexp = match(/([a-f0-9]{6,64})/) + +// anchoredIdentifierRegexp is used to check or match an +// identifier value, anchored at start and end of string. +const anchoredIdentifierRegexp = anchored(IdentifierRegexp) + +// anchoredShortIdentifierRegexp is used to check if a value +// is a possible identifier prefix, anchored at start and end +// of string. +const anchoredShortIdentifierRegexp = anchored(ShortIdentifierRegexp) + +interface VersionInfo { + tag?: string; + digest?: string; +} + +class ImageReference { + domain?: string; + path: string; + tag?: string; + digest?: string; + + constructor(domain: string|undefined, path: string, version?: VersionInfo) { + this.domain = domain; + this.path = path; + if (version) { + this.tag = version.tag; + this.digest = version.digest; + } + } + + static fromString(s: string): ImageReference { + const matches = s.match(ReferenceRegexp); + if (matches == null) { + throw new Error(`invalid image reference`); + } + + const name = matches[1], + tag = matches[2], + digest = matches[3]; + + if (name.length > NameTotalLengthMax) { + throw new Error(`repository name must not be more than ${NameTotalLengthMax} characters`); + } + + const nameMatches = name.match(anchoredNameRegexp); + if (nameMatches == null) { + throw new Error(`invalid image reference`); + } + const domain = nameMatches[1], + path = nameMatches[2]; + + return new ImageReference(domain, path, { tag, digest }); + } +} + +export { + ImageReference, +}; diff --git a/tests/image-reference.test.js b/tests/image-reference.test.js new file mode 100644 index 0000000..ddedfa0 --- /dev/null +++ b/tests/image-reference.test.js @@ -0,0 +1,38 @@ +import { ImageReference } from '../src/image-reference'; + +// from https://github.com/docker/distribution/blob/master/reference/reference_test.go +[ + ['test_com', { path: 'test_com' }], + ['test.com:tag', { path: 'test.com', tag: 'tag' }], + ['test.com:5000', { path: 'test.com', tag: '5000' }], + ['test.com/repo:tag', { domain: 'test.com', path: 'repo', tag: 'tag' }], + ['test.com:5000/repo', { domain: 'test.com:5000', path: 'repo' } ], + ['test.com:5000/repo:tag', { domain: 'test.com:5000', path: 'repo', tag: 'tag' } ], + ['test:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', { + domain: 'test:5000', + path: 'repo', + digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + }], + ['test:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', { + domain: 'test:5000', + path: 'repo', + tag: 'tag', + digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + }], + ['test:5000/repo', { domain: 'test:5000', path: 'repo' } ], + [':justtag', { err: 'invalid' }], + ['b.gcr.io/test.example.com/my-app:test.example.com', { + domain: 'b.gcr.io', + path: 'test.example.com/my-app', + tag: 'test.example.com', + }], +].forEach(([input, expected]) => { + test(input, () => { + const f = s => ImageReference.fromString(s); + if (expected.err) { + expect(() => f(input)).toThrow(expected.err); + } else { + expect(f(input)).toEqual(expected); + } + }); +});