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 }], }); }); });