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,158 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { OneNoteParserError } from '../parser/error.js';
/** A bounded, little-endian reader. Every child view remains inside its range. */
export class BinaryReader {
readonly bytes: Uint8Array;
readonly start: number;
readonly end: number;
readonly structure: string;
private cursor: number;
constructor(
bytes: Uint8Array,
start = 0,
length = bytes.byteLength - start,
structure = 'binary data'
) {
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(length) ||
start < 0 ||
length < 0 ||
start + length > bytes.byteLength
) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Invalid ${structure} range (${start}, ${length}) for ${bytes.byteLength} bytes`,
{ offset: start, structure }
);
}
this.bytes = bytes;
this.start = start;
this.end = start + length;
this.cursor = start;
this.structure = structure;
}
get offset(): number {
return this.cursor;
}
get relativeOffset(): number {
return this.cursor - this.start;
}
get remaining(): number {
return this.end - this.cursor;
}
seek(relativeOffset: number): void {
if (
!Number.isSafeInteger(relativeOffset) ||
relativeOffset < 0 ||
relativeOffset > this.end - this.start
) {
this.fail(`Invalid seek to relative offset ${relativeOffset}`);
}
this.cursor = this.start + relativeOffset;
}
skip(length: number): void {
this.ensure(length);
this.cursor += length;
}
u8(): number {
this.ensure(1);
return this.bytes[this.cursor++]!;
}
u16(): number {
this.ensure(2);
const value = this.dataView(2).getUint16(0, true);
this.cursor += 2;
return value;
}
u32(): number {
this.ensure(4);
const value = this.dataView(4).getUint32(0, true);
this.cursor += 4;
return value;
}
u64(): bigint {
this.ensure(8);
const value = this.dataView(8).getBigUint64(0, true);
this.cursor += 8;
return value;
}
read(length: number): Uint8Array {
this.ensure(length);
const value = this.bytes.subarray(this.cursor, this.cursor + length);
this.cursor += length;
return value;
}
viewAt(
absoluteOffset: number,
length: number,
structure = this.structure
): BinaryReader {
if (
!Number.isSafeInteger(absoluteOffset) ||
!Number.isSafeInteger(length) ||
absoluteOffset < 0 ||
length < 0 ||
absoluteOffset + length > this.bytes.byteLength
) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`${structure} range (${absoluteOffset}, ${length}) exceeds ${this.bytes.byteLength} bytes`,
{ offset: absoluteOffset, structure }
);
}
return new BinaryReader(this.bytes, absoluteOffset, length, structure);
}
subview(length: number, structure = this.structure): BinaryReader {
this.ensure(length);
const view = new BinaryReader(this.bytes, this.cursor, length, structure);
this.cursor += length;
return view;
}
private ensure(length: number): void {
if (
!Number.isSafeInteger(length) ||
length < 0 ||
length > this.remaining
) {
this.fail(
`Tried to read ${String(length)} bytes with ${this.remaining} remaining`
);
}
}
private dataView(length: number): DataView {
return new DataView(
this.bytes.buffer,
this.bytes.byteOffset + this.cursor,
length
);
}
private fail(message: string): never {
throw new OneNoteParserError('BOUNDS_EXCEEDED', message, {
offset: this.cursor,
structure: this.structure,
});
}
}

View File

@@ -0,0 +1,28 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import type { BinaryReader } from './BinaryReader.js';
const HEX = Array.from({ length: 256 }, (_, value) =>
value.toString(16).padStart(2, '0').toUpperCase()
);
/** Read the mixed-endian GUID layout used by OneStore. */
export function readGuid(reader: BinaryReader): string {
const bytes = reader.read(16);
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15] as const;
const h = order.map((index) => HEX[bytes[index]!]!);
return `{${h.slice(0, 4).join('')}-${h.slice(4, 6).join('')}-${h.slice(6, 8).join('')}-${h.slice(8, 10).join('')}-${h.slice(10).join('')}}`;
}
export function normalizeGuid(value: string): string {
const plain = value.replace(/[{}-]/g, '').toUpperCase();
if (!/^[0-9A-F]{32}$/.test(plain)) {
throw new TypeError(`Invalid GUID: ${value}`);
}
return `{${plain.slice(0, 8)}-${plain.slice(8, 12)}-${plain.slice(12, 16)}-${plain.slice(16, 20)}-${plain.slice(20)}}`;
}
export const NIL_GUID = '{00000000-0000-0000-0000-000000000000}';

View File

@@ -0,0 +1,35 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { OneNoteParserError } from '../parser/error.js';
const FILETIME_UNIX_EPOCH_TICKS = 116_444_736_000_000_000n;
const TICKS_PER_MILLISECOND = 10_000n;
const SECONDS_1980_TO_UNIX = 315_532_800;
export function filetimeToIso(value: bigint, structure: string): string {
const unixMilliseconds =
(value - FILETIME_UNIX_EPOCH_TICKS) / TICKS_PER_MILLISECOND;
const numeric = Number(unixMilliseconds);
if (!Number.isSafeInteger(numeric)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`FILETIME is outside the supported date range: ${value}`,
{ structure }
);
}
const date = new Date(numeric);
if (Number.isNaN(date.getTime())) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid FILETIME: ${value}`,
{ structure }
);
}
return date.toISOString();
}
export function oneNoteTimeToIso(value: number): string {
return new Date((value + SECONDS_1980_TO_UNIX) * 1000).toISOString();
}

15
src/onenote/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export {
detectOneNoteFormat,
type OneNoteFormatDetection,
type OneNoteFormatKind,
} from './parser/detect-format.js';
export { OneNoteParserError } from './parser/error.js';
export {
DEFAULT_ONENOTE_PARSER_LIMITS,
type OneNoteParserLimits,
} from './parser/limits.js';
export {
parseOneNoteSection,
type ParseOneNoteSectionOptions,
} from './parser/parse-section.js';
export type { OneNotePageSummaryDto, OneNoteSectionDto } from './model/dto.js';

View File

@@ -0,0 +1,9 @@
export interface ParserDiagnostic {
severity: 'warning' | 'error';
code: string;
message: string;
offset?: number;
structure?: string;
propertyId?: string;
recoverable: boolean;
}

68
src/onenote/model/dto.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { ParserDiagnostic } from './diagnostics.js';
export interface OneNotePageSummaryDto {
id: string;
title: string;
createdAt?: string;
updatedAt?: string;
level?: number;
/** Complete plain text extracted for this page. */
text: string;
textPreview: string;
}
export type OneNoteContentBlockDto =
| { kind: 'text'; text: string }
| { kind: 'line-break' }
| { kind: 'link'; text: string; href?: string }
| {
kind: 'unsupported';
sourceKind: string;
description: string;
};
export interface OneNotePageDto extends OneNotePageSummaryDto {
blocks: OneNoteContentBlockDto[];
diagnostics: ParserDiagnostic[];
}
export interface OneNoteSectionDto {
format: 'one';
sourceName: string;
sectionName?: string;
pages: OneNotePageSummaryDto[];
diagnostics: ParserDiagnostic[];
parserInfo: {
implementation: 'typescript';
supportedVariant: string;
referenceRevision: string;
};
}
export interface OneNotePackageEntryDto {
path: string;
normalizedPath: string;
kind: 'section' | 'table-of-contents' | 'other';
compressedSize?: number;
uncompressedSize: number;
compressionMethod?: string;
parseStatus: 'not-requested' | 'parsed' | 'unsupported' | 'failed';
}
export interface OneNotePackageDto {
format: 'onepkg';
sourceName: string;
sourceSize: number;
entries: OneNotePackageEntryDto[];
sections: OneNotePackagedSectionDto[];
diagnostics: ParserDiagnostic[];
}
export interface OneNotePackagedSectionDto {
id: string;
path: string;
displayName: string;
parseStatus: OneNotePackageEntryDto['parseStatus'];
section?: OneNoteSectionDto;
diagnostics: ParserDiagnostic[];
}

View File

