318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { OneNoteSectionDto } from '../model/dto.js';
|
|
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
|
import { OnePkgError } from './errors.js';
|
|
import { LzxDecoder } from './lzx/decoder.js';
|
|
import { MsZipDecoder } from './mszip/decoder.js';
|
|
import { openOneNotePackage } from './package.js';
|
|
|
|
/*
|
|
* These byte-for-byte CAB fixtures are copied from rust-cab's MIT-licensed
|
|
* `src/cabinet.rs` tests at commit
|
|
* c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2.
|
|
* Copyright (c) 2017 Matthew D. Steele.
|
|
*/
|
|
function binary(value: string): Uint8Array {
|
|
return Uint8Array.from(value, (character) => character.charCodeAt(0));
|
|
}
|
|
|
|
const UNCOMPRESSED_CAB = binary(
|
|
'MSCF\0\0\0\0\x59\0\0\0\0\0\0\0' +
|
|
'\x2c\0\0\0\0\0\0\0\x03\x01\x01\0\x01\0\0\0\x34\x12\0\0' +
|
|
'\x43\0\0\0\x01\0\0\0' +
|
|
'\x0e\0\0\0\0\0\0\0\0\0\x6c\x22\xba\x59\x01\0hi.txt\0' +
|
|
'\x4c\x1a\x2e\x7f\x0e\0\x0e\0Hello, world!\n'
|
|
);
|
|
|
|
const TWO_FILE_UNCOMPRESSED_CAB = binary(
|
|
'MSCF\0\0\0\0\x80\0\0\0\0\0\0\0' +
|
|
'\x2c\0\0\0\0\0\0\0\x03\x01\x01\0\x02\0\0\0\x34\x12\0\0' +
|
|
'\x5b\0\0\0\x01\0\0\0' +
|
|
'\x0e\0\0\0\0\0\0\0\0\0\x6c\x22\xe7\x59\x01\0hi.txt\0' +
|
|
'\x0f\0\0\0\x0e\0\0\0\0\0\x6c\x22\xe7\x59\x01\0bye.txt\0' +
|
|
'\0\0\0\0\x1d\0\x1d\0Hello, world!\nSee you later!\n'
|
|
);
|
|
|
|
const MSZIP_CAB = binary(
|
|
'MSCF\0\0\0\0\x61\0\0\0\0\0\0\0' +
|
|
'\x2c\0\0\0\0\0\0\0\x03\x01\x01\0\x01\0\0\0\x34\x12\0\0' +
|
|
'\x43\0\0\0\x01\0\x01\0' +
|
|
'\x0e\0\0\0\0\0\0\0\0\0\x6c\x22\xe7\x59\x01\0hi.txt\0' +
|
|
'\0\0\0\0\x16\0\x0e\0' +
|
|
'CK\xf3H\xcd\xc9\xc9\xd7Q(\xcf/\xcaIQ\xe4\x02\x00$\xf2\x04\x94'
|
|
);
|
|
|
|
/*
|
|
* Generated two-block CAB; generation details and hashes are recorded in
|
|
* tests/fixtures/README.md. Its second raw DEFLATE stream requires the first
|
|
* block's 32 KiB history.
|
|
*/
|
|
const MULTI_BLOCK_MSZIP_CAB = Uint8Array.from(
|
|
Buffer.from(
|
|
'TVNDRgAAAABRAgAAAAAAACwAAAAAAAAAAwEBAAEAAADvvgAASAAAAAIAAQAAkAAAAAAAAAAA' +
|
|
'AAAAACAAaGlzdG9yeS5iaW4Ak4QnWtcBAIBDS+3P1yIQAABAURQtRIpK2Tu7oU1SqRCS7BAa' +
|
|
'2iVZFdo0aBDaGVGRVLaWEYq21RSiRNmeevIX9/zBERAUGjZcWGTEyFGjx4iKiY+VkBwnNX6C' +
|
|
'tMzESZNlp0yVk1dQVFJWUVVT19DUmqato6unb2A4fcbMWUaz58ydN3/BQmOTRaaLzZYsXWa+' +
|
|
'fMVKC0urVdY2tqvt1tivdXB0cnZxdVvn7uG53svbZ8PGTZt9t2zdtn3Hzl279/jt9d8XEBgU' +
|
|
'HLL/wMHQsPBDh48cPXb8RETkyVOnz0RFnz13/kJM7MW4+IRLl69cvXb9xs3EpOSUW6lpt+/c' +
|
|
'Tc+4l3k/68HDR9k5uXn5BYVFj588ffa8uKS07EV5ReXLV1XVr9+8fff+w8ea2rr6hk+fv3z9' +
|
|
'9r3xR1Nzy8/Wtl+/2/90dP7919Xd09vXPzAoQJ06derUqVOnTp06derUqVOnTp06derUqVOn' +
|
|
'Tp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOn' +
|
|
'Tp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOn' +
|
|
'Tp06derUqQ/V/wM/Q7GFIgAAEENL7c+BDAAAAMAgf+t7fGWQurq6urq6urq6urq6urr6vx4=',
|
|
'base64'
|
|
)
|
|
);
|
|
|
|
const LZX_CAB = binary(
|
|
'\x4d\x53\x43\x46\x00\x00\x00\x00\x97\x00\x00\x00\x00\x00\x00' +
|
|
'\x00\x2c\x00\x00\x00\x00\x00\x00\x00\x03\x01\x01\x00\x02\x00' +
|
|
'\x00\x00\x2d\x05\x00\x00\x5b\x00\x00\x00\x01\x00\x03\x13\x0f' +
|
|
'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x21\x53\x0d\xb2\x20\x00' +
|
|
'\x68\x69\x2e\x74\x78\x74\x00\x10\x00\x00\x00\x0f\x00\x00\x00' +
|
|
'\x00\x00\x21\x53\x0b\xb2\x20\x00\x62\x79\x65\x2e\x74\x78\x74' +
|
|
'\x00\x5c\xef\x2a\xc7\x34\x00\x1f\x00\x5b\x80\x80\x8d\x00\x30' +
|
|
'\xf0\x01\x10\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x48' +
|
|
'\x65\x6c\x6c\x6f\x2c\x20\x77\x6f\x72\x6c\x64\x21\x0d\x0a\x53' +
|
|
'\x65\x65\x20\x79\x6f\x75\x20\x6c\x61\x74\x65\x72\x21\x0d\x0a' +
|
|
'\x00'
|
|
);
|
|
|
|
function findAscii(bytes: Uint8Array, value: string): number {
|
|
const needle = new TextEncoder().encode(value);
|
|
outer: for (
|
|
let offset = 0;
|
|
offset <= bytes.length - needle.length;
|
|
offset += 1
|
|
) {
|
|
for (let index = 0; index < needle.length; index += 1) {
|
|
if (bytes[offset + index] !== needle[index]) continue outer;
|
|
}
|
|
return offset;
|
|
}
|
|
throw new Error(`Could not find ${value}`);
|
|
}
|
|
|
|
function expectCode(action: () => unknown, code: string): void {
|
|
try {
|
|
action();
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(OnePkgError);
|
|
expect((error as OnePkgError).diagnostic.code).toBe(code);
|
|
return;
|
|
}
|
|
throw new Error(`Expected OnePkgError ${code}`);
|
|
}
|
|
|
|
describe('CAB enumeration and extraction', () => {
|
|
it('enumerates and extracts rust-cab uncompressed data', async () => {
|
|
const listing = enumerateCabinet(UNCOMPRESSED_CAB);
|
|
expect(listing.entries).toHaveLength(1);
|
|
expect(listing.entries[0]).toMatchObject({
|
|
normalizedPath: 'hi.txt',
|
|
uncompressedSize: 14,
|
|
compression: { kind: 'none' },
|
|
});
|
|
|
|
const extracted = await extractCabinet(UNCOMPRESSED_CAB);
|
|
expect(new TextDecoder().decode(extracted.extractedEntries[0]!.bytes)).toBe(
|
|
'Hello, world!\n'
|
|
);
|
|
});
|
|
|
|
it('extracts the rust-cab LZX 0x1303 fixture', async () => {
|
|
const listing = enumerateCabinet(LZX_CAB);
|
|
expect(listing.folders[0]!.compression).toMatchObject({
|
|
kind: 'lzx',
|
|
raw: 0x1303,
|
|
windowBits: 19,
|
|
});
|
|
|
|
const extracted = await extractCabinet(LZX_CAB);
|
|
expect(
|
|
extracted.extractedEntries.map((entry) => [
|
|
entry.normalizedPath,
|
|
new TextDecoder().decode(entry.bytes),
|
|
])
|
|
).toEqual([
|
|
['hi.txt', 'Hello, world!\r\n'],
|
|
['bye.txt', 'See you later!\r\n'],
|
|
]);
|
|
});
|
|
|
|
it('extracts the rust-cab single-block MSZIP fixture', async () => {
|
|
const listing = enumerateCabinet(MSZIP_CAB);
|
|
expect(listing.folders[0]!.compression).toEqual({
|
|
kind: 'mszip',
|
|
raw: 1,
|
|
label: 'MSZIP',
|
|
});
|
|
expect(listing.diagnostics).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: 'cab-compression-unsupported' }),
|
|
])
|
|
);
|
|
|
|
const extracted = await extractCabinet(MSZIP_CAB);
|
|
expect(new TextDecoder().decode(extracted.extractedEntries[0]!.bytes)).toBe(
|
|
'Hello, world!\n'
|
|
);
|
|
});
|
|
|
|
it('retains the MSZIP dictionary across two CFDATA blocks', async () => {
|
|
expect(MULTI_BLOCK_MSZIP_CAB).toHaveLength(593);
|
|
expect(
|
|
createHash('sha256').update(MULTI_BLOCK_MSZIP_CAB).digest('hex')
|
|
).toBe('9f71b97623395492f401ddc152ca648e8416996b14092b0df42ad712c27f9cb2');
|
|
const listing = enumerateCabinet(MULTI_BLOCK_MSZIP_CAB);
|
|
expect(listing.folders[0]).toMatchObject({
|
|
dataBlockCount: 2,
|
|
compressedSize: 505,
|
|
uncompressedSize: 36_864,
|
|
compression: { kind: 'mszip' },
|
|
});
|
|
|
|
const extracted = await extractCabinet(MULTI_BLOCK_MSZIP_CAB);
|
|
const output = extracted.extractedEntries[0]!.bytes;
|
|
expect(output).toHaveLength(36_864);
|
|
expect(createHash('sha256').update(output).digest('hex')).toBe(
|
|
'cb441d1f6e7d9c5789a306c51a00a654c9b7715c2c3d624e3d2a75bd0af549ff'
|
|
);
|
|
expect(output).toEqual(
|
|
Uint8Array.from({ length: 36_864 }, (_, index) => index % 251)
|
|
);
|
|
|
|
const firstBlock = MULTI_BLOCK_MSZIP_CAB.subarray(80, 551);
|
|
const secondBlock = MULTI_BLOCK_MSZIP_CAB.subarray(559, 593);
|
|
const decoder = new MsZipDecoder();
|
|
expect(decoder.decompressNext(firstBlock, 32_768)).toEqual(
|
|
output.subarray(0, 32_768)
|
|
);
|
|
expect(decoder.decompressNext(secondBlock, 4_096)).toEqual(
|
|
output.subarray(32_768)
|
|
);
|
|
expectCode(
|
|
() => new MsZipDecoder().decompressNext(secondBlock, 4_096),
|
|
'mszip-invalid-deflate'
|
|
);
|
|
});
|
|
|
|
it('rejects malformed MSZIP framing, DEFLATE, and output sizes', async () => {
|
|
const signature = MSZIP_CAB.slice();
|
|
const signatureOffset = findAscii(signature, 'CK');
|
|
signature[signatureOffset] = 0;
|
|
await expect(extractCabinet(signature)).rejects.toMatchObject({
|
|
diagnostic: { code: 'mszip-invalid-signature' },
|
|
});
|
|
|
|
const deflate = MSZIP_CAB.slice();
|
|
deflate[findAscii(deflate, 'CK') + 2] = 0x07;
|
|
await expect(extractCabinet(deflate)).rejects.toMatchObject({
|
|
diagnostic: { code: 'mszip-invalid-deflate' },
|
|
});
|
|
|
|
const wrongOutputSize = MSZIP_CAB.slice();
|
|
const outputSizeOffset = findAscii(wrongOutputSize, 'CK') - 2;
|
|
new DataView(
|
|
wrongOutputSize.buffer,
|
|
wrongOutputSize.byteOffset,
|
|
wrongOutputSize.byteLength
|
|
).setUint16(outputSizeOffset, 15, true);
|
|
await expect(extractCabinet(wrongOutputSize)).rejects.toMatchObject({
|
|
diagnostic: { code: 'mszip-output-size-mismatch' },
|
|
});
|
|
});
|
|
|
|
it('retains an uncompressed LZX block across CAB chunks', () => {
|
|
const decoder = new LzxDecoder(18, { maxOperations: 100 });
|
|
const first = binary(
|
|
'\x00\x30\x70\x00' + '\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00abc'
|
|
);
|
|
expect(new TextDecoder().decode(decoder.decompressNext(first, 3))).toBe(
|
|
'abc'
|
|
);
|
|
expect(
|
|
new TextDecoder().decode(decoder.decompressNext(binary('defg'), 4))
|
|
).toBe('defg');
|
|
decoder.finish();
|
|
});
|
|
|
|
it('rejects checksum corruption', async () => {
|
|
const corrupted = UNCOMPRESSED_CAB.slice();
|
|
corrupted[corrupted.length - 1] = corrupted[corrupted.length - 1]! ^ 1;
|
|
await expect(extractCabinet(corrupted)).rejects.toMatchObject({
|
|
diagnostic: { code: 'cab-checksum-mismatch' },
|
|
});
|
|
});
|
|
|
|
it('rejects traversal paths and case-insensitive duplicate paths', () => {
|
|
const traversal = UNCOMPRESSED_CAB.slice();
|
|
traversal.set(binary('../x.x'), findAscii(traversal, 'hi.txt'));
|
|
expectCode(() => enumerateCabinet(traversal), 'cab-path-invalid-segment');
|
|
|
|
const duplicate = TWO_FILE_UNCOMPRESSED_CAB.slice();
|
|
duplicate.set(binary('HI.TXT\0\0'), findAscii(duplicate, 'bye.txt'));
|
|
expectCode(() => enumerateCabinet(duplicate), 'cab-duplicate-path');
|
|
});
|
|
|
|
it('reports an out-of-range folder data offset as a CAB diagnostic', () => {
|
|
const malformed = UNCOMPRESSED_CAB.slice();
|
|
new DataView(
|
|
malformed.buffer,
|
|
malformed.byteOffset,
|
|
malformed.byteLength
|
|
).setUint32(36, malformed.length + 1, true);
|
|
expectCode(() => enumerateCabinet(malformed), 'cab-invalid-data-offset');
|
|
});
|
|
|
|
it('enforces compression-ratio limits and cancellation', () => {
|
|
expectCode(
|
|
() =>
|
|
enumerateCabinet(UNCOMPRESSED_CAB, {
|
|
limits: { maxCompressionRatio: 0.5 },
|
|
}),
|
|
'cab-compression-ratio-limit'
|
|
);
|
|
expectCode(
|
|
() =>
|
|
enumerateCabinet(UNCOMPRESSED_CAB, {
|
|
cancellation: { isCancelled: () => true },
|
|
}),
|
|
'onepkg-cancelled'
|
|
);
|
|
});
|
|
|
|
it('keeps section parsing behind the injected callback boundary', async () => {
|
|
const oneCab = UNCOMPRESSED_CAB.slice();
|
|
oneCab.set(binary('hi.one'), findAscii(oneCab, 'hi.txt'));
|
|
const section: OneNoteSectionDto = {
|
|
format: 'one',
|
|
sourceName: 'hi.one',
|
|
pages: [],
|
|
diagnostics: [],
|
|
parserInfo: {
|
|
implementation: 'typescript',
|
|
supportedVariant: 'test',
|
|
referenceRevision: 'test',
|
|
},
|
|
};
|
|
const result = await openOneNotePackage(oneCab, {
|
|
sourceName: 'fixture.onepkg',
|
|
parseSection: ({ normalizedPath, bytes }) => {
|
|
expect(normalizedPath).toBe('hi.one');
|
|
expect(bytes).toHaveLength(14);
|
|
return { status: 'parsed', value: section };
|
|
},
|
|
});
|
|
expect(result.package).toMatchObject({
|
|
sourceName: 'fixture.onepkg',
|
|
sourceSize: oneCab.length,
|
|
entries: [{ parseStatus: 'parsed' }],
|
|
sections: [{ path: 'hi.one', section }],
|
|
});
|
|
});
|
|
});
|