From d5bc1f28c858ee205eded34598701ce64bbcc176 Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Tue, 29 Nov 2022 09:26:37 -0800 Subject: [PATCH] `getCollectionRef()` to centralize validating Collection identifier (#303) * create getCollectionRef() to centralize validating Collection identifier * missing a quote * another quote --- .../containerCollectionsOCI.ts | 52 ++++++++++++++---- src/spec-node/featuresCLI/publish.ts | 12 ++--- src/spec-node/templatesCLI/publish.ts | 12 ++--- .../containerFeaturesOCI.test.ts | 54 ++++++++++++++++++- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 9a23f0e8..302a27c2 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -13,7 +13,9 @@ export const DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE = 'application/vnd.devconta export type HEADERS = { 'authorization'?: string; 'user-agent': string; 'content-type'?: string; 'accept'?: string }; -// ghcr.io/devcontainers/features/go:1.0.0 +// Represents the unique OCI identifier for a Feature or Template. +// eg: ghcr.io/devcontainers/features/go:1.0.0 +// Constructed by 'getRef()' export interface OCIRef { registry: string; // 'ghcr.io' owner: string; // 'devcontainers' @@ -24,11 +26,14 @@ export interface OCIRef { version?: string; // '1.0.0' } -// ghcr.io/devcontainers/features:latest +// Represents the unique OCI identifier for a Collection's Metadata artifact. +// eg: ghcr.io/devcontainers/features:latest +// Constructed by 'getCollectionRef()' export interface OCICollectionRef { registry: string; // 'ghcr.io' path: string; // 'devcontainers/features' - version: 'latest'; // 'latest' + resource: string; // 'ghcr.io/devcontainers/features' + version: 'latest'; // 'latest' (always) } export interface OCILayer { @@ -100,13 +105,15 @@ export function getRef(output: Log, input: string): OCIRef | undefined { const path = `${namespace}/${id}`; - output.write(`resource: ${resource}`, LogLevel.Trace); - output.write(`id: ${id}`, LogLevel.Trace); - output.write(`version: ${version}`, LogLevel.Trace); - output.write(`owner: ${owner}`, LogLevel.Trace); - output.write(`namespace: ${namespace}`, LogLevel.Trace); // TODO: We assume 'namespace' includes at least one slash (eg: 'devcontainers/features') - output.write(`registry: ${registry}`, LogLevel.Trace); - output.write(`path: ${path}`, LogLevel.Trace); + output.write(`> input: ${input}`, LogLevel.Trace); + output.write(`>`, LogLevel.Trace); + output.write(`> resource: ${resource}`, LogLevel.Trace); + output.write(`> id: ${id}`, LogLevel.Trace); + output.write(`> version: ${version}`, LogLevel.Trace); + output.write(`> owner: ${owner}`, LogLevel.Trace); + output.write(`> namespace: ${namespace}`, LogLevel.Trace); // TODO: We assume 'namespace' includes at least one slash (eg: 'devcontainers/features') + output.write(`> registry: ${registry}`, LogLevel.Trace); + output.write(`> path: ${path}`, LogLevel.Trace); // Validate results of parse. @@ -131,6 +138,31 @@ export function getRef(output: Log, input: string): OCIRef | undefined { }; } +export function getCollectionRef(output: Log, registry: string, namespace: string): OCICollectionRef | undefined { + // Normalize input by downcasing entire string + registry = registry.toLowerCase(); + namespace = namespace.toLowerCase(); + + const path = namespace; + const resource = `${registry}/${path}`; + + output.write(`> Inputs: registry='${registry}' namespace='${namespace}'`, LogLevel.Trace); + output.write(`>`, LogLevel.Trace); + output.write(`> resource: ${resource}`, LogLevel.Trace); + + if (!regexForPath.exec(path)) { + output.write(`Parsed path '${path}' from input failed validation.`, LogLevel.Error); + return undefined; + } + + return { + registry, + path, + resource, + version: 'latest' + }; +} + // Validate if a manifest exists and is reachable about the declared feature/template. // Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#pulling-manifests export async function fetchOCIManifestIfExists(output: Log, env: NodeJS.ProcessEnv, ref: OCIRef | OCICollectionRef, manifestDigest?: string, authToken?: string): Promise { diff --git a/src/spec-node/featuresCLI/publish.ts b/src/spec-node/featuresCLI/publish.ts index 34027f13..1946c370 100644 --- a/src/spec-node/featuresCLI/publish.ts +++ b/src/spec-node/featuresCLI/publish.ts @@ -12,7 +12,7 @@ import { loadNativeModule } from '../../spec-common/commonUtils'; import { PackageCommandInput } from '../collectionCommonUtils/package'; import { OCICollectionFileName } from '../collectionCommonUtils/packageCommandImpl'; import { publishOptions } from '../collectionCommonUtils/publish'; -import { getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; +import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl'; const collectionType = 'feature'; @@ -88,11 +88,11 @@ async function featuresPublish({ } } - const featureCollectionRef: OCICollectionRef = { - registry: registry, - path: namespace, - version: 'latest' - }; + const featureCollectionRef: OCICollectionRef | undefined = getCollectionRef(output, registry, namespace); + if (!featureCollectionRef) { + output.write(`(!) Could not parse provided collection identifier with registry '${registry}' and namespace '${namespace}'`, LogLevel.Error); + process.exit(1); + } if (! await doPublishMetadata(featureCollectionRef, outputDir, output, collectionType)) { output.write(`(!) ERR: Failed to publish '${featureCollectionRef.registry}/${featureCollectionRef.path}'`, LogLevel.Error); diff --git a/src/spec-node/templatesCLI/publish.ts b/src/spec-node/templatesCLI/publish.ts index 94bd9078..ca5dca78 100644 --- a/src/spec-node/templatesCLI/publish.ts +++ b/src/spec-node/templatesCLI/publish.ts @@ -12,7 +12,7 @@ import { loadNativeModule } from '../../spec-common/commonUtils'; import { PackageCommandInput } from '../collectionCommonUtils/package'; import { OCICollectionFileName } from '../collectionCommonUtils/packageCommandImpl'; import { packageTemplates } from './packageImpl'; -import { getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; +import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl'; const collectionType = 'template'; @@ -86,11 +86,11 @@ async function templatesPublish({ await doPublishCommand(t.version, templateRef, outputDir, output, collectionType); } - const templateCollectionRef: OCICollectionRef = { - registry: registry, - path: namespace, - version: 'latest' - }; + const templateCollectionRef: OCICollectionRef | undefined = getCollectionRef(output, registry, namespace); + if (!templateCollectionRef) { + output.write(`(!) Could not parse provided collection identifier with registry '${registry}' and namespace '${namespace}'`, LogLevel.Error); + process.exit(1); + } await doPublishMetadata(templateCollectionRef, outputDir, output, collectionType); diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index fafcdfb8..b3db8ecb 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -1,9 +1,61 @@ import { assert } from 'chai'; -import { getRef, getManifest, getBlob } from '../../spec-configuration/containerCollectionsOCI'; +import { getRef, getManifest, getBlob, getCollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); +describe('getCollectionRef()', async function () { + this.timeout('120s'); + + + it('valid getCollectionRef()', async () => { + const collectionRef = getCollectionRef(output, 'ghcr.io', 'devcontainers/templates'); + if (!collectionRef) { + assert.fail('collectionRef should not be undefined'); + } + assert.ok(collectionRef); + assert.equal(collectionRef.registry, 'ghcr.io'); + assert.equal(collectionRef.path, 'devcontainers/templates'); + assert.equal(collectionRef.resource, 'ghcr.io/devcontainers/templates'); + assert.equal(collectionRef.version, 'latest'); + }); + + it('valid getCollectionRef() that was originally uppercase', async () => { + const collectionRef = getCollectionRef(output, 'GHCR.IO', 'DEVCONTAINERS/TEMPLATES'); + if (!collectionRef) { + assert.fail('collectionRef should not be undefined'); + } + assert.ok(collectionRef); + assert.equal(collectionRef.registry, 'ghcr.io'); + assert.equal(collectionRef.path, 'devcontainers/templates'); + assert.equal(collectionRef.resource, 'ghcr.io/devcontainers/templates'); + assert.equal(collectionRef.version, 'latest'); + }); + + it('valid getCollectionRef() with port in registry', async () => { + const collectionRef = getCollectionRef(output, 'ghcr.io:8001', 'devcontainers/templates'); + if (!collectionRef) { + assert.fail('collectionRef should not be undefined'); + } + assert.ok(collectionRef); + assert.equal(collectionRef.registry, 'ghcr.io:8001'); + assert.equal(collectionRef.path, 'devcontainers/templates'); + assert.equal(collectionRef.resource, 'ghcr.io:8001/devcontainers/templates'); + assert.equal(collectionRef.version, 'latest'); + }); + + it('invalid getCollectionRef() with an invalid character in path', async () => { + const collectionRef = getCollectionRef(output, 'ghcr.io', 'devcont%ainers/templates'); + assert.isUndefined(collectionRef); + }); + + it('invalid getCollectionRef() with too many slashes in path', async () => { + const collectionRef = getCollectionRef(output, 'ghcr.io', 'devcontainers//templates'); + assert.isUndefined(collectionRef); + }); + +}); + describe('getRef()', async function () { this.timeout('120s');