feat: parse rich OneNote content resources
This commit is contained in:
336
src/onenote/onestore/file-data.ts
Normal file
336
src/onenote/onestore/file-data.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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_node/shared,objects/file_data_store}.rs`. Deviations: file payloads
|
||||
// remain bounded zero-copy byte ranges, all string/count arithmetic is checked,
|
||||
// and unavailable external/invalid references remain explicit metadata.
|
||||
|
||||
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||
import { normalizeGuid, readGuid } from '../binary/guid.js';
|
||||
import { OneNoteParserError } from '../parser/error.js';
|
||||
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||
import type {
|
||||
CompactId,
|
||||
ExGuid,
|
||||
FileDataBlobReference,
|
||||
FileNode,
|
||||
FileNodeList,
|
||||
ObjectPropSet,
|
||||
StoreFileData,
|
||||
} from './types.js';
|
||||
|
||||
const FILE_DATA_STORE_HEADER = '{BDE316E7-2665-4511-A4C4-8D4D0B7A9EAC}';
|
||||
const FILE_DATA_STORE_FOOTER = '{71FBA722-0F79-4A0B-BB13-899256426B24}';
|
||||
const FILE_DATA_FIXED_PREFIX_BYTES = 36;
|
||||
|
||||
export type FileDataStore = ReadonlyMap<string, FileDataBlobReference>;
|
||||
|
||||
export interface ParsedFileDataDeclaration {
|
||||
compactId: CompactId;
|
||||
jcid: number;
|
||||
fileData: StoreFileData;
|
||||
}
|
||||
|
||||
export function emptyObjectPropSet(): ObjectPropSet {
|
||||
return {
|
||||
objectIds: [],
|
||||
objectSpaceIds: [],
|
||||
contextIds: [],
|
||||
properties: { entries: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the optional root-level FileDataStoreListReferenceFND (0x090). */
|
||||
export function parseFileDataStore(
|
||||
bytes: Uint8Array,
|
||||
rootList: FileNodeList,
|
||||
limits: OneNoteParserLimits
|
||||
): FileDataStore {
|
||||
const storeNodes = rootList.nodes.filter((node) => node.id === 0x090);
|
||||
if (storeNodes.length > 1) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'Root FileNodeList contains more than one file-data store',
|
||||
{ structure: 'FileDataStoreListReferenceFND' }
|
||||
);
|
||||
}
|
||||
const storeNode = storeNodes[0];
|
||||
if (!storeNode) return new Map();
|
||||
if (!storeNode.childList) {
|
||||
throw new OneNoteParserError(
|
||||
'MISSING_REFERENCE',
|
||||
'FileDataStoreListReferenceFND has no child FileNodeList',
|
||||
{ offset: storeNode.offset, structure: 'FileDataStoreListReferenceFND' }
|
||||
);
|
||||
}
|
||||
|
||||
const result = new Map<string, FileDataBlobReference>();
|
||||
let totalBytes = 0;
|
||||
for (const node of storeNode.childList.nodes) {
|
||||
if (node.id !== 0x094) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
`Unexpected FileNode 0x${node.id.toString(16)} in file-data store`,
|
||||
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||
);
|
||||
}
|
||||
if (result.size >= limits.maxFileDataObjects) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
`File-data object count exceeds ${limits.maxFileDataObjects}`,
|
||||
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||
);
|
||||
}
|
||||
if (
|
||||
!node.dataReference ||
|
||||
node.dataReference.nil ||
|
||||
node.dataReference.zero
|
||||
) {
|
||||
throw new OneNoteParserError(
|
||||
'MISSING_REFERENCE',
|
||||
'FileDataStoreObjectReferenceFND has no data reference',
|
||||
{ offset: node.offset, structure: 'FileDataStoreObjectReferenceFND' }
|
||||
);
|
||||
}
|
||||
|
||||
const nodeReader = new BinaryReader(
|
||||
bytes,
|
||||
node.payloadOffset,
|
||||
node.payloadLength,
|
||||
'FileDataStoreObjectReferenceFND'
|
||||
);
|
||||
const guid = readGuid(nodeReader);
|
||||
if (nodeReader.remaining !== 0) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'FileDataStoreObjectReferenceFND has trailing payload bytes',
|
||||
{
|
||||
offset: nodeReader.offset,
|
||||
structure: 'FileDataStoreObjectReferenceFND',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (result.has(guid)) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
`Duplicate file-data GUID ${guid}`,
|
||||
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||
);
|
||||
}
|
||||
|
||||
const blob = parseFileDataStoreObject(bytes, node, limits);
|
||||
totalBytes = checkedAdd(totalBytes, blob.size, node.offset);
|
||||
if (totalBytes > limits.maxTotalFileDataBytes) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
`File-data payloads exceed ${limits.maxTotalFileDataBytes} bytes`,
|
||||
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||
);
|
||||
}
|
||||
result.set(guid, blob);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Parse ObjectDeclarationFileData3RefCountFND (0x072/0x073). */
|
||||
export function parseFileDataDeclaration(
|
||||
bytes: Uint8Array,
|
||||
node: FileNode,
|
||||
store: FileDataStore,
|
||||
limits: OneNoteParserLimits
|
||||
): ParsedFileDataDeclaration {
|
||||
if (node.id !== 0x072 && node.id !== 0x073) {
|
||||
throw new TypeError('Expected a file-data object declaration');
|
||||
}
|
||||
const reader = new BinaryReader(
|
||||
bytes,
|
||||
node.payloadOffset,
|
||||
node.payloadLength,
|
||||
'ObjectDeclarationFileData3RefCountFND'
|
||||
);
|
||||
const compactId = readCompactId(reader);
|
||||
const jcid = reader.u32();
|
||||
if (node.id === 0x072) reader.u8();
|
||||
else reader.u32();
|
||||
const dataReference = readStorageString(reader, limits);
|
||||
const extension = readStorageString(reader, limits);
|
||||
if (reader.remaining !== 0) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'File-data declaration has trailing payload bytes',
|
||||
{
|
||||
offset: reader.offset,
|
||||
structure: 'ObjectDeclarationFileData3RefCountFND',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
compactId,
|
||||
jcid,
|
||||
fileData: resolveFileData(dataReference, extension, store, node),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCompactFileDataId(
|
||||
compactId: CompactId,
|
||||
mapping: ReadonlyMap<number, string>
|
||||
): ExGuid {
|
||||
const guid = mapping.get(compactId.guidIndex);
|
||||
if (!guid) {
|
||||
throw new OneNoteParserError(
|
||||
'MISSING_REFERENCE',
|
||||
`GlobalIdTable has no GUID at index ${compactId.guidIndex}`,
|
||||
{ structure: 'CompactId' }
|
||||
);
|
||||
}
|
||||
return { guid, value: compactId.value };
|
||||
}
|
||||
|
||||
function parseFileDataStoreObject(
|
||||
bytes: Uint8Array,
|
||||
node: FileNode,
|
||||
limits: OneNoteParserLimits
|
||||
): FileDataBlobReference {
|
||||
const reference = node.dataReference!;
|
||||
const reader = new BinaryReader(
|
||||
bytes,
|
||||
reference.offset,
|
||||
reference.size,
|
||||
'FileDataStoreObject'
|
||||
);
|
||||
if (readGuid(reader) !== FILE_DATA_STORE_HEADER) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'Invalid FileDataStoreObject header GUID',
|
||||
{ offset: reference.offset, structure: 'FileDataStoreObject' }
|
||||
);
|
||||
}
|
||||
const rawLength = reader.u64();
|
||||
if (rawLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
'File-data length exceeds the safe integer range',
|
||||
{ offset: reader.offset - 8, structure: 'FileDataStoreObject' }
|
||||
);
|
||||
}
|
||||
const size = Number(rawLength);
|
||||
if (size > limits.maxFileDataBytes) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
`File-data payload is ${size} bytes (limit ${limits.maxFileDataBytes})`,
|
||||
{ offset: reader.offset - 8, structure: 'FileDataStoreObject' }
|
||||
);
|
||||
}
|
||||
reader.u32(); // unused; consumers MUST ignore it
|
||||
reader.u64(); // reserved; consumers MUST ignore it
|
||||
const dataOffset = reader.offset;
|
||||
reader.skip(size);
|
||||
const padding = (8 - ((FILE_DATA_FIXED_PREFIX_BYTES + size) % 8)) % 8;
|
||||
reader.skip(padding);
|
||||
if (readGuid(reader) !== FILE_DATA_STORE_FOOTER) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'Invalid FileDataStoreObject footer GUID',
|
||||
{ offset: reader.offset - 16, structure: 'FileDataStoreObject' }
|
||||
);
|
||||
}
|
||||
if (reader.remaining !== 0) {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'FileDataStoreObject has trailing bytes',
|
||||
{ offset: reader.offset, structure: 'FileDataStoreObject' }
|
||||
);
|
||||
}
|
||||
return { source: bytes, offset: dataOffset, size };
|
||||
}
|
||||
|
||||
function readStorageString(
|
||||
reader: BinaryReader,
|
||||
limits: OneNoteParserLimits
|
||||
): string {
|
||||
const characterCount = reader.u32();
|
||||
if (characterCount > Math.floor(limits.maxVectorBytes / 2)) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
`Storage string contains ${characterCount} UTF-16 code units`,
|
||||
{ offset: reader.offset - 4, structure: 'StringInStorageBuffer' }
|
||||
);
|
||||
}
|
||||
const byteLength = characterCount * 2;
|
||||
try {
|
||||
return new TextDecoder('utf-16le', { fatal: true }).decode(
|
||||
reader.read(byteLength)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof OneNoteParserError) throw error;
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
'StringInStorageBuffer contains invalid UTF-16',
|
||||
{ offset: reader.offset - byteLength, structure: 'StringInStorageBuffer' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileData(
|
||||
dataReference: string,
|
||||
extension: string,
|
||||
store: FileDataStore,
|
||||
node: FileNode
|
||||
): StoreFileData {
|
||||
if (dataReference.startsWith('<ifndf>')) {
|
||||
const rawGuid = dataReference.slice('<ifndf>'.length);
|
||||
let storageGuid: string;
|
||||
try {
|
||||
storageGuid = normalizeGuid(rawGuid);
|
||||
} catch {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
`Invalid embedded file-data GUID ${rawGuid}`,
|
||||
{
|
||||
offset: node.offset,
|
||||
structure: 'ObjectDeclarationFileData3RefCountFND',
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
referenceKind: 'embedded',
|
||||
dataReference,
|
||||
extension,
|
||||
storageGuid,
|
||||
blob: store.get(storageGuid),
|
||||
};
|
||||
}
|
||||
if (dataReference.startsWith('<file>')) {
|
||||
return { referenceKind: 'external', dataReference, extension };
|
||||
}
|
||||
if (dataReference === '<invfdo>') {
|
||||
return { referenceKind: 'invalid', dataReference, extension };
|
||||
}
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
`Unknown file-data reference ${dataReference}`,
|
||||
{ offset: node.offset, structure: 'ObjectDeclarationFileData3RefCountFND' }
|
||||
);
|
||||
}
|
||||
|
||||
function readCompactId(reader: BinaryReader): CompactId {
|
||||
const raw = reader.u32();
|
||||
return { value: raw & 0xff, guidIndex: raw >>> 8 };
|
||||
}
|
||||
|
||||
function checkedAdd(left: number, right: number, offset: number): number {
|
||||
const result = left + right;
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
'File-data byte count exceeds the safe integer range',
|
||||
{ offset, structure: 'FileDataStoreList' }
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user