@@ -0,0 +1,47 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// IDs selected from MS-ONE and onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/shared/jcid.rs`,
// `one/property/mod.rs`, and `one/property_set/*.rs`.
export const JCID = {
SectionNode: 0x00060007,
PageSeriesNode: 0x00060008,
PageNode: 0x0006000b,
OutlineNode: 0x0006000c,
OutlineElementNode: 0x0006000d,
RichTextNode: 0x0006000e,
ImageNode: 0x00060011,
InkContainer: 0x00060014,
OutlineGroup: 0x00060019,
TableNode: 0x00060022,
TitleNode: 0x0006002c,
EmbeddedFileNode: 0x00060035,
PageManifestNode: 0x00060037,
PageMetadata: 0x00020030,
SectionMetadata: 0x00020031,
ParagraphStyleObject: 0x0012004d,
} as const;
export const PROPERTY = {
NotebookManagementEntityGuid: 0x1c001c30,
SectionDisplayName: 0x1c00349b,
ElementChildNodes: 0x24001c20,
ChildGraphSpaceElementNodes: 0x2c001d63,
MetaDataObjectsAboveGraphSpace: 0x24003442,
ContentChildNodes: 0x24001c1f,
StructureElementChildNodes: 0x24001d5f,
TopologyCreationTimeStamp: 0x18001c65,
LastModifiedTime: 0x14001d7a,
PageLevel: 0x14001dff,
CachedTitleString: 0x1c001cf3,
CachedTitleStringFromPage: 0x1c001d3c,
RichEditTextUnicode: 0x1c001c22,
TextExtendedAscii: 0x1c003498,
TextRunFormatting: 0x24001e13,
TextRunIndex: 0x1c001e12,
Hidden: 0x08001e16,
} as const;

661
src/onenote/one/section.ts Normal file
View File

