feat: add local OneNote and ONEPKG reader
This commit is contained in:
86
src/onenote/onepkg/binary-reader.ts
Normal file
86
src/onenote/onepkg/binary-reader.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { fail } from './errors.js';
|
||||
|
||||
export class BinaryReader {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly end: number;
|
||||
offset: number;
|
||||
private readonly view: DataView;
|
||||
|
||||
constructor(bytes: Uint8Array, offset = 0, end = bytes.byteLength) {
|
||||
if (offset < 0 || end < offset || end > bytes.byteLength) {
|
||||
throw new RangeError('Invalid BinaryReader range');
|
||||
}
|
||||
this.bytes = bytes;
|
||||
this.offset = offset;
|
||||
this.end = end;
|
||||
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
ensure(length: number, structure: string): void {
|
||||
if (
|
||||
!Number.isSafeInteger(length) ||
|
||||
length < 0 ||
|
||||
this.offset > this.end - length
|
||||
) {
|
||||
fail(
|
||||
'cab-unexpected-eof',
|
||||
`Unexpected end of cabinet while reading ${structure}.`,
|
||||
{ offset: this.offset, structure }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
readU8(structure: string): number {
|
||||
this.ensure(1, structure);
|
||||
return this.bytes[this.offset++]!;
|
||||
}
|
||||
|
||||
readU16(structure: string): number {
|
||||
this.ensure(2, structure);
|
||||
const value = this.view.getUint16(this.offset, true);
|
||||
this.offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
readU32(structure: string): number {
|
||||
this.ensure(4, structure);
|
||||
const value = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
readBytes(length: number, structure: string): Uint8Array {
|
||||
this.ensure(length, structure);
|
||||
const result = this.bytes.subarray(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return result;
|
||||
}
|
||||
|
||||
skip(length: number, structure: string): void {
|
||||
this.ensure(length, structure);
|
||||
this.offset += length;
|
||||
}
|
||||
|
||||
readNullTerminatedBytes(maxBytes: number, structure: string): Uint8Array {
|
||||
const start = this.offset;
|
||||
while (this.offset < this.end && this.bytes[this.offset] !== 0) {
|
||||
if (this.offset - start >= maxBytes) {
|
||||
fail(
|
||||
'cab-name-too-long',
|
||||
`Cabinet string exceeds the configured ${maxBytes}-byte limit.`,
|
||||
{ offset: start, structure }
|
||||
);
|
||||
}
|
||||
this.offset += 1;
|
||||
}
|
||||
if (this.offset >= this.end) {
|
||||
fail('cab-unterminated-string', 'Cabinet string is not NUL-terminated.', {
|
||||
offset: start,
|
||||
structure,
|
||||
});
|
||||
}
|
||||
const value = this.bytes.subarray(start, this.offset);
|
||||
this.offset += 1;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
727
src/onenote/onepkg/cabinet.ts
Normal file
727
src/onenote/onepkg/cabinet.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* CAB structure parsing and extraction translated from rust-cab 0.6.0.
|
||||
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
|
||||
* Copyright (c) 2017 Matthew D. Steele, MIT License.
|
||||
* Modified for immutable browser Uint8Array input, strict archive-path handling,
|
||||
* explicit resource limits, cancellation, and whole-folder extraction.
|
||||
*/
|
||||
|
||||
import { BinaryReader } from './binary-reader.js';
|
||||
import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
||||
import { calculateDataBlockChecksum } from './checksum.js';
|
||||
import { fail, warning } from './errors.js';
|
||||
import { LzxDecoder } from './lzx/decoder.js';
|
||||
import { decodeCabFileName, normalizeCabPath } from './path.js';
|
||||
import {
|
||||
resolveLimits,
|
||||
type CabCompressionMethod,
|
||||
type CabEnumerationResult,
|
||||
type CabExtractionResult,
|
||||
type CabFileEntry,
|
||||
type CabOpenOptions,
|
||||
type OnePkgLimits,
|
||||
} from './types.js';
|
||||
|
||||
const CAB_SIGNATURE = 0x4643_534d;
|
||||
const FLAG_PREVIOUS_CABINET = 0x0001;
|
||||
const FLAG_NEXT_CABINET = 0x0002;
|
||||
const FLAG_RESERVE_PRESENT = 0x0004;
|
||||
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
|
||||
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
|
||||
|
||||
interface CabDataBlock {
|
||||
headerOffset: number;
|
||||
checksum: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
reserve: Uint8Array;
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
interface CabFolderInternal {
|
||||
index: number;
|
||||
firstDataBlockOffset: number;
|
||||
dataBlockCount: number;
|
||||
compression: CabCompressionMethod;
|
||||
blocks: CabDataBlock[];
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
dataEnd: number;
|
||||
}
|
||||
|
||||
interface ParsedCabinet {
|
||||
bytes: Uint8Array;
|
||||
limits: OnePkgLimits;
|
||||
folders: CabFolderInternal[];
|
||||
result: CabEnumerationResult;
|
||||
}
|
||||
|
||||
function checkedAdd(
|
||||
left: number,
|
||||
right: number,
|
||||
code: string,
|
||||
structure: string
|
||||
): number {
|
||||
const sum = left + right;
|
||||
if (!Number.isSafeInteger(sum)) {
|
||||
fail(code, `Integer overflow while reading ${structure}.`, { structure });
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
function parseCompression(
|
||||
raw: number,
|
||||
limits: OnePkgLimits,
|
||||
offset: number
|
||||
): CabCompressionMethod {
|
||||
const kind = raw & 0x000f;
|
||||
if (kind === 0) {
|
||||
if (raw !== 0) {
|
||||
fail(
|
||||
'cab-invalid-compression',
|
||||
`Invalid uncompressed type 0x${raw.toString(16)}.`,
|
||||
{
|
||||
offset,
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
}
|
||||
);
|
||||
}
|
||||
return { kind: 'none', raw, label: 'None' };
|
||||
}
|
||||
if (kind === 1) {
|
||||
if (raw !== 1) {
|
||||
fail(
|
||||
'cab-invalid-compression',
|
||||
`Invalid MSZIP type 0x${raw.toString(16)}.`,
|
||||
{
|
||||
offset,
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
}
|
||||
);
|
||||
}
|
||||
return { kind: 'mszip', raw, label: 'MSZIP' };
|
||||
}
|
||||
if (kind === 2) {
|
||||
const level = (raw & 0x00f0) >>> 4;
|
||||
const memoryBits = (raw & 0x1f00) >>> 8;
|
||||
if (level < 1 || level > 7 || memoryBits < 10 || memoryBits > 21) {
|
||||
fail(
|
||||
'cab-invalid-compression',
|
||||
`Invalid Quantum type 0x${raw.toString(16)}.`,
|
||||
{
|
||||
offset,
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
}
|
||||
);
|
||||
}
|
||||
return { kind: 'quantum', raw, label: 'Quantum', level, memoryBits };
|
||||
}
|
||||
if (kind === 3) {
|
||||
if ((raw & ~0x1f0f) !== 0) {
|
||||
fail(
|
||||
'cab-invalid-compression',
|
||||
`Invalid LZX type 0x${raw.toString(16)}.`,
|
||||
{
|
||||
offset,
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
}
|
||||
);
|
||||
}
|
||||
const windowBits = (raw & 0x1f00) >>> 8;
|
||||
if (windowBits < 15 || windowBits > 25) {
|
||||
fail('cab-invalid-lzx-window', `Invalid LZX window 2^${windowBits}.`, {
|
||||
offset,
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
});
|
||||
}
|
||||
const windowBytes = 2 ** windowBits;
|
||||
if (windowBytes > limits.maxLzxWindowBytes) {
|
||||
fail(
|
||||
'cab-lzx-window-limit',
|
||||
`LZX window ${windowBytes} exceeds the configured ${limits.maxLzxWindowBytes}-byte limit.`,
|
||||
{ offset, structure: 'CFFOLDER.typeCompress' }
|
||||
);
|
||||
}
|
||||
return { kind: 'lzx', raw, label: 'LZX', windowBits, windowBytes };
|
||||
}
|
||||
fail(
|
||||
'cab-invalid-compression',
|
||||
`Unknown CAB compression type 0x${raw.toString(16)}.`,
|
||||
{
|
||||
offset,
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function parseCabinet(
|
||||
bytes: Uint8Array,
|
||||
options: CabOpenOptions
|
||||
): ParsedCabinet {
|
||||
if (!(bytes instanceof Uint8Array)) {
|
||||
throw new TypeError('CAB input must be a Uint8Array');
|
||||
}
|
||||
const limits = resolveLimits(options.limits);
|
||||
if (bytes.byteLength > limits.maxCabinetBytes) {
|
||||
fail(
|
||||
'cab-input-limit',
|
||||
`Cabinet is ${bytes.byteLength} bytes; configured limit is ${limits.maxCabinetBytes}.`,
|
||||
{ structure: 'CFHEADER' }
|
||||
);
|
||||
}
|
||||
checkCancellation(options.cancellation);
|
||||
|
||||
const header = new BinaryReader(bytes);
|
||||
if (header.readU32('CFHEADER.signature') !== CAB_SIGNATURE) {
|
||||
fail(
|
||||
'cab-invalid-signature',
|
||||
'Input does not begin with the MSCF CAB signature.',
|
||||
{
|
||||
offset: 0,
|
||||
structure: 'CFHEADER.signature',
|
||||
}
|
||||
);
|
||||
}
|
||||
const reserved1 = header.readU32('CFHEADER.reserved1');
|
||||
const declaredSize = header.readU32('CFHEADER.cbCabinet');
|
||||
const reserved2 = header.readU32('CFHEADER.reserved2');
|
||||
const firstFileOffset = header.readU32('CFHEADER.coffFiles');
|
||||
const reserved3 = header.readU32('CFHEADER.reserved3');
|
||||
const minorVersion = header.readU8('CFHEADER.versionMinor');
|
||||
const majorVersion = header.readU8('CFHEADER.versionMajor');
|
||||
const folderCount = header.readU16('CFHEADER.cFolders');
|
||||
const fileCount = header.readU16('CFHEADER.cFiles');
|
||||
const flags = header.readU16('CFHEADER.flags');
|
||||
const cabinetSetId = header.readU16('CFHEADER.setID');
|
||||
const cabinetSetIndex = header.readU16('CFHEADER.iCabinet');
|
||||
|
||||
if (declaredSize < 36 || declaredSize > bytes.byteLength) {
|
||||
fail(
|
||||
'cab-invalid-size',
|
||||
`Declared cabinet size ${declaredSize} is outside the available ${bytes.byteLength} bytes.`,
|
||||
{ offset: 8, structure: 'CFHEADER.cbCabinet' }
|
||||
);
|
||||
}
|
||||
if (declaredSize > limits.maxCabinetBytes) {
|
||||
fail(
|
||||
'cab-input-limit',
|
||||
'Declared cabinet size exceeds the configured limit.',
|
||||
{
|
||||
offset: 8,
|
||||
structure: 'CFHEADER.cbCabinet',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (majorVersion > 1 || (majorVersion === 1 && minorVersion > 3)) {
|
||||
fail(
|
||||
'cab-version-unsupported',
|
||||
`CAB version ${majorVersion}.${minorVersion} is not supported.`,
|
||||
{ offset: 24, structure: 'CFHEADER.version' }
|
||||
);
|
||||
}
|
||||
if ((flags & ~0x0007) !== 0) {
|
||||
fail(
|
||||
'cab-invalid-flags',
|
||||
`Unknown CAB header flags 0x${flags.toString(16)}.`,
|
||||
{
|
||||
offset: 30,
|
||||
structure: 'CFHEADER.flags',
|
||||
}
|
||||
);
|
||||
}
|
||||
if ((flags & (FLAG_PREVIOUS_CABINET | FLAG_NEXT_CABINET)) !== 0) {
|
||||
fail(
|
||||
'cab-multipart-unsupported',
|
||||
'Multi-cabinet archives are not supported for local .onepkg files.',
|
||||
{ offset: 30, structure: 'CFHEADER.flags' }
|
||||
);
|
||||
}
|
||||
if (folderCount > limits.maxFolders || fileCount > limits.maxFiles) {
|
||||
fail(
|
||||
'cab-entry-count-limit',
|
||||
`Cabinet contains ${folderCount} folders and ${fileCount} files, exceeding configured limits.`,
|
||||
{ structure: 'CFHEADER' }
|
||||
);
|
||||
}
|
||||
|
||||
// Re-bind reads to the trusted cbCabinet boundary before parsing variable data.
|
||||
const reader = new BinaryReader(bytes, header.offset, declaredSize);
|
||||
let folderReserveSize = 0;
|
||||
let dataReserveSize = 0;
|
||||
if ((flags & FLAG_RESERVE_PRESENT) !== 0) {
|
||||
const headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
|
||||
folderReserveSize = reader.readU8('CFHEADER.cbCFFolder');
|
||||
dataReserveSize = reader.readU8('CFHEADER.cbCFData');
|
||||
reader.skip(headerReserveSize, 'CFHEADER.abReserve');
|
||||
}
|
||||
|
||||
const folders: CabFolderInternal[] = [];
|
||||
let totalDataBlocks = 0;
|
||||
for (let index = 0; index < folderCount; index += 1) {
|
||||
if ((index & 0xff) === 0) checkCancellation(options.cancellation);
|
||||
const folderOffset = reader.offset;
|
||||
const firstDataBlockOffset = reader.readU32('CFFOLDER.coffCabStart');
|
||||
const dataBlockCount = reader.readU16('CFFOLDER.cCFData');
|
||||
const compressionRawOffset = reader.offset;
|
||||
const compression = parseCompression(
|
||||
reader.readU16('CFFOLDER.typeCompress'),
|
||||
limits,
|
||||
compressionRawOffset
|
||||
);
|
||||
reader.skip(folderReserveSize, 'CFFOLDER.abReserve');
|
||||
totalDataBlocks = checkedAdd(
|
||||
totalDataBlocks,
|
||||
dataBlockCount,
|
||||
'cab-data-block-count-overflow',
|
||||
'CFFOLDER.cCFData'
|
||||
);
|
||||
if (totalDataBlocks > limits.maxDataBlocks) {
|
||||
fail(
|
||||
'cab-data-block-count-limit',
|
||||
`Cabinet exceeds the configured ${limits.maxDataBlocks}-block limit.`,
|
||||
{ offset: folderOffset, structure: 'CFFOLDER.cCFData' }
|
||||
);
|
||||
}
|
||||
folders.push({
|
||||
index,
|
||||
firstDataBlockOffset,
|
||||
dataBlockCount,
|
||||
compression,
|
||||
blocks: [],
|
||||
compressedSize: 0,
|
||||
uncompressedSize: 0,
|
||||
dataEnd: firstDataBlockOffset,
|
||||
});
|
||||
}
|
||||
|
||||
if (firstFileOffset < reader.offset || firstFileOffset >= declaredSize) {
|
||||
fail('cab-invalid-file-table-offset', 'CAB file table offset is invalid.', {
|
||||
offset: 16,
|
||||
structure: 'CFHEADER.coffFiles',
|
||||
});
|
||||
}
|
||||
const fileReader = new BinaryReader(bytes, firstFileOffset, declaredSize);
|
||||
const entries: CabFileEntry[] = [];
|
||||
const diagnostics = [];
|
||||
const duplicatePaths = new Set<string>();
|
||||
let extractedByteTotal = 0;
|
||||
for (let index = 0; index < fileCount; index += 1) {
|
||||
if ((index & 0xff) === 0) checkCancellation(options.cancellation);
|
||||
const entryOffset = fileReader.offset;
|
||||
const uncompressedSize = fileReader.readU32('CFFILE.cbFile');
|
||||
const uncompressedOffset = fileReader.readU32('CFFILE.uoffFolderStart');
|
||||
const folderIndex = fileReader.readU16('CFFILE.iFolder');
|
||||
fileReader.readU16('CFFILE.date');
|
||||
fileReader.readU16('CFFILE.time');
|
||||
const attributes = fileReader.readU16('CFFILE.attribs');
|
||||
const nameOffset = fileReader.offset;
|
||||
const nameBytes = fileReader.readNullTerminatedBytes(
|
||||
limits.maxNameBytes,
|
||||
'CFFILE.szName'
|
||||
);
|
||||
|
||||
if (folderIndex >= 0xfffd) {
|
||||
fail(
|
||||
'cab-multipart-file-unsupported',
|
||||
'CAB file spans another cabinet; multi-cabinet files are unsupported.',
|
||||
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
||||
);
|
||||
}
|
||||
const folder = folders[folderIndex];
|
||||
if (folder === undefined) {
|
||||
fail(
|
||||
'cab-folder-index-out-of-range',
|
||||
'CAB file folder index is out of range.',
|
||||
{
|
||||
offset: entryOffset + 8,
|
||||
structure: 'CFFILE.iFolder',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (uncompressedSize > limits.maxFileBytes) {
|
||||
fail(
|
||||
'cab-file-size-limit',
|
||||
`CAB file size ${uncompressedSize} exceeds the configured ${limits.maxFileBytes}-byte limit.`,
|
||||
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
||||
);
|
||||
}
|
||||
extractedByteTotal = checkedAdd(
|
||||
extractedByteTotal,
|
||||
uncompressedSize,
|
||||
'cab-extracted-size-overflow',
|
||||
'CFFILE.cbFile'
|
||||
);
|
||||
if (extractedByteTotal > limits.maxExtractedBytes) {
|
||||
fail(
|
||||
'cab-extracted-size-limit',
|
||||
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
|
||||
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
||||
);
|
||||
}
|
||||
|
||||
const path = decodeCabFileName(
|
||||
nameBytes,
|
||||
(attributes & ATTRIBUTE_NAME_IS_UTF8) !== 0,
|
||||
nameOffset
|
||||
);
|
||||
if (
|
||||
(attributes & ATTRIBUTE_NAME_IS_UTF8) === 0 &&
|
||||
nameBytes.some((byte) => byte > 0x7f)
|
||||
) {
|
||||
diagnostics.push(
|
||||
warning(
|
||||
'cab-unmarked-utf8-name',
|
||||
`Decoded unmarked non-ASCII CAB filename as strict UTF-8: ${path}`,
|
||||
{ offset: nameOffset, structure: 'CFFILE.szName' }
|
||||
)
|
||||
);
|
||||
}
|
||||
const normalized = normalizeCabPath(path, limits, nameOffset);
|
||||
if (duplicatePaths.has(normalized.duplicateKey)) {
|
||||
fail(
|
||||
'cab-duplicate-path',
|
||||
`CAB contains a duplicate normalized path: ${normalized.normalizedPath}`,
|
||||
{ offset: nameOffset, structure: 'CFFILE.szName' }
|
||||
);
|
||||
}
|
||||
duplicatePaths.add(normalized.duplicateKey);
|
||||
entries.push({
|
||||
index,
|
||||
path: normalized.path,
|
||||
normalizedPath: normalized.normalizedPath,
|
||||
folderIndex,
|
||||
uncompressedOffset,
|
||||
uncompressedSize,
|
||||
attributes,
|
||||
compression: folder.compression,
|
||||
});
|
||||
}
|
||||
const metadataEnd = fileReader.offset;
|
||||
|
||||
let totalFolderOutput = 0;
|
||||
let totalLzxOutput = 0;
|
||||
for (const folder of folders) {
|
||||
if (folder.firstDataBlockOffset > declaredSize) {
|
||||
fail(
|
||||
'cab-invalid-data-offset',
|
||||
'CAB folder data offset is beyond the declared cabinet.',
|
||||
{
|
||||
offset: folder.firstDataBlockOffset,
|
||||
structure: 'CFFOLDER.coffCabStart',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (
|
||||
folder.dataBlockCount > 0 &&
|
||||
folder.firstDataBlockOffset < metadataEnd
|
||||
) {
|
||||
fail(
|
||||
'cab-data-overlaps-metadata',
|
||||
'CAB data blocks overlap the file table.',
|
||||
{
|
||||
offset: folder.firstDataBlockOffset,
|
||||
structure: 'CFFOLDER.coffCabStart',
|
||||
}
|
||||
);
|
||||
}
|
||||
const blockReader = new BinaryReader(
|
||||
bytes,
|
||||
folder.firstDataBlockOffset,
|
||||
declaredSize
|
||||
);
|
||||
for (
|
||||
let blockIndex = 0;
|
||||
blockIndex < folder.dataBlockCount;
|
||||
blockIndex += 1
|
||||
) {
|
||||
if ((blockIndex & 0xff) === 0) checkCancellation(options.cancellation);
|
||||
const headerOffset = blockReader.offset;
|
||||
const checksum = blockReader.readU32('CFDATA.csum');
|
||||
const compressedSize = blockReader.readU16('CFDATA.cbData');
|
||||
const uncompressedSize = blockReader.readU16('CFDATA.cbUncomp');
|
||||
const reserve = blockReader.readBytes(
|
||||
dataReserveSize,
|
||||
'CFDATA.abReserve'
|
||||
);
|
||||
const dataOffset = blockReader.offset;
|
||||
blockReader.skip(compressedSize, 'CFDATA.ab');
|
||||
if (
|
||||
compressedSize === 0 ||
|
||||
uncompressedSize === 0 ||
|
||||
uncompressedSize > MAX_CAB_BLOCK_OUTPUT
|
||||
) {
|
||||
fail(
|
||||
'cab-invalid-data-block-size',
|
||||
`Invalid CAB data block sizes ${compressedSize}/${uncompressedSize}.`,
|
||||
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
||||
);
|
||||
}
|
||||
if (
|
||||
folder.compression.kind === 'none' &&
|
||||
compressedSize !== uncompressedSize
|
||||
) {
|
||||
fail(
|
||||
'cab-uncompressed-size-mismatch',
|
||||
'Uncompressed CAB block has different compressed and output sizes.',
|
||||
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
||||
);
|
||||
}
|
||||
folder.compressedSize = checkedAdd(
|
||||
folder.compressedSize,
|
||||
compressedSize,
|
||||
'cab-folder-size-overflow',
|
||||
'CFDATA.cbData'
|
||||
);
|
||||
folder.uncompressedSize = checkedAdd(
|
||||
folder.uncompressedSize,
|
||||
uncompressedSize,
|
||||
'cab-folder-size-overflow',
|
||||
'CFDATA.cbUncomp'
|
||||
);
|
||||
if (folder.uncompressedSize > limits.maxFolderUncompressedBytes) {
|
||||
fail(
|
||||
'cab-folder-size-limit',
|
||||
`CAB folder exceeds the configured ${limits.maxFolderUncompressedBytes}-byte output limit.`,
|
||||
{ offset: headerOffset + 6, structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
folder.blocks.push({
|
||||
headerOffset,
|
||||
checksum,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
reserve,
|
||||
dataOffset,
|
||||
});
|
||||
}
|
||||
folder.dataEnd = blockReader.offset;
|
||||
if (
|
||||
folder.uncompressedSize / folder.compressedSize >
|
||||
limits.maxCompressionRatio
|
||||
) {
|
||||
fail(
|
||||
'cab-compression-ratio-limit',
|
||||
`CAB folder compression ratio exceeds the configured ${limits.maxCompressionRatio}:1 limit.`,
|
||||
{ offset: folder.firstDataBlockOffset, structure: 'CFFOLDER/CFDATA' }
|
||||
);
|
||||
}
|
||||
totalFolderOutput = checkedAdd(
|
||||
totalFolderOutput,
|
||||
folder.uncompressedSize,
|
||||
'cab-total-size-overflow',
|
||||
'CFDATA.cbUncomp'
|
||||
);
|
||||
if (folder.compression.kind === 'lzx') {
|
||||
totalLzxOutput = checkedAdd(
|
||||
totalLzxOutput,
|
||||
folder.uncompressedSize,
|
||||
'lzx-operation-limit',
|
||||
'CFDATA.cbUncomp'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
|
||||
fail(
|
||||
'cab-total-size-limit',
|
||||
`CAB output exceeds the configured ${limits.maxTotalUncompressedBytes}-byte limit.`,
|
||||
{ structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
if (totalLzxOutput > limits.maxDecodeOperations) {
|
||||
fail(
|
||||
'lzx-operation-limit',
|
||||
`LZX output exceeds the configured ${limits.maxDecodeOperations}-operation limit.`,
|
||||
{ structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
|
||||
const dataRanges = folders
|
||||
.filter((folder) => folder.dataBlockCount > 0)
|
||||
.map((folder) => ({
|
||||
start: folder.firstDataBlockOffset,
|
||||
end: folder.dataEnd,
|
||||
}))
|
||||
.sort((left, right) => left.start - right.start);
|
||||
for (let index = 1; index < dataRanges.length; index += 1) {
|
||||
if (dataRanges[index]!.start < dataRanges[index - 1]!.end) {
|
||||
fail('cab-overlapping-folders', 'CAB folder data ranges overlap.', {
|
||||
offset: dataRanges[index]!.start,
|
||||
structure: 'CFFOLDER.coffCabStart',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const folder = folders[entry.folderIndex]!;
|
||||
const fileEnd = checkedAdd(
|
||||
entry.uncompressedOffset,
|
||||
entry.uncompressedSize,
|
||||
'cab-file-range-overflow',
|
||||
'CFFILE'
|
||||
);
|
||||
if (fileEnd > folder.uncompressedSize) {
|
||||
fail(
|
||||
'cab-file-range-out-of-bounds',
|
||||
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
|
||||
{ structure: 'CFFILE' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.byteLength > declaredSize) {
|
||||
diagnostics.push(
|
||||
warning(
|
||||
'cab-trailing-data',
|
||||
`Ignored ${bytes.byteLength - declaredSize} bytes after the declared cabinet.`,
|
||||
{ offset: declaredSize, structure: 'CFHEADER.cbCabinet' }
|
||||
)
|
||||
);
|
||||
}
|
||||
if (reserved1 !== 0 || reserved2 !== 0 || reserved3 !== 0) {
|
||||
diagnostics.push(
|
||||
warning(
|
||||
'cab-reserved-header-data',
|
||||
'CAB header contains non-zero reserved fields.',
|
||||
{ structure: 'CFHEADER' }
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const folder of folders) {
|
||||
if (
|
||||
folder.compression.kind === 'mszip' ||
|
||||
folder.compression.kind === 'quantum'
|
||||
) {
|
||||
diagnostics.push(
|
||||
warning(
|
||||
'cab-compression-unsupported',
|
||||
`${folder.compression.label} metadata can be listed, but this build cannot extract it.`,
|
||||
{ structure: `CFFOLDER[${folder.index}].typeCompress` }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bytes,
|
||||
limits,
|
||||
folders,
|
||||
result: {
|
||||
cabinetSetId,
|
||||
cabinetSetIndex,
|
||||
declaredSize,
|
||||
entries,
|
||||
folders: folders.map((folder) => ({
|
||||
index: folder.index,
|
||||
dataBlockCount: folder.dataBlockCount,
|
||||
compressedSize: folder.compressedSize,
|
||||
uncompressedSize: folder.uncompressedSize,
|
||||
compression: folder.compression,
|
||||
})),
|
||||
diagnostics,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function enumerateCabinet(
|
||||
bytes: Uint8Array,
|
||||
options: CabOpenOptions = {}
|
||||
): CabEnumerationResult {
|
||||
return parseCabinet(bytes, options).result;
|
||||
}
|
||||
|
||||
export async function extractCabinet(
|
||||
bytes: Uint8Array,
|
||||
options: CabOpenOptions = {}
|
||||
): Promise<CabExtractionResult> {
|
||||
const parsed = parseCabinet(bytes, options);
|
||||
const byFolder = new Map<number, CabFileEntry[]>();
|
||||
for (const entry of parsed.result.entries) {
|
||||
const current = byFolder.get(entry.folderIndex);
|
||||
if (current === undefined) byFolder.set(entry.folderIndex, [entry]);
|
||||
else current.push(entry);
|
||||
}
|
||||
|
||||
const extractedEntries = [];
|
||||
for (const folder of parsed.folders) {
|
||||
checkCancellation(options.cancellation);
|
||||
if (
|
||||
folder.compression.kind === 'mszip' ||
|
||||
folder.compression.kind === 'quantum'
|
||||
) {
|
||||
fail(
|
||||
'cab-compression-unsupported',
|
||||
`${folder.compression.label} extraction is not implemented.`,
|
||||
{ structure: `CFFOLDER[${folder.index}].typeCompress` }
|
||||
);
|
||||
}
|
||||
|
||||
const folderOutput = new Uint8Array(folder.uncompressedSize);
|
||||
const decoder =
|
||||
folder.compression.kind === 'lzx'
|
||||
? new LzxDecoder(folder.compression.windowBits, {
|
||||
maxOperations: parsed.limits.maxDecodeOperations,
|
||||
checkCancellation: () => checkCancellation(options.cancellation),
|
||||
})
|
||||
: undefined;
|
||||
let outputOffset = 0;
|
||||
for (const block of folder.blocks) {
|
||||
checkCancellation(options.cancellation);
|
||||
const compressed = parsed.bytes.subarray(
|
||||
block.dataOffset,
|
||||
block.dataOffset + block.compressedSize
|
||||
);
|
||||
if (block.checksum !== 0) {
|
||||
const actual = calculateDataBlockChecksum(
|
||||
block.reserve,
|
||||
compressed,
|
||||
block.compressedSize,
|
||||
block.uncompressedSize
|
||||
);
|
||||
if (actual !== block.checksum) {
|
||||
fail(
|
||||
'cab-checksum-mismatch',
|
||||
`CAB data block checksum mismatch: expected 0x${block.checksum.toString(16).padStart(8, '0')}, got 0x${actual.toString(16).padStart(8, '0')}.`,
|
||||
{ offset: block.headerOffset, structure: 'CFDATA.csum' }
|
||||
);
|
||||
}
|
||||
}
|
||||
const output =
|
||||
decoder === undefined
|
||||
? compressed
|
||||
: decoder.decompressNext(compressed, block.uncompressedSize);
|
||||
if (output.length !== block.uncompressedSize) {
|
||||
fail(
|
||||
'cab-output-size-mismatch',
|
||||
`CAB decoder returned ${output.length} bytes; expected ${block.uncompressedSize}.`,
|
||||
{ offset: block.headerOffset + 6, structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
folderOutput.set(output, outputOffset);
|
||||
outputOffset += output.length;
|
||||
await yieldForCancellation(options.cancellation);
|
||||
}
|
||||
decoder?.finish();
|
||||
if (outputOffset !== folderOutput.length) {
|
||||
fail(
|
||||
'cab-folder-output-size-mismatch',
|
||||
'CAB folder output is incomplete.',
|
||||
{
|
||||
structure: `CFFOLDER[${folder.index}]`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of byFolder.get(folder.index) ?? []) {
|
||||
const start = entry.uncompressedOffset;
|
||||
extractedEntries.push({
|
||||
...entry,
|
||||
bytes: folderOutput.slice(start, start + entry.uncompressedSize),
|
||||
});
|
||||
}
|
||||
}
|
||||
extractedEntries.sort((left, right) => left.index - right.index);
|
||||
|
||||
return { ...parsed.result, extractedEntries };
|
||||
}
|
||||
20
src/onenote/onepkg/cancellation.ts
Normal file
20
src/onenote/onepkg/cancellation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { fail } from './errors.js';
|
||||
import type { OnePkgCancellation } from './types.js';
|
||||
|
||||
export function checkCancellation(
|
||||
cancellation: OnePkgCancellation | undefined
|
||||
): void {
|
||||
if (cancellation?.signal?.aborted || cancellation?.isCancelled?.()) {
|
||||
fail('onepkg-cancelled', 'Opening the OneNote package was cancelled.', {
|
||||
structure: 'onepkg',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function yieldForCancellation(
|
||||
cancellation: OnePkgCancellation | undefined
|
||||
): Promise<void> {
|
||||
checkCancellation(cancellation);
|
||||
await cancellation?.yield?.();
|
||||
checkCancellation(cancellation);
|
||||
}
|
||||
68
src/onenote/onepkg/checksum.ts
Normal file
68
src/onenote/onepkg/checksum.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Direct TypeScript translation of rust-cab's checksum state machine.
|
||||
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
|
||||
* Copyright (c) 2017 Matthew D. Steele, MIT License.
|
||||
* Modified for bounded Uint8Array input and CAB CFDATA verification.
|
||||
*/
|
||||
|
||||
export class CabChecksum {
|
||||
private checksum = 0;
|
||||
private remainder = 0;
|
||||
private remainderShift = 0;
|
||||
|
||||
update(bytes: Uint8Array): void {
|
||||
for (const byte of bytes) {
|
||||
this.remainder = (this.remainder | (byte << this.remainderShift)) >>> 0;
|
||||
if (this.remainderShift === 24) {
|
||||
this.checksum = (this.checksum ^ this.remainder) >>> 0;
|
||||
this.remainder = 0;
|
||||
this.remainderShift = 0;
|
||||
} else {
|
||||
this.remainderShift += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value(): number {
|
||||
switch (this.remainderShift) {
|
||||
case 0:
|
||||
return this.checksum >>> 0;
|
||||
case 8:
|
||||
return (this.checksum ^ this.remainder) >>> 0;
|
||||
case 16:
|
||||
return (
|
||||
(this.checksum ^
|
||||
(this.remainder >>> 8) ^
|
||||
((this.remainder & 0xff) << 8)) >>>
|
||||
0
|
||||
);
|
||||
case 24:
|
||||
return (
|
||||
(this.checksum ^
|
||||
(this.remainder >>> 16) ^
|
||||
(this.remainder & 0xff00) ^
|
||||
((this.remainder & 0xff) << 16)) >>>
|
||||
0
|
||||
);
|
||||
default:
|
||||
throw new Error('Invalid CAB checksum state');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateDataBlockChecksum(
|
||||
reserve: Uint8Array,
|
||||
compressed: Uint8Array,
|
||||
compressedSize: number,
|
||||
uncompressedSize: number
|
||||
): number {
|
||||
const checksum = new CabChecksum();
|
||||
checksum.update(reserve);
|
||||
checksum.update(compressed);
|
||||
return (
|
||||
(checksum.value() ^ ((compressedSize | (uncompressedSize << 16)) >>> 0)) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
64
src/onenote/onepkg/errors.ts
Normal file
64
src/onenote/onepkg/errors.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
|
||||
export class OnePkgError extends Error {
|
||||
readonly diagnostic: ParserDiagnostic;
|
||||
|
||||
constructor(diagnostic: ParserDiagnostic, options?: ErrorOptions) {
|
||||
super(diagnostic.message, options);
|
||||
this.name = 'OnePkgError';
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiagnosticLocation {
|
||||
offset?: number;
|
||||
structure?: string;
|
||||
}
|
||||
|
||||
export function fail(
|
||||
code: string,
|
||||
message: string,
|
||||
location: DiagnosticLocation = {}
|
||||
): never {
|
||||
throw new OnePkgError({
|
||||
severity: 'error',
|
||||
code,
|
||||
message,
|
||||
...location,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function warning(
|
||||
code: string,
|
||||
message: string,
|
||||
location: DiagnosticLocation = {}
|
||||
): ParserDiagnostic {
|
||||
return {
|
||||
severity: 'warning',
|
||||
code,
|
||||
message,
|
||||
...location,
|
||||
recoverable: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function diagnosticFromUnknown(
|
||||
error: unknown,
|
||||
code: string,
|
||||
message: string,
|
||||
structure?: string
|
||||
): ParserDiagnostic {
|
||||
if (error instanceof OnePkgError) {
|
||||
return error.diagnostic;
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? `: ${error.message}` : '';
|
||||
return {
|
||||
severity: 'error',
|
||||
code,
|
||||
message: `${message}${detail}`,
|
||||
structure,
|
||||
recoverable: true,
|
||||
};
|
||||
}
|
||||
23
src/onenote/onepkg/index.ts
Normal file
23
src/onenote/onepkg/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||
export { OnePkgError } from './errors.js';
|
||||
export { openOneNotePackage } from './package.js';
|
||||
export type {
|
||||
OneNotePackageOpenOptions,
|
||||
OneNotePackageOpenResult,
|
||||
OneNotePackageSectionParseOutcome,
|
||||
OneNotePackageSectionParseRequest,
|
||||
OneNotePackageSectionParser,
|
||||
ParsedOneNotePackageSection,
|
||||
} from './package.js';
|
||||
export {
|
||||
DEFAULT_ONEPKG_LIMITS,
|
||||
type CabCompressionMethod,
|
||||
type CabEnumerationResult,
|
||||
type CabExtractionResult,
|
||||
type CabFileEntry,
|
||||
type CabFolderSummary,
|
||||
type CabOpenOptions,
|
||||
type ExtractedCabFile,
|
||||
type OnePkgCancellation,
|
||||
type OnePkgLimits,
|
||||
} from './types.js';
|
||||
114
src/onenote/onepkg/joplin-fixture.test.ts
Normal file
114
src/onenote/onepkg/joplin-fixture.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseOneNoteSection } from '../parser/parse-section.js';
|
||||
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||
import { openOneNotePackage } from './package.js';
|
||||
|
||||
/*
|
||||
* Joplin CLI test fixture, AGPL-3.0-or-later. See tests/fixtures/README.md.
|
||||
* Pinned upstream revision: 1e73aad7eb08fde5d9e4cb533df40052a5cd32d7
|
||||
*/
|
||||
const encodedFixture = readFileSync(
|
||||
resolve(process.cwd(), 'tests/fixtures/joplin-test.onepkg.base64'),
|
||||
'utf8'
|
||||
);
|
||||
const JOPLIN_ONEPKG = Uint8Array.from(
|
||||
Buffer.from(encodedFixture.replaceAll(/\s/gu, ''), 'base64')
|
||||
);
|
||||
|
||||
const ONE_MAGIC = Uint8Array.from([
|
||||
0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0,
|
||||
0x29, 0x96, 0xd3,
|
||||
]);
|
||||
|
||||
const EXPECTED_PATHS = [
|
||||
'Another section.one',
|
||||
'Open Notebook.onetoc2',
|
||||
'Section group/A.one',
|
||||
'Section group/B.one',
|
||||
'Section group/Open Notebook.onetoc2',
|
||||
'Tést!.one',
|
||||
'⅀⸨ Unicode ⸩.one',
|
||||
];
|
||||
|
||||
describe('pinned Joplin .onepkg fixture', () => {
|
||||
it('retains the reviewed upstream bytes', () => {
|
||||
expect(JOPLIN_ONEPKG).toHaveLength(11_066);
|
||||
expect(createHash('sha256').update(JOPLIN_ONEPKG).digest('hex')).toBe(
|
||||
'83cb622d40f0ab127038b629553b6926be3e2a6ce642f169df0e2614c13e8469'
|
||||
);
|
||||
});
|
||||
|
||||
it('enumerates all seven UTF-8/nested entries using LZX 0x1203', () => {
|
||||
const listing = enumerateCabinet(JOPLIN_ONEPKG);
|
||||
expect(listing.entries.map((entry) => entry.normalizedPath)).toEqual(
|
||||
EXPECTED_PATHS
|
||||
);
|
||||
expect(listing.folders).toHaveLength(1);
|
||||
expect(listing.folders[0]).toMatchObject({
|
||||
dataBlockCount: 3,
|
||||
compression: { kind: 'lzx', raw: 0x1203, windowBits: 18 },
|
||||
});
|
||||
expect(
|
||||
listing.diagnostics.filter(
|
||||
(diagnostic) => diagnostic.code === 'cab-unmarked-utf8-name'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('extracts every entry and preserves native .one headers', async () => {
|
||||
const extraction = await extractCabinet(JOPLIN_ONEPKG);
|
||||
expect(
|
||||
extraction.extractedEntries.map((entry) => entry.normalizedPath)
|
||||
).toEqual(EXPECTED_PATHS);
|
||||
const sections = extraction.extractedEntries.filter((entry) =>
|
||||
entry.normalizedPath.toLowerCase().endsWith('.one')
|
||||
);
|
||||
expect(sections).toHaveLength(5);
|
||||
for (const section of sections) {
|
||||
expect(section.bytes.subarray(0, ONE_MAGIC.length)).toEqual(ONE_MAGIC);
|
||||
}
|
||||
|
||||
const opened = await openOneNotePackage(JOPLIN_ONEPKG, {
|
||||
sourceName: 'test.onepkg',
|
||||
});
|
||||
expect(opened.package.entries).toHaveLength(7);
|
||||
expect(opened.package.sections).toHaveLength(5);
|
||||
expect(
|
||||
opened.package.sections.every(
|
||||
(section) => section.parseStatus === 'not-requested'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('opens and parses all five packaged sections end to end', async () => {
|
||||
const opened = await openOneNotePackage(JOPLIN_ONEPKG, {
|
||||
sourceName: 'test.onepkg',
|
||||
parseSection: ({ bytes, normalizedPath }) => ({
|
||||
status: 'parsed',
|
||||
value: parseOneNoteSection(bytes, { sourceName: normalizedPath }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(opened.package.sections).toHaveLength(5);
|
||||
expect(
|
||||
opened.package.sections.map((section) => section.parseStatus)
|
||||
).toEqual(['parsed', 'parsed', 'parsed', 'parsed', 'parsed']);
|
||||
expect(
|
||||
opened.package.entries.some((entry) => entry.parseStatus === 'failed')
|
||||
).toBe(false);
|
||||
|
||||
const testSection = opened.package.sections.find(
|
||||
(section) => section.path === 'Tést!.one'
|
||||
);
|
||||
expect(testSection?.section?.sectionName).toBe('Tést!');
|
||||
expect(testSection?.section?.pages[0]).toMatchObject({
|
||||
title: 'Testing…',
|
||||
text: 'Link to page: Page 2',
|
||||
});
|
||||
});
|
||||
});
|
||||
141
src/onenote/onepkg/lzx/bit-reader.ts
Normal file
141
src/onenote/onepkg/lzx/bit-reader.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Direct TypeScript port of lzxd 0.2.5 `bitstream.rs`.
|
||||
* lzxd commit 4748e43594e3e30cff2ace3a6ad7a376c9816fdd
|
||||
* Copyright (c) 2020 Lonami, MIT OR Apache-2.0 (MIT selected here).
|
||||
* Modified to use checked browser-native Uint8Array reads.
|
||||
*/
|
||||
|
||||
import { fail } from '../errors.js';
|
||||
|
||||
export class LzxBitReader {
|
||||
private byteOffset = 0;
|
||||
private word = 0;
|
||||
private bitsRemaining = 0;
|
||||
|
||||
constructor(private readonly bytes: Uint8Array) {}
|
||||
|
||||
get offset(): number {
|
||||
return this.byteOffset;
|
||||
}
|
||||
|
||||
get remainingRawBytes(): number {
|
||||
return this.bytes.length - this.byteOffset;
|
||||
}
|
||||
|
||||
private loadWord(): void {
|
||||
if (this.byteOffset > this.bytes.length - 2) {
|
||||
fail('lzx-unexpected-eof', 'Unexpected end of LZX chunk.', {
|
||||
offset: this.byteOffset,
|
||||
structure: 'LZX bitstream',
|
||||
});
|
||||
}
|
||||
this.word =
|
||||
this.bytes[this.byteOffset]! | (this.bytes[this.byteOffset + 1]! << 8);
|
||||
this.byteOffset += 2;
|
||||
this.bitsRemaining = 16;
|
||||
}
|
||||
|
||||
readBits(bitCount: number): number {
|
||||
if (!Number.isInteger(bitCount) || bitCount < 0 || bitCount > 32) {
|
||||
throw new RangeError('LZX bit reads must contain between 0 and 32 bits');
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
let remaining = bitCount;
|
||||
while (remaining > 0) {
|
||||
if (this.bitsRemaining === 0) {
|
||||
this.loadWord();
|
||||
}
|
||||
const take = Math.min(remaining, this.bitsRemaining);
|
||||
const shift = this.bitsRemaining - take;
|
||||
const part = (this.word >>> shift) & (2 ** take - 1);
|
||||
result = result * 2 ** take + part;
|
||||
this.bitsRemaining -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
readBit(): number {
|
||||
return this.readBits(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Huffman lookup may look beyond the end of a chunk. The reference decoder
|
||||
* pads that look-ahead with zeroes; consuming the actual code remains strict.
|
||||
*/
|
||||
peekBits(bitCount: number): number {
|
||||
if (!Number.isInteger(bitCount) || bitCount < 0 || bitCount > 32) {
|
||||
throw new RangeError('LZX bit peeks must contain between 0 and 32 bits');
|
||||
}
|
||||
|
||||
let offset = this.byteOffset;
|
||||
let word = this.word;
|
||||
let wordRemaining = this.bitsRemaining;
|
||||
let result = 0;
|
||||
let remaining = bitCount;
|
||||
while (remaining > 0) {
|
||||
if (wordRemaining === 0) {
|
||||
if (offset > this.bytes.length - 2) {
|
||||
word = 0;
|
||||
} else {
|
||||
word = this.bytes[offset]! | (this.bytes[offset + 1]! << 8);
|
||||
offset += 2;
|
||||
}
|
||||
wordRemaining = 16;
|
||||
}
|
||||
const take = Math.min(remaining, wordRemaining);
|
||||
const shift = wordRemaining - take;
|
||||
result = result * 2 ** take + ((word >>> shift) & (2 ** take - 1));
|
||||
wordRemaining -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
readU32LittleEndian(): number {
|
||||
const low = this.readBits(16);
|
||||
const high = this.readBits(16);
|
||||
return (low + high * 0x1_0000) >>> 0;
|
||||
}
|
||||
|
||||
readU24BigEndianBits(): number {
|
||||
return this.readBits(16) * 0x100 + this.readBits(8);
|
||||
}
|
||||
|
||||
/** Align exactly as lzxd 0.2.5 does before an uncompressed block. */
|
||||
alignToWord(): void {
|
||||
if (this.bitsRemaining === 0) {
|
||||
this.readBits(16);
|
||||
} else {
|
||||
this.bitsRemaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
readRawByte(): number {
|
||||
if (this.byteOffset >= this.bytes.length) {
|
||||
fail('lzx-unexpected-eof', 'Missing LZX uncompressed-block padding.', {
|
||||
offset: this.byteOffset,
|
||||
structure: 'LZX uncompressed block',
|
||||
});
|
||||
}
|
||||
return this.bytes[this.byteOffset++]!;
|
||||
}
|
||||
|
||||
readRaw(length: number): Uint8Array {
|
||||
if (length < 0 || this.byteOffset > this.bytes.length - length) {
|
||||
fail('lzx-unexpected-eof', 'Unexpected end of LZX raw block.', {
|
||||
offset: this.byteOffset,
|
||||
structure: 'LZX uncompressed block',
|
||||
});
|
||||
}
|
||||
const value = this.bytes.subarray(
|
||||
this.byteOffset,
|
||||
this.byteOffset + length
|
||||
);
|
||||
this.byteOffset += length;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
430
src/onenote/onepkg/lzx/decoder.ts
Normal file
430
src/onenote/onepkg/lzx/decoder.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Direct TypeScript port of lzxd 0.2.5 `lib.rs`, `block.rs`, and `window.rs`.
|
||||
* lzxd commit 4748e43594e3e30cff2ace3a6ad7a376c9816fdd
|
||||
* Copyright (c) 2020 Lonami, MIT OR Apache-2.0 (MIT selected here).
|
||||
* Modified for CAB chunks, bounded allocation/operations, cancellation, and
|
||||
* strict errors instead of assertions.
|
||||
*/
|
||||
|
||||
import { fail } from '../errors.js';
|
||||
import { LzxBitReader } from './bit-reader.js';
|
||||
import { LzxCanonicalTree, LzxHuffmanTree } from './huffman.js';
|
||||
|
||||
const MAX_CHUNK_SIZE = 32 * 1024;
|
||||
const POSITION_SLOTS: Readonly<Record<number, number>> = {
|
||||
15: 30,
|
||||
16: 32,
|
||||
17: 34,
|
||||
18: 36,
|
||||
19: 38,
|
||||
20: 42,
|
||||
21: 50,
|
||||
22: 66,
|
||||
23: 98,
|
||||
24: 162,
|
||||
25: 290,
|
||||
};
|
||||
|
||||
interface VerbatimBlock {
|
||||
kind: 'verbatim';
|
||||
size: number;
|
||||
remaining: number;
|
||||
mainTree: LzxHuffmanTree;
|
||||
lengthTree?: LzxHuffmanTree;
|
||||
}
|
||||
|
||||
interface AlignedBlock {
|
||||
kind: 'aligned';
|
||||
size: number;
|
||||
remaining: number;
|
||||
alignedTree: LzxHuffmanTree;
|
||||
mainTree: LzxHuffmanTree;
|
||||
lengthTree?: LzxHuffmanTree;
|
||||
}
|
||||
|
||||
interface UncompressedBlock {
|
||||
kind: 'uncompressed';
|
||||
size: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
type LzxBlock = VerbatimBlock | AlignedBlock | UncompressedBlock;
|
||||
|
||||
export interface LzxDecoderOptions {
|
||||
maxOperations: number;
|
||||
checkCancellation?: () => void;
|
||||
}
|
||||
|
||||
function footerBits(positionSlot: number): number {
|
||||
if (positionSlot < 4) return 0;
|
||||
if (positionSlot >= 36) return 17;
|
||||
return Math.floor((positionSlot - 2) / 2);
|
||||
}
|
||||
|
||||
function basePosition(positionSlot: number): number {
|
||||
if (positionSlot < 4) return positionSlot;
|
||||
if (positionSlot >= 36) return (positionSlot - 34) * 0x2_0000;
|
||||
return (2 + (positionSlot & 1)) * 2 ** footerBits(positionSlot);
|
||||
}
|
||||
|
||||
export class LzxDecoder {
|
||||
private readonly window: Uint8Array;
|
||||
private readonly windowMask: number;
|
||||
private windowPosition = 0;
|
||||
private readonly mainTree: LzxCanonicalTree;
|
||||
private readonly lengthTree = new LzxCanonicalTree(249);
|
||||
private readonly positionSlotCount: number;
|
||||
private repeatedOffsets = [1, 1, 1];
|
||||
private firstChunkRead = false;
|
||||
private currentBlock?: LzxBlock;
|
||||
private e8TranslationSize?: number;
|
||||
private totalOutput = 0;
|
||||
private operations = 0;
|
||||
private nextCancellationCheck = 4_096;
|
||||
|
||||
constructor(
|
||||
windowBits: number,
|
||||
private readonly options: LzxDecoderOptions
|
||||
) {
|
||||
const positionSlotCount = POSITION_SLOTS[windowBits];
|
||||
if (positionSlotCount === undefined) {
|
||||
fail(
|
||||
'lzx-invalid-window',
|
||||
`Unsupported LZX window size 2^${windowBits}.`,
|
||||
{
|
||||
structure: 'CFFOLDER.typeCompress',
|
||||
}
|
||||
);
|
||||
}
|
||||
const windowSize = 2 ** windowBits;
|
||||
this.window = new Uint8Array(windowSize);
|
||||
this.windowMask = windowSize - 1;
|
||||
this.positionSlotCount = positionSlotCount;
|
||||
this.mainTree = new LzxCanonicalTree(256 + 8 * positionSlotCount);
|
||||
}
|
||||
|
||||
decompressNext(chunk: Uint8Array, outputLength: number): Uint8Array {
|
||||
if (
|
||||
!Number.isInteger(outputLength) ||
|
||||
outputLength <= 0 ||
|
||||
outputLength > MAX_CHUNK_SIZE
|
||||
) {
|
||||
fail(
|
||||
'lzx-invalid-chunk-size',
|
||||
`LZX CAB chunk output size ${outputLength} is outside 1..${MAX_CHUNK_SIZE}.`,
|
||||
{ structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
|
||||
const reader = new LzxBitReader(chunk);
|
||||
if (!this.firstChunkRead) {
|
||||
this.firstChunkRead = true;
|
||||
if (reader.readBit() !== 0) {
|
||||
this.e8TranslationSize = reader.readBits(32) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
const output = new Uint8Array(outputLength);
|
||||
let outputOffset = 0;
|
||||
while (outputOffset < output.length) {
|
||||
this.checkWork();
|
||||
if (
|
||||
this.currentBlock === undefined ||
|
||||
this.currentBlock.remaining === 0
|
||||
) {
|
||||
if (
|
||||
this.currentBlock?.kind === 'uncompressed' &&
|
||||
this.currentBlock.size % 2 !== 0
|
||||
) {
|
||||
reader.readRawByte();
|
||||
}
|
||||
this.currentBlock = this.readBlock(reader);
|
||||
}
|
||||
|
||||
const block = this.currentBlock;
|
||||
if (block.kind === 'uncompressed') {
|
||||
// An LZX uncompressed block may continue in the next CAB CFDATA
|
||||
// chunk. Consume only the raw bytes present in this compressed chunk.
|
||||
const length = Math.min(
|
||||
block.remaining,
|
||||
output.length - outputOffset,
|
||||
reader.remainingRawBytes
|
||||
);
|
||||
if (length === 0) {
|
||||
fail(
|
||||
'lzx-unexpected-eof',
|
||||
'LZX chunk ended before producing its declared CAB output size.',
|
||||
{ offset: reader.offset, structure: 'LZX uncompressed block' }
|
||||
);
|
||||
}
|
||||
const raw = reader.readRaw(length);
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
this.writeByte(raw[index]!, output, outputOffset + index);
|
||||
}
|
||||
outputOffset += length;
|
||||
this.advanceBlock(block, length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mainElement = block.mainTree.decode(reader);
|
||||
if (mainElement < 256) {
|
||||
this.ensureAdvance(block, output, outputOffset, 1);
|
||||
this.writeByte(mainElement, output, outputOffset);
|
||||
outputOffset += 1;
|
||||
this.advanceBlock(block, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lengthHeader = (mainElement - 256) & 7;
|
||||
const matchLength =
|
||||
lengthHeader === 7
|
||||
? (block.lengthTree ?? this.missingLengthTree()).decode(reader) + 9
|
||||
: lengthHeader + 2;
|
||||
const positionSlot = (mainElement - 256) >>> 3;
|
||||
if (positionSlot >= this.positionSlotCount) {
|
||||
fail(
|
||||
'lzx-invalid-position-slot',
|
||||
'LZX match position is out of range.',
|
||||
{
|
||||
offset: reader.offset,
|
||||
structure: 'LZX token',
|
||||
}
|
||||
);
|
||||
}
|
||||
const matchOffset = this.decodeMatchOffset(
|
||||
reader,
|
||||
positionSlot,
|
||||
block.kind === 'aligned' ? block.alignedTree : undefined
|
||||
);
|
||||
this.ensureAdvance(block, output, outputOffset, matchLength);
|
||||
if (
|
||||
matchOffset <= 0 ||
|
||||
matchOffset > this.window.length ||
|
||||
matchOffset > this.totalOutput + outputOffset
|
||||
) {
|
||||
fail(
|
||||
'lzx-invalid-match-offset',
|
||||
`LZX match offset ${matchOffset} is outside the populated window.`,
|
||||
{ offset: reader.offset, structure: 'LZX token' }
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < matchLength; index += 1) {
|
||||
const value =
|
||||
this.window[
|
||||
(this.windowPosition - matchOffset + this.window.length) &
|
||||
this.windowMask
|
||||
]!;
|
||||
this.writeByte(value, output, outputOffset + index);
|
||||
}
|
||||
outputOffset += matchLength;
|
||||
this.advanceBlock(block, matchLength);
|
||||
}
|
||||
|
||||
const chunkOffset = this.totalOutput;
|
||||
this.totalOutput += output.length;
|
||||
if (
|
||||
this.e8TranslationSize !== undefined &&
|
||||
chunkOffset < 0x4000_0000 &&
|
||||
output.length > 10
|
||||
) {
|
||||
this.postprocessE8(output, chunkOffset, this.e8TranslationSize);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.currentBlock !== undefined && this.currentBlock.remaining !== 0) {
|
||||
fail(
|
||||
'lzx-incomplete-block',
|
||||
`LZX stream ended with ${this.currentBlock.remaining} uncompressed block bytes missing.`,
|
||||
{ structure: 'LZX block' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readBlock(reader: LzxBitReader): LzxBlock {
|
||||
const kind = reader.readBits(3);
|
||||
const size = reader.readU24BigEndianBits();
|
||||
if (size === 0) {
|
||||
fail('lzx-invalid-block-size', 'LZX block size cannot be zero.', {
|
||||
offset: reader.offset,
|
||||
structure: 'LZX block header',
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 1 || kind === 2) {
|
||||
let alignedTree: LzxHuffmanTree | undefined;
|
||||
if (kind === 2) {
|
||||
const alignedLengths = new Uint8Array(8);
|
||||
for (let index = 0; index < alignedLengths.length; index += 1) {
|
||||
alignedLengths[index] = reader.readBits(3);
|
||||
}
|
||||
alignedTree = LzxHuffmanTree.fromPathLengths(alignedLengths);
|
||||
}
|
||||
this.mainTree.updateWithPretree(reader, 0, 256);
|
||||
this.mainTree.updateWithPretree(
|
||||
reader,
|
||||
256,
|
||||
256 + 8 * this.positionSlotCount
|
||||
);
|
||||
this.lengthTree.updateWithPretree(reader, 0, 249);
|
||||
const mainTree = this.mainTree.create()!;
|
||||
const lengthTree = this.lengthTree.create(true);
|
||||
if (kind === 2) {
|
||||
return {
|
||||
kind: 'aligned',
|
||||
size,
|
||||
remaining: size,
|
||||
alignedTree: alignedTree!,
|
||||
mainTree,
|
||||
lengthTree,
|
||||
};
|
||||
}
|
||||
return { kind: 'verbatim', size, remaining: size, mainTree, lengthTree };
|
||||
}
|
||||
|
||||
if (kind === 3) {
|
||||
reader.alignToWord();
|
||||
this.repeatedOffsets = [
|
||||
reader.readU32LittleEndian(),
|
||||
reader.readU32LittleEndian(),
|
||||
reader.readU32LittleEndian(),
|
||||
];
|
||||
return { kind: 'uncompressed', size, remaining: size };
|
||||
}
|
||||
|
||||
fail('lzx-invalid-block-type', `Invalid LZX block type ${kind}.`, {
|
||||
offset: reader.offset,
|
||||
structure: 'LZX block header',
|
||||
});
|
||||
}
|
||||
|
||||
private decodeMatchOffset(
|
||||
reader: LzxBitReader,
|
||||
positionSlot: number,
|
||||
alignedTree: LzxHuffmanTree | undefined
|
||||
): number {
|
||||
if (positionSlot === 0) return this.repeatedOffsets[0]!;
|
||||
if (positionSlot === 1) {
|
||||
const offset = this.repeatedOffsets[1]!;
|
||||
[this.repeatedOffsets[0], this.repeatedOffsets[1]] = [
|
||||
this.repeatedOffsets[1]!,
|
||||
this.repeatedOffsets[0]!,
|
||||
];
|
||||
return offset;
|
||||
}
|
||||
if (positionSlot === 2) {
|
||||
const offset = this.repeatedOffsets[2]!;
|
||||
[this.repeatedOffsets[0], this.repeatedOffsets[2]] = [
|
||||
this.repeatedOffsets[2]!,
|
||||
this.repeatedOffsets[0]!,
|
||||
];
|
||||
return offset;
|
||||
}
|
||||
|
||||
const bitCount = footerBits(positionSlot);
|
||||
let formattedOffset: number;
|
||||
if (alignedTree !== undefined && bitCount >= 3) {
|
||||
formattedOffset =
|
||||
basePosition(positionSlot) +
|
||||
reader.readBits(bitCount - 3) * 8 +
|
||||
alignedTree.decode(reader);
|
||||
} else {
|
||||
formattedOffset = basePosition(positionSlot) + reader.readBits(bitCount);
|
||||
}
|
||||
const offset = formattedOffset - 2;
|
||||
this.repeatedOffsets[2] = this.repeatedOffsets[1]!;
|
||||
this.repeatedOffsets[1] = this.repeatedOffsets[0]!;
|
||||
this.repeatedOffsets[0] = offset;
|
||||
return offset;
|
||||
}
|
||||
|
||||
private missingLengthTree(): never {
|
||||
fail(
|
||||
'lzx-empty-length-tree',
|
||||
'LZX token needs a length footer but the length tree is empty.',
|
||||
{ structure: 'LZX length tree' }
|
||||
);
|
||||
}
|
||||
|
||||
private ensureAdvance(
|
||||
block: LzxBlock,
|
||||
output: Uint8Array,
|
||||
outputOffset: number,
|
||||
length: number
|
||||
): void {
|
||||
if (length <= 0 || length > block.remaining) {
|
||||
fail('lzx-block-overread', 'LZX token exceeds its declared block size.', {
|
||||
structure: 'LZX block',
|
||||
});
|
||||
}
|
||||
if (length > output.length - outputOffset) {
|
||||
fail(
|
||||
'lzx-chunk-overread',
|
||||
'LZX token crosses the declared CAB chunk output boundary.',
|
||||
{ structure: 'CFDATA.cbUncomp' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private advanceBlock(block: LzxBlock, length: number): void {
|
||||
if (length <= 0 || length > block.remaining) {
|
||||
fail('lzx-block-overread', 'LZX data exceeds its declared block size.', {
|
||||
structure: 'LZX block',
|
||||
});
|
||||
}
|
||||
block.remaining -= length;
|
||||
this.operations += length;
|
||||
}
|
||||
|
||||
private writeByte(
|
||||
value: number,
|
||||
output: Uint8Array,
|
||||
outputOffset: number
|
||||
): void {
|
||||
this.window[this.windowPosition] = value;
|
||||
this.windowPosition = (this.windowPosition + 1) & this.windowMask;
|
||||
output[outputOffset] = value;
|
||||
}
|
||||
|
||||
private checkWork(): void {
|
||||
if (this.operations > this.options.maxOperations) {
|
||||
fail(
|
||||
'lzx-operation-limit',
|
||||
`LZX decoding exceeded the configured ${this.options.maxOperations}-operation limit.`,
|
||||
{ structure: 'LZX stream' }
|
||||
);
|
||||
}
|
||||
if (this.operations >= this.nextCancellationCheck) {
|
||||
this.options.checkCancellation?.();
|
||||
this.nextCancellationCheck = this.operations + 4_096;
|
||||
}
|
||||
}
|
||||
|
||||
private postprocessE8(
|
||||
data: Uint8Array,
|
||||
chunkOffset: number,
|
||||
translationSize: number
|
||||
): void {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
let processed = 0;
|
||||
while (processed < data.length) {
|
||||
const relative = data.subarray(processed).indexOf(0xe8);
|
||||
if (relative < 0) return;
|
||||
const position = processed + relative;
|
||||
if (data.length - position <= 10) return;
|
||||
const currentPointer = chunkOffset + position;
|
||||
const absoluteValue = view.getInt32(position + 1, true);
|
||||
if (absoluteValue >= -currentPointer && absoluteValue < translationSize) {
|
||||
const relativeValue =
|
||||
absoluteValue > 0
|
||||
? absoluteValue - currentPointer
|
||||
: absoluteValue + translationSize;
|
||||
view.setInt32(position + 1, relativeValue, true);
|
||||
}
|
||||
processed = position + 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
161
src/onenote/onepkg/lzx/huffman.ts
Normal file
161
src/onenote/onepkg/lzx/huffman.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Direct TypeScript port of lzxd 0.2.5 `tree.rs`.
|
||||
* lzxd commit 4748e43594e3e30cff2ace3a6ad7a376c9816fdd
|
||||
* Copyright (c) 2020 Lonami, MIT OR Apache-2.0 (MIT selected here).
|
||||
* Modified to use typed arrays and explicit malformed-tree diagnostics.
|
||||
*/
|
||||
|
||||
import { fail } from '../errors.js';
|
||||
import { LzxBitReader } from './bit-reader.js';
|
||||
|
||||
export class LzxCanonicalTree {
|
||||
private readonly pathLengths: Uint8Array;
|
||||
|
||||
constructor(elementCount: number) {
|
||||
this.pathLengths = new Uint8Array(elementCount);
|
||||
}
|
||||
|
||||
create(allowEmpty = false): LzxHuffmanTree | undefined {
|
||||
let largestLength = 0;
|
||||
for (const length of this.pathLengths) {
|
||||
largestLength = Math.max(largestLength, length);
|
||||
}
|
||||
if (largestLength === 0) {
|
||||
if (allowEmpty) return undefined;
|
||||
fail('lzx-empty-tree', 'LZX stream requires a non-empty Huffman tree.', {
|
||||
structure: 'LZX Huffman tree',
|
||||
});
|
||||
}
|
||||
return LzxHuffmanTree.fromPathLengths(this.pathLengths, largestLength);
|
||||
}
|
||||
|
||||
updateWithPretree(reader: LzxBitReader, start: number, end: number): void {
|
||||
if (start < 0 || end < start || end > this.pathLengths.length) {
|
||||
throw new RangeError('Invalid LZX tree update range');
|
||||
}
|
||||
|
||||
const pretreeLengths = new Uint8Array(20);
|
||||
for (let index = 0; index < pretreeLengths.length; index += 1) {
|
||||
pretreeLengths[index] = reader.readBits(4);
|
||||
}
|
||||
const pretree = LzxHuffmanTree.fromPathLengths(pretreeLengths);
|
||||
|
||||
let index = start;
|
||||
while (index < end) {
|
||||
const code = pretree.decode(reader);
|
||||
if (code <= 16) {
|
||||
this.pathLengths[index] = (17 + this.pathLengths[index]! - code) % 17;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code === 17 || code === 18) {
|
||||
const run =
|
||||
code === 17 ? reader.readBits(4) + 4 : reader.readBits(5) + 20;
|
||||
if (index + run > end) {
|
||||
fail(
|
||||
'lzx-invalid-pretree-rle',
|
||||
'LZX zero path-length run exceeds its tree range.',
|
||||
{ offset: reader.offset, structure: 'LZX pretree' }
|
||||
);
|
||||
}
|
||||
this.pathLengths.fill(0, index, index + run);
|
||||
index += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code === 19) {
|
||||
const run = reader.readBits(1) + 4;
|
||||
const delta = pretree.decode(reader);
|
||||
if (delta > 16 || index + run > end) {
|
||||
fail(
|
||||
'lzx-invalid-pretree-rle',
|
||||
'LZX repeated path-length run is invalid.',
|
||||
{ offset: reader.offset, structure: 'LZX pretree' }
|
||||
);
|
||||
}
|
||||
const value = (17 + this.pathLengths[index]! - delta) % 17;
|
||||
this.pathLengths.fill(value, index, index + run);
|
||||
index += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
fail('lzx-invalid-pretree-element', `Invalid LZX pretree code ${code}.`, {
|
||||
offset: reader.offset,
|
||||
structure: 'LZX pretree',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LzxHuffmanTree {
|
||||
private constructor(
|
||||
private readonly pathLengths: Uint8Array,
|
||||
private readonly largestLength: number,
|
||||
private readonly decodeTable: Uint16Array
|
||||
) {}
|
||||
|
||||
static fromPathLengths(
|
||||
source: Uint8Array,
|
||||
knownLargestLength?: number
|
||||
): LzxHuffmanTree {
|
||||
const pathLengths = source.slice();
|
||||
let largestLength = knownLargestLength ?? 0;
|
||||
if (knownLargestLength === undefined) {
|
||||
for (const length of pathLengths) {
|
||||
largestLength = Math.max(largestLength, length);
|
||||
}
|
||||
}
|
||||
if (largestLength === 0 || largestLength > 16) {
|
||||
fail(
|
||||
largestLength === 0 ? 'lzx-empty-tree' : 'lzx-invalid-path-lengths',
|
||||
largestLength === 0
|
||||
? 'LZX stream contains an empty Huffman tree.'
|
||||
: `LZX Huffman path length ${largestLength} is invalid.`,
|
||||
{ structure: 'LZX Huffman tree' }
|
||||
);
|
||||
}
|
||||
|
||||
const decodeTable = new Uint16Array(2 ** largestLength);
|
||||
let position = 0;
|
||||
for (let bitLength = 1; bitLength <= largestLength; bitLength += 1) {
|
||||
const amount = 2 ** (largestLength - bitLength);
|
||||
for (let symbol = 0; symbol < pathLengths.length; symbol += 1) {
|
||||
if (pathLengths[symbol] !== bitLength) continue;
|
||||
if (position > decodeTable.length - amount) {
|
||||
fail(
|
||||
'lzx-invalid-path-lengths',
|
||||
'LZX Huffman path lengths are over-subscribed.',
|
||||
{ structure: 'LZX Huffman tree' }
|
||||
);
|
||||
}
|
||||
decodeTable.fill(symbol, position, position + amount);
|
||||
position += amount;
|
||||
}
|
||||
}
|
||||
if (position !== decodeTable.length) {
|
||||
fail(
|
||||
'lzx-invalid-path-lengths',
|
||||
'LZX Huffman path lengths do not form a complete tree.',
|
||||
{ structure: 'LZX Huffman tree' }
|
||||
);
|
||||
}
|
||||
|
||||
return new LzxHuffmanTree(pathLengths, largestLength, decodeTable);
|
||||
}
|
||||
|
||||
decode(reader: LzxBitReader): number {
|
||||
const symbol = this.decodeTable[reader.peekBits(this.largestLength)]!;
|
||||
const bitLength = this.pathLengths[symbol]!;
|
||||
if (bitLength === 0) {
|
||||
fail('lzx-invalid-huffman-code', 'Invalid LZX Huffman code.', {
|
||||
offset: reader.offset,
|
||||
structure: 'LZX Huffman tree',
|
||||
});
|
||||
}
|
||||
reader.readBits(bitLength);
|
||||
return symbol;
|
||||
}
|
||||
}
|
||||
177
src/onenote/onepkg/package.ts
Normal file
177
src/onenote/onepkg/package.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
OneNotePackageDto,
|
||||
OneNotePackageEntryDto,
|
||||
OneNotePackagedSectionDto,
|
||||
OneNoteSectionDto,
|
||||
} from '../model/dto.js';
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
import { checkCancellation } from './cancellation.js';
|
||||
import { extractCabinet } from './cabinet.js';
|
||||
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
|
||||
import type {
|
||||
CabOpenOptions,
|
||||
ExtractedCabFile,
|
||||
OnePkgCancellation,
|
||||
} from './types.js';
|
||||
|
||||
export interface OneNotePackageSectionParseRequest {
|
||||
sourceName: string;
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
bytes: Uint8Array;
|
||||
cancellation?: OnePkgCancellation;
|
||||
}
|
||||
|
||||
export type OneNotePackageSectionParseOutcome =
|
||||
| {
|
||||
status: 'parsed';
|
||||
value: OneNoteSectionDto;
|
||||
diagnostics?: ParserDiagnostic[];
|
||||
}
|
||||
| { status: 'unsupported'; diagnostics?: ParserDiagnostic[] };
|
||||
|
||||
/**
|
||||
* Deliberately independent from the CAB implementation: a `.one` parser is
|
||||
* injected and receives only one extracted section plus cancellation context.
|
||||
*/
|
||||
export type OneNotePackageSectionParser = (
|
||||
request: OneNotePackageSectionParseRequest
|
||||
) =>
|
||||
| OneNotePackageSectionParseOutcome
|
||||
| Promise<OneNotePackageSectionParseOutcome>;
|
||||
|
||||
export interface OneNotePackageOpenOptions extends CabOpenOptions {
|
||||
sourceName?: string;
|
||||
parseSection?: OneNotePackageSectionParser;
|
||||
}
|
||||
|
||||
export interface ParsedOneNotePackageSection {
|
||||
normalizedPath: string;
|
||||
value: OneNoteSectionDto;
|
||||
}
|
||||
|
||||
export interface OneNotePackageOpenResult {
|
||||
package: OneNotePackageDto;
|
||||
/** Extracted in memory only. CAB paths are never written to the filesystem. */
|
||||
extractedEntries: ExtractedCabFile[];
|
||||
parsedSections: ParsedOneNotePackageSection[];
|
||||
}
|
||||
|
||||
function entryKind(path: string): OneNotePackageEntryDto['kind'] {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith('.one')) return 'section';
|
||||
if (lower.endsWith('.onetoc2')) return 'table-of-contents';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function compressionLabel(entry: ExtractedCabFile): string {
|
||||
return entry.compression.kind === 'lzx'
|
||||
? `${entry.compression.label} 2^${entry.compression.windowBits}`
|
||||
: entry.compression.label;
|
||||
}
|
||||
|
||||
function sectionDisplayName(path: string): string {
|
||||
const fileName = path.slice(path.lastIndexOf('/') + 1);
|
||||
return fileName.toLowerCase().endsWith('.one')
|
||||
? fileName.slice(0, -4)
|
||||
: fileName;
|
||||
}
|
||||
|
||||
export async function openOneNotePackage(
|
||||
bytes: Uint8Array,
|
||||
options: OneNotePackageOpenOptions = {}
|
||||
): Promise<OneNotePackageOpenResult> {
|
||||
const sourceName = options.sourceName ?? 'notebook.onepkg';
|
||||
const extraction = await extractCabinet(bytes, options);
|
||||
const diagnostics = [...extraction.diagnostics];
|
||||
const packageEntries: OneNotePackageEntryDto[] =
|
||||
extraction.extractedEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
normalizedPath: entry.normalizedPath,
|
||||
kind: entryKind(entry.normalizedPath),
|
||||
uncompressedSize: entry.uncompressedSize,
|
||||
compressionMethod: compressionLabel(entry),
|
||||
parseStatus: 'not-requested',
|
||||
}));
|
||||
const parsedSections: ParsedOneNotePackageSection[] = [];
|
||||
const packagedSections: OneNotePackagedSectionDto[] = [];
|
||||
|
||||
for (let index = 0; index < extraction.extractedEntries.length; index += 1) {
|
||||
const entry = extraction.extractedEntries[index]!;
|
||||
const packageEntry = packageEntries[index]!;
|
||||
if (packageEntry.kind !== 'section') continue;
|
||||
|
||||
const sectionDiagnostics: ParserDiagnostic[] = [];
|
||||
let parsedSection: OneNoteSectionDto | undefined;
|
||||
if (options.parseSection !== undefined) {
|
||||
checkCancellation(options.cancellation);
|
||||
try {
|
||||
const outcome = await options.parseSection({
|
||||
sourceName: entry.normalizedPath,
|
||||
path: entry.path,
|
||||
normalizedPath: entry.normalizedPath,
|
||||
bytes: entry.bytes,
|
||||
cancellation: options.cancellation,
|
||||
});
|
||||
if (outcome.status === 'parsed') {
|
||||
packageEntry.parseStatus = 'parsed';
|
||||
parsedSection = outcome.value;
|
||||
parsedSections.push({
|
||||
normalizedPath: entry.normalizedPath,
|
||||
value: outcome.value,
|
||||
});
|
||||
} else {
|
||||
packageEntry.parseStatus = 'unsupported';
|
||||
if (outcome.diagnostics === undefined) {
|
||||
sectionDiagnostics.push(
|
||||
warning(
|
||||
'one-section-unsupported',
|
||||
`Section parser does not support ${entry.normalizedPath}.`,
|
||||
{ structure: entry.normalizedPath }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
sectionDiagnostics.push(...(outcome.diagnostics ?? []));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof OnePkgError &&
|
||||
error.diagnostic.code === 'onepkg-cancelled'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
packageEntry.parseStatus = 'failed';
|
||||
sectionDiagnostics.push({
|
||||
...diagnosticFromUnknown(
|
||||
error,
|
||||
'one-section-parse-failed',
|
||||
`Could not parse ${entry.normalizedPath}`,
|
||||
entry.normalizedPath
|
||||
),
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
packagedSections.push({
|
||||
id: entry.normalizedPath,
|
||||
path: entry.normalizedPath,
|
||||
displayName: sectionDisplayName(entry.normalizedPath),
|
||||
parseStatus: packageEntry.parseStatus,
|
||||
section: parsedSection,
|
||||
diagnostics: sectionDiagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
package: {
|
||||
format: 'onepkg',
|
||||
sourceName,
|
||||
sourceSize: bytes.byteLength,
|
||||
entries: packageEntries,
|
||||
sections: packagedSections,
|
||||
diagnostics,
|
||||
},
|
||||
extractedEntries: extraction.extractedEntries,
|
||||
parsedSections,
|
||||
};
|
||||
}
|
||||
99
src/onenote/onepkg/path.ts
Normal file
99
src/onenote/onepkg/path.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { fail } from './errors.js';
|
||||
import type { OnePkgLimits } from './types.js';
|
||||
|
||||
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export function decodeCabFileName(
|
||||
bytes: Uint8Array,
|
||||
utf8: boolean,
|
||||
offset: number
|
||||
): string {
|
||||
try {
|
||||
// OneNote/Joplin exports are observed to store UTF-8 names without setting
|
||||
// ATTR_NAME_IS_UTF. Strict UTF-8 is the only deterministic compatibility
|
||||
// fallback: malformed input is rejected and no legacy code page is guessed.
|
||||
return STRICT_UTF8.decode(bytes);
|
||||
} catch (error) {
|
||||
fail(
|
||||
utf8 ? 'cab-name-invalid-utf8' : 'cab-name-encoding-unsupported',
|
||||
utf8
|
||||
? `CAB filename is not valid UTF-8${error instanceof Error ? `: ${error.message}` : '.'}`
|
||||
: 'A non-ASCII CAB filename is not valid UTF-8; no legacy code-page guess was made.',
|
||||
{ offset, structure: 'CFFILE.szName' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NormalizedCabPath {
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
duplicateKey: string;
|
||||
}
|
||||
|
||||
export function normalizeCabPath(
|
||||
path: string,
|
||||
limits: OnePkgLimits,
|
||||
offset: number
|
||||
): NormalizedCabPath {
|
||||
if (path.length === 0) {
|
||||
fail('cab-path-empty', 'CAB entry has an empty path.', {
|
||||
offset,
|
||||
structure: 'CFFILE.szName',
|
||||
});
|
||||
}
|
||||
if (path.length > limits.maxPathCharacters) {
|
||||
fail(
|
||||
'cab-path-too-long',
|
||||
`CAB path exceeds the configured ${limits.maxPathCharacters}-character limit.`,
|
||||
{ offset, structure: 'CFFILE.szName' }
|
||||
);
|
||||
}
|
||||
if (/^[\\/]/u.test(path) || /^[A-Za-z]:/u.test(path)) {
|
||||
fail('cab-path-absolute', `Absolute CAB path is not allowed: ${path}`, {
|
||||
offset,
|
||||
structure: 'CFFILE.szName',
|
||||
});
|
||||
}
|
||||
|
||||
const slashPath = path.replaceAll('\\', '/');
|
||||
const rawSegments = slashPath.split('/');
|
||||
if (rawSegments.length > limits.maxPathDepth) {
|
||||
fail(
|
||||
'cab-path-too-deep',
|
||||
`CAB path exceeds the configured ${limits.maxPathDepth}-segment limit.`,
|
||||
{ offset, structure: 'CFFILE.szName' }
|
||||
);
|
||||
}
|
||||
|
||||
const segments = rawSegments.map((segment) => {
|
||||
if (segment.length === 0 || segment === '.' || segment === '..') {
|
||||
fail(
|
||||
'cab-path-invalid-segment',
|
||||
`CAB path contains an unsafe or empty segment: ${path}`,
|
||||
{ offset, structure: 'CFFILE.szName' }
|
||||
);
|
||||
}
|
||||
if (
|
||||
[...segment].some((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
})
|
||||
) {
|
||||
fail(
|
||||
'cab-path-control-character',
|
||||
`CAB path contains a control character: ${path}`,
|
||||
{ offset, structure: 'CFFILE.szName' }
|
||||
);
|
||||
}
|
||||
return segment.normalize('NFC');
|
||||
});
|
||||
|
||||
const normalizedPath = segments.join('/');
|
||||
return {
|
||||
path,
|
||||
normalizedPath,
|
||||
// CAB paths have Windows semantics. NFC + lower-case catches the common
|
||||
// duplicate aliases without writing any archive path to the filesystem.
|
||||
duplicateKey: normalizedPath.toLowerCase(),
|
||||
};
|
||||
}
|
||||
120
src/onenote/onepkg/types.ts
Normal file
120
src/onenote/onepkg/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
|
||||
export type CabCompressionMethod =
|
||||
| { kind: 'none'; raw: number; label: 'None' }
|
||||
| { kind: 'mszip'; raw: number; label: 'MSZIP' }
|
||||
| {
|
||||
kind: 'quantum';
|
||||
raw: number;
|
||||
label: 'Quantum';
|
||||
level: number;
|
||||
memoryBits: number;
|
||||
}
|
||||
| {
|
||||
kind: 'lzx';
|
||||
raw: number;
|
||||
label: 'LZX';
|
||||
windowBits: number;
|
||||
windowBytes: number;
|
||||
};
|
||||
|
||||
export interface CabFileEntry {
|
||||
index: number;
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
folderIndex: number;
|
||||
uncompressedOffset: number;
|
||||
uncompressedSize: number;
|
||||
attributes: number;
|
||||
compression: CabCompressionMethod;
|
||||
}
|
||||
|
||||
export interface CabFolderSummary {
|
||||
index: number;
|
||||
dataBlockCount: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
compression: CabCompressionMethod;
|
||||
}
|
||||
|
||||
export interface CabEnumerationResult {
|
||||
cabinetSetId: number;
|
||||
cabinetSetIndex: number;
|
||||
declaredSize: number;
|
||||
entries: CabFileEntry[];
|
||||
folders: CabFolderSummary[];
|
||||
diagnostics: ParserDiagnostic[];
|
||||
}
|
||||
|
||||
export interface ExtractedCabFile extends CabFileEntry {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CabExtractionResult extends CabEnumerationResult {
|
||||
extractedEntries: ExtractedCabFile[];
|
||||
}
|
||||
|
||||
export interface OnePkgCancellation {
|
||||
signal?: AbortSignal;
|
||||
isCancelled?: () => boolean;
|
||||
/** Called between CAB data blocks so a worker can yield to its event loop. */
|
||||
yield?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface OnePkgLimits {
|
||||
maxCabinetBytes: number;
|
||||
maxFolders: number;
|
||||
maxFiles: number;
|
||||
maxDataBlocks: number;
|
||||
maxNameBytes: number;
|
||||
maxPathCharacters: number;
|
||||
maxPathDepth: number;
|
||||
maxFileBytes: number;
|
||||
maxFolderUncompressedBytes: number;
|
||||
maxTotalUncompressedBytes: number;
|
||||
maxExtractedBytes: number;
|
||||
/** Maximum per-folder uncompressed CFDATA bytes / compressed payload bytes. */
|
||||
maxCompressionRatio: number;
|
||||
maxLzxWindowBytes: number;
|
||||
maxDecodeOperations: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ONEPKG_LIMITS: Readonly<OnePkgLimits> = {
|
||||
maxCabinetBytes: 512 * 1024 * 1024,
|
||||
maxFolders: 1_024,
|
||||
maxFiles: 10_000,
|
||||
maxDataBlocks: 131_072,
|
||||
maxNameBytes: 255,
|
||||
maxPathCharacters: 4_096,
|
||||
maxPathDepth: 64,
|
||||
maxFileBytes: 256 * 1024 * 1024,
|
||||
maxFolderUncompressedBytes: 512 * 1024 * 1024,
|
||||
maxTotalUncompressedBytes: 512 * 1024 * 1024,
|
||||
maxExtractedBytes: 512 * 1024 * 1024,
|
||||
maxCompressionRatio: 200,
|
||||
maxLzxWindowBytes: 8 * 1024 * 1024,
|
||||
maxDecodeOperations: 1_000_000_000,
|
||||
};
|
||||
|
||||
export interface CabOpenOptions {
|
||||
limits?: Partial<OnePkgLimits>;
|
||||
cancellation?: OnePkgCancellation;
|
||||
}
|
||||
|
||||
export function resolveLimits(
|
||||
limits: Partial<OnePkgLimits> | undefined
|
||||
): OnePkgLimits {
|
||||
const resolved = { ...DEFAULT_ONEPKG_LIMITS, ...limits };
|
||||
for (const [name, value] of Object.entries(resolved)) {
|
||||
if (name === 'maxCompressionRatio') {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new TypeError(`${name} must be a positive finite number`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new TypeError(`${name} must be a positive safe integer`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
Reference in New Issue
Block a user