337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
// 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;
|
|
}
|