feat: add bounded MSZIP cabinet extraction

This commit is contained in:
2026-07-22 19:01:50 +02:00
parent a0c26d604d
commit d282b832ac
15 changed files with 318 additions and 48 deletions

View File

@@ -1,9 +1,12 @@
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';
/*
@@ -33,6 +36,37 @@ const TWO_FILE_UNCOMPRESSED_CAB = binary(
'\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' +
@@ -109,6 +143,89 @@ describe('CAB enumeration and extraction', () => {
]);
});
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(