import { createHash, randomBytes } from 'node:crypto'; import { cidToString, createBlobCid } from '@pdsjs/core/repo'; import { describe, expect, it } from 'vitest'; import { cidToDigest, digestFromHashBytes, digestToCid, isValidDigest, } from '../src/digest.js'; import { isValidName, isValidTag, nameToRkey, rkeyToName, } from '../src/lexicon.js'; import { MEDIA_TYPES, parseManifest } from '../src/manifest.js'; import { blobRef, parseRepoRecord, recordBlobCids, removeManifest, serializeRepoRecord, } from '../src/record.js'; describe('digest', () => { it('round-trips digest to CID and back', () => { const hex = createHash('sha256').update('layer bytes').digest('hex'); const digest = `sha256:${hex}`; const cid = digestToCid(digest); expect(cid.startsWith('b')).toBe(true); expect(cidToDigest(cid)).toBe(digest); }); it('matches the CID the blob upload path computes', async () => { const bytes = new Uint8Array(randomBytes(1024)); const uploadCid = cidToString(await createBlobCid(bytes)); const hash = createHash('sha256').update(bytes).digest(); expect(digestToCid(digestFromHashBytes(hash))).toBe(uploadCid); expect(cidToDigest(uploadCid)).toBe(`sha256:${hash.toString('hex')}`); }); it('validates digest strings', () => { expect(isValidDigest(`sha256:${'a'.repeat(64)}`)).toBe(true); expect(isValidDigest(`sha512:${'a'.repeat(128)}`)).toBe(false); expect(isValidDigest(`sha256:${'A'.repeat(64)}`)).toBe(false); expect(isValidDigest('sha256:short')).toBe(false); expect(() => digestToCid('sha256:nope')).toThrow(/unsupported digest/); }); it('refuses a CID that is not sha256', () => { expect(() => cidToDigest('bnope')).toThrow(); }); }); describe('names and tags', () => { it('accepts spec-shaped repository names', () => { for (const name of [ 'app', 'my-app', 'a.b', 'a_b', 'a__b', 'chad/app', 'a/b/c', 'app2', ]) { expect(isValidName(name), name).toBe(true); } }); it('refuses names outside the grammar', () => { for (const name of ['', 'UPPER', 'a//b', 'a/', '/a', '-a', 'a-', 'a:b']) { expect(isValidName(name), name).toBe(false); } }); it('round-trips names through record keys', () => { expect(nameToRkey('chad/app')).toBe('chad:app'); expect(rkeyToName('chad:app')).toBe('chad/app'); expect(rkeyToName(nameToRkey('a/b/c'))).toBe('a/b/c'); expect(nameToRkey('app')).toBe('app'); }); it('validates tags', () => { expect(isValidTag('latest')).toBe(true); expect(isValidTag('v1.2.3')).toBe(true); expect(isValidTag('_hidden')).toBe(true); expect(isValidTag('.dot')).toBe(false); expect(isValidTag('a'.repeat(129))).toBe(false); }); }); describe('manifest parsing', () => { const layer = { mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip', digest: `sha256:${'1'.repeat(64)}`, size: 123, }; const config = { mediaType: 'application/vnd.oci.image.config.v1+json', digest: `sha256:${'2'.repeat(64)}`, size: 7, }; it('reads config and layers from an image manifest', () => { const body = new TextEncoder().encode( JSON.stringify({ schemaVersion: 2, mediaType: MEDIA_TYPES.ociManifest, config, layers: [layer], }), ); const parsed = parseManifest(body, MEDIA_TYPES.ociManifest); expect(parsed.isIndex).toBe(false); expect(parsed.mediaType).toBe(MEDIA_TYPES.ociManifest); expect(parsed.blobs.map((b) => b.digest)).toEqual([ config.digest, layer.digest, ]); expect(parsed.children).toEqual([]); }); it('reads children from an index', () => { const body = new TextEncoder().encode( JSON.stringify({ schemaVersion: 2, mediaType: MEDIA_TYPES.ociIndex, manifests: [ { mediaType: MEDIA_TYPES.ociManifest, digest: layer.digest }, ], }), ); const parsed = parseManifest(body, MEDIA_TYPES.ociIndex); expect(parsed.isIndex).toBe(true); expect(parsed.children.map((c) => c.digest)).toEqual([layer.digest]); expect(parsed.blobs).toEqual([]); }); it('takes the media type from the header when the body has none', () => { const body = new TextEncoder().encode( JSON.stringify({ schemaVersion: 2, config, layers: [] }), ); const parsed = parseManifest(body, MEDIA_TYPES.dockerManifest); expect(parsed.mediaType).toBe(MEDIA_TYPES.dockerManifest); }); it('refuses a body that contradicts the header', () => { const body = new TextEncoder().encode( JSON.stringify({ mediaType: MEDIA_TYPES.ociManifest }), ); expect(() => parseManifest(body, MEDIA_TYPES.dockerManifest)).toThrow( /does not match/, ); }); it('refuses bytes that are not a JSON object', () => { expect(() => parseManifest(new TextEncoder().encode('not json'), ''), ).toThrow(/not valid JSON/); expect(() => parseManifest(new TextEncoder().encode('[1]'), '')).toThrow( /not a JSON object/, ); }); it('keeps urls on a descriptor so foreign layers are recognizable', () => { const body = new TextEncoder().encode( JSON.stringify({ schemaVersion: 2, mediaType: MEDIA_TYPES.ociManifest, config, layers: [{ ...layer, urls: ['https://elsewhere.example/layer'] }], }), ); const parsed = parseManifest(body, MEDIA_TYPES.ociManifest); expect(parsed.blobs[1].urls).toEqual(['https://elsewhere.example/layer']); }); }); describe('record', () => { const manifestDigest = `sha256:${'3'.repeat(64)}`; const layerDigest = `sha256:${'4'.repeat(64)}`; const record = { name: 'chad/app', tags: [{ tag: 'latest', digest: manifestDigest }], manifests: [ { digest: manifestDigest, mediaType: MEDIA_TYPES.ociManifest, blob: blobRef(digestToCid(manifestDigest), MEDIA_TYPES.ociManifest, 7), }, ], layers: [ { digest: layerDigest, blob: blobRef(digestToCid(layerDigest), 'application/octet-stream', 9), }, ], createdAt: '2026-08-19T00:00:00.000Z', }; it('round-trips through serialization', () => { const parsed = parseRepoRecord(serializeRepoRecord(record)); expect(parsed).not.toBeNull(); expect(parsed?.name).toBe('chad/app'); expect(parsed?.tags).toEqual(record.tags); expect(parsed?.manifests).toEqual(record.manifests); expect(parsed?.layers).toEqual(record.layers); }); it('collects every referenced blob CID once', () => { const cids = recordBlobCids(record); expect(cids).toContain(digestToCid(manifestDigest)); expect(cids).toContain(digestToCid(layerDigest)); expect(cids).toHaveLength(2); }); it('drops a manifest, its tags and its unshared layers', () => { const next = removeManifest(record, manifestDigest, new Set()); expect(next.tags).toEqual([]); expect(next.manifests).toEqual([]); expect(next.layers).toEqual([]); }); it('keeps layers another manifest still references', () => { const next = removeManifest(record, manifestDigest, new Set([layerDigest])); expect(next.layers).toEqual(record.layers); }); it('parses nothing from a malformed value', () => { expect(parseRepoRecord(null)).toBeNull(); expect(parseRepoRecord({})).toBeNull(); expect(parseRepoRecord({ name: 5 })).toBeNull(); }); });