@@ -0,0 +1,661 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onenote/{section,page_series,
// page,outline,rich_text}.rs` and the corresponding `one/property_set/*.rs`
// modules. Deviations: title/plain-text traversal only, bounded graph walking,
// serializable DTOs, and diagnostics for unsupported content.
import { BinaryReader } from '../binary/BinaryReader.js';
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNotePageSummaryDto, OneNoteSectionDto } from '../model/dto.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import {
countObjectReferences,
countObjectSpaceReferences,
findProperty,
referenceOffset,
} from '../onestore/property-set.js';
import type { DesktopOneStore } from '../onestore/desktop-store.js';
import type {
ExGuid,
ObjectSpace,
PropertyEntry,
StoreObject,
} from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
import { JCID, PROPERTY } from './constants.js';
interface OneContext {
store: DesktopOneStore;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
extractedChars: number;
currentPage?: string;
}
type PageWithTextDto = OneNotePageSummaryDto & { text: string };
export function buildSectionDto(
store: DesktopOneStore,
sourceName: string,
limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[]
): OneNoteSectionDto {
const context: OneContext = {
store,
limits,
diagnostics,
extractedChars: 0,
};
const root = store.rootObjectSpace;
const metadata = rootObject(root, 2, 'section metadata root');
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
const displayName = optionalString(metadata, PROPERTY.SectionDisplayName);
const section = rootObject(root, 1, 'section content root');
assertJcid(section, JCID.SectionNode, 'SectionNode');
const pageSeriesIds = objectReferences(section, PROPERTY.ElementChildNodes);
const pages: PageWithTextDto[] = [];
for (const seriesId of pageSeriesIds) {
const series = getObject(root, seriesId, 'PageSeriesNode');
assertJcid(series, JCID.PageSeriesNode, 'PageSeriesNode');
const pageSpaceIds = objectSpaceReferences(
series,
PROPERTY.ChildGraphSpaceElementNodes
);
for (const pageSpaceId of pageSpaceIds) {
context.currentPage = undefined;
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
if (!pageSpace) {
addDiagnostic(
context,
'MISSING_PAGE_SPACE',
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
'PageSeriesNode'
);
continue;
}
try {
if (pages.length >= limits.maxPages) {
addDiagnostic(
context,
'PAGE_LIMIT_REACHED',
`Page count exceeds ${limits.maxPages}`,
'PageSeriesNode',
'error'
);
break;
}
pages.push(parsePage(context, pageSpace));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addDiagnostic(
context,
'PAGE_PARSE_FAILED',
`Could not parse page ${exGuidKey(pageSpaceId)}: ${message}`,
'Page',
'error'
);
}
}
if (pages.length >= limits.maxPages) break;
}
return {
format: 'one',
sourceName,
sectionName: displayName ?? stripOneExtension(sourceName),
pages,
diagnostics,
parserInfo: {
implementation: 'typescript',
supportedVariant: 'desktop-revision-store',
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
},
};
}
function parsePage(context: OneContext, space: ObjectSpace): PageWithTextDto {
const metadata = rootObject(space, 2, 'page metadata root');
assertJcid(metadata, JCID.PageMetadata, 'PageMetadata');
const pageGuid = requiredGuid(
metadata,
PROPERTY.NotebookManagementEntityGuid,
'PageMetadata entity GUID'
);
context.currentPage = pageGuid;
const created = optionalU64(metadata, PROPERTY.TopologyCreationTimeStamp);
const level = optionalU32(metadata, PROPERTY.PageLevel);
const cachedMetadataTitle = optionalString(
metadata,
PROPERTY.CachedTitleString
);
const manifest = rootObject(space, 1, 'page content root');
assertJcid(manifest, JCID.PageManifestNode, 'PageManifestNode');
const pageId = objectReferences(manifest, PROPERTY.ContentChildNodes)[0];
if (!pageId) throw new Error('PageManifestNode has no page reference');
const page = getObject(space, pageId, 'PageNode');
assertJcid(page, JCID.PageNode, 'PageNode');
const updated = optionalU32(page, PROPERTY.LastModifiedTime);
const cachedPageTitle = optionalString(
page,
PROPERTY.CachedTitleStringFromPage
);
const titleId = objectReferences(
page,
PROPERTY.StructureElementChildNodes
)[0];
const contentIds = objectReferences(page, PROPERTY.ElementChildNodes);
let titleText: string | undefined;
if (titleId) {
try {
const title = getObject(space, titleId, 'TitleNode');
assertJcid(title, JCID.TitleNode, 'TitleNode');
const titleOutlines = objectReferences(title, PROPERTY.ElementChildNodes);
for (const outlineId of titleOutlines) {
const paragraphs = extractGraphTextSafely(
context,
space,
outlineId,
'title'
);
titleText = paragraphs.map(cleanTitle).find((item) => item.length > 0);
if (titleText) break;
}
} catch (error) {
addDiagnostic(
context,
'TITLE_PARSE_FAILED',
error instanceof Error ? error.message : String(error),
'TitleNode'
);
}
}
const bodyParagraphs: string[] = [];
for (const contentId of contentIds) {
bodyParagraphs.push(
...extractGraphTextSafely(context, space, contentId, 'page content')
);
}
let text = bodyParagraphs
.map((paragraph) => paragraph.split('\u000b').join('\n').trimEnd())
.filter((paragraph) => paragraph.length > 0)
.join('\n');
const remaining =
context.limits.maxExtractedTextChars - context.extractedChars;
if (text.length > remaining) {
text = text.slice(0, Math.max(0, remaining));
addDiagnostic(
context,
'TEXT_LIMIT_REACHED',
`Extracted text was truncated at ${context.limits.maxExtractedTextChars} characters`,
`Page ${pageGuid}`
);
}
context.extractedChars += text.length;
const bodyFallback = bodyParagraphs
.map(cleanTitle)
.find((item) => item.length > 0);
const title =
titleText ??
bodyFallback ??
cachedPageTitle ??
cachedMetadataTitle ??
'Untitled page';
return {
id: pageGuid,
title,
createdAt:
created === undefined ? undefined : safeFiletime(context, created),
updatedAt: updated === undefined ? undefined : oneNoteTimeToIso(updated),
level,
text,
textPreview: preview(text),
};
}
function extractGraphTextSafely(
context: OneContext,
space: ObjectSpace,
id: ExGuid,
label: string
): string[] {
try {
return extractGraphText(context, space, id, new Set(), 0);
} catch (error) {
addDiagnostic(
context,
'CONTENT_PARSE_FAILED',
`Could not extract ${label} ${exGuidKey(id)}: ${error instanceof Error ? error.message : String(error)}`,
'MS-ONE content graph'
);
return [];
}
}
function extractGraphText(
context: OneContext,
space: ObjectSpace,
id: ExGuid,
active: Set<string>,
depth: number
): string[] {
if (depth > context.limits.maxGraphDepth) {
addDiagnostic(
context,
'GRAPH_DEPTH_LIMIT',
`Content graph depth exceeds ${context.limits.maxGraphDepth}`,
'MS-ONE content graph'
);
return [];
}
const key = exGuidKey(id);
if (active.has(key)) {
addDiagnostic(
context,
'CONTENT_GRAPH_CYCLE',
`Cycle detected at object ${key}`,
'MS-ONE content graph'
);
return [];
}
const object = space.objects.get(key);
if (!object) {
addDiagnostic(
context,
'MISSING_CONTENT_OBJECT',
`Content object ${key} is missing`,
'MS-ONE content graph'
);
return [];
}
active.add(key);
try {
if (object.jcid === JCID.RichTextNode) {
const text = richText(context, space, object);
return text === undefined ? [] : [text];
}
if (object.jcid === JCID.OutlineNode || object.jcid === JCID.OutlineGroup) {
return traverseReferences(
context,
space,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
);
}
if (object.jcid === JCID.OutlineElementNode) {
const contents = traverseReferences(
context,
space,
objectReferences(object, PROPERTY.ContentChildNodes),
active,
depth
);
const children = traverseReferences(
context,
space,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
);
return [...contents, ...children];
}
addDiagnostic(
context,
unsupportedCode(object.jcid),
`Content object JCID 0x${object.jcid.toString(16)} is not yet rendered`,
`Object ${key}`
);
return [];
} finally {
active.delete(key);
}
}
function traverseReferences(
context: OneContext,
space: ObjectSpace,
ids: ExGuid[],
active: Set<string>,
depth: number
): string[] {
const result: string[] = [];
for (const id of ids) {
result.push(...extractGraphText(context, space, id, active, depth + 1));
}
return result;
}
function richText(
context: OneContext,
space: ObjectSpace,
object: StoreObject
): string | undefined {
let text = optionalString(object, PROPERTY.RichEditTextUnicode);
if (text === undefined)
text = optionalLatin1(object, PROPERTY.TextExtendedAscii);
if (text === undefined) return undefined;
const styles = objectReferences(object, PROPERTY.TextRunFormatting);
if (styles.length === 0) return text.split('\0').join('');
const indices = optionalU32Vector(object, PROPERTY.TextRunIndex) ?? [];
const hidden = styles.map((styleId) => {
const style = space.objects.get(exGuidKey(styleId));
if (!style) {
addDiagnostic(
context,
'MISSING_TEXT_STYLE',
`Text-run style ${exGuidKey(styleId)} is missing`,
'RichTextNode'
);
return false;
}
return optionalBool(style, PROPERTY.Hidden) ?? false;
});
const boundaries = [0, ...indices];
if (boundaries.length === hidden.length) boundaries.push(text.length);
let visible = '';
for (let index = 0; index < hidden.length; index += 1) {
if (hidden[index]) continue;
const start = Math.min(boundaries[index] ?? text.length, text.length);
const end = Math.min(boundaries[index + 1] ?? text.length, text.length);
if (start < end) visible += text.slice(start, end);
}
// OneNote producers sometimes retain UTF-16 terminators inside text vectors.
return visible.split('\0').join('');
}
function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
const property = findProperty(object.props, propertyId);
if (!property) return [];
let count: number;
if (property.value.kind === 'objectId') count = 1;
else if (property.value.kind === 'objectIds') count = property.value.count;
else
throw new Error(
`Property 0x${propertyId.toString(16)} is not an object reference`
);
const offset = referenceOffset(
object.props,
propertyId,
countObjectReferences
);
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
}
function objectSpaceReferences(
object: StoreObject,
propertyId: number
): ExGuid[] {
const property = findProperty(object.props, propertyId);
if (!property) return [];
let count: number;
if (property.value.kind === 'objectSpaceId') count = 1;
else if (property.value.kind === 'objectSpaceIds')
count = property.value.count;
else {
throw new Error(
`Property 0x${propertyId.toString(16)} is not an object-space reference`
);
}
const offset = referenceOffset(
object.props,
propertyId,
countObjectSpaceReferences
);
return resolveReferenceSlice(
object,
object.props.objectSpaceIds,
offset,
count
);
}
function resolveReferenceSlice(
object: StoreObject,
ids: { guidIndex: number; value: number }[],
offset: number,
count: number
): ExGuid[] {
if (offset + count > ids.length) {
throw new Error(
`Reference range ${offset}+${count} exceeds stream length ${ids.length}`
);
}
return ids.slice(offset, offset + count).map((id) => {
const guid = object.mapping.get(id.guidIndex);
if (!guid) throw new Error(`GUID mapping ${id.guidIndex} is missing`);
return { guid, value: id.value };
});
}
function rootObject(
space: ObjectSpace,
role: number,
structure: string
): StoreObject {
const id = space.roots.get(role);
if (!id) throw new Error(`Object space has no ${structure}`);
return getObject(space, id, structure);
}
function getObject(
space: ObjectSpace,
id: ExGuid,
structure: string
): StoreObject {
const object = space.objects.get(exGuidKey(id));
if (!object)
throw new Error(`${structure} object ${exGuidKey(id)} is missing`);
return object;
}
function assertJcid(
object: StoreObject,
expected: number,
structure: string
): void {
if (object.jcid !== expected) {
throw new Error(
`${structure} has JCID 0x${object.jcid.toString(16)}, expected 0x${expected.toString(16)}`
);
}
}
function optionalEntry(
object: StoreObject,
propertyId: number
): PropertyEntry | undefined {
return findProperty(object.props, propertyId);
}
function optionalString(
object: StoreObject,
propertyId: number
): string | undefined {
const entry = optionalEntry(object, propertyId);
if (!entry) return undefined;
if (entry.value.kind !== 'bytes') {
throw new Error(
`Property 0x${propertyId.toString(16)} is not a byte vector`
);
}
if (entry.value.value.byteLength % 2 !== 0) {
throw new Error(
`UTF-16 property 0x${propertyId.toString(16)} has odd length`
);
}
return new TextDecoder('utf-16le', { fatal: true }).decode(entry.value.value);
}
function optionalLatin1(
object: StoreObject,
propertyId: number
): string | undefined {
const entry = optionalEntry(object, propertyId);
if (!entry) return undefined;
if (entry.value.kind !== 'bytes') {
throw new Error(
`Property 0x${propertyId.toString(16)} is not a byte vector`
);
}
let result = '';
for (const value of entry.value.value) result += String.fromCharCode(value);
return result;
}
function requiredGuid(
object: StoreObject,
propertyId: number,
structure: string
): string {
const entry = optionalEntry(object, propertyId);
if (
!entry ||
entry.value.kind !== 'bytes' ||
entry.value.value.length !== 16
) {
throw new Error(`${structure} is absent or not 16 bytes`);
}
const reader = new BinaryReader(entry.value.value, 0, 16, structure);
const bytes = reader.read(16);
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
const hex = order.map((index) => bytes[index]!.toString(16).padStart(2, '0'));
return `{${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}}`.toUpperCase();
}
function optionalBool(
object: StoreObject,
propertyId: number
): boolean | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'bool') throw new Error('Expected a Boolean property');
return value.value;
}
function optionalU32(
object: StoreObject,
propertyId: number
): number | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u32') throw new Error('Expected a u32 property');
return value.value;
}
function optionalU64(
object: StoreObject,
propertyId: number
): bigint | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u64') throw new Error('Expected a u64 property');
return value.value;
}
function optionalU32Vector(
object: StoreObject,
propertyId: number
): number[] | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'bytes' || value.value.byteLength % 4 !== 0) {
throw new Error('Expected a u32 vector property');
}
const reader = new BinaryReader(
value.value,
0,
value.value.byteLength,
'u32 vector'
);
const result: number[] = [];
while (reader.remaining > 0) result.push(reader.u32());
return result;
}
function safeFiletime(context: OneContext, value: bigint): string | undefined {
try {
return filetimeToIso(value, 'PageMetadata creation time');
} catch (error) {
addDiagnostic(
context,
'INVALID_PAGE_TIMESTAMP',
error instanceof Error ? error.message : String(error),
'PageMetadata'
);
return undefined;
}
}
function cleanTitle(text: string): string {
const marker = '\ufddfHYPERLINK "';
let result = text;
while (result.startsWith('\u000b')) result = result.slice(1);
result = result.trim();
let start = result.indexOf(marker);
while (start >= 0) {
const valueStart = start + marker.length;
const quote = result.indexOf('"', valueStart);
result =
quote < 0
? result.slice(0, start)
: result.slice(0, start) + result.slice(quote + 1);
start = result.indexOf(marker);
}
return result.trim();
}
function preview(text: string): string {
const compact = text.replace(/\s+/g, ' ').trim();
return compact.length <= 500 ? compact : `${compact.slice(0, 497)}`;
}
function unsupportedCode(jcid: number): string {
if (jcid === JCID.ImageNode) return 'UNSUPPORTED_IMAGE';
if (jcid === JCID.TableNode) return 'UNSUPPORTED_TABLE';
if (jcid === JCID.EmbeddedFileNode) return 'UNSUPPORTED_EMBEDDED_FILE';
if (jcid === JCID.InkContainer) return 'UNSUPPORTED_INK';
return 'UNSUPPORTED_CONTENT_OBJECT';
}
function addDiagnostic(
context: OneContext,
code: string,
message: string,
structure: string,
severity: 'warning' | 'error' = 'warning'
): void {
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
context.diagnostics.push({
severity,
code,
message,
structure: context.currentPage
? `${structure} (page ${context.currentPage})`
: structure,
recoverable: true,
});
}
function stripOneExtension(filename: string): string {
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
}

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

