diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index 571b8e7..a475418 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -182,7 +182,7 @@ function configureFetchMock( ) } -const testManifest: ociContainer.Manifest = { +const testManifest: ociContainer.OCIImageManifest = { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', artifactType: 'application/vnd.oci.image.manifest.v1+json', @@ -526,8 +526,10 @@ function validateRequestConfig(url: string, config: any): void { } } -function cloneLayers(layers: ociContainer.Layer[]): ociContainer.Layer[] { - const result: ociContainer.Layer[] = [] +function cloneLayers( + layers: ociContainer.Descriptor[] +): ociContainer.Descriptor[] { + const result: ociContainer.Descriptor[] = [] for (const layer of layers) { result.push({ ...layer }) // this is _NOT_ a deep clone } diff --git a/__tests__/oci-container.test.ts b/__tests__/oci-container.test.ts index 10f1514..f610c4c 100644 --- a/__tests__/oci-container.test.ts +++ b/__tests__/oci-container.test.ts @@ -1,36 +1,19 @@ -import { createActionPackageManifest, sha256Digest } from '../src/oci-container' +import { + createActionPackageManifest, + sha256Digest, + sizeInBytes, + OCIImageManifest, + createSigstoreAttestationManifest, + OCIIndexManifest, + createReferrerTagManifest +} from '../src/oci-container' import { FileMetadata } from '../src/fs-helper' +const createdTimestamp = '2021-01-01T00:00:00.000Z' + describe('sha256Digest', () => { it('calculates the SHA256 digest of the provided manifest', () => { - const date = new Date('2021-01-01T00:00:00Z') - const repo = 'test-org/test-repo' - const version = '1.2.3' - const repoId = '123' - const ownerId = '456' - const sourceCommit = 'abc' - const tarFile: FileMetadata = { - path: '/test/test/test.tar.gz', - sha256: 'tarSha', - size: 123 - } - const zipFile: FileMetadata = { - path: '/test/test/test.zip', - sha256: 'zipSha', - size: 456 - } - - const manifest = createActionPackageManifest( - tarFile, - zipFile, - repo, - repoId, - ownerId, - sourceCommit, - version, - date - ) - + const { manifest } = testActionPackageManifest() const digest = sha256Digest(manifest) const expectedDigest = 'sha256:dd8537ef913cf87e25064a074973ed2c62699f1dbd74d0dd78e85d394a5758b5' @@ -39,25 +22,17 @@ describe('sha256Digest', () => { }) }) +describe('size', () => { + it('returns the total size of the provided manifest', () => { + const { manifest } = testActionPackageManifest() + const size = sizeInBytes(manifest) + expect(size).toBe(1133) + }) +}) + describe('createActionPackageManifest', () => { it('creates a manifest containing the provided information', () => { - const date = new Date() - const repo = 'test-org/test-repo' - const sanitizedRepo = 'test-org-test-repo' - const version = '1.2.3' - const repoId = '123' - const ownerId = '456' - const sourceCommit = 'abc' - const tarFile: FileMetadata = { - path: '/test/test/test.tar.gz', - sha256: 'tarSha', - size: 123 - } - const zipFile: FileMetadata = { - path: '/test/test/test.zip', - sha256: 'zipSha', - size: 456 - } + const { manifest, zipFile, tarFile } = testActionPackageManifest() const expectedJSON = `{ "schemaVersion": 2, @@ -79,7 +54,7 @@ describe('createActionPackageManifest', () => { "size":${tarFile.size}, "digest":"${tarFile.sha256}", "annotations":{ - "org.opencontainers.image.title":"${sanitizedRepo}_${version}.tar.gz" + "org.opencontainers.image.title":"test-org-test-repo_1.2.3.tar.gz" } }, { @@ -87,12 +62,12 @@ describe('createActionPackageManifest', () => { "size":${zipFile.size}, "digest":"${zipFile.sha256}", "annotations":{ - "org.opencontainers.image.title":"${sanitizedRepo}_${version}.zip" + "org.opencontainers.image.title":"test-org-test-repo_1.2.3.zip" } } ], "annotations":{ - "org.opencontainers.image.created":"${date.toISOString()}", + "org.opencontainers.image.created":"${createdTimestamp}", "action.tar.gz.digest":"${tarFile.sha256}", "action.zip.digest":"${zipFile.sha256}", "com.github.package.type":"actions_oci_pkg", @@ -103,26 +78,141 @@ describe('createActionPackageManifest', () => { } }` - const manifest = createActionPackageManifest( - { - path: 'test.tar.gz', - size: tarFile.size, - sha256: tarFile.sha256 - }, - { - path: 'test.zip', - size: zipFile.size, - sha256: zipFile.sha256 - }, - repo, - repoId, - ownerId, - sourceCommit, - version, - date - ) - const manifestJSON = JSON.stringify(manifest) expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, '')) }) }) + +describe('createSigstoreAttestationManifest', () => { + it('creates a manifest containing the provided information', () => { + const manifest = testAttestationManifest() + + const expectedJSON = `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "config": { + "mediaType": "application/vnd.oci.empty.v1+json", + "size": 2, + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "layers": [ + { + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "size": 10, + "digest": "bundleDigest" + } + ], + "subject": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": 100, + "digest": "subjectDigest" + }, + "annotations": { + "dev.sigstore.bundle.content": "dsse-envelope", + "dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1", + "com.github.package.type": "actions_oci_pkg_attestation", + "org.opencontainers.image.created": "2021-01-01T00:00:00.000Z" + } +} +` + + const manifestJSON = JSON.stringify(manifest) + + expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, '')) + }) +}) + +describe('createReferrerIndexManifest', () => { + it('creates a manifest containing the provided information', () => { + const manifest = testReferrerIndexManifest() + + const expectedJSON = ` +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "size": 100, + "digest": "attDigest", + "annotations": { + "com.github.package.type": "actions_oci_pkg_attestation", + "org.opencontainers.image.created": "2021-01-01T00:00:00.000Z", + "dev.sigstore.bundle.content": "dsse-envelope", + "dev.sigstore.bundle.predicateType": "https://slsa.dev/provenance/v1" + } + } + ], + "annotations": { + "com.github.package.type": "actions_oci_pkg_referrer_tag", + "org.opencontainers.image.created": "2021-01-01T00:00:00.000Z" + } +} + ` + + const manifestJSON = JSON.stringify(manifest) + + expect(manifestJSON).toEqual(expectedJSON.replace(/\s/g, '')) + }) +}) + +function testActionPackageManifest(): { + manifest: OCIImageManifest + tarFile: FileMetadata + zipFile: FileMetadata +} { + const date = new Date('2021-01-01T00:00:00Z') + const repo = 'test-org/test-repo' + const version = '1.2.3' + const repoId = '123' + const ownerId = '456' + const sourceCommit = 'abc' + const tarFile: FileMetadata = { + path: '/test/test/test.tar.gz', + sha256: 'tarSha', + size: 123 + } + const zipFile: FileMetadata = { + path: '/test/test/test.zip', + sha256: 'zipSha', + size: 456 + } + + const manifest = createActionPackageManifest( + tarFile, + zipFile, + repo, + repoId, + ownerId, + sourceCommit, + version, + date + ) + + return { + manifest, + tarFile, + zipFile + } +} + +function testAttestationManifest(): OCIImageManifest { + return createSigstoreAttestationManifest( + 10, + 'bundleDigest', + 100, + 'subjectDigest', + new Date(createdTimestamp) + ) +} + +function testReferrerIndexManifest(): OCIIndexManifest { + return createReferrerTagManifest( + 'attDigest', + 100, + new Date(createdTimestamp), + new Date(createdTimestamp) + ) +} diff --git a/badges/coverage.svg b/badges/coverage.svg index c5876fe..f6ea690 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97.17%Coverage97.17% \ No newline at end of file +Coverage: 97.39%Coverage97.39% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 053ad57..8f74b27 100644 --- a/dist/index.js +++ b/dist/index.js @@ -107754,25 +107754,40 @@ var __importStar = (this && this.__importStar) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.createActionPackageManifest = createActionPackageManifest; +exports.createSigstoreAttestationManifest = createSigstoreAttestationManifest; +exports.createReferrerTagManifest = createReferrerTagManifest; exports.sha256Digest = sha256Digest; +exports.sizeInBytes = sizeInBytes; const crypto = __importStar(__nccwpck_require__(6113)); +const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json'; +const imageManifestMediaType = 'application/vnd.oci.image.manifest.v1+json'; +const actionsPackageMediaType = 'application/vnd.github.actions.package.v1+json'; +const actionsPackageTarLayerMediaType = 'application/vnd.github.actions.package.layer.v1.tar+gzip'; +const actionsPackageZipLayerMediaType = 'application/vnd.github.actions.package.layer.v1.zip'; +const sigstoreBundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json'; +const ociEmptyMediaType = 'application/vnd.oci.empty.v1+json'; +const actionPackageAnnotationValue = 'actions_oci_pkg'; +const actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation'; +const actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_tag'; +const emptyConfigSize = 2; +const emptyConfigSha = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'; // Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package. -function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created) { +function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created = new Date()) { const configLayer = createConfigLayer(); const sanitizedRepo = sanitizeRepository(repository); const tarLayer = createTarLayer(tarFile, sanitizedRepo, version); const zipLayer = createZipLayer(zipFile, sanitizedRepo, version); const manifest = { schemaVersion: 2, - mediaType: 'application/vnd.oci.image.manifest.v1+json', - artifactType: 'application/vnd.github.actions.package.v1+json', + mediaType: imageManifestMediaType, + artifactType: actionsPackageMediaType, config: configLayer, layers: [configLayer, tarLayer, zipLayer], annotations: { 'org.opencontainers.image.created': created.toISOString(), 'action.tar.gz.digest': tarFile.sha256, 'action.zip.digest': zipFile.sha256, - 'com.github.package.type': 'actions_oci_pkg', + 'com.github.package.type': actionPackageAnnotationValue, 'com.github.package.version': version, 'com.github.source.repo.id': repoId, 'com.github.source.repo.owner.id': ownerId, @@ -107781,6 +107796,59 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner }; return manifest; } +function createSigstoreAttestationManifest(bundleSize, bundleDigest, subjectSize, subjectDigest, created = new Date()) { + const configLayer = createConfigLayer(); + const sigstoreAttestationLayer = { + mediaType: sigstoreBundleMediaType, + size: bundleSize, + digest: bundleDigest + }; + const subject = { + mediaType: imageManifestMediaType, + size: subjectSize, + digest: subjectDigest + }; + const manifest = { + schemaVersion: 2, + mediaType: imageManifestMediaType, + artifactType: sigstoreBundleMediaType, + config: configLayer, + layers: [sigstoreAttestationLayer], + subject, + annotations: { + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': 'https://slsa.dev/provenance/v1', + 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + }; + return manifest; +} +function createReferrerTagManifest(attestationDigest, attestationSize, attestationCreated, created = new Date()) { + const manifest = { + schemaVersion: 2, + mediaType: imageIndexMediaType, + manifests: [ + { + mediaType: imageManifestMediaType, + artifactType: sigstoreBundleMediaType, + size: attestationSize, + digest: attestationDigest, + annotations: { + 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': attestationCreated.toISOString(), + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': 'https://slsa.dev/provenance/v1' + } + } + ], + annotations: { + 'com.github.package.type': actionPackageReferrerTagAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + }; + return manifest; +} // Calculate the SHA256 digest of a given manifest. // This should match the digest which the GitHub container registry calculates for this manifest. function sha256Digest(manifest) { @@ -107791,17 +107859,21 @@ function sha256Digest(manifest) { const hexHash = hash.digest('hex'); return `sha256:${hexHash}`; } +function sizeInBytes(manifest) { + const data = JSON.stringify(manifest); + return Buffer.byteLength(data, 'utf8'); +} function createConfigLayer() { const configLayer = { - mediaType: 'application/vnd.oci.empty.v1+json', - size: 2, - digest: 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' + mediaType: ociEmptyMediaType, + size: emptyConfigSize, + digest: emptyConfigSha }; return configLayer; } function createZipLayer(zipFile, repository, version) { const zipLayer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.zip', + mediaType: actionsPackageZipLayerMediaType, size: zipFile.size, digest: zipFile.sha256, annotations: { @@ -107812,7 +107884,7 @@ function createZipLayer(zipFile, repository, version) { } function createTarLayer(tarFile, repository, version) { const tarLayer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', + mediaType: actionsPackageTarLayerMediaType, size: tarFile.size, digest: tarFile.sha256, annotations: { diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index 7aba4ef..cf8c570 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -11,7 +11,7 @@ export async function publishOCIArtifact( semver: string, zipFile: FileMetadata, tarFile: FileMetadata, - manifest: ociContainer.Manifest + manifest: ociContainer.OCIImageManifest ): Promise<{ packageURL: URL; publishedDigest: string }> { const b64Token = Buffer.from(token).toString('base64') @@ -81,7 +81,7 @@ export async function publishOCIArtifact( } async function uploadLayer( - layer: ociContainer.Layer, + layer: ociContainer.Descriptor, file: FileMetadata, registryURL: URL, checkBlobEndpoint: string, diff --git a/src/oci-container.ts b/src/oci-container.ts index 66268dc..dd3b4bb 100644 --- a/src/oci-container.ts +++ b/src/oci-container.ts @@ -1,19 +1,46 @@ import { FileMetadata } from './fs-helper' import * as crypto from 'crypto' -export interface Manifest { +const imageIndexMediaType = 'application/vnd.oci.image.index.v1+json' +const imageManifestMediaType = 'application/vnd.oci.image.manifest.v1+json' +const actionsPackageMediaType = 'application/vnd.github.actions.package.v1+json' +const actionsPackageTarLayerMediaType = + 'application/vnd.github.actions.package.layer.v1.tar+gzip' +const actionsPackageZipLayerMediaType = + 'application/vnd.github.actions.package.layer.v1.zip' +const sigstoreBundleMediaType = 'application/vnd.dev.sigstore.bundle.v0.3+json' +const ociEmptyMediaType = 'application/vnd.oci.empty.v1+json' + +const actionPackageAnnotationValue = 'actions_oci_pkg' +const actionPackageAttestationAnnotationValue = 'actions_oci_pkg_attestation' +const actionPackageReferrerTagAnnotationValue = 'actions_oci_pkg_referrer_tag' + +const emptyConfigSize = 2 +const emptyConfigSha = + 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' + +export interface OCIImageManifest { schemaVersion: number mediaType: string artifactType: string - config: Layer - layers: Layer[] + config: Descriptor + layers: Descriptor[] + subject?: Descriptor annotations: { [key: string]: string } } -export interface Layer { +export interface OCIIndexManifest { + schemaVersion: number + mediaType: string + manifests: Descriptor[] + annotations: { [key: string]: string } +} + +export interface Descriptor { mediaType: string size: number digest: string + artifactType?: string annotations?: { [key: string]: string } } @@ -26,24 +53,24 @@ export function createActionPackageManifest( ownerId: string, sourceCommit: string, version: string, - created: Date -): Manifest { + created: Date = new Date() +): OCIImageManifest { const configLayer = createConfigLayer() const sanitizedRepo = sanitizeRepository(repository) const tarLayer = createTarLayer(tarFile, sanitizedRepo, version) const zipLayer = createZipLayer(zipFile, sanitizedRepo, version) - const manifest: Manifest = { + const manifest: OCIImageManifest = { schemaVersion: 2, - mediaType: 'application/vnd.oci.image.manifest.v1+json', - artifactType: 'application/vnd.github.actions.package.v1+json', + mediaType: imageManifestMediaType, + artifactType: actionsPackageMediaType, config: configLayer, layers: [configLayer, tarLayer, zipLayer], annotations: { 'org.opencontainers.image.created': created.toISOString(), 'action.tar.gz.digest': tarFile.sha256, 'action.zip.digest': zipFile.sha256, - 'com.github.package.type': 'actions_oci_pkg', + 'com.github.package.type': actionPackageAnnotationValue, 'com.github.package.version': version, 'com.github.source.repo.id': repoId, 'com.github.source.repo.owner.id': ownerId, @@ -54,9 +81,83 @@ export function createActionPackageManifest( return manifest } +export function createSigstoreAttestationManifest( + bundleSize: number, + bundleDigest: string, + subjectSize: number, + subjectDigest: string, + created: Date = new Date() +): OCIImageManifest { + const configLayer = createConfigLayer() + + const sigstoreAttestationLayer: Descriptor = { + mediaType: sigstoreBundleMediaType, + size: bundleSize, + digest: bundleDigest + } + + const subject: Descriptor = { + mediaType: imageManifestMediaType, + size: subjectSize, + digest: subjectDigest + } + + const manifest: OCIImageManifest = { + schemaVersion: 2, + mediaType: imageManifestMediaType, + artifactType: sigstoreBundleMediaType, + config: configLayer, + layers: [sigstoreAttestationLayer], + subject, + + annotations: { + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': 'https://slsa.dev/provenance/v1', + 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + } + + return manifest +} + +export function createReferrerTagManifest( + attestationDigest: string, + attestationSize: number, + attestationCreated: Date, + created: Date = new Date() +): OCIIndexManifest { + const manifest: OCIIndexManifest = { + schemaVersion: 2, + mediaType: imageIndexMediaType, + manifests: [ + { + mediaType: imageManifestMediaType, + artifactType: sigstoreBundleMediaType, + size: attestationSize, + digest: attestationDigest, + annotations: { + 'com.github.package.type': actionPackageAttestationAnnotationValue, + 'org.opencontainers.image.created': attestationCreated.toISOString(), + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': 'https://slsa.dev/provenance/v1' + } + } + ], + annotations: { + 'com.github.package.type': actionPackageReferrerTagAnnotationValue, + 'org.opencontainers.image.created': created.toISOString() + } + } + + return manifest +} + // Calculate the SHA256 digest of a given manifest. // This should match the digest which the GitHub container registry calculates for this manifest. -export function sha256Digest(manifest: Manifest): string { +export function sha256Digest( + manifest: OCIImageManifest | OCIIndexManifest +): string { const data = JSON.stringify(manifest) const buffer = Buffer.from(data, 'utf8') const hash = crypto.createHash('sha256') @@ -65,12 +166,18 @@ export function sha256Digest(manifest: Manifest): string { return `sha256:${hexHash}` } -function createConfigLayer(): Layer { - const configLayer: Layer = { - mediaType: 'application/vnd.oci.empty.v1+json', - size: 2, - digest: - 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' +export function sizeInBytes( + manifest: OCIImageManifest | OCIIndexManifest +): number { + const data = JSON.stringify(manifest) + return Buffer.byteLength(data, 'utf8') +} + +function createConfigLayer(): Descriptor { + const configLayer: Descriptor = { + mediaType: ociEmptyMediaType, + size: emptyConfigSize, + digest: emptyConfigSha } return configLayer @@ -80,9 +187,9 @@ function createZipLayer( zipFile: FileMetadata, repository: string, version: string -): Layer { - const zipLayer: Layer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.zip', +): Descriptor { + const zipLayer: Descriptor = { + mediaType: actionsPackageZipLayerMediaType, size: zipFile.size, digest: zipFile.sha256, annotations: { @@ -97,9 +204,9 @@ function createTarLayer( tarFile: FileMetadata, repository: string, version: string -): Layer { - const tarLayer: Layer = { - mediaType: 'application/vnd.github.actions.package.layer.v1.tar+gzip', +): Descriptor { + const tarLayer: Descriptor = { + mediaType: actionsPackageTarLayerMediaType, size: tarFile.size, digest: tarFile.sha256, annotations: {