encryptFiles(files, opts) writes a .dkc of format 3 as capsule.EncryptFiles at spec-v0.10, on the sealer of the previous commit: - newHead: the comment, with CR LF and a lone CR turned into LF, the declared author and every path checked with the rules of the reader, in the words of a writer (spec 62.1 rule 15); the files in the byte order of their paths, not the UTF-16 order of JavaScript strings; and the mtime in seconds from 1970 to 9999, none outside. - L measured with a head whose salt and SHA-256 are zero, at most 16 MiB and L_MAX; the first reading hashes each file; the head with a fresh salt, the empty security area and the frame are checked with the rules of the reader before anything is written. - BODY is the content of seal: the frame, the area, the head and the files read a second time, which fail if a size or a SHA-256 changed (rule 18), with the texts of readSource. FileSource describes a file (path, size, mtime in milliseconds, open), and fileSource makes the one of a File or a Blob. The draws gain the salt of the head. lengths.ts gains bodyLength and headLength, which measure the head from the sizes of its CBOR items without the Unicode tables, and mtimeSeconds and headComment, which the writer shares. Tests: the five fixtures that EncryptFiles wrote are reproduced byte for byte, PRELUDE, PUBLIC_HEADER, CONTROL_CBOR, HEAD_CBOR and BODY, and their .dkk; capsuleLength with bodyLength gives the size written, and headLength agrees with encodeHead on 300 random heads around every boundary of the CBOR heads; the invalid inputs give the texts that capsule.EncryptFiles gives to the same inputs, taken from the reference with a scratch program. writer.ts, encrypt.ts and lengths.ts stay at 100 %. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
3daa1f770c
commit
4c67b07640
@ -0,0 +1,489 @@
|
||||
// Tests of encryptFiles (encrypt.ts and writer.ts, plan of format 3 in
|
||||
// datekeys-ts, step 4): capsules of format 3 written here open with open to
|
||||
// their files, reproduce the deterministic sections of the fixtures of the
|
||||
// Go reference and their BODY byte for byte, measure what lengths.ts says
|
||||
// before writing, and fail with the texts of capsule.EncryptFiles, taken
|
||||
// from the reference at spec-v0.10, with their output aborted.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type AccessKey, decodeAccessKey, encodeAccessKey } from './accesskey.ts';
|
||||
import { ACCESS_SLOTS } from './age.ts';
|
||||
import { decrypt, decryptAll } from './agefile.ts';
|
||||
import { compareBytes, concatBytes, sha256, utf8Bytes } from './bytes.ts';
|
||||
import { type Instant, parseRFC3339 } from './datekey.ts';
|
||||
import { encryptFiles, type EncryptOptions, type FileSource, fileSource } from './encrypt.ts';
|
||||
import { errorCode } from './errors.ts';
|
||||
import { ExtensionSet } from './extension.ts';
|
||||
import { FORMAT_3 } from './framing.ts';
|
||||
import { decodeHead, encodeHead, type Head, type HeadFile } from './head.ts';
|
||||
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
|
||||
import { bodyLength, capsuleLength, headComment, headLength, type HeadShape, mtimeSeconds } from './lengths.ts';
|
||||
import { accessIdentity, open, type OpenOptions, payloadIdentity, timeIdentity } from './open.ts';
|
||||
import { BLOQUE256, MAX_PAYLOAD_LENGTH, type Padding, REFORZADO } from './padding.ts';
|
||||
import { quicknet } from './profile.ts';
|
||||
import { type Release, suppliedRelease } from './release.ts';
|
||||
import { MemorySink } from './sink.ts';
|
||||
import { split } from './testing/capsule.ts';
|
||||
import { encryptFilesWith, wordsFor } from './testing/encrypt.ts';
|
||||
import { h, hx, readBytes, readJSON } from './testing/testdata.ts';
|
||||
import { newX25519Identity, x25519PublicKey } from './x25519.ts';
|
||||
|
||||
interface FixtureRecord {
|
||||
release: { round: number; signature: string };
|
||||
unlock_at: string;
|
||||
access_policy: 'time_only' | 'time_and_key';
|
||||
prelude: string;
|
||||
public_header: string;
|
||||
control_cbor: string;
|
||||
capsule_id: string;
|
||||
payload_identity: string;
|
||||
payload_length: number;
|
||||
padding: Padding;
|
||||
padded_length: number;
|
||||
plaintext_file: string;
|
||||
access_key_file?: string;
|
||||
access_key_stanza?: number;
|
||||
head_cbor: string;
|
||||
salt: string;
|
||||
comment?: string;
|
||||
declared_author?: string;
|
||||
content_offset: number;
|
||||
files?: { path: string; size: number; start: number; end: number; sha256: string; mtime?: number }[];
|
||||
}
|
||||
|
||||
const record = (name: string): FixtureRecord => readJSON<FixtureRecord>(`fixtures/${name}.json`);
|
||||
const releaseOf = (fx: FixtureRecord): Release => ({ round: fx.release.round, signature: h(fx.release.signature) });
|
||||
const R1000 = releaseOf(record('format3_single'));
|
||||
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
// Round r opens at genesis + (r - 1)·3 s.
|
||||
const roundAt = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
|
||||
const te = new TextEncoder();
|
||||
|
||||
const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
|
||||
profile: quicknet(),
|
||||
unlockAt: roundAt(1000),
|
||||
policy: TIME_ONLY,
|
||||
now: () => GENESIS,
|
||||
...extra,
|
||||
});
|
||||
const opening = (r: Release, extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(r), now: () => roundAt(r.round), ...extra });
|
||||
|
||||
// A file whose every reading gives `bytes`.
|
||||
const source = (path: string, bytes: Uint8Array | string, mtime?: number): FileSource => {
|
||||
const b = typeof bytes === 'string' ? te.encode(bytes) : bytes;
|
||||
return { path, size: b.length, ...(mtime === undefined ? {} : { mtime }), open: () => new Blob([b as Uint8Array<ArrayBuffer>]).stream() };
|
||||
};
|
||||
|
||||
// A file of `size` bytes whose readings give the texts in turn, the last one
|
||||
// from then on.
|
||||
function changing(path: string, size: number, ...readings: string[]): FileSource {
|
||||
let n = 0;
|
||||
return { path, size, open: () => new Blob([te.encode(readings[Math.min(n++, readings.length - 1)]!)]).stream() };
|
||||
}
|
||||
|
||||
// A stream of the chunks given, then an error when one is given.
|
||||
function chunked(chunks: readonly Uint8Array[], error?: Error): ReadableStream<Uint8Array> {
|
||||
const list = [...chunks];
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(c) {
|
||||
const next = list.shift();
|
||||
if (next !== undefined) c.enqueue(next);
|
||||
else if (error !== undefined) c.error(error);
|
||||
else c.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// An output that records what it receives and how it ended.
|
||||
function recorder(): { stream: WritableStream<Uint8Array>; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
|
||||
const stream = new WritableStream<Uint8Array>({
|
||||
write: (c) => void chunks.push(c.slice()),
|
||||
close: () => void (state.closed = true),
|
||||
abort: (reason) => void (state.aborted = reason ?? 'aborted'),
|
||||
});
|
||||
return { stream, chunks, state };
|
||||
}
|
||||
|
||||
async function failure(p: Promise<unknown>): Promise<Error> {
|
||||
try {
|
||||
await p;
|
||||
} catch (err) {
|
||||
return err as Error;
|
||||
}
|
||||
throw new Error('expected a failure');
|
||||
}
|
||||
|
||||
// The plaintext of PAYLOAD_AGE of a capsule, with its I_PAYLOAD.
|
||||
async function plaintextOf(dkc: Uint8Array, id: Uint8Array): Promise<Uint8Array> {
|
||||
const plain = await decrypt(new Blob([split(dkc).payload as Uint8Array<ArrayBuffer>]).stream(), payloadIdentity(id), 'PAYLOAD_AGE');
|
||||
return new Uint8Array(await new Response(plain).arrayBuffer());
|
||||
}
|
||||
|
||||
// Opens a capsule of format 3 into memory: its head and its files.
|
||||
async function openFiles(dkc: Uint8Array, r: Release, extra: Partial<OpenOptions> = {}): Promise<{ head: Head; files: Map<string, string> }> {
|
||||
const sink = new MemorySink();
|
||||
const res = await open(dkc, opening(r, { sink, ...extra }));
|
||||
expect(res.error?.message).toBeUndefined();
|
||||
const opened = sink.opened!;
|
||||
return { head: opened.head, files: new Map(opened.head.files.map((f, i) => [f.path, hx(opened.files[i]!)])) };
|
||||
}
|
||||
|
||||
describe('encryptFiles, the fixtures of format 3 of the Go reference', () => {
|
||||
// The five that capsule.EncryptFiles wrote; the other four hold security
|
||||
// areas that only a generator of test vectors writes.
|
||||
const names = ['format3_single', 'format3_tree', 'format3_comment_only', 'format3_bloque256', 'format3_time_and_key_portable'];
|
||||
|
||||
it.each(names)('%s: reproduces PRELUDE, PUBLIC_HEADER, CONTROL_CBOR, HEAD_CBOR and BODY byte for byte, the lengths and the .dkk', async (name) => {
|
||||
const fx = record(name);
|
||||
const body = readBytes(`fixtures/${fx.plaintext_file}`);
|
||||
// Given in reverse order: the writer puts them in the byte order of their paths.
|
||||
const files = (fx.files ?? [])
|
||||
.map((f) => source(f.path, body.subarray(fx.content_offset + f.start, fx.content_offset + f.end), f.mtime === undefined ? undefined : f.mtime * 1000))
|
||||
.reverse();
|
||||
const dkk = fx.access_key_file === undefined ? undefined : decodeAccessKey(readBytes(`fixtures/${fx.access_key_file}`));
|
||||
const keyed = fx.access_policy === 'time_and_key';
|
||||
const res = await encryptFilesWith(
|
||||
files,
|
||||
options({
|
||||
unlockAt: parseRFC3339(fx.unlock_at),
|
||||
policy: keyed ? TIME_AND_KEY : TIME_ONLY,
|
||||
newPortableKey: dkk !== undefined,
|
||||
padding: fx.padding,
|
||||
...(fx.comment === undefined ? {} : { comment: fx.comment }),
|
||||
...(fx.declared_author === undefined ? {} : { author: fx.declared_author }),
|
||||
}),
|
||||
{
|
||||
capsuleId: h(fx.capsule_id),
|
||||
payloadIdentity: h(fx.payload_identity),
|
||||
salt: h(fx.salt),
|
||||
...(dkk === undefined ? {} : { accessIdentity: dkk.material, credentialId: dkk.credentialId }),
|
||||
...(keyed ? { words: wordsFor([fx.access_key_stanza!]) } : {}),
|
||||
},
|
||||
);
|
||||
const written = split(res.dkc!);
|
||||
const original = split(readBytes(`fixtures/${name}.dkc`));
|
||||
expect(hx(written.prelude)).toBe(fx.prelude);
|
||||
expect(hx(written.header)).toBe(fx.public_header);
|
||||
expect([written.sealed.length, written.payload.length]).toEqual([original.sealed.length, original.payload.length]);
|
||||
expect([res.format, res.length, res.padding, res.paddedLength]).toEqual([FORMAT_3, fx.payload_length, fx.padding, fx.padded_length]);
|
||||
expect(hx(encodeHead(res.head!))).toBe(fx.head_cbor);
|
||||
const r = releaseOf(fx);
|
||||
const sealed = await decryptAll(written.sealed, timeIdentity(quicknet(), r.round, r), 'age');
|
||||
const control = keyed ? await decryptAll(sealed, accessIdentity([dkk!.material], ACCESS_SLOTS), 'age') : sealed;
|
||||
expect(hx(control)).toBe(fx.control_cbor);
|
||||
// BODY, and the zeros of its padding up to P.
|
||||
const plaintext = await plaintextOf(res.dkc!, h(fx.payload_identity));
|
||||
expect(hx(plaintext)).toBe(hx(concatBytes(body, new Uint8Array(fx.padded_length - fx.payload_length))));
|
||||
if (dkk !== undefined) {
|
||||
const want: AccessKey = { ...dkk, verification: { capsuleDigest: await sha256(res.dkc!) } };
|
||||
expect(hx(encodeAccessKey(res.portableKey!))).toBe(hx(encodeAccessKey(want)));
|
||||
}
|
||||
const opened = await openFiles(res.dkc!, r, dkk === undefined ? {} : { accessKey: res.portableKey! });
|
||||
expect(opened.head).toEqual(decodeHead(h(fx.head_cbor)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptFiles', () => {
|
||||
it('writes the files in the byte order of their paths, whatever the order given, and open gives them back', async () => {
|
||||
// UTF-16 puts U+10000 before U+FFFD; UTF-8 after.
|
||||
const files = [source('z.txt', 'zeta'), source('\u{10000}.txt', 'linear b'), source('carpeta/\u00f1.txt', 'e\u00f1e'), source('\ufffd.txt', 'reemplazo'), source('a', '')];
|
||||
const res = await encryptFiles(files, options());
|
||||
expect(res.head!.files.map((f) => f.path)).toEqual(['a', 'carpeta/\u00f1.txt', 'z.txt', '\ufffd.txt', '\u{10000}.txt']);
|
||||
const { files: got } = await openFiles(res.dkc!, R1000);
|
||||
for (const f of files) expect(got.get(f.path), f.path).toBe(hx(new Uint8Array(await new Response(f.open()).arrayBuffer())));
|
||||
expect(res.format).toBe(FORMAT_3);
|
||||
});
|
||||
|
||||
it('stores the mtime in seconds from 1970 to 9999, and none when it falls outside or is unknown', async () => {
|
||||
const last = 253402300799;
|
||||
const cases: [string, number | undefined, number | undefined][] = [
|
||||
['unknown', undefined, undefined],
|
||||
['before 1970', -1, undefined],
|
||||
['1970', 0, 0],
|
||||
['a second and a half', 1500, 1],
|
||||
['the last millisecond of 9999', last * 1000 + 999, last],
|
||||
['10000', (last + 1) * 1000, undefined],
|
||||
];
|
||||
const res = await encryptFiles(
|
||||
cases.map(([name, mtime]) => source(name, name, mtime)),
|
||||
options(),
|
||||
);
|
||||
const byPath = new Map(res.head!.files.map((f) => [f.path, f.mtime]));
|
||||
for (const [name, mtime, want] of cases) {
|
||||
expect(byPath.get(name), name).toBe(want);
|
||||
expect(mtimeSeconds(mtime), name).toBe(want);
|
||||
}
|
||||
const { head } = await openFiles(res.dkc!, R1000);
|
||||
expect(head.files.map((f) => f.mtime)).toEqual(res.head!.files.map((f) => f.mtime));
|
||||
});
|
||||
|
||||
it('turns CR LF and a lone CR of the comment into LF, keeps the declared author, and writes a capsule of a comment alone', async () => {
|
||||
const res = await encryptFiles([], options({ comment: 'uno\u000d\u000ados\u000dtres\u000a', author: 'Ana L\u00f3pez' }));
|
||||
expect([res.head!.comment, res.head!.author, res.head!.files]).toEqual(['uno\u000ados\u000atres\u000a', 'Ana L\u00f3pez', []]);
|
||||
expect(headComment('a\u000d\u000a\u000d\u000ab\u000d')).toBe('a\u000a\u000ab\u000a');
|
||||
const { head } = await openFiles(res.dkc!, R1000);
|
||||
expect([head.comment, head.author]).toEqual([res.head!.comment, res.head!.author]);
|
||||
});
|
||||
|
||||
it('writes time_and_key with recipients and a portable key, each of which opens it alone', async () => {
|
||||
const ids = [newX25519Identity(), newX25519Identity()];
|
||||
const files = [source('carta.txt', 'para vosotros')];
|
||||
const res = await encryptFiles(files, options({ policy: TIME_AND_KEY, recipients: ids.map(x25519PublicKey), newPortableKey: true, padding: BLOQUE256 }));
|
||||
expect(res.padding).toBe(BLOQUE256);
|
||||
for (const extra of [{ identities: [ids[0]!] }, { identities: [ids[1]!] }, { accessKey: res.portableKey! }]) {
|
||||
const { files: got } = await openFiles(res.dkc!, R1000, extra);
|
||||
expect(got.get('carta.txt')).toBe(hx(te.encode('para vosotros')));
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the extensions of the head: a critical one the reader must know, a noncritical one it may ignore', async () => {
|
||||
const x = { id: 'x.example', version: 1, data: undefined };
|
||||
const res = await encryptFiles([source('a', 'x')], options({ headCritical: [x], headNoncritical: [{ id: 'y.example', version: 2, data: Uint8Array.of(1) }] }));
|
||||
expect(res.head!.critical).toEqual([x]);
|
||||
const unknown = await open(res.dkc!, opening(R1000, { sink: new MemorySink() }));
|
||||
expect([unknown.error?.message, unknown.inspection.checks.at(-1)?.step]).toEqual(['capsule: head: extension x.example v1: ERR_EXTENSION_CRITICAL_UNKNOWN', 17]);
|
||||
await openFiles(res.dkc!, R1000, { extensions: new ExtensionSet([['x.example', [1]]]) });
|
||||
});
|
||||
|
||||
it('streams into an output, closed at the end, and reports its progress', async () => {
|
||||
const big = new Uint8Array(200_000).map((_, i) => i % 251);
|
||||
const out = recorder();
|
||||
const progress: [number, number][] = [];
|
||||
const res = await encryptFiles(
|
||||
[{ path: 'big.bin', size: big.length, open: () => chunked([big.subarray(0, 150_000), new Uint8Array(0), big.subarray(150_000)]) }],
|
||||
options({ output: out.stream, progress: (w, t) => progress.push([w, t]) }),
|
||||
);
|
||||
expect([res.dkc, out.state]).toEqual([undefined, { closed: true, aborted: undefined }]);
|
||||
const dkc = concatBytes(...out.chunks);
|
||||
expect([dkc.length, progress.at(-1)]).toEqual([res.size, [res.size, res.size]]);
|
||||
const { files } = await openFiles(dkc, R1000);
|
||||
expect(files.get('big.bin')).toBe(hx(big));
|
||||
});
|
||||
|
||||
it('makes the source of a File or a Blob with fileSource', async () => {
|
||||
const file = new File(['hola'], 'nota.txt', { lastModified: 1_790_769_600_500 });
|
||||
expect(fileSource('nota.txt', file)).toMatchObject({ path: 'nota.txt', size: 4, mtime: 1_790_769_600_500 });
|
||||
expect(fileSource('x', file, 5)).toMatchObject({ mtime: 5 });
|
||||
expect(fileSource('y', new Blob(['ab']))).not.toHaveProperty('mtime');
|
||||
const res = await encryptFiles([fileSource('docs/nota.txt', file)], options());
|
||||
expect(res.head!.files[0]!.mtime).toBe(1_790_769_600);
|
||||
const { files } = await openFiles(res.dkc!, R1000);
|
||||
expect(files.get('docs/nota.txt')).toBe(hx(te.encode('hola')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lengths of format 3', () => {
|
||||
const shapes: [string, HeadShape][] = [
|
||||
['one file', { files: [{ path: 'nota.txt', size: 5, mtime: 1_790_769_600_000 }] }],
|
||||
['a comment alone', { files: [], comment: 'solo\u000d\u000aesto', author: 'Ana' }],
|
||||
['files of every size class', { files: [23, 24, 255, 256, 65535, 65536, 70_000].map((size, i) => ({ path: `f${i}.bin`, size })) }],
|
||||
['extensions', { files: [{ path: 'a', size: 1 }], critical: [{ id: 'x.example', version: 1, data: undefined }], noncritical: [{ id: 'y.example', version: 300, data: new Uint8Array(30) }] }],
|
||||
];
|
||||
|
||||
it.each(shapes)('gives the exact size of the .dkc before writing it: %s', async (_, shape) => {
|
||||
const files = shape.files.map((f) => source(f.path, new Uint8Array(f.size), f.mtime));
|
||||
for (const [policy, extra] of [
|
||||
[TIME_ONLY, {}],
|
||||
[TIME_AND_KEY, { newPortableKey: true }],
|
||||
] as const) {
|
||||
const res = await encryptFiles(
|
||||
files,
|
||||
options({
|
||||
policy,
|
||||
...extra,
|
||||
...(shape.comment === undefined ? {} : { comment: shape.comment }),
|
||||
...(shape.author === undefined ? {} : { author: shape.author }),
|
||||
...(shape.critical === undefined ? {} : { headCritical: shape.critical }),
|
||||
...(shape.noncritical === undefined ? {} : { headNoncritical: shape.noncritical }),
|
||||
}),
|
||||
);
|
||||
expect(bodyLength(shape)).toBe(res.length);
|
||||
const size = capsuleLength({ profileId: 'datekeys:quicknet:v1', round: res.dateKey.round, policy, length: bodyLength(shape) });
|
||||
expect([size, res.size]).toEqual([res.dkc!.length, res.dkc!.length]);
|
||||
}
|
||||
});
|
||||
|
||||
// A seeded generator, and the head that the writer builds for a shape.
|
||||
function random(seed: number): () => number {
|
||||
let x = seed >>> 0;
|
||||
return () => {
|
||||
x = (x * 1664525 + 1013904223) >>> 0;
|
||||
return x / 2 ** 32;
|
||||
};
|
||||
}
|
||||
function headOf(shape: HeadShape): Head {
|
||||
const sorted = [...shape.files].sort((a, b) => compareBytes(utf8Bytes(a.path), utf8Bytes(b.path)));
|
||||
let end = 0;
|
||||
const files = sorted.map((f): HeadFile => {
|
||||
const mtime = mtimeSeconds(f.mtime);
|
||||
const file = { path: f.path, size: f.size, start: end, end: end + f.size, sha256: new Uint8Array(32), ...(mtime === undefined ? {} : { mtime }) };
|
||||
end += f.size;
|
||||
return file;
|
||||
});
|
||||
return {
|
||||
salt: new Uint8Array(32),
|
||||
comment: headComment(shape.comment ?? ''),
|
||||
author: shape.author ?? '',
|
||||
files,
|
||||
critical: shape.critical ?? [],
|
||||
noncritical: shape.noncritical ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
it('measures the head as encodeHead writes it, around every boundary of the heads of CBOR', () => {
|
||||
const next = random(7);
|
||||
const pick = <T>(list: readonly T[]): T => list[Math.floor(next() * list.length)]!;
|
||||
const lengths = [0, 1, 22, 23, 24, 25, 254, 255, 256, 257, 300];
|
||||
const sizes = [0, 1, 23, 24, 255, 256, 65535, 65536, 2 ** 32 - 1, 2 ** 32, 2 ** 40];
|
||||
const mtimes = [undefined, -1000, 0, 23_000, 24_000, 255_000, 256_000, 65_535_000, 65_536_000, 2 ** 32 * 1000, 253402300799_000];
|
||||
const letters = ['a', 'b', '\u00f1', '\u20ac', '\u{1f600}'];
|
||||
const text = (n: number): string => Array.from({ length: n }, () => pick(letters)).join('');
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const count = pick([0, 1, 2, 5, 23, 24]);
|
||||
const files = Array.from({ length: count }, (_, k) => ({
|
||||
path: `${k}-${text(pick(lengths))}`,
|
||||
size: pick(sizes),
|
||||
...(() => {
|
||||
const m = pick(mtimes);
|
||||
return m === undefined ? {} : { mtime: m };
|
||||
})(),
|
||||
}));
|
||||
const shape: HeadShape = {
|
||||
files,
|
||||
comment: text(pick(lengths)) + pick(['', '\u000d\u000a', '\u000d']),
|
||||
author: text(pick([0, 1, 23, 24, 100])),
|
||||
critical: pick([[], [{ id: text(pick([1, 23, 24])), version: pick([1, 23, 24, 256, 2 ** 32 - 1]), data: undefined }]]),
|
||||
noncritical: pick([[], [{ id: 'y', version: 1, data: new Uint8Array(pick([1, 23, 24, 255, 256])) }]]),
|
||||
};
|
||||
expect(headLength(shape), `shape ${i}`).toBe(encodeHead(headOf(shape)).length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptFiles, invalid inputs', () => {
|
||||
// The texts of capsule.EncryptFiles at spec-v0.10 for the same inputs.
|
||||
const a = source('a.txt', 'hola');
|
||||
const cases: [string, FileSource[], Partial<EncryptOptions>, string][] = [
|
||||
['no files and no comment', [], {}, 'capsule: a format 3 capsule holds at least one file or a comment (spec §62.1 rule 14)'],
|
||||
['length set', [a], { length: 4 }, 'capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files'],
|
||||
['a comment with U+202E', [a], { comment: 'a\u202eb' }, 'capsule: comment: text: bidirectional control U+202E'],
|
||||
['a comment of 16385 bytes', [a], { comment: 'a'.repeat(16385) }, 'capsule: comment: 16385 bytes, more than 16384'],
|
||||
['an author with LF', [a], { author: 'a\u000ab' }, 'capsule: declared author: text: control U+000A in the declared author'],
|
||||
['an author of 257 bytes', [a], { author: 'a'.repeat(257) }, 'capsule: declared author: 257 bytes, more than 256'],
|
||||
['an author with a leading space', [a], { author: ' Ana' }, 'capsule: declared author: text: the declared author starts or ends with U+0020'],
|
||||
['a path given twice', [a, source('a.txt', 'x')], {}, 'capsule: path "a.txt" given twice'],
|
||||
['an empty path', [source('', 'x')], {}, 'capsule: path "": R1: 0 bytes, not 1 to 1024'],
|
||||
['a path of 1025 bytes', [source('a'.repeat(1025), 'x')], {}, `capsule: path "${'a'.repeat(1025)}": R1: 1025 bytes, not 1 to 1024`],
|
||||
['a negative size', [{ path: 'a', size: -1, open: a.open }], {}, 'capsule: file "a": negative size -1'],
|
||||
['no open', [{ path: 'a', size: 1 } as FileSource], {}, 'capsule: file "a": Source.Open is nil'],
|
||||
['files above L_MAX', [{ path: 'a', size: 2 ** 52, open: a.open }, { path: 'b', size: 2 ** 52, open: a.open }], {}, 'capsule: the files add up to more than 8936830510563328 bytes, the maximum of L'],
|
||||
['one file of L_MAX bytes', [{ path: 'a', size: MAX_PAYLOAD_LENGTH, open: a.open }], {}, 'capsule: content of 8936830510563968 bytes exceeds L_MAX = 8936830510563328'],
|
||||
['path ..', [source('..', 'x')], {}, 'capsule: path "..": R3: segment 1: the segment is two dots'],
|
||||
['path a/', [source('a/', 'x')], {}, 'capsule: path "a/": R2: segment 2 is empty'],
|
||||
['path CON.txt', [source('CON.txt', 'x')], {}, 'capsule: path "CON.txt": R6: segment 1: CON is a reserved device name'],
|
||||
['path .datekeys-x', [source('.datekeys-x', 'x')], {}, 'capsule: path ".datekeys-x": R10: the first segment starts with ".datekeys-"'],
|
||||
['paths A.txt and a.txt', [source('a.txt', 'x'), source('A.txt', 'y')], {}, 'capsule: paths "A.txt" and "a.txt": R7: path 2 collides with path 1 in segment 1'],
|
||||
['paths a and a/b', [source('a/b', 'x'), source('a', 'y')], {}, 'capsule: paths "a" and "a/b": R7: path 2 makes a file of path 1 a folder, or the reverse, in segment 1'],
|
||||
['a path with U+3000 at the end', [source('a\u00a0b', 'x'), source('c\u3000', 'y')], {}, 'capsule: path "c\\u3000": R6c: segment 1: code page 1250 maps the segment to one that breaks R5: the segment ends with U+0020'],
|
||||
[
|
||||
'a head above 16 MiB',
|
||||
[a],
|
||||
{ headNoncritical: [{ id: 'x.example', version: 1, data: new Uint8Array(16 << 20).fill(1) }] },
|
||||
'capsule: the head is 16777342 bytes, more than 16777216: fewer files or shorter paths',
|
||||
],
|
||||
[
|
||||
'a head extension in both arrays',
|
||||
[a],
|
||||
{ headCritical: [{ id: 'x.example', version: 1, data: undefined }], headNoncritical: [{ id: 'x.example', version: 1, data: undefined }] },
|
||||
'extension x.example: both critical and noncritical: ERR_NON_CANONICAL_CBOR',
|
||||
],
|
||||
['an open that fails', [{ path: 'a', size: 1, open: () => { throw new Error('no such file'); } }], {}, 'capsule: file "a": no such file'],
|
||||
['a read that fails', [{ path: 'a', size: 1, open: () => chunked([], new Error('disk on fire')) }], {}, 'capsule: file "a": disk on fire'],
|
||||
['a first reading shorter', [changing('a', 4, 'hol')], {}, 'capsule: file "a": 3 bytes, not its size of 4'],
|
||||
['a first reading longer', [changing('a', 4, 'holas')], {}, 'capsule: file "a": more than its size of 4 bytes'],
|
||||
['a second reading shorter', [changing('a', 4, 'hola', 'hol')], {}, 'capsule: file "a" changed after its first reading: 3 bytes, not its size of 4'],
|
||||
['a second reading longer', [changing('a', 4, 'hola', 'holas')], {}, 'capsule: file "a" changed after its first reading: more than its size of 4 bytes'],
|
||||
['a second reading with another content', [changing('a', 4, 'hola', 'HOLA')], {}, 'capsule: file "a" changed after its first reading: its SHA-256 is another'],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', async (_, files, extra, text) => {
|
||||
const out = recorder();
|
||||
const err = await failure(encryptFiles(files, options({ ...extra, output: out.stream })));
|
||||
expect(err.message).toBe(text);
|
||||
expect([out.state.closed, out.state.aborted]).toEqual([false, err]);
|
||||
});
|
||||
|
||||
it('refuses a text or a path that is not well formed, and more than 65535 implicit folders', async () => {
|
||||
expect((await failure(encryptFiles([source('a', 'x')], options({ comment: 'a\ud800' })))).message).toBe('capsule: comment: not valid UTF-8');
|
||||
expect((await failure(encryptFiles([source('a\ud800', 'x')], options()))).message).toMatch(/^capsule: path ".*": R1: not valid UTF-8$/);
|
||||
// 2115 files of 32 segments, each of whose folders is new: 65565 folders.
|
||||
const deep = Array.from({ length: 2115 }, (_, i) => source([`r${String(i).padStart(4, '0')}`, ...Array.from({ length: 30 }, (_, k) => `s${String(k + 1).padStart(2, '0')}`), 'f'].join('/'), ''));
|
||||
expect((await failure(encryptFiles(deep, options()))).message).toBe('capsule: paths: R9: 65565 folders, more than 65535');
|
||||
// What a file throws need not be an Error.
|
||||
const odd: FileSource = {
|
||||
path: 'a',
|
||||
size: 1,
|
||||
open: () => {
|
||||
throw 'locked';
|
||||
},
|
||||
};
|
||||
expect((await failure(encryptFiles([odd], options()))).message).toBe('capsule: file "a": locked');
|
||||
});
|
||||
|
||||
it('refuses 65536 files before reading any', async () => {
|
||||
let opened = 0;
|
||||
const files = Array.from({ length: 65536 }, (_, i): FileSource => ({ path: `f${String(i).padStart(5, '0')}`, size: 0, open: () => (opened++, new Blob([]).stream()) }));
|
||||
expect((await failure(encryptFiles(files, options()))).message).toBe('capsule: 65536 files, more than 65535');
|
||||
expect(opened).toBe(0);
|
||||
});
|
||||
|
||||
it('writes nothing before the second reading, and aborts the output after a failure in it', async () => {
|
||||
const first = recorder();
|
||||
await failure(encryptFiles([changing('a', 4, 'hol')], options({ output: first.stream })));
|
||||
expect(first.chunks).toEqual([]);
|
||||
const second = recorder();
|
||||
const err = await failure(encryptFiles([source('a', 'x'), changing('b', 4, 'hola', 'HOLA')], options({ output: second.stream })));
|
||||
expect(err.message).toBe('capsule: file "b" changed after its first reading: its SHA-256 is another');
|
||||
expect([second.chunks.length > 0, second.state.closed, second.state.aborted]).toEqual([true, false, err]);
|
||||
// A second reading whose stream fails in the middle of the file gives
|
||||
// the error of the file, and the stream, already failed, is not
|
||||
// cancelled.
|
||||
let cancelled: unknown;
|
||||
let n = 0;
|
||||
const flaky: FileSource = {
|
||||
path: 'c',
|
||||
size: 8,
|
||||
open: () =>
|
||||
n++ === 0
|
||||
? new Blob(['12345678']).stream()
|
||||
: new ReadableStream<Uint8Array>({
|
||||
start: (c) => c.enqueue(te.encode('1234')),
|
||||
pull: (c) => c.error(new Error('gone')),
|
||||
cancel: (reason) => void (cancelled = reason),
|
||||
}),
|
||||
};
|
||||
expect((await failure(encryptFiles([flaky], options()))).message).toBe('capsule: file "c": gone');
|
||||
expect(cancelled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses inputs of the wrong type as the caller errors they are', async () => {
|
||||
const a = source('a', 'x');
|
||||
for (const [files, extra, text] of [
|
||||
['nope', {}, 'encrypt: the files are an array of FileSource'],
|
||||
[[null], {}, 'encrypt: file 0 is not a FileSource'],
|
||||
[[{ path: 1, size: 0, open: a.open }], {}, 'encrypt: file 0: path is not a string'],
|
||||
[[{ path: 'a', size: 1.5, open: a.open }], {}, 'encrypt: file 0: size is not a safe integer'],
|
||||
[[{ path: 'a', size: 0, mtime: Number.NaN, open: a.open }], {}, 'encrypt: file 0: mtime is not a finite number of milliseconds'],
|
||||
[[a], { comment: 5 }, 'encrypt: EncryptOptions.comment is not a string'],
|
||||
[[a], { author: {} }, 'encrypt: EncryptOptions.author is not a string'],
|
||||
] as const) {
|
||||
const err = await failure(encryptFiles(files as unknown as FileSource[], options(extra as Partial<EncryptOptions>)));
|
||||
expect([err instanceof TypeError, err.message]).toEqual([true, text]);
|
||||
}
|
||||
expect(await failure(encryptFiles([a], null as unknown as EncryptOptions))).toBeInstanceOf(TypeError);
|
||||
expect(errorCode(await failure(encryptFiles([a], options({ padding: 3 as Padding }))))).toBe('');
|
||||
expect((await failure(encryptFiles([a], options({ padding: REFORZADO, unlockAt: GENESIS })))).message).toMatch(/is not in the future$/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue