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

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

View 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 };
}

View 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);
}

View 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
);
}

View 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,
};
}

View 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';

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

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View 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,
};
}

View 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
View 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;
}