feat: add local OneNote and ONEPKG reader
This commit is contained in:
200
src/onenote/onepkg/cabinet.test.ts
Normal file
200
src/onenote/onepkg/cabinet.test.ts
Normal 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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user