import { cborEncodeDagCbor } from '@pdsjs/core/repo'; import { describe, expect, it } from 'vitest'; import { compareBytes, encodeVarint, MAX_RECORD_SIZE, RepoVerificationError, readStar, STAR_MAGIC, serializeRepo, verifyRecord, verifyRepoStar, } from '../src/star.js'; import { fromHex, referenceCommit, toHex, vectors } from './helpers/vectors.js'; const records = vectors.commit.records.map((/** @type {any} */ r) => ({ collection: r.collection, rkey: r.rkey, bytes: fromHex(r.valueHex), })); const params = { space: vectors.commit.space, repo: vectors.commit.repo, sharedSecret: fromHex(vectors.commit.secretHex), mac: fromHex(vectors.commit.macHex), }; /** * A STAR archive from raw frames. * @param {Uint8Array} meta * @param {Array<[Uint8Array, Uint8Array]>} pairs */ function frames(meta, pairs) { /** @type {number[]} */ const out = [...STAR_MAGIC, ...encodeVarint(meta.length), ...meta]; for (const [k, v] of pairs) { out.push( 1, ...encodeVarint(k.length), ...k, ...encodeVarint(v.length), ...v, ); } out.push(0); return new Uint8Array(out); } const enc = new TextEncoder(); const meta = () => cborEncodeDagCbor({ $type: 'atproto-space-repo', ...referenceCommit() }); describe('serializeRepo', () => { it('reproduces the reference archive byte for byte', () => { expect(toHex(serializeRepo(referenceCommit(), records))).toBe( vectors.commit.starHex, ); }); it('sorts records given out of order', () => { expect( toHex(serializeRepo(referenceCommit(), [...records].reverse())), ).toBe(vectors.commit.starHex); }); it('refuses a duplicate path', () => { expect(() => serializeRepo(referenceCommit(), [records[0], records[0]]), ).toThrow(/duplicate/); }); }); describe('verifyRepoStar', () => { it('verifies the reference archive', async () => { const { commit, records: out } = await verifyRepoStar( fromHex(vectors.commit.starHex), params, ); expect(commit).toEqual(referenceCommit()); expect(out.map((r) => r.record)).toEqual([ { text: 'hello' }, { text: 'world' }, ]); expect(out.map((r) => r.cid)).toEqual( vectors.commit.records.map((/** @type {any} */ r) => r.cid), ); }); it('refuses an archive missing a record', async () => { const star = serializeRepo(referenceCommit(), [records[0]]); await expect(verifyRepoStar(star, params)).rejects.toThrow(/do not match/); }); it('refuses a commit the MAC does not cover', async () => { await expect( verifyRepoStar(fromHex(vectors.commit.starHex), { ...params, sharedSecret: new Uint8Array(32), }), ).rejects.toThrow(/commit failed verification/); }); it('refuses records out of order', async () => { const star = frames(meta(), [ [ enc.encode(`${records[1].collection}/${records[1].rkey}`), records[1].bytes, ], [ enc.encode(`${records[0].collection}/${records[0].rkey}`), records[0].bytes, ], ]); await expect(verifyRepoStar(star, params)).rejects.toThrow(/out of order/); }); it('refuses a bad path, bad CBOR or a non-map record', async () => { for (const [key, value, pattern] of /** @type {const} */ ([ ['nopath', records[0].bytes, /invalid record path/], ['app.example.post/a', new Uint8Array([0xa1]), /./], ['app.example.post/a', cborEncodeDagCbor([1]), /invalid record at/], ])) { await expect( verifyRepoStar(frames(meta(), [[enc.encode(key), value]]), params), ).rejects.toThrow(pattern); } }); it('refuses metadata of another type or shape', async () => { for (const m of [ { ...referenceCommit(), $type: 'something-else' }, { $type: 'atproto-space-repo', ...referenceCommit(), repo: 'nope' }, { $type: 'atproto-space-repo', ...referenceCommit(), data: 'nope' }, ]) { await expect( verifyRepoStar(frames(cborEncodeDagCbor(m), []), params), ).rejects.toThrow(RepoVerificationError); } }); }); describe('readStar', () => { it('refuses bad magic, missing metadata and truncation', () => { expect(() => readStar(new Uint8Array([1, 2, 3, 4]))).toThrow(/magic/); expect(() => readStar(STAR_MAGIC)).toThrow(/unexpected end/); const star = fromHex(vectors.commit.starHex); expect(() => readStar(star.subarray(0, star.length - 1))).toThrow( /unexpected end/, ); // Ended where a pair's value should start. const open = frames(meta(), []).slice(0, -1); expect(() => readStar(new Uint8Array([...open, 1, 1, 0x61]))).toThrow( /unexpected end/, ); expect(() => readStar(open)).toThrow(/unexpected end/); }); it('refuses an archive without its control bytes, or with more after the end', () => { const open = frames(meta(), []).slice(0, -1); expect(() => readStar(new Uint8Array([...open, 7]))).toThrow( /invalid control byte: 7/, ); expect(() => readStar(new Uint8Array([...open, 0, 1]))).toThrow( /after end of archive/, ); }); it('refuses metadata that is not a canonical typed map', () => { expect(() => readStar(frames(new Uint8Array([0xa1]), []))).toThrow( RepoVerificationError, ); expect(() => readStar(frames(cborEncodeDagCbor({ a: 1 }), []))).toThrow( /\$type/, ); // { $type: 'x', a: 1 } with its keys out of canonical order, which puts // the shorter key first. const nonCanonical = new Uint8Array([ 0xa2, 0x65, ...enc.encode('$type'), 0x61, 0x78, 0x61, 0x61, 0x01, ]); expect(() => readStar(frames(nonCanonical, []))).toThrow(/canonical/); }); it('refuses a padded or overlong varint and an oversized value', () => { expect(() => readStar(new Uint8Array([...STAR_MAGIC, 0x81, 0x00]))).toThrow( /non-canonical/, ); expect(() => readStar( new Uint8Array([ ...STAR_MAGIC, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01, ]), ), ).toThrow(/too long/); expect(() => readStar(frames(meta(), [[enc.encode('a/b'), new Uint8Array(10)]]), { maxValueLength: 5, }), ).toThrow(/exceeds limit/); }); it('encodes lengths past 32 bits', () => { expect(toHex(encodeVarint(2 ** 35))).toBe('808080808001'); expect(MAX_RECORD_SIZE).toBe(1024 * 1024); }); it('orders bytes lexicographically', () => { expect(compareBytes(enc.encode('a'), enc.encode('ab'))).toBeLessThan(0); expect(compareBytes(enc.encode('b'), enc.encode('ab'))).toBeGreaterThan(0); }); }); describe('verifyRecord', () => { const { uri, cid } = vectors.record; const recordParams = { uri, sharedSecret: fromHex(vectors.record.secretHex), mac: fromHex(vectors.record.macHex), }; const record = { uri, cid, value: { text: 'hello' } }; it('verifies the reference record', async () => { expect(await verifyRecord(record, recordParams)).toBe(true); }); it('refuses another uri, value, cid or MAC', async () => { expect( await verifyRecord(record, { ...recordParams, uri: `${uri}x` }), ).toBe(false); expect( await verifyRecord({ ...record, value: { text: 'world' } }, recordParams), ).toBe(false); expect( await verifyRecord({ ...record, cid: 'not-a-cid' }, recordParams), ).toBe(false); expect( await verifyRecord(record, { ...recordParams, mac: new Uint8Array(32), }), ).toBe(false); }); });