View File

@@ -0,0 +1,560 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/
// one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object,
// object_group_list,object_space,revision,revision_manifest_list,
// root_file_node_list}.rs`. Deviations: selected section nodes only,
// serializable diagnostics, explicit limits, and attachment payloads skipped.
import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import {
DESKTOP_REVISION_STORE_FORMAT,
ONE_SECTION_FILE_TYPE,
detectOneNoteFormat,
} from '../parser/detect-format.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import {
DesktopFileNodeParser,
readChunkReference64x32,
readTransactionNodeCounts,
} from './file-node.js';
import { parseObjectPropSet } from './property-set.js';
import type {
CompactId,
ExGuid,
FileNode,
FileNodeList,
ObjectSpace,
StoreObject,
} from './types.js';
import { exGuidKey } from './types.js';
export interface DesktopOneStore {
rootObjectSpace: ObjectSpace;
objectSpaces: Map<string, ObjectSpace>;
}
interface ParseContext {
bytes: Uint8Array;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
objectCount: number;
}
export function parseDesktopOneStore(
bytes: Uint8Array,
limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[]
): DesktopOneStore {
if (bytes.byteLength > limits.maxFileBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`OneNote section is ${bytes.byteLength} bytes (limit ${limits.maxFileBytes})`,
{ structure: 'OneStore file' }
);
}
const detected = detectOneNoteFormat(bytes);
if (detected.kind !== 'desktop-one') {
throw new OneNoteParserError(
detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT',
detected.kind === 'fsshttp-one'
? 'This OneNote section uses FSSHTTP packaging, which is not yet supported'
: `Expected a desktop .one section; detected ${detected.kind}`,
{ structure: 'OneStore header' }
);
}
if (bytes.byteLength < 1024) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'OneStore header is shorter than 1024 bytes',
{ structure: 'OneStore header' }
);
}
const header = new BinaryReader(bytes, 0, 1024, 'OneStore header');
const fileType = readGuid(header);
header.skip(32);
const fileFormat = readGuid(header);
if (
fileType !== ONE_SECTION_FILE_TYPE ||
fileFormat !== DESKTOP_REVISION_STORE_FORMAT
) {
throw new OneNoteParserError(
'INVALID_FORMAT',
'The OneStore header changed during format validation',
{ structure: 'OneStore header' }
);
}
header.seek(160);
const transactionLogReference = readChunkReference64x32(header);
const rootReference = readChunkReference64x32(header);
const nodeCounts = readTransactionNodeCounts(
bytes,
transactionLogReference,
limits
);
const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts);
const rootList = fileNodeParser.parseList(rootReference);
const context: ParseContext = {
bytes,
limits,
diagnostics,
objectCount: 0,
};
let rootObjectSpaceId: ExGuid | undefined;
const objectSpaces = new Map<string, ObjectSpace>();
for (const node of rootList.nodes) {
if (node.id === 0x004) {
rootObjectSpaceId = readExGuid(nodeReader(context, node));
} else if (node.id === 0x008) {
const objectSpace = parseObjectSpace(context, node);
objectSpaces.set(exGuidKey(objectSpace.id), objectSpace);
} else if (node.id !== 0x090 && node.id !== 0x0ff) {
diagnostic(
context,
'UNSUPPORTED_ROOT_NODE',
`Skipped unsupported root FileNode 0x${node.id.toString(16)}`,
node
);
}
}
if (!rootObjectSpaceId) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Root FileNodeList has no ObjectSpaceManifestRootFND',
{ structure: 'RootFileNodeList' }
);
}
const rootObjectSpace = objectSpaces.get(exGuidKey(rootObjectSpaceId));
if (!rootObjectSpace) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`Root object space ${exGuidKey(rootObjectSpaceId)} is absent`,
{ structure: 'RootFileNodeList' }
);
}
return { rootObjectSpace, objectSpaces };
}
function parseObjectSpace(context: ParseContext, node: FileNode): ObjectSpace {
const id = readExGuid(nodeReader(context, node));
const manifest = requiredChildList(
node,
'ObjectSpaceManifestListReferenceFND'
);
const revisions = manifest.nodes.filter((item) => item.id === 0x010);
const lastRevision = revisions.at(-1);
if (!lastRevision) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Object-space manifest has no revision-manifest reference',
{ offset: node.offset, structure: 'ObjectSpaceManifestList' }
);
}
const roots = new Map<number, ExGuid>();
const objects = new Map<string, StoreObject>();
parseRevisionManifestList(
context,
requiredChildList(lastRevision, 'RevisionManifestListReferenceFND'),
id,
roots,
objects
);
return { id, roots, objects };
}
function parseRevisionManifestList(
context: ParseContext,
list: FileNodeList,
contextId: ExGuid,
roots: Map<number, ExGuid>,
objects: Map<string, StoreObject>
): void {
if (list.nodes[0]?.id !== 0x014) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'RevisionManifestList does not begin with RevisionManifestListStartFND',
{ structure: 'RevisionManifestList' }
);
}
let index = 1;
while (index < list.nodes.length) {
const node = list.nodes[index]!;
if (node.id === 0x01c) {
index += 1;
} else if (node.id === 0x05c || node.id === 0x05d) {
index += 1;
} else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) {
index = parseRevision(
context,
list.nodes,
index + 1,
contextId,
roots,
objects
);
} else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in RevisionManifestList`,
{ offset: node.offset, structure: 'RevisionManifestList' }
);
}
}
}
function parseRevision(
context: ParseContext,
nodes: FileNode[],
startIndex: number,
contextId: ExGuid,
roots: Map<number, ExGuid>,
objects: Map<string, StoreObject>
): number {
let index = startIndex;
let mostRecentMapping: ReadonlyMap<number, string> | undefined;
while (index < nodes.length) {
const node = nodes[index]!;
if (node.id === 0x01c) return index + 1;
if (node.id === 0x0b0) {
parseObjectGroup(
context,
requiredChildList(node, 'ObjectGroupListReferenceFND'),
contextId,
objects
);
index += 1;
if (nodes[index]?.id === 0x084) index += 1;
continue;
}
if (node.id === 0x0b4) {
index = parseInlineObjectGroup(context, nodes, index, contextId, objects);
continue;
}
if (node.id === 0x021 || node.id === 0x022) {
const result = parseGlobalIdTable(context, nodes, index);
index = result.nextIndex;
mostRecentMapping = result.mapping;
while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) {
parseObjectDeclaration(
context,
nodes[index]!,
contextId,
mostRecentMapping,
objects
);
index += 1;
if (nodes[index]?.id === 0x084) index += 1;
}
continue;
}
if (node.id === 0x05a) {
const reader = nodeReader(context, node);
const objectId = readExGuid(reader);
const role = reader.u32();
if (role !== 1 && role !== 2 && role !== 4) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid object-space root role ${role}`,
{ offset: node.offset, structure: 'RootObjectReference3FND' }
);
}
if (!roots.has(role)) roots.set(role, objectId);
index += 1;
continue;
}
if (node.id === 0x059) {
if (!mostRecentMapping) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
'Compact root reference appears before a GlobalIdTable',
{ offset: node.offset, structure: 'RootObjectReference2FNDX' }
);
}
const reader = nodeReader(context, node);
const objectId = resolveCompactId(
readCompactId(reader),
mostRecentMapping
);
const role = reader.u32();
if (!roots.has(role)) roots.set(role, objectId);
index += 1;
continue;
}
if (node.id === 0x08c || node.id === 0x084) {
index += 1;
continue;
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in revision`,
{ offset: node.offset, structure: 'Revision' }
);
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Revision has no RevisionManifestEndFND',
{ structure: 'Revision' }
);
}
function parseObjectGroup(
context: ParseContext,
list: FileNodeList,
contextId: ExGuid,
objects: Map<string, StoreObject>
): void {
const consumed = parseInlineObjectGroup(
context,
list.nodes,
0,
contextId,
objects
);
if (consumed !== list.nodes.length) {
const node = list.nodes[consumed];
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'ObjectGroupList contains data after ObjectGroupEndFND',
{ offset: node?.offset, structure: 'ObjectGroupList' }
);
}
}
function parseInlineObjectGroup(
context: ParseContext,
nodes: FileNode[],
startIndex: number,
contextId: ExGuid,
objects: Map<string, StoreObject>
): number {
if (nodes[startIndex]?.id !== 0x0b4) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'ObjectGroupList does not begin with ObjectGroupStartFND',
{ structure: 'ObjectGroupList' }
);
}
const table = parseGlobalIdTable(context, nodes, startIndex + 1);
let index = table.nextIndex;
while (index < nodes.length && nodes[index]!.id !== 0x0b8) {
const node = nodes[index]!;
if (isObjectDeclaration(node.id)) {
parseObjectDeclaration(context, node, contextId, table.mapping, objects);
} else if (node.id !== 0x08c) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in ObjectGroupList`,
{ offset: node.offset, structure: 'ObjectGroupList' }
);
}
index += 1;
}
if (nodes[index]?.id !== 0x0b8) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'ObjectGroupList has no ObjectGroupEndFND',
{ structure: 'ObjectGroupList' }
);
}
return index + 1;
}
function parseGlobalIdTable(
context: ParseContext,
nodes: FileNode[],
startIndex: number
): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
const start = nodes[startIndex];
if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Expected a GlobalIdTable start node',
{ structure: 'GlobalIdTable' }
);
}
const mapping = new Map<number, string>();
let index = startIndex + 1;
while (index < nodes.length) {
const node = nodes[index]!;
if (node.id === 0x028) {
return { mapping, nextIndex: index + 1 };
}
if (node.id === 0x024) {
const reader = nodeReader(context, node);
mapping.set(reader.u32(), readGuid(reader));
} else if (node.id === 0x025 || node.id === 0x026) {
diagnostic(
context,
'UNSUPPORTED_GLOBAL_ID_MAPPING',
`Skipped dependent GlobalIdTable entry 0x${node.id.toString(16)}`,
node
);
} else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected node 0x${node.id.toString(16)} in GlobalIdTable`,
{ offset: node.offset, structure: 'GlobalIdTable' }
);
}
index += 1;
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'GlobalIdTable has no end node',
{ structure: 'GlobalIdTable' }
);
}
function parseObjectDeclaration(
context: ParseContext,
node: FileNode,
contextId: ExGuid,
mapping: ReadonlyMap<number, string>,
objects: Map<string, StoreObject>
): void {
if (node.id === 0x072 || node.id === 0x073) {
diagnostic(
context,
'UNSUPPORTED_ATTACHMENT_DECLARATION',
'Attachment object metadata is not extracted in this parser phase',
node
);
return;
}
if (!node.dataReference) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
'Object declaration has no property-set reference',
{ offset: node.offset, structure: 'ObjectDeclaration2RefCountFND' }
);
}
const reader = nodeReader(context, node);
const compactId = readCompactId(reader);
const jcid = reader.u32();
reader.u8(); // reference-stream presence flags
if (node.id === 0x0a4) reader.u8();
else if (node.id === 0x0a5) reader.u32();
else if (node.id === 0x0c4) {
reader.u8();
reader.skip(16);
} else if (node.id === 0x0c5) {
reader.u32();
reader.skip(16);
}
const id = resolveCompactId(compactId, mapping);
const props = parseObjectPropSet(
context.bytes,
node.dataReference,
context.limits
);
objects.set(exGuidKey(id), {
id,
jcid,
props,
mapping: new Map(mapping),
contextId,
});
context.objectCount += 1;
if (context.objectCount > context.limits.maxObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object count exceeds ${context.limits.maxObjects}`,
{ offset: node.offset, structure: 'ObjectDeclaration' }
);
}
}
function isObjectDeclaration(nodeId: number): boolean {
return (
nodeId === 0x0a4 ||
nodeId === 0x0a5 ||
nodeId === 0x0c4 ||
nodeId === 0x0c5 ||
nodeId === 0x072 ||
nodeId === 0x073
);
}
function resolveCompactId(
compact: CompactId,
mapping: ReadonlyMap<number, string>
): ExGuid {
const guid = mapping.get(compact.guidIndex);
if (!guid) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`GlobalIdTable has no GUID at index ${compact.guidIndex}`,
{ structure: 'CompactId' }
);
}
return { guid, value: compact.value };
}
function readCompactId(reader: BinaryReader): CompactId {
const raw = reader.u32();
return { value: raw & 0xff, guidIndex: raw >>> 8 };
}
function readExGuid(reader: BinaryReader): ExGuid {
return { guid: readGuid(reader), value: reader.u32() };
}
function requiredChildList(node: FileNode, structure: string): FileNodeList {
if (!node.childList) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`${structure} does not point to a FileNodeList`,
{ offset: node.offset, structure }
);
}
return node.childList;
}
function nodeReader(context: ParseContext, node: FileNode): BinaryReader {
return new BinaryReader(
context.bytes,
node.payloadOffset,
node.payloadLength,
`FileNode 0x${node.id.toString(16)}`
);
}
function diagnostic(
context: ParseContext,
code: string,
message: string,
node: FileNode
): void {
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
context.diagnostics.push({
severity: 'warning',
code,
message,
offset: node.offset,
structure: `FileNode 0x${node.id.toString(16)}`,
recoverable: true,
});
}

