You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
164 lines
13 KiB
164 lines
13 KiB
// 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']);
|
|
});
|
|
});
|