feat: add bounded MSZIP cabinet extraction
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
||||
import { calculateDataBlockChecksum } from './checksum.js';
|
||||
import { fail, warning } from './errors.js';
|
||||
import { LzxDecoder } from './lzx/decoder.js';
|
||||
import { MsZipDecoder } from './mszip/decoder.js';
|
||||
import { decodeCabFileName, normalizeCabPath } from './path.js';
|
||||
import {
|
||||
resolveLimits,
|
||||
@@ -30,6 +31,7 @@ const FLAG_NEXT_CABINET = 0x0002;
|
||||
const FLAG_RESERVE_PRESENT = 0x0004;
|
||||
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
|
||||
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
|
||||
const MAX_MSZIP_BLOCK_INPUT = 32 * 1024 + 12;
|
||||
|
||||
interface CabDataBlock {
|
||||
headerOffset: number;
|
||||
@@ -458,6 +460,16 @@ function parseCabinet(
|
||||
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
||||
);
|
||||
}
|
||||
if (
|
||||
folder.compression.kind === 'mszip' &&
|
||||
compressedSize > MAX_MSZIP_BLOCK_INPUT
|
||||
) {
|
||||
fail(
|
||||
'cab-invalid-mszip-block-size',
|
||||
`MSZIP CAB data block size ${compressedSize} exceeds ${MAX_MSZIP_BLOCK_INPUT} bytes.`,
|
||||
{ offset: headerOffset + 4, structure: 'CFDATA.cbData' }
|
||||
);
|
||||
}
|
||||
if (
|
||||
folder.compression.kind === 'none' &&
|
||||
compressedSize !== uncompressedSize
|
||||
@@ -589,10 +601,7 @@ function parseCabinet(
|
||||
);
|
||||
}
|
||||
for (const folder of folders) {
|
||||
if (
|
||||
folder.compression.kind === 'mszip' ||
|
||||
folder.compression.kind === 'quantum'
|
||||
) {
|
||||
if (folder.compression.kind === 'quantum') {
|
||||
diagnostics.push(
|
||||
warning(
|
||||
'cab-compression-unsupported',
|
||||
@@ -646,10 +655,7 @@ export async function extractCabinet(
|
||||
const extractedEntries = [];
|
||||
for (const folder of parsed.folders) {
|
||||
checkCancellation(options.cancellation);
|
||||
if (
|
||||
folder.compression.kind === 'mszip' ||
|
||||
folder.compression.kind === 'quantum'
|
||||
) {
|
||||
if (folder.compression.kind === 'quantum') {
|
||||
fail(
|
||||
'cab-compression-unsupported',
|
||||
`${folder.compression.label} extraction is not implemented.`,
|
||||
@@ -665,6 +671,8 @@ export async function extractCabinet(
|
||||
checkCancellation: () => checkCancellation(options.cancellation),
|
||||
})
|
||||
: undefined;
|
||||
const msZipDecoder =
|
||||
folder.compression.kind === 'mszip' ? new MsZipDecoder() : undefined;
|
||||
let outputOffset = 0;
|
||||
for (const block of folder.blocks) {
|
||||
checkCancellation(options.cancellation);
|
||||
@@ -688,9 +696,11 @@ export async function extractCabinet(
|
||||
}
|
||||
}
|
||||
const output =
|
||||
decoder === undefined
|
||||
? compressed
|
||||
: decoder.decompressNext(compressed, block.uncompressedSize);
|
||||
decoder !== undefined
|
||||
? decoder.decompressNext(compressed, block.uncompressedSize)
|
||||
: msZipDecoder !== undefined
|
||||
? msZipDecoder.decompressNext(compressed, block.uncompressedSize)
|
||||
: compressed;
|
||||
if (output.length !== block.uncompressedSize) {
|
||||
fail(
|
||||
'cab-output-size-mismatch',
|
||||
|
||||
95
src/onenote/onepkg/mszip/decoder.ts
Normal file
95
src/onenote/onepkg/mszip/decoder.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* MSZIP block framing and 32 KiB history semantics follow rust-cab 0.6.0.
|
||||
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
|
||||
* Copyright (c) 2017 Matthew D. Steele, MIT License.
|
||||
* Raw DEFLATE decoding is provided by fflate 0.8.3 (MIT).
|
||||
* Modified for fixed browser buffers and strict block/output validation.
|
||||
*/
|
||||
|
||||
import { inflateSync } from 'fflate';
|
||||
|
||||
import { fail } from '../errors.js';
|
||||
|
||||
const MAX_BLOCK_OUTPUT = 32 * 1024;
|
||||
const HISTORY_SIZE = 32 * 1024;
|
||||
const SIGNATURE_FIRST = 0x43;
|
||||
const SIGNATURE_SECOND = 0x4b;
|
||||
|
||||
export class MsZipDecoder {
|
||||
private history = new Uint8Array(0);
|
||||
|
||||
decompressNext(chunk: Uint8Array, outputLength: number): Uint8Array {
|
||||
if (
|
||||
!Number.isInteger(outputLength) ||
|
||||
outputLength <= 0 ||
|
||||
outputLength > MAX_BLOCK_OUTPUT
|
||||
) {
|
||||
fail(
|
||||
'mszip-invalid-chunk-size',
|
||||
`MSZIP CAB chunk output size ${outputLength} is outside 1..${MAX_BLOCK_OUTPUT}.`,
|
||||
{ structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
if (
|
||||
chunk.length < 2 ||
|
||||
chunk[0] !== SIGNATURE_FIRST ||
|
||||
chunk[1] !== SIGNATURE_SECOND
|
||||
) {
|
||||
fail(
|
||||
'mszip-invalid-signature',
|
||||
'MSZIP data block does not begin with the CK signature.',
|
||||
{ structure: 'MSZIP block' }
|
||||
);
|
||||
}
|
||||
|
||||
let output: Uint8Array;
|
||||
try {
|
||||
// One extra byte lets us distinguish an exact result from a stream that
|
||||
// expands beyond the trusted CFDATA declaration. fflate writes into this
|
||||
// caller-owned buffer, so malformed input cannot trigger output growth.
|
||||
output = inflateSync(chunk.subarray(2), {
|
||||
dictionary: this.history.length === 0 ? undefined : this.history,
|
||||
out: new Uint8Array(outputLength + 1),
|
||||
});
|
||||
} catch {
|
||||
fail(
|
||||
'mszip-invalid-deflate',
|
||||
'MSZIP block contains an invalid raw DEFLATE stream.',
|
||||
{ structure: 'MSZIP block' }
|
||||
);
|
||||
}
|
||||
|
||||
if (output.length !== outputLength) {
|
||||
fail(
|
||||
'mszip-output-size-mismatch',
|
||||
`MSZIP block produced ${output.length} bytes; expected ${outputLength}.`,
|
||||
{ structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
|
||||
this.updateHistory(output);
|
||||
return output;
|
||||
}
|
||||
|
||||
private updateHistory(output: Uint8Array): void {
|
||||
const historyLength = Math.min(
|
||||
HISTORY_SIZE,
|
||||
this.history.length + output.length
|
||||
);
|
||||
const nextHistory = new Uint8Array(historyLength);
|
||||
const oldBytes = Math.min(
|
||||
this.history.length,
|
||||
historyLength - output.length
|
||||
);
|
||||
if (oldBytes > 0) {
|
||||
nextHistory.set(this.history.subarray(this.history.length - oldBytes));
|
||||
}
|
||||
nextHistory.set(
|
||||
output.subarray(output.length - (historyLength - oldBytes)),
|
||||
oldBytes
|
||||
);
|
||||
this.history = nextHistory;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user