// Tests of head.ts: the encoding of heads byte for byte, and their decoding // in the layers of spec §69.1 on the cases of TestDecodeHeadLayers of the Go // reference and a few more, with the error texts of the reference at // spec-v0.10. The vectors of testdata/vectors/head_schema.json run in // vectors.test.ts. import { describe, expect, it } from 'vitest'; import { errorCode } from './errors.ts'; import { ExtensionSet } from './extension.ts'; import { checkHeadEnd, decodeHead, decodeWrittenHead, encodeHead, type Head, type HeadFile } from './head.ts'; import { MAX_PAYLOAD_LENGTH } from './padding.ts'; import { arr, b, bn, ext, map, t, u } from './testing/cborhex.ts'; import { h, hx } from './testing/testdata.ts'; // The code of the error that fn throws, '' for one without a code, and // its message. function caught(fn: () => unknown): [string, string] { try { fn(); } catch (err) { return [errorCode(err), (err as Error).message]; } throw new Error('no error'); } const zeros = (n: number): Uint8Array => new Uint8Array(n); // A head as the tests of the reference build it: keys 0 and 1 and a salt of // zeros, then the pairs given, in ascending order of their keys. const head = (...pairs: [number, string][]): Uint8Array => h(map([0, t('datekeys-head')], [1, u(1)], [2, bn(32)], ...pairs)); const file = (path: string, size = 0, start = 0, end = 0): string => map([0, t(path)], [1, u(size)], [2, u(start)], [3, u(end)], [4, bn(32)]); const withKeys = (...pairs: [number, string][]): string => map([0, t('a')], [1, u(0)], [2, u(0)], [3, u(0)], [4, bn(32)], ...pairs); const SAMPLE: Head = { salt: Uint8Array.from({ length: 32 }, (_, i) => (i === 0 ? 1 : 0)), comment: 'Para ti \u2764\ufe0f', author: 'Ana López', files: [ { path: 'fotos/playa.jpg', size: 10, start: 0, end: 10, sha256: Uint8Array.from({ length: 32 }, (_, i) => (i === 0 ? 2 : 0)), mtime: 1759190400 }, { path: 'nota.txt', size: 5, start: 10, end: 15, sha256: zeros(32) }, ], critical: [], noncritical: [], }; const EMPTY: Head = { salt: zeros(32), comment: '', author: '', files: [], critical: [], noncritical: [] }; describe('encodeHead', () => { it('writes the head as the reference does, byte for byte', () => { expect(hx(encodeHead(SAMPLE))).toBe( 'a6006d646174656b6579732d6865616401010258200100000000000000000000000000000000000000000000000000000000000000036e5061726120746920e29da4efb88f046a416e61204cc3b370657a0582a6006f666f746f732f706c6179612e6a7067010a0200030a0458200200000000000000000000000000000000000000000000000000000000000000051a68db1d80a500686e6f74612e7478740105020a030f0458200000000000000000000000000000000000000000000000000000000000000000', ); const nota: HeadFile = { path: 'nota.txt', size: 1000, start: 0, end: 1000, sha256: zeros(32), mtime: 1759190400 }; expect(encodeHead(EMPTY).length).toBe(53); expect(encodeHead({ ...EMPTY, comment: 'x' }).length).toBe(56); expect(encodeHead({ ...EMPTY, files: [nota] }).length).toBe(117); expect(encodeHead({ ...EMPTY, files: [{ path: 'a', size: 0, start: 0, end: 0, sha256: zeros(32) }] }).length).toBe(100); const extensions = { ...EMPTY, critical: [{ id: 'x.example', version: 1, data: undefined }], noncritical: [{ id: 'y.example', version: 2, data: undefined }] }; expect(hx(encodeHead(extensions))).toBe( 'a5006d646174656b6579732d68656164010102582000000000000000000000000000000000000000000000000000000000000000000681a20069782e6578616d706c6501010781a20069792e6578616d706c650102', ); }); it('refuses more than 65535 files, an extension in both arrays, and a salt or a SHA-256 that is not 32 bytes', () => { const many = Array.from({ length: 65536 }, (_, i): HeadFile => ({ path: `f${String(i).padStart(5, '0')}`, size: 0, start: 0, end: 0, sha256: zeros(32) })); expect(caught(() => encodeHead({ ...EMPTY, files: many }))).toEqual(['', 'capsule: head: 65536 files, more than 65535']); const x = { id: 'x.example', version: 1, data: undefined }; expect(caught(() => encodeHead({ ...EMPTY, critical: [x], noncritical: [x] }))).toEqual([ 'ERR_NON_CANONICAL_CBOR', 'extension x.example: both critical and noncritical: ERR_NON_CANONICAL_CBOR', ]); expect(() => encodeHead({ ...EMPTY, salt: zeros(31) })).toThrow(RangeError); expect(() => encodeHead({ ...EMPTY, files: [{ path: 'a', size: 0, start: 0, end: 0, sha256: zeros(31) }] })).toThrow(RangeError); }); }); describe('decodeHead', () => { it('reads back what encodeHead writes', () => { expect(decodeHead(encodeHead(SAMPLE))).toEqual(SAMPLE); expect(decodeHead(encodeHead(EMPTY))).toEqual(EMPTY); }); // An array of 65536 files, whose head is written by hand: arr() would // take them as 65536 arguments. const many = '9a00010000' + Array.from({ length: 65536 }, (_, i) => file('a' + String.fromCharCode(97 + (i % 26)) + 'x'.repeat(Math.floor(i / 26) % 3))).join(''); it.each([ // Layer 2. ['another type tag', h(map([0, t('datekeys-control')], [1, u(1)], [2, bn(32)])), 'ERR_NON_CANONICAL_CBOR', 'codec: type "datekeys-control", want "datekeys-head"'], ['version 2', h(map([0, t('datekeys-head')], [1, u(2)], [2, bn(32)])), 'ERR_UNSUPPORTED_VERSION', 'codec: datekeys-head schema version 2, want 1'], ['version 2 and ..', h(map([0, t('datekeys-head')], [1, u(2)], [2, bn(32)], [5, arr(file('..'))])), 'ERR_UNSUPPORTED_VERSION', 'codec: datekeys-head schema version 2, want 1'], // Layer 3. ['no salt', h(map([0, t('datekeys-head')], [1, u(1)])), 'ERR_NON_CANONICAL_CBOR', 'key 2 is missing'], ['a salt of 31 bytes', h(map([0, t('datekeys-head')], [1, u(1)], [2, bn(31)])), 'ERR_NON_CANONICAL_CBOR', 'key 2: codec: offset 21: a byte string of 31 bytes outside 32..32'], ['an empty comment', head([3, t('')]), 'ERR_NON_CANONICAL_CBOR', 'key 3: empty comment'], ['a comment of 16385 bytes', head([3, t('a'.repeat(16385))]), 'ERR_NON_CANONICAL_CBOR', 'key 3: codec: offset 57: a text string of 16385 bytes outside 0..16384'], ['a comment that is not UTF-8', head([3, '62c328']), 'ERR_NON_CANONICAL_CBOR', 'key 3: codec: offset 54: text string is not valid UTF-8'], ['an author of 257 bytes', head([4, t('a'.repeat(257))]), 'ERR_NON_CANONICAL_CBOR', 'key 4: codec: offset 57: a text string of 257 bytes outside 0..256'], ['an empty author', head([4, t('')]), 'ERR_NON_CANONICAL_CBOR', 'key 4: empty declared author'], ['an empty array of files', head([5, arr()]), 'ERR_NON_CANONICAL_CBOR', 'key 5: empty array of files'], ['65536 files', head([5, many]), 'ERR_NON_CANONICAL_CBOR', 'key 5: codec: offset 59: array of 65536 items, at most 65535'], ['R1: an empty path', head([5, arr(file(''))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 0: R1: the path is empty'], ['R1: a path of 1025 bytes', head([5, arr(file('a'.repeat(1025)))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 0: codec: offset 60: a text string of 1025 bytes outside 0..1024'], ['R8: b before a', head([5, arr(file('b'), file('a'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], ['R8: a repeated path', head([5, arr(file('a'), file('a'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], ['R8 before R3: b/.. and a', head([5, arr(file('b/..'), file('a'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], // UTF-16 puts U+10000 (D800 DC00) before U+FFFD; UTF-8 puts it after. ['R8 in bytes: U+10000 before U+FFFD', head([5, arr(file('\u{10000}'), file('\ufffd'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], ['a size above L_MAX', head([5, arr(file('a', MAX_PAYLOAD_LENGTH + 1))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 1: codec: offset 69: unsigned integer 8936830510563329 above 8936830510563328'], ['an mtime after 9999', head([5, arr(withKeys([5, u(253402300800)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 5: codec: offset 110: unsigned integer 253402300800 above 253402300799'], ['a file with key 6', head([5, arr(withKeys([6, u(0)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 6: key 6 is not defined'], ['a file without its SHA-256', head([5, arr(map([0, t('a')], [1, u(0)], [2, u(0)], [3, u(0)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 4 is missing'], ['a file with a SHA-256 of 31 bytes', head([5, arr(map([0, t('a')], [1, u(0)], [2, u(0)], [3, u(0)], [4, bn(31)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 4: codec: offset 68: a byte string of 31 bytes outside 32..32'], ['a file that is not a map', head([5, arr(u(1))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: codec: offset 55: an unsigned integer where a map was expected'], ['an unknown key 8', head([8, u(1)]), 'ERR_NON_CANONICAL_CBOR', 'key 8 is not defined'], ['a byte more', h(hx(head()) + '00'), 'ERR_NON_CANONICAL_CBOR', 'codec: offset 53: 1 trailing bytes'], ['an extension in both arrays', head([6, arr(ext('x.example'))], [7, arr(ext('x.example'))]), 'ERR_NON_CANONICAL_CBOR', 'extension x.example: both critical and noncritical'], ['an empty critical array', head([6, arr()]), 'ERR_NON_CANONICAL_CBOR', 'key 6: extension: empty array; an absent array omits its key'], // Layer 4, in key order. ['a comment with U+202E', head([3, t('a\u202eb')]), 'ERR_HEAD_INVALID', 'comment: text: bidirectional control U+202E'], ['a comment with the tag U+E0041', head([3, t('a\u{e0041}')]), 'ERR_HEAD_INVALID', 'comment: text: invisible U+E0041'], ['an author with LF', head([4, t('a\u000ab')]), 'ERR_HEAD_INVALID', 'declared author: text: control U+000A in the declared author'], ['R3: ..', head([5, arr(file('..'))]), 'ERR_HEAD_INVALID', 'file 1: R3: segment 1: the segment is two dots'], ['R2: /a', head([5, arr(file('/a'))]), 'ERR_HEAD_INVALID', 'file 1: R2: segment 1 is empty'], ['R4b: a and VS16', head([5, arr(file('a\ufe0f'))]), 'ERR_HEAD_INVALID', 'file 1: R4b: segment 1: U+FE0F is not part of an emoji variation sequence'], ['R6: CON.txt', head([5, arr(file('CON.txt'))]), 'ERR_HEAD_INVALID', 'file 1: R6: segment 1: CON is a reserved device name'], ['R10: .datekeys-x', head([5, arr(file('.datekeys-x'))]), 'ERR_HEAD_INVALID', 'file 1: R10: the first segment starts with ".datekeys-"'], ['layout: a first start that is not 0', head([5, arr(file('a', 1, 1, 2))]), 'ERR_HEAD_INVALID', 'file 1: start 1 is not 0, the end of the file before'], ['layout: end minus start is not size', head([5, arr(file('a', 2, 0, 1))]), 'ERR_HEAD_INVALID', 'file 1: from start 0 to end 1 is not the size 2'], ['layout: end before start', head([5, arr(file('a', 0, 5, 4))]), 'ERR_HEAD_INVALID', 'file 1: start 5 is not 0, the end of the file before'], ['layout: a gap', head([5, arr(file('a', 1, 0, 1), file('b', 1, 2, 3))]), 'ERR_HEAD_INVALID', 'file 2: start 2 is not 1, the end of the file before'], ['R7: A.txt and a.txt', head([5, arr(file('A.txt'), file('a.txt'))]), 'ERR_HEAD_INVALID', 'R7: path 2 collides with path 1 in segment 1'], ['a comment and a path that break', head([3, t('a\u202e')], [5, arr(file('..'))]), 'ERR_HEAD_INVALID', 'comment: text: bidirectional control U+202E'], ['an author and a path that break', head([4, t(' a')], [5, arr(file('..'))]), 'ERR_HEAD_INVALID', 'declared author: text: the declared author starts or ends with U+0020'], ['a path that breaks, then an unknown critical extension', head([5, arr(file('..'))], [6, arr(ext('x.example'))]), 'ERR_HEAD_INVALID', 'file 1: R3: segment 1: the segment is two dots'], ['an unknown critical extension', head([6, arr(ext('x.example'))]), 'ERR_EXTENSION_CRITICAL_UNKNOWN', 'extension x.example v1'], ])('%s', (_, input, code, detail) => { expect(caught(() => decodeHead(input))).toEqual([code, `capsule: head: ${detail}: ${code}`]); }); it('refuses a head above 16 MiB before reading it', () => { expect(caught(() => decodeHead(new Uint8Array(16777275)))).toEqual(['ERR_INTEGRITY', 'capsule: head: 16777275 bytes, more than 16777216: ERR_INTEGRITY']); }); it('takes paths in the order of their UTF-8 bytes, the last mtime, a known critical extension and an unknown noncritical one', () => { const paths = decodeHead(head([5, arr(file('\ufffd'), file('\u{10000}'))])).files.map((f) => f.path); expect(paths).toEqual(['\ufffd', '\u{10000}']); expect(decodeHead(head([5, arr(withKeys([5, u(253402300799)]))])).files[0]!.mtime).toBe(253402300799); const critical = head([6, arr(ext('x.example'))]); expect(decodeHead(critical, new ExtensionSet([['x.example', [1]]])).critical).toEqual([{ id: 'x.example', version: 1, data: undefined }]); expect(decodeWrittenHead(critical).critical).toHaveLength(1); expect(decodeHead(head([7, arr(ext('x.example'))])).noncritical).toHaveLength(1); }); }); describe('checkHeadEnd', () => { it('requires the files to end at C, or C to be 0 without files', () => { const sample = decodeHead(encodeHead(SAMPLE)); expect(() => checkHeadEnd(sample, 15)).not.toThrow(); expect(caught(() => checkHeadEnd(sample, 16))).toEqual(['ERR_INTEGRITY', 'capsule: BODY: the files end at byte 15 of a content of 16 bytes: ERR_INTEGRITY']); expect(() => checkHeadEnd(EMPTY, 0)).not.toThrow(); expect(caught(() => checkHeadEnd(EMPTY, 1))).toEqual(['ERR_INTEGRITY', 'capsule: BODY: the files end at byte 0 of a content of 1 bytes: ERR_INTEGRITY']); }); });