View File

@@ -0,0 +1,440 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/file_structure/
// {file_node_list_fragment,parse_context,transaction_log_fragment}.rs`,
// `onestore/desktop/file_node/{mod,file_node_chunk_reference}.rs`, and
// `onestore/desktop/common/file_chunk_reference.rs`. Deviations: strict byte
// bounds, cycle/depth/count limits, and no eager decoding of unrelated data.
import { BinaryReader } from '../binary/BinaryReader.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type { ChunkReference, FileNode, FileNodeList } from './types.js';
const FILE_NODE_LIST_MAGIC = 0xa4567ab1f5f7f4c4n;
const FILE_NODE_LIST_FOOTER = 0x8bc215c38233ba4bn;
const NIL_U64 = 0xffffffffffffffffn;
export interface OneStoreHeader {
fileType: string;
legacyFileVersion: string;
fileFormat: string;
rootList: ChunkReference;
}
interface ListParseState {
fragments: number;
nodes: number;
}
export class DesktopFileNodeParser {
private readonly root: BinaryReader;
private readonly limits: OneNoteParserLimits;
private readonly listCache = new Map<string, FileNodeList>();
private readonly activeFragments = new Set<string>();
private readonly state: ListParseState = { fragments: 0, nodes: 0 };
private readonly remainingNodeCounts: Map<number, number>;
constructor(
bytes: Uint8Array,
limits: OneNoteParserLimits,
nodeCounts: ReadonlyMap<number, number> = new Map()
) {
this.root = new BinaryReader(bytes, 0, bytes.byteLength, 'OneStore file');
this.limits = limits;
this.remainingNodeCounts = new Map(nodeCounts);
}
parseList(reference: ChunkReference, depth = 0): FileNodeList {
if (reference.nil || reference.zero || reference.size === 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'A required FileNodeList reference is nil or empty',
{ offset: reference.offset, structure: 'FileNodeList' }
);
}
if (depth > this.limits.maxListDepth) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FileNodeList nesting exceeds ${this.limits.maxListDepth}`,
{ offset: reference.offset, structure: 'FileNodeList' }
);
}
const initialKey = referenceKey(reference);
const cached = this.listCache.get(initialKey);
if (cached) return cached;
const nodes: FileNode[] = [];
const seenFragments = new Set<string>();
let current = reference;
let listId: number | undefined;
let expectedSequence = 0;
while (!current.nil && !current.zero && current.size !== 0) {
this.state.fragments += 1;
if (this.state.fragments > this.limits.maxListFragments) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FileNodeList fragment count exceeds ${this.limits.maxListFragments}`,
{ offset: current.offset, structure: 'FileNodeList' }
);
}
const key = referenceKey(current);
if (seenFragments.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Cycle detected in a FileNodeList fragment chain',
{ offset: current.offset, structure: 'FileNodeList' }
);
}
seenFragments.add(key);
if (this.activeFragments.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Cycle detected in FileNodeList fragments',
{ offset: current.offset, structure: 'FileNodeList' }
);
}
this.activeFragments.add(key);
try {
const fragment = this.root.viewAt(
current.offset,
current.size,
'FileNodeListFragment'
);
if (fragment.remaining < 36) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'FileNodeListFragment is shorter than its 36-byte framing',
{ offset: current.offset, structure: 'FileNodeListFragment' }
);
}
if (fragment.u64() !== FILE_NODE_LIST_MAGIC) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Invalid FileNodeListFragment magic',
{ offset: current.offset, structure: 'FileNodeListFragment' }
);
}
const fragmentListId = fragment.u32();
const sequence = fragment.u32();
if (listId === undefined) listId = fragmentListId;
if (fragmentListId !== listId || sequence !== expectedSequence) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Inconsistent FileNodeList fragment identity or sequence',
{ offset: current.offset, structure: 'FileNodeListFragment' }
);
}
expectedSequence += 1;
let remainingNodeCount = this.remainingNodeCounts.get(fragmentListId);
const nodeRegionEnd = fragment.end - 20;
while (
fragment.offset + 4 <= nodeRegionEnd &&
(remainingNodeCount === undefined || remainingNodeCount > 0)
) {
const firstWord = new DataView(
fragment.bytes.buffer,
fragment.bytes.byteOffset + fragment.offset,
4
).getUint32(0, true);
if (firstWord === 0) {
fragment.skip(4);
continue;
}
const node = this.parseNode(fragment, nodeRegionEnd, depth);
this.state.nodes += 1;
if (this.state.nodes > this.limits.maxFileNodes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FileNode count exceeds ${this.limits.maxFileNodes}`,
{ offset: node.offset, structure: 'FileNode' }
);
}
if (node.id === 0x0ff) {
// ChunkTerminatorFND ends logical decoding. Remaining bytes in the
// node region are opaque padding and need not contain zero words.
fragment.skip(nodeRegionEnd - fragment.offset);
break;
}
if (node.id !== 0) {
nodes.push(node);
if (remainingNodeCount !== undefined) remainingNodeCount -= 1;
}
}
if (remainingNodeCount !== undefined)
this.remainingNodeCounts.set(fragmentListId, remainingNodeCount);
// The fragment format permits up to three trailing padding bytes.
if (fragment.offset < nodeRegionEnd)
fragment.skip(nodeRegionEnd - fragment.offset);
current = readChunkReference64x32(fragment);
if (fragment.u64() !== FILE_NODE_LIST_FOOTER) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Invalid FileNodeListFragment footer',
{ offset: fragment.offset - 8, structure: 'FileNodeListFragment' }
);
}
} finally {
this.activeFragments.delete(key);
}
}
const result: FileNodeList = { id: listId ?? 0, nodes };
this.listCache.set(initialKey, result);
return result;
}
private parseNode(
reader: BinaryReader,
nodeRegionEnd: number,
depth: number
): FileNode {
const offset = reader.offset;
const firstWord = reader.u32();
const id = firstWord & 0x3ff;
const declaredSize = (firstWord >>> 10) & 0x1fff;
const stpFormat = (firstWord >>> 23) & 0x3;
const cbFormat = (firstWord >>> 25) & 0x3;
const baseType = (firstWord >>> 27) & 0xf;
if (id === 0) {
return {
id,
declaredSize,
offset,
payloadOffset: reader.offset,
payloadLength: 0,
};
}
if (declaredSize < 4 || offset + declaredSize > nodeRegionEnd) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`FileNode 0x${id.toString(16)} has invalid declared size ${declaredSize}`,
{ offset, structure: 'FileNode' }
);
}
let dataReference: ChunkReference | undefined;
let childList: FileNodeList | undefined;
if (baseType === 1 || baseType === 2) {
dataReference = readNodeChunkReference(reader, stpFormat, cbFormat);
if (baseType === 2) {
childList = this.parseList(dataReference, depth + 1);
}
} else if (baseType !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unsupported FileNode base type ${baseType}`,
{ offset, structure: 'FileNode' }
);
}
const consumed = reader.offset - offset;
const payloadLength = declaredSize - consumed;
if (payloadLength < 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'FileNode reference exceeds the declared node size',
{ offset, structure: 'FileNode' }
);
}
const payloadOffset = reader.offset;
reader.skip(payloadLength);
return {
id,
declaredSize,
offset,
payloadOffset,
payloadLength,
dataReference,
childList,
};
}
}
export function readChunkReference64x32(reader: BinaryReader): ChunkReference {
const rawOffset = reader.u64();
const rawSize = reader.u32();
return chunkReference(rawOffset, BigInt(rawSize), rawOffset === NIL_U64);
}
/** Read transaction-log node counts used to delimit opaque fragment padding. */
export function readTransactionNodeCounts(
bytes: Uint8Array,
reference: ChunkReference,
limits: OneNoteParserLimits
): Map<number, number> {
const root = new BinaryReader(bytes, 0, bytes.byteLength, 'OneStore file');
const result = new Map<number, number>();
const seen = new Set<string>();
let current = reference;
let fragments = 0;
let entries = 0;
while (!current.nil && !current.zero && current.size !== 0) {
fragments += 1;
if (fragments > limits.maxTransactionFragments) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Transaction-log fragment count exceeds ${limits.maxTransactionFragments}`,
{ offset: current.offset, structure: 'TransactionLog' }
);
}
const key = referenceKey(current);
if (seen.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Cycle detected in transaction-log fragments',
{ offset: current.offset, structure: 'TransactionLog' }
);
}
seen.add(key);
const fragment = root.viewAt(
current.offset,
current.size,
'TransactionLogFragment'
);
if (fragment.remaining < 12) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'TransactionLogFragment has an invalid size',
{ offset: current.offset, structure: 'TransactionLogFragment' }
);
}
const count = Math.floor((fragment.remaining - 12) / 8);
entries += count;
if (entries > limits.maxTransactionEntries) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Transaction-log entry count exceeds ${limits.maxTransactionEntries}`,
{ offset: current.offset, structure: 'TransactionLog' }
);
}
for (let index = 0; index < count; index += 1) {
const listId = fragment.u32();
const nodeCount = fragment.u32();
if (listId === 1) continue; // TransactionEntry sentinel.
result.set(listId, Math.max(result.get(listId) ?? 0, nodeCount));
}
current = readChunkReference64x32(fragment);
// Transaction fragments are allocation-aligned and can carry up to seven
// unused bytes after the next-fragment reference.
if (fragment.remaining > 0) fragment.skip(fragment.remaining);
}
return result;
}
function readNodeChunkReference(
reader: BinaryReader,
stpFormat: number,
cbFormat: number
): ChunkReference {
let rawOffset: bigint;
let offsetNil: boolean;
switch (stpFormat) {
case 0:
rawOffset = reader.u64();
offsetNil = rawOffset === NIL_U64;
break;
case 1: {
const value = reader.u32();
rawOffset = BigInt(value);
offsetNil = value === 0xffffffff;
break;
}
case 2: {
const value = reader.u16();
rawOffset = BigInt(value) * 8n;
offsetNil = value === 0xffff;
break;
}
case 3: {
const value = reader.u32();
rawOffset = BigInt(value) * 8n;
offsetNil = value === 0xffffffff;
break;
}
default:
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid FileNode stp format ${stpFormat}`,
{ offset: reader.offset, structure: 'FileNodeChunkReference' }
);
}
let rawSize: bigint;
switch (cbFormat) {
case 0:
rawSize = BigInt(reader.u32());
break;
case 1:
rawSize = reader.u64();
break;
case 2:
rawSize = BigInt(reader.u8()) * 8n;
break;
case 3:
rawSize = BigInt(reader.u16()) * 8n;
break;
default:
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid FileNode cb format ${cbFormat}`,
{ offset: reader.offset, structure: 'FileNodeChunkReference' }
);
}
return chunkReference(rawOffset, rawSize, offsetNil);
}
function chunkReference(
rawOffset: bigint,
rawSize: bigint,
nil: boolean
): ChunkReference {
if (nil && rawSize === 0n) {
return { offset: 0, size: 0, nil: true, zero: false };
}
if (rawOffset > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Chunk offset is too large: ${rawOffset}`,
{ structure: 'FileChunkReference' }
);
}
if (rawSize > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Chunk size is too large: ${rawSize}`,
{ structure: 'FileChunkReference' }
);
}
return {
offset: Number(rawOffset),
size: Number(rawSize),
nil: nil && rawSize === 0n,
zero: rawOffset === 0n && rawSize === 0n,
};
}
function referenceKey(reference: ChunkReference): string {
return `${reference.offset}:${reference.size}`;
}

