feat: parse bounded FSSHTTPB packages

This commit is contained in:
2026-07-22 19:15:51 +02:00
parent c99615f5e6
commit 30b0357530
10 changed files with 1795 additions and 0 deletions

View File

@@ -0,0 +1,723 @@
// 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: `fsshttpb/data_element/*`.
// Deviations: immutable zero-copy payload views, duplicate rejection, exact
// header-length validation, explicit count/byte limits, and offset-aware errors.
import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import type { ExGuid } from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import {
expectFssHttpEnd,
expectFssHttpStart,
finishFssHttpBody,
hasFssHttpEnd,
readCompactNumber,
readFssHttpCellId,
readFssHttpCellIdArray,
readFssHttpExGuid,
readFssHttpExGuidArray,
readFssHttpSerialNumber,
readFssHttpStreamHeader,
takeFssHttpHeaderBody,
} from './primitives.js';
import {
FSSHTTP_OBJECT,
fssHttpCellKey,
type FssHttpDataElementFragment,
type FssHttpDataElementPackage,
type FssHttpObjectDeclaration,
type FssHttpObjectGroup,
type FssHttpObjectGroupData,
type FssHttpRevisionManifest,
type FssHttpStorageIndex,
type FssHttpStorageManifest,
type FssHttpStreamHeader,
} from './types.js';
interface DataElementContext {
limits: OneNoteParserLimits;
elementCount: number;
objectCount: number;
blobCount: number;
totalBlobBytes: number;
}
export function parseFssHttpDataElementPackage(
reader: BinaryReader,
limits: OneNoteParserLimits
): FssHttpDataElementPackage {
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.dataElementPackage,
true,
2
);
const body = takeFssHttpHeaderBody(
reader,
header,
'Data Element Package start'
);
if (body.u8() !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Data Element Package reserved byte is not zero',
{ offset: body.offset - 1, structure: 'Data Element Package' }
);
}
finishFssHttpBody(body, 'Data Element Package start');
const result: FssHttpDataElementPackage = {
storageIndexes: new Map(),
storageManifests: new Map(),
cellManifests: new Map(),
revisionManifests: new Map(),
objectGroups: new Map(),
fragments: new Map(),
blobs: new Map(),
};
const context: DataElementContext = {
limits,
elementCount: 0,
objectCount: 0,
blobCount: 0,
totalBlobBytes: 0,
};
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElementPackage)) {
context.elementCount += 1;
if (context.elementCount > limits.maxStreamIds) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Data element count exceeds ${limits.maxStreamIds}`,
{ offset: reader.offset, structure: 'Data Element Package' }
);
}
parseDataElement(reader, result, context);
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElementPackage);
return result;
}
function parseDataElement(
reader: BinaryReader,
result: FssHttpDataElementPackage,
context: DataElementContext
): void {
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.dataElement,
true,
2
);
const body = takeFssHttpHeaderBody(reader, header, 'Data Element start');
const id = readFssHttpExGuid(body);
readFssHttpSerialNumber(body);
const type = readCompactNumber(body, 0xff, 'Data element type');
finishFssHttpBody(body, 'Data Element start');
switch (type) {
case 0x01:
insertUnique(
result.storageIndexes,
id,
parseStorageIndex(reader, context),
header.offset,
'Storage Index'
);
break;
case 0x02:
insertUnique(
result.storageManifests,
id,
parseStorageManifest(reader, context),
header.offset,
'Storage Manifest'
);
break;
case 0x03:
insertUnique(
result.cellManifests,
id,
parseCellManifest(reader),
header.offset,
'Cell Manifest'
);
break;
case 0x04:
insertUnique(
result.revisionManifests,
id,
parseRevisionManifest(reader, context),
header.offset,
'Revision Manifest'
);
break;
case 0x05:
insertUnique(
result.objectGroups,
id,
parseObjectGroup(reader, context),
header.offset,
'Object Group'
);
break;
case 0x06:
insertUnique(
result.fragments,
id,
parseDataElementFragment(reader, context),
header.offset,
'Data Element Fragment'
);
break;
case 0x0a:
insertUnique(
result.blobs,
id,
parseObjectDataBlob(reader, context),
header.offset,
'Object Data BLOB'
);
break;
default:
throw new OneNoteParserError(
'UNSUPPORTED_FORMAT',
`Unsupported FSSHTTPB data element type 0x${type.toString(16)}`,
{ offset: header.offset, structure: 'Data Element' }
);
}
}
function parseStorageIndex(
reader: BinaryReader,
context: DataElementContext
): FssHttpStorageIndex {
const manifestMappings: FssHttpStorageIndex['manifestMappings'] = [];
const cellMappings: FssHttpStorageIndex['cellMappings'] = new Map();
const revisionMappings: FssHttpStorageIndex['revisionMappings'] = new Map();
let count = 0;
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement)) {
count = boundedIncrement(
count,
context.limits.maxStreamIds,
reader,
'Storage Index mappings'
);
const header = readFssHttpStreamHeader(reader, 2);
if (header.compound) unexpectedCompound(header, 'Storage Index mapping');
const body = takeFssHttpHeaderBody(reader, header, 'Storage Index mapping');
if (header.type === FSSHTTP_OBJECT.storageIndexManifestMapping) {
manifestMappings.push({
id: readFssHttpExGuid(body),
serial: readFssHttpSerialNumber(body),
});
} else if (header.type === FSSHTTP_OBJECT.storageIndexCellMapping) {
const cellId = readFssHttpCellId(body);
const mapping = {
cellId,
id: readFssHttpExGuid(body),
serial: readFssHttpSerialNumber(body),
};
insertUniqueKey(
cellMappings,
fssHttpCellKey(cellId),
mapping,
header.offset,
'Storage Index Cell Mapping'
);
} else if (header.type === FSSHTTP_OBJECT.storageIndexRevisionMapping) {
const id = readFssHttpExGuid(body);
const mapping = {
id,
revision: readFssHttpExGuid(body),
serial: readFssHttpSerialNumber(body),
};
insertUnique(
revisionMappings,
id,
mapping,
header.offset,
'Storage Index Revision Mapping'
);
} else {
unexpectedType(header, 'Storage Index');
}
finishFssHttpBody(body, 'Storage Index mapping');
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return { manifestMappings, cellMappings, revisionMappings };
}
function parseStorageManifest(
reader: BinaryReader,
context: DataElementContext
): FssHttpStorageManifest {
const schemaHeader = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.storageManifest,
false,
2
);
const schemaBody = takeFssHttpHeaderBody(
reader,
schemaHeader,
'Storage Manifest schema'
);
const schema = readGuid(schemaBody);
finishFssHttpBody(schemaBody, 'Storage Manifest schema');
const roots = new Map<string, ReturnType<typeof readFssHttpCellId>>();
let count = 0;
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement)) {
count = boundedIncrement(
count,
context.limits.maxStreamIds,
reader,
'Storage Manifest roots'
);
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.storageManifestRoot,
false,
2
);
const body = takeFssHttpHeaderBody(reader, header, 'Storage Manifest root');
const rootId = readFssHttpExGuid(body);
const cell = readFssHttpCellId(body);
finishFssHttpBody(body, 'Storage Manifest root');
insertUniqueKey(
roots,
exGuidKey(rootId),
cell,
header.offset,
'Storage Manifest root'
);
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return { schema, roots };
}
function parseCellManifest(reader: BinaryReader): ExGuid {
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.cellManifest,
false,
2
);
const body = takeFssHttpHeaderBody(reader, header, 'Cell Manifest');
const revisionId = readFssHttpExGuid(body);
finishFssHttpBody(body, 'Cell Manifest');
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return revisionId;
}
function parseRevisionManifest(
reader: BinaryReader,
context: DataElementContext
): FssHttpRevisionManifest {
const manifestHeader = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.revisionManifest,
false,
2
);
const manifestBody = takeFssHttpHeaderBody(
reader,
manifestHeader,
'Revision Manifest'
);
const revisionId = readFssHttpExGuid(manifestBody);
const baseRevisionId = readFssHttpExGuid(manifestBody);
finishFssHttpBody(manifestBody, 'Revision Manifest');
const roots: FssHttpRevisionManifest['roots'] = [];
const objectGroupIds: ExGuid[] = [];
let count = 0;
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement)) {
count = boundedIncrement(
count,
context.limits.maxStreamIds,
reader,
'Revision Manifest entries'
);
const header = readFssHttpStreamHeader(reader, 2);
if (header.compound) unexpectedCompound(header, 'Revision Manifest entry');
const body = takeFssHttpHeaderBody(
reader,
header,
'Revision Manifest entry'
);
if (header.type === FSSHTTP_OBJECT.revisionManifestRoot) {
roots.push({
role: readFssHttpExGuid(body),
objectId: readFssHttpExGuid(body),
});
} else if (header.type === FSSHTTP_OBJECT.revisionManifestGroupReference) {
objectGroupIds.push(readFssHttpExGuid(body));
} else {
unexpectedType(header, 'Revision Manifest');
}
finishFssHttpBody(body, 'Revision Manifest entry');
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return { revisionId, baseRevisionId, roots, objectGroupIds };
}
function parseObjectGroup(
reader: BinaryReader,
context: DataElementContext
): FssHttpObjectGroup {
const declarationsHeader = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.objectGroupDeclaration,
true
);
const declarationsBody = takeFssHttpHeaderBody(
reader,
declarationsHeader,
'Object Group Declarations start'
);
finishFssHttpBody(declarationsBody, 'Object Group Declarations start');
const declarations: FssHttpObjectDeclaration[] = [];
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupDeclaration)) {
context.objectCount += 1;
if (context.objectCount > context.limits.maxObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FSSHTTPB object declaration count exceeds ${context.limits.maxObjects}`,
{ offset: reader.offset, structure: 'Object Group Declarations' }
);
}
declarations.push(parseObjectDeclaration(reader, context));
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupDeclaration);
const changeFrequencies: number[] = [];
let dataHeader = readFssHttpStreamHeader(reader);
if (dataHeader.type === FSSHTTP_OBJECT.objectGroupMetadataBlock) {
if (!dataHeader.compound)
unexpectedCompound(dataHeader, 'Object Group Metadata Block', true);
const metadataBody = takeFssHttpHeaderBody(
reader,
dataHeader,
'Object Group Metadata Block start'
);
finishFssHttpBody(metadataBody, 'Object Group Metadata Block start');
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupMetadataBlock)) {
if (changeFrequencies.length >= context.limits.maxObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object Group metadata count exceeds ${context.limits.maxObjects}`,
{ offset: reader.offset, structure: 'Object Group Metadata Block' }
);
}
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.objectGroupMetadata,
false,
4
);
const body = takeFssHttpHeaderBody(
reader,
header,
'Object Group Metadata'
);
const frequency = readCompactNumber(body, 4, 'Object change frequency');
changeFrequencies.push(frequency);
finishFssHttpBody(body, 'Object Group Metadata');
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupMetadataBlock);
dataHeader = readFssHttpStreamHeader(reader);
}
if (
dataHeader.type !== FSSHTTP_OBJECT.objectGroupData ||
!dataHeader.compound
) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Expected Object Group Data, found type 0x${dataHeader.type.toString(16)}`,
{ offset: dataHeader.offset, structure: 'Object Group' }
);
}
const dataStartBody = takeFssHttpHeaderBody(
reader,
dataHeader,
'Object Group Data start'
);
finishFssHttpBody(dataStartBody, 'Object Group Data start');
const data: FssHttpObjectGroupData[] = [];
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupData)) {
if (data.length >= context.limits.maxObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object Group data count exceeds ${context.limits.maxObjects}`,
{ offset: reader.offset, structure: 'Object Group Data' }
);
}
data.push(parseObjectGroupData(reader, context));
}
expectFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupData);
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return { declarations, changeFrequencies, data };
}
function parseObjectDeclaration(
reader: BinaryReader,
context: DataElementContext
): FssHttpObjectDeclaration {
const header = readFssHttpStreamHeader(reader);
if (header.compound) unexpectedCompound(header, 'Object declaration');
const body = takeFssHttpHeaderBody(reader, header, 'Object declaration');
const objectId = readFssHttpExGuid(body);
let result: FssHttpObjectDeclaration;
if (header.type === FSSHTTP_OBJECT.objectGroupObject) {
result = {
kind: 'object',
objectId,
partitionId: readCompactNumber(body, 4, 'Object partition ID'),
dataSize: readCompactNumber(
body,
context.limits.maxVectorBytes,
'Object data size'
),
objectReferenceCount: readCompactNumber(
body,
context.limits.maxObjectReferences,
'Object reference count'
),
cellReferenceCount: readCompactNumber(
body,
context.limits.maxObjectReferences,
'Cell reference count'
),
};
} else if (header.type === FSSHTTP_OBJECT.objectGroupDataBlob) {
result = {
kind: 'blob',
objectId,
blobId: readFssHttpExGuid(body),
partitionId: readCompactNumber(body, 4, 'Object partition ID'),
objectReferenceCount: readCompactNumber(
body,
context.limits.maxObjectReferences,
'Object reference count'
),
cellReferenceCount: readCompactNumber(
body,
context.limits.maxObjectReferences,
'Cell reference count'
),
};
} else {
unexpectedType(header, 'Object Group Declarations');
}
finishFssHttpBody(body, 'Object declaration');
return result;
}
function parseObjectGroupData(
reader: BinaryReader,
context: DataElementContext
): FssHttpObjectGroupData {
const header = readFssHttpStreamHeader(reader);
if (header.compound) unexpectedCompound(header, 'Object Group data entry');
const body = takeFssHttpHeaderBody(reader, header, 'Object Group data entry');
const objectReferences = readFssHttpExGuidArray(body, context.limits);
const cellReferences = readFssHttpCellIdArray(body, context.limits);
let result: FssHttpObjectGroupData;
if (header.type === FSSHTTP_OBJECT.objectGroupDataExcluded) {
result = {
kind: 'excluded',
objectReferences,
cellReferences,
dataSize: readCompactNumber(
body,
context.limits.maxVectorBytes,
'Excluded object data size'
),
};
} else if (header.type === FSSHTTP_OBJECT.objectGroupDataObject) {
const size = readCompactNumber(
body,
context.limits.maxVectorBytes,
'Object binary item size'
);
result = {
kind: 'object',
objectReferences,
cellReferences,
data: body.read(size),
};
} else if (header.type === FSSHTTP_OBJECT.objectGroupBlobReference) {
result = {
kind: 'blob-reference',
objectReferences,
cellReferences,
blobId: readFssHttpExGuid(body),
};
} else {
unexpectedType(header, 'Object Group Data');
}
finishFssHttpBody(body, 'Object Group data entry');
return result;
}
function parseObjectDataBlob(
reader: BinaryReader,
context: DataElementContext
) {
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.objectDataBlob,
false
);
const body = takeFssHttpHeaderBody(reader, header, 'Object Data BLOB');
const size = readCompactNumber(
body,
context.limits.maxFileDataBytes,
'Object Data BLOB size'
);
context.blobCount += 1;
context.totalBlobBytes += size;
if (context.blobCount > context.limits.maxFileDataObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object Data BLOB count exceeds ${context.limits.maxFileDataObjects}`,
{ offset: header.offset, structure: 'Object Data BLOB' }
);
}
if (context.totalBlobBytes > context.limits.maxTotalFileDataBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object Data BLOB bytes exceed ${context.limits.maxTotalFileDataBytes}`,
{ offset: header.offset, structure: 'Object Data BLOB' }
);
}
const offset = body.offset;
body.skip(size);
finishFssHttpBody(body, 'Object Data BLOB');
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return { source: reader.bytes, offset, size };
}
function parseDataElementFragment(
reader: BinaryReader,
context: DataElementContext
): FssHttpDataElementFragment {
const header = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.dataElementFragment,
false,
4
);
const body = takeFssHttpHeaderBody(reader, header, 'Data Element Fragment');
const dataElementId = readFssHttpExGuid(body);
const fragmentSize = readCompactNumber(
body,
context.limits.maxFileBytes,
'Fragmented data element size'
);
const chunkOffset = readCompactNumber(
body,
fragmentSize,
'Fragment chunk offset'
);
const chunkLength = readCompactNumber(
body,
context.limits.maxVectorBytes,
'Fragment chunk length'
);
if (chunkOffset + chunkLength > fragmentSize) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Fragment chunk range exceeds the fragmented data element size',
{ offset: header.offset, structure: 'Data Element Fragment' }
);
}
if (body.remaining !== chunkLength) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Fragment header declares ${chunkLength} data bytes; ${body.remaining} remain`,
{ offset: header.offset, structure: 'Data Element Fragment' }
);
}
const data = body.read(chunkLength);
finishFssHttpBody(body, 'Data Element Fragment');
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
return { dataElementId, fragmentSize, chunkOffset, chunkLength, data };
}
function boundedIncrement(
current: number,
limit: number,
reader: BinaryReader,
structure: string
): number {
const next = current + 1;
if (next > limit) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`${structure} count exceeds ${limit}`,
{ offset: reader.offset, structure }
);
}
return next;
}
function insertUnique<T>(
map: Map<string, T>,
id: ExGuid,
value: T,
offset: number,
structure: string
): void {
insertUniqueKey(map, exGuidKey(id), value, offset, structure);
}
function insertUniqueKey<T>(
map: Map<string, T>,
key: string,
value: T,
offset: number,
structure: string
): void {
if (map.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`${structure} identifier ${key} is duplicated`,
{ offset, structure }
);
}
map.set(key, value);
}
function unexpectedType(header: FssHttpStreamHeader, structure: string): never {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected stream object type 0x${header.type.toString(16)}`,
{ offset: header.offset, structure }
);
}
function unexpectedCompound(
header: FssHttpStreamHeader,
structure: string,
expected = false
): never {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`${structure} must be ${expected ? 'compound' : 'single'}`,
{ offset: header.offset, structure }
);
}

View File

@@ -0,0 +1,5 @@
export { parseFssHttpOneStorePackage } from './package.js';
export type {
FssHttpDataElementPackage,
FssHttpOneStorePackage,
} from './types.js';

View File

@@ -0,0 +1,111 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ONE_SECTION_FILE_TYPE } from '../parser/detect-format.js';
import { resolveParserLimits } from '../parser/limits.js';
import {
FSSHTTP_SECTION_SCHEMA,
parseFssHttpOneStorePackage,
} from './package.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 packaging', () => {
it('retains and parses the pinned Joplin Quick Notes fixture', () => {
const bytes = fixtureBytes();
expect(bytes).toHaveLength(17_197);
expect(createHash('sha256').update(bytes).digest('hex')).toBe(
'5e501ef5f539cd2f149786f57659be9d0762913d863a69867deb9c8e8c43efe2'
);
const parsed = parseFssHttpOneStorePackage(bytes, resolveParserLimits());
expect(parsed).toMatchObject({
fileType: ONE_SECTION_FILE_TYPE,
cellSchema: FSSHTTP_SECTION_SCHEMA,
});
expect({
indexes: parsed.dataElements.storageIndexes.size,
manifests: parsed.dataElements.storageManifests.size,
cells: parsed.dataElements.cellManifests.size,
revisions: parsed.dataElements.revisionManifests.size,
groups: parsed.dataElements.objectGroups.size,
fragments: parsed.dataElements.fragments.size,
blobs: parsed.dataElements.blobs.size,
}).toEqual({
indexes: 1,
manifests: 1,
cells: 4,
revisions: 9,
groups: 9,
fragments: 0,
blobs: 0,
});
});
it('rejects malformed reserved bytes with an absolute offset', () => {
const bytes = fixtureBytes();
bytes[64] = 1;
expect(() =>
parseFssHttpOneStorePackage(bytes, resolveParserLimits())
).toThrowError(
expect.objectContaining({
code: 'INVALID_STRUCTURE',
offset: 64,
structure: 'OneStore Packaging Structure',
})
);
});
it('rejects a truncated package as a bounded parse failure', () => {
const bytes = fixtureBytes().subarray(0, 1_000);
expect(() =>
parseFssHttpOneStorePackage(bytes, resolveParserLimits())
).toThrowError(
expect.objectContaining({
code: 'BOUNDS_EXCEEDED',
offset: expect.any(Number),
})
);
});
it('enforces source and data-element count limits', () => {
const bytes = fixtureBytes();
expect(() =>
parseFssHttpOneStorePackage(
bytes,
resolveParserLimits({ maxFileBytes: bytes.byteLength - 1 })
)
).toThrowError(
expect.objectContaining({ code: 'LIMIT_EXCEEDED', offset: 0 })
);
expect(() =>
parseFssHttpOneStorePackage(
bytes,
resolveParserLimits({ maxStreamIds: 1 })
)
).toThrowError(expect.objectContaining({ code: 'LIMIT_EXCEEDED' }));
});
it('allows only zero bytes after the packaging end marker', () => {
const bytes = fixtureBytes();
bytes[bytes.length - 1] = 1;
expect(() =>
parseFssHttpOneStorePackage(bytes, resolveParserLimits())
).toThrowError(
expect.objectContaining({
code: 'INVALID_STRUCTURE',
offset: bytes.length - 1,
structure: 'OneStore Packaging Structure padding',
})
);
});
});

View File

@@ -0,0 +1,128 @@
// 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: `fsshttpb/packaging.rs`.
// Deviations: direct immutable browser bytes, exact envelope consumption,
// explicit bounds, and offset-aware failures.
import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import {
FSSHTTP_PACKAGING_FORMAT,
ONE_SECTION_FILE_TYPE,
ONE_TOC_FILE_TYPE,
} from '../parser/detect-format.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import { parseFssHttpDataElementPackage } from './data-elements.js';
import {
expectFssHttpEnd,
expectFssHttpStart,
finishFssHttpBody,
readFssHttpExGuid,
takeFssHttpHeaderBody,
} from './primitives.js';
import { FSSHTTP_OBJECT, type FssHttpOneStorePackage } from './types.js';
export const FSSHTTP_SECTION_SCHEMA = '{1F937CB4-B26F-445F-B9F8-17E20160E461}';
export const FSSHTTP_TOC_SCHEMA = '{E4DBFD38-E5C7-408B-A8A1-0E7B421E1F5F}';
/** Parse the MS-ONESTORE 2.8.1 package envelope and its data elements. */
export function parseFssHttpOneStorePackage(
bytes: Uint8Array,
limits: OneNoteParserLimits
): FssHttpOneStorePackage {
if (bytes.byteLength > limits.maxFileBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FSSHTTP-packaged OneNote source is ${bytes.byteLength} bytes (limit ${limits.maxFileBytes})`,
{ offset: 0, structure: 'OneStore Packaging Structure' }
);
}
const reader = new BinaryReader(
bytes,
0,
bytes.byteLength,
'OneStore Packaging Structure'
);
const fileType = readGuid(reader);
const fileIdentity = readGuid(reader);
const legacyFileVersion = readGuid(reader);
const fileFormat = readGuid(reader);
if (fileType !== ONE_SECTION_FILE_TYPE && fileType !== ONE_TOC_FILE_TYPE) {
throw new OneNoteParserError(
'INVALID_FORMAT',
`Unsupported package-store file type ${fileType}`,
{ offset: 0, structure: 'OneStore Packaging Structure' }
);
}
if (fileFormat !== FSSHTTP_PACKAGING_FORMAT) {
throw new OneNoteParserError(
'INVALID_FORMAT',
`Expected package-store format ${FSSHTTP_PACKAGING_FORMAT}, found ${fileFormat}`,
{ offset: 48, structure: 'OneStore Packaging Structure' }
);
}
if (reader.u32() !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Package-store reserved bytes are not zero',
{ offset: 64, structure: 'OneStore Packaging Structure' }
);
}
const packagingHeader = expectFssHttpStart(
reader,
FSSHTTP_OBJECT.oneNotePackaging,
true,
4
);
const packagingBody = takeFssHttpHeaderBody(
reader,
packagingHeader,
'OneNote Packaging start'
);
const storageIndexId = readFssHttpExGuid(packagingBody);
const cellSchema = readGuid(packagingBody);
finishFssHttpBody(packagingBody, 'OneNote Packaging start');
if (
(fileType === ONE_SECTION_FILE_TYPE &&
cellSchema !== FSSHTTP_SECTION_SCHEMA) ||
(fileType === ONE_TOC_FILE_TYPE && cellSchema !== FSSHTTP_TOC_SCHEMA)
) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Cell schema ${cellSchema} does not match package file type ${fileType}`,
{ offset: packagingHeader.bodyOffset, structure: 'guidCellSchemaId' }
);
}
const dataElements = parseFssHttpDataElementPackage(reader, limits);
expectFssHttpEnd(reader, FSSHTTP_OBJECT.oneNotePackaging);
// OneDrive exports observed in the pinned corpus pad the package to the
// physical file size. The packaging end is authoritative, but non-zero
// trailing data is never accepted as padding.
while (reader.remaining > 0) {
const offset = reader.offset;
if (reader.u8() !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'OneStore Packaging Structure has non-zero trailing data',
{ offset, structure: 'OneStore Packaging Structure padding' }
);
}
}
return {
fileType,
fileIdentity,
legacyFileVersion,
fileFormat,
storageIndexId,
cellSchema,
dataElements,
};
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { BinaryReader } from '../binary/BinaryReader.js';
import {
readCompactU64,
readFssHttpExGuid,
readFssHttpStreamHeader,
} from './primitives.js';
describe('FSSHTTPB primitives', () => {
it.each([
[[0x00], 0n],
[[(0x5a << 1) | 1], 0x5an],
[[0xd2, 0x48], 0x1234n],
[
[0x80, 0xef, 0xcd, 0xab, 0x89, 0x67, 0x45, 0x23, 0x01],
0x0123_4567_89ab_cdefn,
],
] as const)('decodes compact unsigned integer %j', (input, expected) => {
const reader = new BinaryReader(Uint8Array.from(input));
expect(readCompactU64(reader)).toBe(expected);
expect(reader.remaining).toBe(0);
});
it('rejects a truncated extended GUID at the original absolute offset', () => {
const reader = new BinaryReader(
Uint8Array.from([0, 0, 0x80, 1, 2]),
2,
3,
'test ExGuid'
);
expect(() => readFssHttpExGuid(reader)).toThrowError(
expect.objectContaining({ code: 'BOUNDS_EXCEEDED', offset: 3 })
);
});
it('rejects stream-object end markers where a start is required', () => {
const reader = new BinaryReader(Uint8Array.from([0x05]));
expect(() => readFssHttpStreamHeader(reader)).toThrowError(
expect.objectContaining({
code: 'INVALID_STRUCTURE',
offset: 0,
structure: 'FSSHTTPB stream object header',
})
);
});
});

View File

@@ -0,0 +1,279 @@
// 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: `fsshttpb/data/
// {cell_id,compact_u64,exguid,serial_number,stream_object}.rs`. Deviations:
// all ranges and integer conversions are checked with offset-aware errors.
import { BinaryReader } from '../binary/BinaryReader.js';
import { NIL_GUID, readGuid } from '../binary/guid.js';
import type { ExGuid } from '../onestore/types.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type {
FssHttpCellId,
FssHttpSerialNumber,
FssHttpStreamHeader,
} from './types.js';
export function readCompactU64(reader: BinaryReader): bigint {
const offset = reader.offset;
const first = reader.u8();
if (first === 0) return 0n;
if ((first & 0x01) !== 0) return BigInt(first >>> 1);
let width: number;
if ((first & 0x02) !== 0) width = 2;
else if ((first & 0x04) !== 0) width = 3;
else if ((first & 0x08) !== 0) width = 4;
else if ((first & 0x10) !== 0) width = 5;
else if ((first & 0x20) !== 0) width = 6;
else if ((first & 0x40) !== 0) width = 7;
else if ((first & 0x80) !== 0) return reader.u64();
else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid compact unsigned integer marker 0x${first.toString(16)}`,
{ offset, structure: 'Compact unsigned 64-bit integer' }
);
}
const rest = reader.read(width - 1);
let encoded = BigInt(first);
for (let index = 0; index < rest.length; index += 1) {
encoded |= BigInt(rest[index]!) << BigInt((index + 1) * 8);
}
return encoded >> BigInt(width);
}
export function readCompactNumber(
reader: BinaryReader,
limit: number,
structure: string
): number {
const offset = reader.offset;
const value = readCompactU64(reader);
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value > BigInt(limit)) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`${structure} value ${value.toString()} exceeds limit ${limit}`,
{ offset, structure }
);
}
return Number(value);
}
export function readFssHttpExGuid(reader: BinaryReader): ExGuid {
const offset = reader.offset;
const first = reader.u8();
if (first === 0) return { guid: NIL_GUID, value: 0 };
let value: number;
if ((first & 0x07) === 0x04) {
value = first >>> 3;
} else if ((first & 0x3f) === 0x20) {
value = (reader.u8() << 2) | (first >>> 6);
} else if ((first & 0x7f) === 0x40) {
value = (reader.u16() << 1) | (first >>> 7);
} else if (first === 0x80) {
value = reader.u32();
} else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid extended GUID marker 0x${first.toString(16)}`,
{ offset, structure: 'Extended GUID' }
);
}
return { guid: readGuid(reader), value };
}
export function readFssHttpExGuidArray(
reader: BinaryReader,
limits: OneNoteParserLimits
): ExGuid[] {
const count = readCompactNumber(
reader,
limits.maxObjectReferences,
'Extended GUID array count'
);
const result: ExGuid[] = [];
for (let index = 0; index < count; index += 1) {
result.push(readFssHttpExGuid(reader));
}
return result;
}
export function readFssHttpCellId(reader: BinaryReader): FssHttpCellId {
return {
context: readFssHttpExGuid(reader),
objectSpace: readFssHttpExGuid(reader),
};
}
export function readFssHttpCellIdArray(
reader: BinaryReader,
limits: OneNoteParserLimits
): FssHttpCellId[] {
const count = readCompactNumber(
reader,
limits.maxObjectReferences,
'Cell ID array count'
);
const result: FssHttpCellId[] = [];
for (let index = 0; index < count; index += 1) {
result.push(readFssHttpCellId(reader));
}
return result;
}
export function readFssHttpSerialNumber(
reader: BinaryReader
): FssHttpSerialNumber {
const kind = reader.u8();
if (kind === 0) return { guid: NIL_GUID, value: 0n };
return { guid: readGuid(reader), value: reader.u64() };
}
export function readFssHttpStreamHeader(
reader: BinaryReader,
requiredWidth?: 2 | 4
): FssHttpStreamHeader {
const offset = reader.offset;
const marker = reader.bytes[offset]! & 0x03;
let width: 2 | 4;
let raw: number;
if (marker === 0) {
width = 2;
raw = reader.u16();
} else if (marker === 2) {
width = 4;
raw = reader.u32();
} else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected stream object start marker ${marker}`,
{ offset, structure: 'FSSHTTPB stream object header' }
);
}
if (requiredWidth !== undefined && width !== requiredWidth) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Expected a ${requiredWidth * 8}-bit stream object header`,
{ offset, structure: 'FSSHTTPB stream object header' }
);
}
const compound = (raw & 0x04) !== 0;
const type = width === 2 ? (raw >>> 3) & 0x3f : (raw >>> 3) & 0x3fff;
let lengthValue = BigInt(width === 2 ? raw >>> 9 : raw >>> 17);
if (width === 4 && lengthValue === 0x7fffn) {
lengthValue = readCompactU64(reader);
}
if (lengthValue > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Stream object length ${lengthValue.toString()} is not a safe integer`,
{ offset, structure: 'FSSHTTPB stream object header' }
);
}
const length = Number(lengthValue);
const bodyOffset = reader.offset;
const bodyEnd = bodyOffset + length;
if (!Number.isSafeInteger(bodyEnd) || bodyEnd > reader.end) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Stream object body (${bodyOffset}, ${length}) exceeds its containing data`,
{ offset, structure: 'FSSHTTPB stream object header' }
);
}
return { offset, width, compound, type, length, bodyOffset, bodyEnd };
}
export function expectFssHttpStart(
reader: BinaryReader,
type: number,
compound: boolean,
requiredWidth?: 2 | 4
): FssHttpStreamHeader {
const header = readFssHttpStreamHeader(reader, requiredWidth);
if (header.type !== type || header.compound !== compound) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Expected stream object 0x${type.toString(16)} (${compound ? 'compound' : 'single'}), found 0x${header.type.toString(16)} (${header.compound ? 'compound' : 'single'})`,
{ offset: header.offset, structure: 'FSSHTTPB stream object header' }
);
}
return header;
}
export function finishFssHttpHeaderBody(
reader: BinaryReader,
header: FssHttpStreamHeader,
structure: string
): void {
if (reader.offset !== header.bodyEnd) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`${structure} consumed ${reader.offset - header.bodyOffset} bytes; its header declares ${header.length}`,
{ offset: header.offset, structure }
);
}
}
export function takeFssHttpHeaderBody(
reader: BinaryReader,
header: FssHttpStreamHeader,
structure: string
): BinaryReader {
if (reader.offset !== header.bodyOffset) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`${structure} body is not read immediately after its header`,
{ offset: header.offset, structure }
);
}
return reader.subview(header.length, structure);
}
export function finishFssHttpBody(body: BinaryReader, structure: string): void {
if (body.remaining !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`${structure} has ${body.remaining} unread declared bytes`,
{ offset: body.offset, structure }
);
}
}
export function hasFssHttpEnd(reader: BinaryReader, type: number): boolean {
if (type <= 0x3f) {
if (reader.remaining < 1) return false;
const raw = reader.bytes[reader.offset]!;
return (raw & 0x03) === 1 && raw >>> 2 === type;
}
if (reader.remaining < 2) return false;
const raw = new DataView(
reader.bytes.buffer,
reader.bytes.byteOffset + reader.offset,
2
).getUint16(0, true);
return (raw & 0x03) === 3 && raw >>> 2 === type;
}
export function expectFssHttpEnd(reader: BinaryReader, type: number): void {
const offset = reader.offset;
const actual = type <= 0x3f ? reader.u8() : reader.u16();
const marker = actual & 0x03;
const actualType = actual >>> 2;
const expectedMarker = type <= 0x3f ? 1 : 3;
if (marker !== expectedMarker || actualType !== type) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Expected stream object end 0x${type.toString(16)}, found marker ${marker} type 0x${actualType.toString(16)}`,
{ offset, structure: 'FSSHTTPB stream object end' }
);
}
}

