Files
onenote-tools/src/onenote/onestore/file-data.ts

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