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