From eb15fecb3f612873803778b896e1f2982a02615c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 19:26:08 +0200 Subject: [PATCH] feat: open FSSHTTP OneNote sections --- src/onenote/fsshttpb/data-elements.ts | 5 +- src/onenote/fsshttpb/types.ts | 1 + src/onenote/one/property-access.ts | 24 +- src/onenote/one/section-content.ts | 8 +- src/onenote/one/section.ts | 43 +- src/onenote/onestore/desktop-store.ts | 2 + src/onenote/onestore/fsshttp-store.test.ts | 48 ++ src/onenote/onestore/fsshttp-store.ts | 694 +++++++++++++++++++++ src/onenote/onestore/property-set.ts | 21 +- src/onenote/onestore/types.ts | 12 + src/onenote/parser/parse-section.ts | 30 +- src/onenote/parser/parse-toc.ts | 10 + 12 files changed, 877 insertions(+), 21 deletions(-) create mode 100644 src/onenote/onestore/fsshttp-store.test.ts create mode 100644 src/onenote/onestore/fsshttp-store.ts diff --git a/src/onenote/fsshttpb/data-elements.ts b/src/onenote/fsshttpb/data-elements.ts index 7f54071..edcf40f 100644 --- a/src/onenote/fsshttpb/data-elements.ts +++ b/src/onenote/fsshttpb/data-elements.ts @@ -553,11 +553,14 @@ function parseObjectGroupData( context.limits.maxVectorBytes, 'Object binary item size' ); + const offset = body.offset; + const data = body.read(size); result = { kind: 'object', objectReferences, cellReferences, - data: body.read(size), + data, + dataReference: { source: body.bytes, offset, size }, }; } else if (header.type === FSSHTTP_OBJECT.objectGroupBlobReference) { result = { diff --git a/src/onenote/fsshttpb/types.ts b/src/onenote/fsshttpb/types.ts index 69aabcd..09467ab 100644 --- a/src/onenote/fsshttpb/types.ts +++ b/src/onenote/fsshttpb/types.ts @@ -120,6 +120,7 @@ export type FssHttpObjectGroupData = objectReferences: ExGuid[]; cellReferences: FssHttpCellId[]; data: Uint8Array; + dataReference: FileDataBlobReference; } | { kind: 'excluded'; diff --git a/src/onenote/one/property-access.ts b/src/onenote/one/property-access.ts index fc93c5d..a084650 100644 --- a/src/onenote/one/property-access.ts +++ b/src/onenote/one/property-access.ts @@ -21,6 +21,7 @@ import type { ObjectSpace, PropertyEntry, StoreObject, + StoreObjectSpaceReference, } from '../onestore/types.js'; import { exGuidKey } from '../onestore/types.js'; @@ -40,13 +41,16 @@ export function objectReferences( propertyId, countObjectReferences ); + if (object.resolvedObjectIds) { + return resolvedReferenceSlice(object.resolvedObjectIds, offset, count); + } return resolveReferenceSlice(object, object.props.objectIds, offset, count); } export function objectSpaceReferences( object: StoreObject, propertyId: number -): ExGuid[] { +): StoreObjectSpaceReference[] { const property = findProperty(object.props, propertyId); if (!property) return []; let count: number; @@ -60,12 +64,15 @@ export function objectSpaceReferences( propertyId, countObjectSpaceReferences ); + if (object.resolvedObjectSpaceIds) { + return resolvedReferenceSlice(object.resolvedObjectSpaceIds, offset, count); + } return resolveReferenceSlice( object, object.props.objectSpaceIds, offset, count - ); + ).map((id) => ({ id, key: exGuidKey(id) })); } export function rootObject( @@ -257,6 +264,19 @@ function resolveReferenceSlice( }); } +function resolvedReferenceSlice( + ids: readonly T[], + offset: number, + count: number +): T[] { + if (offset + count > ids.length) { + throw new Error( + `Reference range ${offset}+${count} exceeds resolved stream length ${ids.length}` + ); + } + return ids.slice(offset, offset + count); +} + function propertyTypeError(propertyId: number, expected: string): Error { return new Error(`Property 0x${propertyId.toString(16)} is not ${expected}`); } diff --git a/src/onenote/one/section-content.ts b/src/onenote/one/section-content.ts index c238855..2077ff3 100644 --- a/src/onenote/one/section-content.ts +++ b/src/onenote/one/section-content.ts @@ -83,14 +83,14 @@ export function buildSectionContentModel( series, PROPERTY.ChildGraphSpaceElementNodes ); - for (const pageSpaceId of pageSpaceIds) { + for (const pageSpaceReference of pageSpaceIds) { context.currentPage = undefined; - const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId)); + const pageSpace = store.objectSpaces.get(pageSpaceReference.key); if (!pageSpace) { addDiagnostic( context, 'MISSING_PAGE_SPACE', - `Page object space ${exGuidKey(pageSpaceId)} is missing`, + `Page object space ${pageSpaceReference.key} is missing`, 'PageSeriesNode' ); continue; @@ -111,7 +111,7 @@ export function buildSectionContentModel( addDiagnostic( context, 'PAGE_PARSE_FAILED', - `Could not parse page ${exGuidKey(pageSpaceId)}: ${error instanceof Error ? error.message : String(error)}`, + `Could not parse page ${pageSpaceReference.key}: ${error instanceof Error ? error.message : String(error)}`, 'Page', 'error' ); diff --git a/src/onenote/one/section.ts b/src/onenote/one/section.ts index 2b8f8fd..0430016 100644 --- a/src/onenote/one/section.ts +++ b/src/onenote/one/section.ts @@ -26,6 +26,7 @@ import type { ObjectSpace, PropertyEntry, StoreObject, + StoreObjectSpaceReference, } from '../onestore/types.js'; import { exGuidKey } from '../onestore/types.js'; import { JCID, PROPERTY } from './constants.js'; @@ -55,7 +56,9 @@ export function buildSectionDto( const root = store.rootObjectSpace; const metadata = rootObject(root, 2, 'section metadata root'); assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata'); - const displayName = optionalString(metadata, PROPERTY.SectionDisplayName); + const displayName = cleanMetadataString( + optionalString(metadata, PROPERTY.SectionDisplayName) + ); const section = rootObject(root, 1, 'section content root'); assertJcid(section, JCID.SectionNode, 'SectionNode'); @@ -69,14 +72,14 @@ export function buildSectionDto( series, PROPERTY.ChildGraphSpaceElementNodes ); - for (const pageSpaceId of pageSpaceIds) { + for (const pageSpaceReference of pageSpaceIds) { context.currentPage = undefined; - const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId)); + const pageSpace = store.objectSpaces.get(pageSpaceReference.key); if (!pageSpace) { addDiagnostic( context, 'MISSING_PAGE_SPACE', - `Page object space ${exGuidKey(pageSpaceId)} is missing`, + `Page object space ${pageSpaceReference.key} is missing`, 'PageSeriesNode' ); continue; @@ -99,7 +102,7 @@ export function buildSectionDto( addDiagnostic( context, 'PAGE_PARSE_FAILED', - `Could not parse page ${exGuidKey(pageSpaceId)}: ${message}`, + `Could not parse page ${pageSpaceReference.key}: ${message}`, 'Page', 'error' ); @@ -116,7 +119,7 @@ export function buildSectionDto( diagnostics, parserInfo: { implementation: 'typescript', - supportedVariant: 'desktop-revision-store', + supportedVariant: store.variant, referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60', }, }; @@ -398,13 +401,16 @@ function objectReferences(object: StoreObject, propertyId: number): ExGuid[] { propertyId, countObjectReferences ); + if (object.resolvedObjectIds) { + return resolvedReferenceSlice(object.resolvedObjectIds, offset, count); + } return resolveReferenceSlice(object, object.props.objectIds, offset, count); } function objectSpaceReferences( object: StoreObject, propertyId: number -): ExGuid[] { +): StoreObjectSpaceReference[] { const property = findProperty(object.props, propertyId); if (!property) return []; let count: number; @@ -421,12 +427,15 @@ function objectSpaceReferences( propertyId, countObjectSpaceReferences ); + if (object.resolvedObjectSpaceIds) { + return resolvedReferenceSlice(object.resolvedObjectSpaceIds, offset, count); + } return resolveReferenceSlice( object, object.props.objectSpaceIds, offset, count - ); + ).map((id) => ({ id, key: exGuidKey(id) })); } function resolveReferenceSlice( @@ -447,6 +456,19 @@ function resolveReferenceSlice( }); } +function resolvedReferenceSlice( + ids: readonly T[], + offset: number, + count: number +): T[] { + if (offset + count > ids.length) { + throw new Error( + `Reference range ${offset}+${count} exceeds resolved stream length ${ids.length}` + ); + } + return ids.slice(offset, offset + count); +} + function rootObject( space: ObjectSpace, role: number, @@ -659,3 +681,8 @@ function addDiagnostic( function stripOneExtension(filename: string): string { return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, ''); } + +function cleanMetadataString(value: string | undefined): string | undefined { + const result = value?.split('\0').join('').trim(); + return result || undefined; +} diff --git a/src/onenote/onestore/desktop-store.ts b/src/onenote/onestore/desktop-store.ts index bad3677..4262d19 100644 --- a/src/onenote/onestore/desktop-store.ts +++ b/src/onenote/onestore/desktop-store.ts @@ -45,6 +45,7 @@ import { exGuidKey } from './types.js'; export interface DesktopOneStore { kind: 'section' | 'table-of-contents'; + variant: 'desktop-revision-store' | 'fsshttpb-package-store'; fileIdentity: string; rootObjectSpace: ObjectSpace; objectSpaces: Map; @@ -185,6 +186,7 @@ function parseDesktopRevisionStore( return { kind: expectedKind === 'desktop-one' ? 'section' : 'table-of-contents', + variant: 'desktop-revision-store', fileIdentity, rootObjectSpace, objectSpaces, diff --git a/src/onenote/onestore/fsshttp-store.test.ts b/src/onenote/onestore/fsshttp-store.test.ts new file mode 100644 index 0000000..47c410b --- /dev/null +++ b/src/onenote/onestore/fsshttp-store.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + parseOneNoteSection, + parseOneNoteSectionContent, +} from '../parser/parse-section.js'; + +function fixtureBytes(): Uint8Array { + const encoded = readFileSync( + resolve(process.cwd(), 'tests/fixtures/joplin-quick-notes-fsshttp.one.b64'), + 'utf8' + ); + return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64')); +} + +describe('FSSHTTPB OneStore adapter', () => { + it('opens the pinned Joplin section through the public parser', () => { + const parsed = parseOneNoteSection(fixtureBytes(), { + sourceName: 'Quick Notes.one', + }); + + expect(parsed).toMatchObject({ + format: 'one', + sourceName: 'Quick Notes.one', + sectionName: 'Section title.one', + parserInfo: { supportedVariant: 'fsshttpb-package-store' }, + }); + expect(parsed.pages).toHaveLength(1); + expect(parsed.pages[0]).toMatchObject({ + title: 'Page title', + text: expect.stringContaining('Page content'), + }); + }); + + it('feeds FSSHTTPB objects into the rich-content model', () => { + const parsed = parseOneNoteSectionContent(fixtureBytes(), { + sourceName: 'Quick Notes.one', + }); + + expect(parsed.sectionName).toBe('Section title.one'); + expect(parsed.pages).toHaveLength(1); + expect(parsed.pages[0]).toMatchObject({ title: 'Page title' }); + expect(parsed.pages[0]?.text).toContain('Page content'); + }); +}); diff --git a/src/onenote/onestore/fsshttp-store.ts b/src/onenote/onestore/fsshttp-store.ts new file mode 100644 index 0000000..999f1b6 --- /dev/null +++ b/src/onenote/onestore/fsshttp-store.ts @@ -0,0 +1,694 @@ +// 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/fsshttpb/*`. +// Deviations: immutable browser-native records, explicit cycle/count bounds, +// newest-revision precedence, and store-specific resolved reference arrays. + +import { BinaryReader } from '../binary/BinaryReader.js'; +import { NIL_GUID, readGuid } from '../binary/guid.js'; +import { + fssHttpCellKey, + type FssHttpCellId, + type FssHttpObjectDeclaration, + type FssHttpObjectGroup, + type FssHttpObjectGroupData, + type FssHttpOneStorePackage, + type FssHttpRevisionManifest, + type FssHttpSerialNumber, + type FssHttpStorageIndex, + type FssHttpStorageIndexCellMapping, + type FssHttpStorageManifest, +} from '../fsshttpb/types.js'; +import type { ParserDiagnostic } from '../model/diagnostics.js'; +import { ONE_SECTION_FILE_TYPE } from '../parser/detect-format.js'; +import { OneNoteParserError } from '../parser/error.js'; +import type { OneNoteParserLimits } from '../parser/limits.js'; +import { parseFssHttpOneStorePackage } from '../fsshttpb/package.js'; +import type { DesktopOneStore } from './desktop-store.js'; +import { findProperty, parseObjectPropSetRange } from './property-set.js'; +import type { + ExGuid, + ObjectSpace, + PropertyEntry, + StoreFileData, + StoreObject, +} from './types.js'; +import { exGuidKey } from './types.js'; + +const HEADER_ROOT = exGuidKey({ + guid: '{1A5A319C-C26B-41AA-B9C5-9BD8C44E07D4}', + value: 1, +}); +const DATA_ROOT = exGuidKey({ + guid: '{84DEFAB9-AAA3-4A0D-A3A8-520C77AC7073}', + value: 2, +}); +const REVISION_ROLE_GUID = '{4A3717F8-1C14-49E7-9526-81D942DE1741}'; +const PROPERTY_FILE_IDENTITY_GUID = 0x1c001d94; +const PROPERTY_FILE_ANCESTOR_IDENTITY_GUID = 0x1c001d95; +const PROPERTY_FILE_NAME_CRC = 0x14001d93; + +interface StoreContext { + package: FssHttpOneStorePackage; + index: FssHttpStorageIndex; + limits: OneNoteParserLimits; + diagnostics: ParserDiagnostic[]; + objectCount: number; +} + +interface GroupEntry { + declaration: FssHttpObjectDeclaration; + data: FssHttpObjectGroupData; +} + +/** Parse an unfragmented FSSHTTPB package into the shared OneStore model. */ +export function parseFssHttpOneStore( + bytes: Uint8Array, + limits: OneNoteParserLimits, + diagnostics: ParserDiagnostic[] +): DesktopOneStore { + const packageStore = parseFssHttpOneStorePackage(bytes, limits); + if (packageStore.dataElements.fragments.size > 0) { + throw new OneNoteParserError( + 'UNSUPPORTED_FORMAT', + 'FSSHTTPB Data Element Fragment reconstruction is not supported', + { structure: 'Data Element Fragment' } + ); + } + + const index = selectStorageIndex(packageStore, limits, diagnostics); + const context: StoreContext = { + package: packageStore, + index, + limits, + diagnostics, + objectCount: 0, + }; + const manifest = selectStorageManifest(context); + if (manifest.schema !== packageStore.cellSchema) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Storage Manifest schema ${manifest.schema} does not match package schema ${packageStore.cellSchema}`, + { structure: 'Storage Manifest' } + ); + } + + const headerCell = requiredManifestRoot(manifest, HEADER_ROOT, 'header'); + const dataRootCell = requiredManifestRoot(manifest, DATA_ROOT, 'data'); + validateHeaderCell(context, headerCell); + + const rootObjectSpace = parseObjectSpace(context, dataRootCell); + const objectSpaces = new Map(); + objectSpaces.set(fssHttpCellKey(dataRootCell), rootObjectSpace); + + const skippedCells = new Set([ + fssHttpCellKey(headerCell), + fssHttpCellKey(dataRootCell), + ]); + for (const mapping of index.cellMappings.values()) { + const key = fssHttpCellKey(mapping.cellId); + if (skippedCells.has(key) || isNilExGuid(mapping.id)) continue; + objectSpaces.set(key, parseObjectSpace(context, mapping.cellId)); + } + + return { + kind: + packageStore.fileType === ONE_SECTION_FILE_TYPE + ? 'section' + : 'table-of-contents', + variant: 'fsshttpb-package-store', + fileIdentity: packageStore.fileIdentity, + rootObjectSpace, + objectSpaces, + }; +} + +function selectStorageIndex( + packageStore: FssHttpOneStorePackage, + limits: OneNoteParserLimits, + diagnostics: ParserDiagnostic[] +): FssHttpStorageIndex { + const indexes = packageStore.dataElements.storageIndexes; + const requested = indexes.get(exGuidKey(packageStore.storageIndexId)); + if (requested) return requested; + if (indexes.size !== 1) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Storage Index ${exGuidKey(packageStore.storageIndexId)} is absent`, + { structure: 'Storage Index' } + ); + } + addDiagnostic( + diagnostics, + limits, + 'FSSHTTP_STORAGE_INDEX_FALLBACK', + `Storage Index ${exGuidKey(packageStore.storageIndexId)} was absent; used the package's only Storage Index`, + 'Storage Index' + ); + return indexes.values().next().value!; +} + +function selectStorageManifest(context: StoreContext): FssHttpStorageManifest { + const manifests = context.package.dataElements.storageManifests; + for (const mapping of context.index.manifestMappings) { + const manifest = manifests.get(exGuidKey(mapping.id)); + if (manifest) return manifest; + } + if (manifests.size !== 1) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + context.index.manifestMappings.length === 0 + ? 'Storage Index has no resolvable Storage Manifest mapping' + : 'Storage Index manifest mappings do not resolve uniquely', + { structure: 'Storage Manifest' } + ); + } + addDiagnostic( + context.diagnostics, + context.limits, + 'FSSHTTP_STORAGE_MANIFEST_FALLBACK', + "Storage Index manifest mappings did not resolve; used the package's only Storage Manifest", + 'Storage Manifest' + ); + return manifests.values().next().value!; +} + +function requiredManifestRoot( + manifest: FssHttpStorageManifest, + key: string, + name: string +): FssHttpCellId { + const cell = manifest.roots.get(key); + if (!cell) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Storage Manifest has no ${name} cell root`, + { structure: 'Storage Manifest root' } + ); + } + return cell; +} + +function validateHeaderCell(context: StoreContext, cell: FssHttpCellId): void { + const mapping = requiredCellMapping(context, cell); + const revisionId = resolveCurrentRevisionId(context, mapping); + if (!revisionId) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + 'Header cell has no current revision', + { structure: 'OneStore package header cell' } + ); + } + const revision = requiredRevisionManifest(context, revisionId); + const entries = revision.objectGroupIds.flatMap((groupId) => + pairedGroupEntries(context, groupId) + ); + const headerEntry = entries.find( + ({ declaration }) => declaration.partitionId === 1 + ); + if (!headerEntry || headerEntry.data.kind !== 'object') { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'Header cell has no object-data property set', + { structure: 'OneStore package header cell' } + ); + } + const props = parseObjectPropSetRange( + headerEntry.data.dataReference.source, + headerEntry.data.dataReference.offset, + headerEntry.data.dataReference.size, + context.limits + ); + requiredBytesProperty( + props.properties.entries, + PROPERTY_FILE_IDENTITY_GUID, + 16 + ); + requiredBytesProperty( + props.properties.entries, + PROPERTY_FILE_ANCESTOR_IDENTITY_GUID, + 16 + ); + const crc = findProperty(props, PROPERTY_FILE_NAME_CRC); + if (!crc || crc.value.kind !== 'u32') { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'Header cell FileNameCrc property is absent or malformed', + { structure: 'OneStore package header cell' } + ); + } +} + +function requiredBytesProperty( + entries: PropertyEntry[], + propertyId: number, + length: number +): Uint8Array { + const entry = entries.find( + (candidate) => candidate.id === (propertyId & 0x03ffffff) + ); + if ( + !entry || + entry.value.kind !== 'bytes' || + entry.value.value.length !== length + ) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Header property 0x${propertyId.toString(16)} is absent or malformed`, + { structure: 'OneStore package header cell' } + ); + } + // Exercise the exact mixed-endian GUID range while preserving bounded reads. + readGuid(new BinaryReader(entry.value.value, 0, length, 'Header GUID')); + return entry.value.value; +} + +function parseObjectSpace( + context: StoreContext, + cell: FssHttpCellId +): ObjectSpace { + const mapping = requiredCellMapping(context, cell); + const revisionId = resolveCurrentRevisionId(context, mapping); + if (!revisionId) { + return { + id: cell.objectSpace, + roots: new Map(), + objects: new Map(), + }; + } + + const roots = new Map(); + const objects = new Map(); + for (const revision of revisionChain(context, revisionId)) { + for (const root of revision.roots) { + const role = parseRevisionRole(root.role); + if (!roots.has(role)) roots.set(role, root.objectId); + } + for (const groupId of revision.objectGroupIds) { + parseObjectGroup(context, cell, groupId, objects); + } + } + return { id: cell.objectSpace, roots, objects }; +} + +function revisionChain( + context: StoreContext, + firstRevisionId: ExGuid +): FssHttpRevisionManifest[] { + const result: FssHttpRevisionManifest[] = []; + const seen = new Set(); + let revisionId: ExGuid | undefined = firstRevisionId; + while (revisionId) { + if (result.length >= context.limits.maxTransactionFragments) { + throw new OneNoteParserError( + 'LIMIT_EXCEEDED', + `Revision chain exceeds ${context.limits.maxTransactionFragments} entries`, + { structure: 'FSSHTTPB revision chain' } + ); + } + const key = exGuidKey(revisionId); + if (seen.has(key)) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Revision chain contains a cycle at ${key}`, + { structure: 'FSSHTTPB revision chain' } + ); + } + seen.add(key); + const revision = requiredRevisionManifest(context, revisionId); + result.push(revision); + if (isNilExGuid(revision.baseRevisionId)) break; + revisionId = resolveRevisionId(context, revision.baseRevisionId); + if (!revisionId) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Base revision ${exGuidKey(revision.baseRevisionId)} is unresolved`, + { structure: 'Revision Manifest' } + ); + } + } + return result; +} + +function parseObjectGroup( + context: StoreContext, + cell: FssHttpCellId, + groupId: ExGuid, + objects: Map +): void { + const entries = pairedGroupEntries(context, groupId); + const partitions = new Map>(); + for (const entry of entries) { + const objectKey = exGuidKey(entry.declaration.objectId); + let objectPartitions = partitions.get(objectKey); + if (!objectPartitions) { + objectPartitions = new Map(); + partitions.set(objectKey, objectPartitions); + } + if (objectPartitions.has(entry.declaration.partitionId)) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object ${objectKey} repeats partition ${entry.declaration.partitionId}`, + { structure: 'Object Group' } + ); + } + objectPartitions.set(entry.declaration.partitionId, entry); + } + + for (const [objectKey, objectPartitions] of partitions) { + if (objects.has(objectKey)) continue; + context.objectCount += 1; + if (context.objectCount > context.limits.maxObjects) { + throw new OneNoteParserError( + 'LIMIT_EXCEEDED', + `Resolved object count exceeds ${context.limits.maxObjects}`, + { structure: 'FSSHTTPB Object Group' } + ); + } + objects.set( + objectKey, + parseStoreObject(context, cell, objectPartitions, objectKey) + ); + } +} + +function pairedGroupEntries( + context: StoreContext, + groupId: ExGuid +): GroupEntry[] { + const group = context.package.dataElements.objectGroups.get( + exGuidKey(groupId) + ); + if (!group) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Object Group ${exGuidKey(groupId)} is absent`, + { structure: 'Object Group' } + ); + } + validateGroupCounts(group, groupId); + return group.declarations.map((declaration, index) => { + const data = group.data[index]!; + validateDeclarationData(declaration, data); + return { declaration, data }; + }); +} + +function validateGroupCounts(group: FssHttpObjectGroup, groupId: ExGuid): void { + if (group.declarations.length !== group.data.length) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object Group ${exGuidKey(groupId)} has ${group.declarations.length} declarations and ${group.data.length} data entries`, + { structure: 'Object Group' } + ); + } + if ( + group.changeFrequencies.length !== 0 && + group.changeFrequencies.length !== group.declarations.length + ) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object Group ${exGuidKey(groupId)} metadata count does not match its declarations`, + { structure: 'Object Group Metadata Block' } + ); + } +} + +function validateDeclarationData( + declaration: FssHttpObjectDeclaration, + data: FssHttpObjectGroupData +): void { + if ( + declaration.objectReferenceCount !== data.objectReferences.length || + declaration.cellReferenceCount !== data.cellReferences.length + ) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object ${exGuidKey(declaration.objectId)} declaration/reference counts do not match its data`, + { structure: 'Object Group' } + ); + } + if (declaration.kind === 'object') { + const actualSize = + data.kind === 'object' + ? data.data.byteLength + : data.kind === 'excluded' + ? data.dataSize + : undefined; + if (actualSize === undefined || declaration.dataSize !== actualSize) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object ${exGuidKey(declaration.objectId)} declaration/data sizes do not match`, + { structure: 'Object Group' } + ); + } + } +} + +function parseStoreObject( + context: StoreContext, + cell: FssHttpCellId, + partitions: ReadonlyMap, + objectKey: string +): StoreObject { + const metadata = requiredObjectPartition( + partitions, + 4, + objectKey, + 'metadata' + ); + const objectData = requiredObjectPartition(partitions, 1, objectKey, 'data'); + const metadataReader = new BinaryReader( + metadata.dataReference.source, + metadata.dataReference.offset, + metadata.dataReference.size, + 'FSSHTTPB object metadata' + ); + const jcid = metadataReader.u32(); + const props = parseObjectPropSetRange( + objectData.dataReference.source, + objectData.dataReference.offset, + objectData.dataReference.size, + context.limits + ); + + const contextCells = objectData.cellReferences.filter( + (reference) => + exGuidKey(reference.objectSpace) === exGuidKey(cell.objectSpace) + ); + const objectSpaceCells = objectData.cellReferences.filter( + (reference) => + exGuidKey(reference.objectSpace) !== exGuidKey(cell.objectSpace) + ); + if (props.objectIds.length < objectData.objectReferences.length) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object ${objectKey} has fewer property-set object IDs than resolved references`, + { structure: 'ObjectPropSet mapping table' } + ); + } + if ( + props.contextIds.length !== contextCells.length || + props.objectSpaceIds.length !== objectSpaceCells.length + ) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object ${objectKey} property-set cell reference counts do not match`, + { structure: 'ObjectPropSet mapping table' } + ); + } + + const objectId = partitions.values().next().value!.declaration.objectId; + return { + id: objectId, + jcid, + props, + mapping: new Map(), + contextId: cell.context, + resolvedObjectIds: objectData.objectReferences, + resolvedObjectSpaceIds: objectSpaceCells.map((reference) => ({ + id: reference.objectSpace, + key: fssHttpCellKey(reference), + })), + resolvedContextIds: contextCells.map((reference) => reference.context), + fileData: parseFileData(context, partitions, objectKey), + }; +} + +function requiredObjectPartition( + partitions: ReadonlyMap, + partitionId: number, + objectKey: string, + name: string +): Extract { + const entry = partitions.get(partitionId); + if (!entry) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Object ${objectKey} ${name} partition is absent`, + { structure: 'Object Group' } + ); + } + if (entry.data.kind === 'excluded') { + throw new OneNoteParserError( + 'UNSUPPORTED_FORMAT', + `Object ${objectKey} ${name} partition is excluded from this package`, + { structure: 'Object Group Data Excluded' } + ); + } + if (entry.data.kind !== 'object') { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Object ${objectKey} ${name} partition is not object data`, + { structure: 'Object Group' } + ); + } + return entry.data; +} + +function parseFileData( + context: StoreContext, + partitions: ReadonlyMap, + objectKey: string +): StoreFileData | undefined { + const entry = partitions.get(2); + if (!entry) return undefined; + if (entry.data.kind !== 'blob-reference') { + throw new OneNoteParserError( + entry.data.kind === 'excluded' + ? 'UNSUPPORTED_FORMAT' + : 'INVALID_STRUCTURE', + `Object ${objectKey} file-data partition is not an available BLOB reference`, + { structure: 'Object Group BLOB reference' } + ); + } + const blob = context.package.dataElements.blobs.get( + exGuidKey(entry.data.blobId) + ); + if (!blob) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Object Data BLOB ${exGuidKey(entry.data.blobId)} is absent`, + { structure: 'Object Data BLOB' } + ); + } + return { + referenceKind: 'embedded', + dataReference: `${exGuidKey(entry.data.blobId)}`, + extension: '', + storageGuid: entry.data.blobId.guid, + blob, + }; +} + +function requiredCellMapping( + context: StoreContext, + cell: FssHttpCellId +): FssHttpStorageIndexCellMapping { + const mapping = context.index.cellMappings.get(fssHttpCellKey(cell)); + if (!mapping) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Cell mapping ${fssHttpCellKey(cell)} is absent`, + { structure: 'Storage Index Cell Mapping' } + ); + } + return mapping; +} + +function resolveCurrentRevisionId( + context: StoreContext, + mapping: FssHttpStorageIndexCellMapping +): ExGuid | undefined { + const cellRevision = context.package.dataElements.cellManifests.get( + exGuidKey(mapping.id) + ); + if (cellRevision && isNilExGuid(cellRevision)) return undefined; + const resolved = cellRevision + ? resolveRevisionId(context, cellRevision) + : resolveRevisionId(context, mapping.id); + if (resolved) return resolved; + + const serialMatches = [...context.index.revisionMappings.values()].filter( + (candidate) => sameSerial(candidate.serial, mapping.serial) + ); + if (serialMatches.length > 1) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'Cell mapping serial number resolves to multiple revision mappings', + { structure: 'Storage Index Revision Mapping' } + ); + } + return serialMatches[0]?.revision; +} + +function resolveRevisionId( + context: StoreContext, + id: ExGuid +): ExGuid | undefined { + const mapped = context.index.revisionMappings.get(exGuidKey(id)); + if (mapped) return mapped.revision; + return context.package.dataElements.revisionManifests.has(exGuidKey(id)) + ? id + : undefined; +} + +function requiredRevisionManifest( + context: StoreContext, + id: ExGuid +): FssHttpRevisionManifest { + const revision = context.package.dataElements.revisionManifests.get( + exGuidKey(id) + ); + if (!revision) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Revision Manifest ${exGuidKey(id)} is absent`, + { structure: 'Revision Manifest' } + ); + } + return revision; +} + +function parseRevisionRole(role: ExGuid): number { + if (role.guid !== REVISION_ROLE_GUID || role.value < 1 || role.value > 4) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Invalid revision root role ${exGuidKey(role)}`, + { structure: 'Revision Manifest root declaration' } + ); + } + return role.value; +} + +function isNilExGuid(id: ExGuid): boolean { + return id.guid === NIL_GUID && id.value === 0; +} + +function sameSerial( + left: FssHttpSerialNumber, + right: FssHttpSerialNumber +): boolean { + return left.guid === right.guid && left.value === right.value; +} + +function addDiagnostic( + diagnostics: ParserDiagnostic[], + limits: OneNoteParserLimits, + code: string, + message: string, + structure: string +): void { + if (diagnostics.length >= limits.maxDiagnostics) return; + diagnostics.push({ + severity: 'warning', + code, + message, + structure, + recoverable: true, + }); +} diff --git a/src/onenote/onestore/property-set.ts b/src/onenote/onestore/property-set.ts index 86ffefc..c83698b 100644 --- a/src/onenote/onestore/property-set.ts +++ b/src/onenote/onestore/property-set.ts @@ -39,13 +39,30 @@ export function parseObjectPropSet( { offset: reference.offset, structure: 'ObjectPropSet' } ); } - const reader = new BinaryReader( + return parseObjectPropSetRange( bytes, reference.offset, reference.size, - 'ObjectPropSet' + limits ); +} +/** Parse a property set from an exact bounded range in the source file. */ +export function parseObjectPropSetRange( + bytes: Uint8Array, + offset: number, + size: number, + limits: OneNoteParserLimits +): ObjectPropSet { + const reader = new BinaryReader(bytes, offset, size, 'ObjectPropSet'); + + return parseObjectPropSetReader(reader, limits); +} + +function parseObjectPropSetReader( + reader: BinaryReader, + limits: OneNoteParserLimits +): ObjectPropSet { const oidHeader = readStreamHeader(reader, limits); const objectIds = readCompactIds(reader, oidHeader.count); let objectSpaceIds: CompactId[] = []; diff --git a/src/onenote/onestore/types.ts b/src/onenote/onestore/types.ts index 77415a9..6949ba7 100644 --- a/src/onenote/onestore/types.ts +++ b/src/onenote/onestore/types.ts @@ -92,12 +92,24 @@ export interface StoreFileData { blob?: FileDataBlobReference; } +/** A resolved object-space reference plus the store-specific lookup key. */ +export interface StoreObjectSpaceReference { + id: ExGuid; + key: string; +} + export interface StoreObject { id: ExGuid; jcid: number; props: ObjectPropSet; mapping: ReadonlyMap; contextId: ExGuid; + /** Pre-resolved FSSHTTPB object stream, indexed like `props.objectIds`. */ + resolvedObjectIds?: readonly ExGuid[]; + /** Pre-resolved FSSHTTPB object-space stream, indexed like `props.objectSpaceIds`. */ + resolvedObjectSpaceIds?: readonly StoreObjectSpaceReference[]; + /** Pre-resolved FSSHTTPB context stream, indexed like `props.contextIds`. */ + resolvedContextIds?: readonly ExGuid[]; fileData?: StoreFileData; } diff --git a/src/onenote/parser/parse-section.ts b/src/onenote/parser/parse-section.ts index 14258d5..ab1183b 100644 --- a/src/onenote/parser/parse-section.ts +++ b/src/onenote/parser/parse-section.ts @@ -7,7 +7,10 @@ import type { OneNoteSectionContentModel } from '../one/content-model.js'; import { buildSectionDto } from '../one/section.js'; import { buildSectionContentModel } from '../one/section-content.js'; import { parseDesktopOneStore } from '../onestore/desktop-store.js'; -import { toBytes } from './detect-format.js'; +import type { DesktopOneStore } from '../onestore/desktop-store.js'; +import { parseFssHttpOneStore } from '../onestore/fsshttp-store.js'; +import { detectOneNoteFormat, toBytes } from './detect-format.js'; +import { OneNoteParserError } from './error.js'; import type { OneNoteParserLimits } from './limits.js'; import { resolveParserLimits } from './limits.js'; @@ -16,7 +19,7 @@ export interface ParseOneNoteSectionOptions { limits?: Partial; } -/** Parse a desktop revision-store `.one` section without network access. */ +/** Parse a supported desktop or FSSHTTPB `.one` section without network access. */ export function parseOneNoteSection( input: ArrayBuffer | Uint8Array, options: ParseOneNoteSectionOptions = {} @@ -24,7 +27,7 @@ export function parseOneNoteSection( const bytes = toBytes(input); const limits = resolveParserLimits(options.limits); const diagnostics: OneNoteSectionDto['diagnostics'] = []; - const store = parseDesktopOneStore(bytes, limits, diagnostics); + const store = parseSectionStore(bytes, limits, diagnostics); return buildSectionDto( store, options.sourceName ?? 'Untitled.one', @@ -41,7 +44,7 @@ export function parseOneNoteSectionContent( const bytes = toBytes(input); const limits = resolveParserLimits(options.limits); const diagnostics: OneNoteSectionContentModel['diagnostics'] = []; - const store = parseDesktopOneStore(bytes, limits, diagnostics); + const store = parseSectionStore(bytes, limits, diagnostics); return buildSectionContentModel( store, options.sourceName ?? 'Untitled.one', @@ -49,3 +52,22 @@ export function parseOneNoteSectionContent( diagnostics ); } + +function parseSectionStore( + bytes: Uint8Array, + limits: OneNoteParserLimits, + diagnostics: OneNoteSectionDto['diagnostics'] +): DesktopOneStore { + if (detectOneNoteFormat(bytes).kind !== 'fsshttp-one') { + return parseDesktopOneStore(bytes, limits, diagnostics); + } + const store = parseFssHttpOneStore(bytes, limits, diagnostics); + if (store.kind !== 'section') { + throw new OneNoteParserError( + 'INVALID_FORMAT', + 'Expected an FSSHTTPB OneNote section, found a table of contents', + { structure: 'OneStore Packaging Structure' } + ); + } + return store; +} diff --git a/src/onenote/parser/parse-toc.ts b/src/onenote/parser/parse-toc.ts index 23f34d8..29669f1 100644 --- a/src/onenote/parser/parse-toc.ts +++ b/src/onenote/parser/parse-toc.ts @@ -187,6 +187,16 @@ function objectReferences(object: StoreObject, propertyId: number): ExGuid[] { propertyId, countObjectReferences ); + if (object.resolvedObjectIds) { + if (offset + count > object.resolvedObjectIds.length) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + 'TOC object-reference range exceeds its resolved OID stream', + { structure: 'TOCEntryIndex_OidIndex' } + ); + } + return object.resolvedObjectIds.slice(offset, offset + count); + } if (offset + count > object.props.objectIds.length) { throw new OneNoteParserError( 'MISSING_REFERENCE',