View File

@@ -0,0 +1,173 @@
// 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: `fsshttpb/{data,
// data_element,packaging}.rs`. Deviations: serializable TypeScript records,
// zero-copy byte ranges, checked integer conversion, and explicit limits.
import type { ExGuid, FileDataBlobReference } from '../onestore/types.js';
export const FSSHTTP_OBJECT = {
dataElement: 0x01,
objectDataBlob: 0x02,
objectGroupDataExcluded: 0x03,
objectGroupDataBlob: 0x05,
storageManifestRoot: 0x07,
revisionManifestRoot: 0x0a,
cellManifest: 0x0b,
storageManifest: 0x0c,
storageIndexRevisionMapping: 0x0d,
storageIndexCellMapping: 0x0e,
storageIndexManifestMapping: 0x11,
dataElementPackage: 0x15,
objectGroupDataObject: 0x16,
objectGroupObject: 0x18,
revisionManifestGroupReference: 0x19,
revisionManifest: 0x1a,
objectGroupBlobReference: 0x1c,
objectGroupDeclaration: 0x1d,
objectGroupData: 0x1e,
dataElementFragment: 0x6a,
objectGroupMetadata: 0x78,
objectGroupMetadataBlock: 0x79,
oneNotePackaging: 0x7a,
} as const;
export interface FssHttpStreamHeader {
offset: number;
width: 2 | 4;
compound: boolean;
type: number;
length: number;
bodyOffset: number;
bodyEnd: number;
}
export interface FssHttpSerialNumber {
guid: string;
value: bigint;
}
export interface FssHttpCellId {
context: ExGuid;
objectSpace: ExGuid;
}
export interface FssHttpStorageIndexManifestMapping {
id: ExGuid;
serial: FssHttpSerialNumber;
}
export interface FssHttpStorageIndexCellMapping {
cellId: FssHttpCellId;
id: ExGuid;
serial: FssHttpSerialNumber;
}
export interface FssHttpStorageIndexRevisionMapping {
id: ExGuid;
revision: ExGuid;
serial: FssHttpSerialNumber;
}
export interface FssHttpStorageIndex {
manifestMappings: FssHttpStorageIndexManifestMapping[];
cellMappings: Map<string, FssHttpStorageIndexCellMapping>;
revisionMappings: Map<string, FssHttpStorageIndexRevisionMapping>;
}
export interface FssHttpStorageManifest {
schema: string;
roots: Map<string, FssHttpCellId>;
}
export interface FssHttpRevisionRoot {
role: ExGuid;
objectId: ExGuid;
}
export interface FssHttpRevisionManifest {
revisionId: ExGuid;
baseRevisionId: ExGuid;
roots: FssHttpRevisionRoot[];
objectGroupIds: ExGuid[];
}
export type FssHttpObjectDeclaration =
| {
kind: 'object';
objectId: ExGuid;
partitionId: number;
dataSize: number;
objectReferenceCount: number;
cellReferenceCount: number;
}
| {
kind: 'blob';
objectId: ExGuid;
blobId: ExGuid;
partitionId: number;
objectReferenceCount: number;
cellReferenceCount: number;
};
export type FssHttpObjectGroupData =
| {
kind: 'object';
objectReferences: ExGuid[];
cellReferences: FssHttpCellId[];
data: Uint8Array;
}
| {
kind: 'excluded';
objectReferences: ExGuid[];
cellReferences: FssHttpCellId[];
dataSize: number;
}
| {
kind: 'blob-reference';
objectReferences: ExGuid[];
cellReferences: FssHttpCellId[];
blobId: ExGuid;
};
export interface FssHttpObjectGroup {
declarations: FssHttpObjectDeclaration[];
changeFrequencies: number[];
data: FssHttpObjectGroupData[];
}
export interface FssHttpDataElementFragment {
dataElementId: ExGuid;
fragmentSize: number;
chunkOffset: number;
chunkLength: number;
data: Uint8Array;
}
export interface FssHttpDataElementPackage {
storageIndexes: Map<string, FssHttpStorageIndex>;
storageManifests: Map<string, FssHttpStorageManifest>;
cellManifests: Map<string, ExGuid>;
revisionManifests: Map<string, FssHttpRevisionManifest>;
objectGroups: Map<string, FssHttpObjectGroup>;
fragments: Map<string, FssHttpDataElementFragment>;
blobs: Map<string, FileDataBlobReference>;
}
export interface FssHttpOneStorePackage {
fileType: string;
fileIdentity: string;
legacyFileVersion: string;
fileFormat: string;
storageIndexId: ExGuid;
cellSchema: string;
dataElements: FssHttpDataElementPackage;
}
export function fssHttpCellKey(cell: FssHttpCellId): string {
return `${cell.context.guid}:${cell.context.value}/${cell.objectSpace.guid}:${cell.objectSpace.value}`;
}