feat: add local OneNote and ONEPKG reader

This commit is contained in:
2026-07-22 17:06:03 +02:00
commit f581cbdced
93 changed files with 14949 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
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 { 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 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('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 }],
});
});
});