diff --git a/__tests__/image-reference.test.ts b/__tests__/image-reference.test.ts new file mode 100644 index 0000000..88326ac --- /dev/null +++ b/__tests__/image-reference.test.ts @@ -0,0 +1,186 @@ +import {ImageReference} from '../src/image-reference'; + +describe('fromString', () => { + // prettier-ignore + test.each([ + [ + 'foo_com', + { + path: 'foo_com' + }, + false + ], + [ + 'foo.com:tag', + { + path: 'foo.com', + tag: 'tag' + }, + false + ], + [ + 'foo.com:5000', + { + path: 'foo.com', + tag: '5000' + }, + false + ], + [ + 'foo.com/repo:tag', + { + domain: 'foo.com', + path: 'repo', + tag: 'tag' + }, + false + ], + [ + 'foo.com:5000/repo', + { + domain: 'foo.com:5000', + path: 'repo' + }, + false + ], + [ + 'foo.com:5000/repo:tag', + { + domain: 'foo.com:5000', + path: 'repo', + tag: 'tag' + }, + false + ], + [ + 'foo:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + { + domain: 'foo:5000', + path: 'repo', + digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + }, + false + ], + [ + 'foo:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + { + domain: 'foo:5000', + path: 'repo', + tag: 'tag', + digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + }, + false + ], + [ + 'foo:5000/repo', + { + domain: 'foo:5000', + path: 'repo' + }, + false + ], + [ + ':justtag', + {}, + true + ], + [ + 'b.gcr.io/foo.example.com/my-app:foo.example.com', + { + domain: 'b.gcr.io', + path: 'foo.example.com/my-app', + tag: 'foo.example.com', + }, + false + ], + [ + 'docker.io/library/ubuntu:18.04@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + { + domain: 'docker.io', + path: 'library/ubuntu', + tag: '18.04', + digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + }, + false + ], + [ + 'ghactionstest/ghactionstest', + { + domain: 'ghactionstest', + path: 'ghactionstest' + }, + false + ], + [ + 'ghcr.io/docker-ghactiontest/test', + { + domain: 'ghcr.io', + path: 'docker-ghactiontest/test' + }, + false + ], + [ + 'registry.gitlab.com/test1716/test', + { + domain: 'registry.gitlab.com', + path: 'test1716/test' + }, + false + ], + [ + '175142243308.dkr.ecr.us-east-2.amazonaws.com/sandbox/test-docker-action', + { + domain: '175142243308.dkr.ecr.us-east-2.amazonaws.com', + path: 'sandbox/test-docker-action' + }, + false + ], + [ + 'public.ecr.aws/q3b5f1u4/test-docker-action', + { + domain: 'public.ecr.aws', + path: 'q3b5f1u4/test-docker-action' + }, + false + ], + [ + 'us-east4-docker.pkg.dev/sandbox-298914/docker-official-github-actions/test-docker-action', + { + domain: 'us-east4-docker.pkg.dev', + path: 'sandbox-298914/docker-official-github-actions/test-docker-action' + }, + false + ], + [ + 'gcr.io/sandbox-298914/test-docker-action', + { + domain: 'gcr.io', + path: 'sandbox-298914/test-docker-action' + }, + false + ], + [ + 'ghcr.io/KTH-Library/kontarion:latest', + { + domain: 'ghcr.io', + path: 'kth-library/kontarion', + tag: 'latest' + }, + false + ], + ])( + 'given %p', + async (input, expected, invalid) => { + try { + const ir = ImageReference.fromString(input); + console.log(ir); + expect(ir).toEqual(expected); + } catch (err) { + if (!invalid) { + console.error(err); + } + expect(true).toBe(invalid); + } + } + ); +}); diff --git a/dist/index.js b/dist/index.js index aee4be1..2783c13 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3498,6 +3498,194 @@ const minVersion = (range, loose) => { module.exports = minVersion +/***/ }), + +/***/ 184: +/***/ (function(__unusedmodule, exports) { + +"use strict"; + +// 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/distribution/distribution/blob/master/reference/reference.go +// Ref: https://github.com/distribution/distribution/blob/master/reference/regexp.go +// Ref: https://github.com/moby/moby/blob/master/image/spec/v1.2.md +// Ref: https://github.com/jkcfg/kubernetes/blob/master/src/image-reference.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ImageReference = void 0; +// nameMaxLength is the maximum total number of characters in a repository name. +const nameMaxLength = 255; +function match(s) { + if (s instanceof RegExp) { + return s; + } + return new RegExp(s); +} +function quoteMeta(s) { + return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} +// literal compiles s into a literal regular expression, escaping any regexp +// reserved characters. +function literal(s) { + return match(quoteMeta(s)); +} +// expression defines a full expression, where each regular expression must +// follow the previous. +function expression(...res) { + 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) { + return match(group(expression(...res)).source + '?'); +} +// repeated wraps the regexp in a non-capturing group to get one or more +// matches. +function repeated(...res) { + return match(group(expression(...res)).source + '+'); +} +// capture wraps the expression in a capturing group. +function capture(...res) { + return match(`(` + expression(...res).source + `)`); +} +// anchored anchors the regular expression by adding start and end delimiters. +function anchored(...res) { + return match(`^` + expression(...res).source + `$`); +} +// group wraps the regexp in a non-capturing group. +function group(...res) { + return match(`(?:${expression(...res).source})`); +} +// alphaNumericRegexp defines the alpha numeric atom, typically a component of +// names. Can contain upper case characters compared to the default API to +// fix https://github.com/docker/build-push-action/issues/237#issue-748654527 +const alphaNumericRegexp = match(/[a-zA-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); +class ImageReference { + constructor(domain, path, version) { + this.domain = domain === null || domain === void 0 ? void 0 : domain.toLowerCase(); + this.path = path === null || path === void 0 ? void 0 : path.toLowerCase(); + if (version) { + this.tag = version.tag; + this.digest = version.digest; + } + } + get image() { + const components = this.path.split('/'); + return components[components.length - 1]; + } + toString() { + let s = ''; + if (this.domain) { + s += this.domain + '/'; + } + s += this.path; + if (this.tag) { + s += ':' + this.tag; + } + if (this.digest) { + s += '@' + this.digest; + } + return s; + } + static fromString(s) { + 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 > nameMaxLength) { + throw new Error(`repository name must not be more than ${nameMaxLength} 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 }); + } + static sanitize(s) { + return this.fromString(s).toString(); + } +} +exports.ImageReference = ImageReference; +//# sourceMappingURL=image-reference.js.map + /***/ }), /***/ 185: @@ -13010,6 +13198,7 @@ const tmp = __importStar(__webpack_require__(517)); const core = __importStar(__webpack_require__(186)); const github = __importStar(__webpack_require__(438)); const buildx = __importStar(__webpack_require__(295)); +const image_reference_1 = __webpack_require__(184); let _defaultContext, _tmpDir; function defaultContext() { var _a, _b; @@ -13077,7 +13266,7 @@ function getBuildArgs(inputs, defaultContext, buildxVersion) { args.push('--label', label); })); yield exports.asyncForEach(inputs.tags, (tag) => __awaiter(this, void 0, void 0, function* () { - args.push('--tag', tag); + args.push('--tag', image_reference_1.ImageReference.sanitize(tag)); })); if (inputs.target) { args.push('--target', inputs.target); diff --git a/src/context.ts b/src/context.ts index 922c430..084e1ae 100644 --- a/src/context.ts +++ b/src/context.ts @@ -9,6 +9,7 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; import * as buildx from './buildx'; +import {ImageReference} from './image-reference'; let _defaultContext, _tmpDir: string; @@ -97,7 +98,7 @@ async function getBuildArgs(inputs: Inputs, defaultContext: string, buildxVersio args.push('--label', label); }); await asyncForEach(inputs.tags, async tag => { - args.push('--tag', tag); + args.push('--tag', ImageReference.sanitize(tag)); }); if (inputs.target) { args.push('--target', inputs.target); diff --git a/src/image-reference.ts b/src/image-reference.ts new file mode 100644 index 0000000..4ecbe52 --- /dev/null +++ b/src/image-reference.ts @@ -0,0 +1,238 @@ +// 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/distribution/distribution/blob/master/reference/reference.go +// Ref: https://github.com/distribution/distribution/blob/master/reference/regexp.go +// Ref: https://github.com/moby/moby/blob/master/image/spec/v1.2.md +// Ref: https://github.com/jkcfg/kubernetes/blob/master/src/image-reference.ts + +// nameMaxLength is the maximum total number of characters in a repository name. +const nameMaxLength = 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})`); +} + +// alphaNumericRegexp defines the alpha numeric atom, typically a component of +// names. Can contain upper case characters compared to the default API to +// fix https://github.com/docker/build-push-action/issues/237#issue-748654527 +const alphaNumericRegexp = match(/[a-zA-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; +} + +export class ImageReference { + domain?: string; + path: string; + tag?: string; + digest?: string; + + constructor(domain: string | undefined, path: string, version?: VersionInfo) { + this.domain = domain?.toLowerCase(); + this.path = path?.toLowerCase(); + if (version) { + this.tag = version.tag; + this.digest = version.digest; + } + } + + get image(): string { + const components = this.path.split('/'); + return components[components.length - 1]; + } + + toString(): string { + let s = ''; + if (this.domain) { + s += this.domain + '/'; + } + s += this.path; + if (this.tag) { + s += ':' + this.tag; + } + if (this.digest) { + s += '@' + this.digest; + } + return s; + } + + 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 > nameMaxLength) { + throw new Error(`repository name must not be more than ${nameMaxLength} 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}); + } + + static sanitize(s: string): string { + return this.fromString(s).toString(); + } +}