View File

@@ -0,0 +1,290 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/shared/
// {object_prop_set,object_stream_header,prop_set,property}.rs`. Deviations:
// recursive/count/vector reads are bounded and unresolved references are
// never silently discarded.
import { BinaryReader } from '../binary/BinaryReader.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type {
ChunkReference,
CompactId,
ObjectPropSet,
PropertyEntry,
PropertySet,
PropertyValue,
} from './types.js';
interface ObjectStreamHeader {
count: number;
extendedStreamsPresent: boolean;
osidStreamNotPresent: boolean;
}
export function parseObjectPropSet(
bytes: Uint8Array,
reference: ChunkReference,
limits: OneNoteParserLimits
): ObjectPropSet {
if (reference.nil || reference.zero) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Object declaration has a nil property-set reference',
{ offset: reference.offset, structure: 'ObjectPropSet' }
);
}
const reader = new BinaryReader(
bytes,
reference.offset,
reference.size,
'ObjectPropSet'
);
const oidHeader = readStreamHeader(reader, limits);
const objectIds = readCompactIds(reader, oidHeader.count);
let objectSpaceIds: CompactId[] = [];
let contextIds: CompactId[] = [];
if (!oidHeader.osidStreamNotPresent) {
const osidHeader = readStreamHeader(reader, limits);
objectSpaceIds = readCompactIds(reader, osidHeader.count);
if (osidHeader.extendedStreamsPresent) {
const contextHeader = readStreamHeader(reader, limits);
contextIds = readCompactIds(reader, contextHeader.count);
}
}
const properties = readPropertySet(reader, limits, 0);
return { objectIds, objectSpaceIds, contextIds, properties };
}
export function findProperty(
props: ObjectPropSet | PropertySet,
rawPropertyId: number
): PropertyEntry | undefined {
const set = 'properties' in props ? props.properties : props;
const id = rawPropertyId & 0x03ffffff;
return set.entries.find((entry) => entry.id === id);
}
export function countObjectReferences(value: PropertyValue): number {
switch (value.kind) {
case 'objectId':
return 1;
case 'objectIds':
return value.count;
case 'propertyValues':
return value.sets.reduce(
(total, set) =>
total + countReferencesInSet(set, countObjectReferences),
0
);
case 'propertySet':
return countReferencesInSet(value.set, countObjectReferences);
default:
return 0;
}
}
export function countObjectSpaceReferences(value: PropertyValue): number {
switch (value.kind) {
case 'objectSpaceId':
return 1;
case 'objectSpaceIds':
return value.count;
case 'propertyValues':
return value.sets.reduce(
(total, set) =>
total + countReferencesInSet(set, countObjectSpaceReferences),
0
);
case 'propertySet':
return countReferencesInSet(value.set, countObjectSpaceReferences);
default:
return 0;
}
}
export function referenceOffset(
props: ObjectPropSet,
rawPropertyId: number,
counter: (value: PropertyValue) => number
): number {
const targetId = rawPropertyId & 0x03ffffff;
const index = props.properties.entries.findIndex(
(entry) => entry.id === targetId
);
if (index < 0) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`Property 0x${rawPropertyId.toString(16)} is absent`,
{ structure: 'ObjectPropSet' }
);
}
return props.properties.entries
.slice(0, index)
.reduce((total, entry) => total + counter(entry.value), 0);
}
function readStreamHeader(
reader: BinaryReader,
limits: OneNoteParserLimits
): ObjectStreamHeader {
const raw = reader.u32();
const count = raw & 0x00ffffff;
if (count > limits.maxStreamIds) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object stream contains ${count} IDs (limit ${limits.maxStreamIds})`,
{ offset: reader.offset - 4, structure: 'ObjectStreamHeader' }
);
}
return {
count,
extendedStreamsPresent: ((raw >>> 30) & 1) !== 0,
osidStreamNotPresent: raw >>> 31 !== 0,
};
}
function readCompactIds(reader: BinaryReader, count: number): CompactId[] {
const ids: CompactId[] = [];
for (let index = 0; index < count; index += 1) {
const raw = reader.u32();
ids.push({ value: raw & 0xff, guidIndex: raw >>> 8 });
}
return ids;
}
function readPropertySet(
reader: BinaryReader,
limits: OneNoteParserLimits,
depth: number
): PropertySet {
if (depth > limits.maxPropertyDepth) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Nested property depth exceeds ${limits.maxPropertyDepth}`,
{ offset: reader.offset, structure: 'PropertySet' }
);
}
const count = reader.u16();
if (count > limits.maxPropertiesPerSet) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`PropertySet contains ${count} properties`,
{ offset: reader.offset - 2, structure: 'PropertySet' }
);
}
const rawIds: number[] = [];
for (let index = 0; index < count; index += 1) rawIds.push(reader.u32());
const entries = rawIds.map((rawId) => {
const type = (rawId >>> 26) & 0x1f;
return {
rawId,
id: rawId & 0x03ffffff,
type,
value: readPropertyValue(reader, rawId, type, limits, depth),
} satisfies PropertyEntry;
});
return { entries };
}
function readPropertyValue(
reader: BinaryReader,
rawId: number,
type: number,
limits: OneNoteParserLimits,
depth: number
): PropertyValue {
switch (type) {
case 0x1:
return { kind: 'empty' };
case 0x2:
return { kind: 'bool', value: rawId >>> 31 === 1 };
case 0x3:
return { kind: 'u8', value: reader.u8() };
case 0x4:
return { kind: 'u16', value: reader.u16() };
case 0x5:
return { kind: 'u32', value: reader.u32() };
case 0x6:
return { kind: 'u64', value: reader.u64() };
case 0x7: {
const length = reader.u32();
if (length > limits.maxVectorBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Property vector is ${length} bytes`,
{ offset: reader.offset - 4, structure: 'PropertyValue' }
);
}
return { kind: 'bytes', value: reader.read(length) };
}
case 0x8:
return { kind: 'objectId' };
case 0x9:
return { kind: 'objectIds', count: boundedCount(reader, limits) };
case 0xa:
return { kind: 'objectSpaceId' };
case 0xb:
return {
kind: 'objectSpaceIds',
count: boundedCount(reader, limits),
};
case 0xc:
return { kind: 'contextId' };
case 0xd:
return { kind: 'contextIds', count: boundedCount(reader, limits) };
case 0x10: {
const count = boundedCount(reader, limits);
const id = reader.u32();
const sets: PropertySet[] = [];
for (let index = 0; index < count; index += 1) {
sets.push(readPropertySet(reader, limits, depth + 1));
}
return { kind: 'propertyValues', id, sets };
}
case 0x11:
return {
kind: 'propertySet',
set: readPropertySet(reader, limits, depth + 1),
};
default:
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected property type 0x${type.toString(16)}`,
{ offset: reader.offset, structure: 'PropertyValue' }
);
}
}
function boundedCount(
reader: BinaryReader,
limits: OneNoteParserLimits
): number {
const count = reader.u32();
if (count > limits.maxObjectReferences) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Reference count ${count} exceeds ${limits.maxObjectReferences}`,
{ offset: reader.offset - 4, structure: 'PropertyValue' }
);
}
return count;
}
function countReferencesInSet(
set: PropertySet,
counter: (value: PropertyValue) => number
): number {
return set.entries.reduce((total, entry) => total + counter(entry.value), 0);
}

View File

@@ -0,0 +1,95 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/common/
// {exguid,file_chunk_reference,object_space_object_stream_header}.rs`,
// `onestore/shared/{compact_id,jcid,object_prop_set}.rs`, and desktop object
// modules. This port uses serializable records and explicit limits.
export interface ExGuid {
guid: string;
value: number;
}
export interface CompactId {
guidIndex: number;
value: number;
}
export interface ChunkReference {
offset: number;
size: number;
nil: boolean;
zero: boolean;
}
export interface FileNodeList {
id: number;
nodes: FileNode[];
}
export interface FileNode {
id: number;
declaredSize: number;
offset: number;
payloadOffset: number;
payloadLength: number;
dataReference?: ChunkReference;
childList?: FileNodeList;
}
export type PropertyValue =
| { kind: 'empty' }
| { kind: 'bool'; value: boolean }
| { kind: 'u8'; value: number }
| { kind: 'u16'; value: number }
| { kind: 'u32'; value: number }
| { kind: 'u64'; value: bigint }
| { kind: 'bytes'; value: Uint8Array }
| { kind: 'objectId' }
| { kind: 'objectIds'; count: number }
| { kind: 'objectSpaceId' }
| { kind: 'objectSpaceIds'; count: number }
| { kind: 'contextId' }
| { kind: 'contextIds'; count: number }
| { kind: 'propertyValues'; id: number; sets: PropertySet[] }
| { kind: 'propertySet'; set: PropertySet };
export interface PropertyEntry {
rawId: number;
id: number;
type: number;
value: PropertyValue;
}
export interface PropertySet {
entries: PropertyEntry[];
}
export interface ObjectPropSet {
objectIds: CompactId[];
objectSpaceIds: CompactId[];
contextIds: CompactId[];
properties: PropertySet;
}
export interface StoreObject {
id: ExGuid;
jcid: number;
props: ObjectPropSet;
mapping: ReadonlyMap<number, string>;
contextId: ExGuid;
}
export interface ObjectSpace {
id: ExGuid;
roots: Map<number, ExGuid>;
objects: Map<string, StoreObject>;
}
export function exGuidKey(id: ExGuid): string {
return `${id.guid}:${id.value}`;
}

View File

@@ -0,0 +1,70 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { BinaryReader } from '../binary/BinaryReader.js';
import { NIL_GUID, readGuid } from '../binary/guid.js';
export const ONE_SECTION_FILE_TYPE = '{7B5C52E4-D88C-4DA7-AEB1-5378D02996D3}';
export const ONE_TOC_FILE_TYPE = '{43FF2FA1-EFD9-4C76-9EE2-10EA5722765F}';
export const DESKTOP_REVISION_STORE_FORMAT =
'{109ADD3F-911B-49F5-A5D0-1791EDC8AED8}';
export const FSSHTTP_PACKAGING_FORMAT =
'{638DE92F-A6D4-4BC1-9A36-B3FC2511A5B7}';
export type OneNoteFormatKind =
| 'desktop-one'
| 'desktop-onetoc2'
| 'fsshttp-one'
| 'not-onenote'
| 'unknown-onenote';
export interface OneNoteFormatDetection {
kind: OneNoteFormatKind;
fileType?: string;
legacyFileVersion?: string;
fileFormat?: string;
}
export function detectOneNoteFormat(
input: ArrayBuffer | Uint8Array
): OneNoteFormatDetection {
const bytes = toBytes(input);
if (bytes.byteLength < 64) return { kind: 'not-onenote' };
const reader = new BinaryReader(bytes, 0, 64, 'OneStore signature');
const fileType = readGuid(reader);
reader.skip(16);
const legacyFileVersion = readGuid(reader);
const fileFormat = readGuid(reader);
if (fileType !== ONE_SECTION_FILE_TYPE && fileType !== ONE_TOC_FILE_TYPE) {
return { kind: 'not-onenote', fileType, legacyFileVersion, fileFormat };
}
if (
fileFormat === FSSHTTP_PACKAGING_FORMAT ||
legacyFileVersion !== NIL_GUID
) {
return { kind: 'fsshttp-one', fileType, legacyFileVersion, fileFormat };
}
if (fileFormat !== DESKTOP_REVISION_STORE_FORMAT) {
return {
kind: 'unknown-onenote',
fileType,
legacyFileVersion,
fileFormat,
};
}
return {
kind:
fileType === ONE_SECTION_FILE_TYPE ? 'desktop-one' : 'desktop-onetoc2',
fileType,
legacyFileVersion,
fileFormat,
};
}
export function toBytes(input: ArrayBuffer | Uint8Array): Uint8Array {
return input instanceof Uint8Array ? input : new Uint8Array(input);
}

View File

@@ -0,0 +1,51 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
export type OneNoteParserErrorCode =
| 'BOUNDS_EXCEEDED'
| 'INVALID_FORMAT'
| 'INVALID_STRUCTURE'
| 'LIMIT_EXCEEDED'
| 'MISSING_REFERENCE'
| 'UNSUPPORTED_FORMAT';
export interface OneNoteParserErrorOptions {
offset?: number;
structure?: string;
cause?: unknown;
}
/** A controlled parse failure safe to serialize across a worker boundary. */
export class OneNoteParserError extends Error {
readonly code: OneNoteParserErrorCode;
readonly offset?: number;
readonly structure?: string;
constructor(
code: OneNoteParserErrorCode,
message: string,
options: OneNoteParserErrorOptions = {}
) {
super(message, { cause: options.cause });
this.name = 'OneNoteParserError';
this.code = code;
this.offset = options.offset;
this.structure = options.structure;
}
}
export function asParserError(
error: unknown,
structure: string
): OneNoteParserError {
if (error instanceof OneNoteParserError) {
return error;
}
return new OneNoteParserError(
'INVALID_STRUCTURE',
error instanceof Error ? error.message : String(error),
{ cause: error, structure }
);
}

View File

@@ -0,0 +1,55 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
export interface OneNoteParserLimits {
maxFileBytes: number;
maxTransactionFragments: number;
maxTransactionEntries: number;
maxListFragments: number;
maxListDepth: number;
maxFileNodes: number;
maxObjects: number;
maxPropertiesPerSet: number;
maxPropertyDepth: number;
maxStreamIds: number;
maxVectorBytes: number;
maxObjectReferences: number;
maxGraphDepth: number;
maxPages: number;
maxDiagnostics: number;
maxExtractedTextChars: number;
}
export const DEFAULT_ONENOTE_PARSER_LIMITS: Readonly<OneNoteParserLimits> = {
maxFileBytes: 512 * 1024 * 1024,
maxTransactionFragments: 16_384,
maxTransactionEntries: 1_000_000,
maxListFragments: 16_384,
maxListDepth: 64,
maxFileNodes: 1_000_000,
maxObjects: 500_000,
maxPropertiesPerSet: 16_384,
maxPropertyDepth: 64,
maxStreamIds: 1_000_000,
maxVectorBytes: 64 * 1024 * 1024,
maxObjectReferences: 1_000_000,
maxGraphDepth: 256,
maxPages: 100_000,
maxDiagnostics: 10_000,
maxExtractedTextChars: 2_000_000,
};
export function resolveParserLimits(
limits: Partial<OneNoteParserLimits> = {}
): OneNoteParserLimits {
const resolved = { ...DEFAULT_ONENOTE_PARSER_LIMITS, ...limits };
for (const [name, value] of Object.entries(resolved)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new TypeError(`${name} must be a positive safe integer`);
}
}
return resolved;
}

View File

@@ -0,0 +1,32 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import type { OneNoteSectionDto } from '../model/dto.js';
import { buildSectionDto } from '../one/section.js';
import { parseDesktopOneStore } from '../onestore/desktop-store.js';
import { toBytes } from './detect-format.js';
import type { OneNoteParserLimits } from './limits.js';
import { resolveParserLimits } from './limits.js';
export interface ParseOneNoteSectionOptions {
sourceName?: string;
limits?: Partial<OneNoteParserLimits>;
}
/** Parse a desktop revision-store `.one` section without network access. */
export function parseOneNoteSection(
input: ArrayBuffer | Uint8Array,
options: ParseOneNoteSectionOptions = {}
): OneNoteSectionDto {
const bytes = toBytes(input);
const limits = resolveParserLimits(options.limits);
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
const store = parseDesktopOneStore(bytes, limits, diagnostics);
return buildSectionDto(
store,
options.sourceName ?? 'Untitled.one',
limits,
diagnostics
);
}