diff --git a/src/onenote/export/export.test.ts b/src/onenote/export/export.test.ts index 5964694..7249427 100644 --- a/src/onenote/export/export.test.ts +++ b/src/onenote/export/export.test.ts @@ -8,6 +8,7 @@ import type { OneNoteTextStyle, } from '../one/content-model.js'; import { + createOneNoteExportPlan, createOneNoteExportSnapshot, createOneNoteStaticNotebookZip, OneNoteExportError, @@ -19,11 +20,17 @@ import { serializeOneNoteStructuredJson, type OneNoteExportSnapshot, } from './index.js'; +import { utf8ByteLength } from './plan.js'; const PNG = Uint8Array.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); +const JPEG = Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, + 0x11, 0x00, 0xff, 0xd9, ]); -const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]); const SOURCE_MARKUP = ''; describe('OneNote export serializers', () => { @@ -260,6 +267,158 @@ describe('OneNote export serializers', () => { }); }); +describe('OneNote export aggregate budgets', () => { + it('counts repeated rich-text representations against one text budget', () => { + const baseline = minimalSnapshot([textBlock('text', '')]); + const baselineCharacters = + createOneNoteExportPlan(baseline).textCharacterCount; + const snapshot = minimalSnapshot([textBlock('text', '0123456789')]); + + expect(() => + serializeOneNoteStructuredJson(snapshot, { + limits: { maxTextCharacters: baselineCharacters + 29 }, + }) + ).toThrowError( + expect.objectContaining>({ + code: 'limit-exceeded', + message: expect.stringContaining('text character'), + }) + ); + }); + + it('counts ink points across blocks and strokes', () => { + const ink = (id: string): OneNoteContentBlock => ({ + kind: 'ink', + id, + strokes: [ + { + points: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ], + }, + ], + children: [], + layout: {}, + }); + const snapshot = minimalSnapshot([ink('ink-1'), ink('ink-2')]); + + expect(() => + serializeOneNoteSemanticHtml(snapshot, { + limits: { maxInkPoints: 3 }, + }) + ).toThrowError( + expect.objectContaining>({ + code: 'limit-exceeded', + message: expect.stringContaining('ink point'), + }) + ); + }); + + it('preflights standalone output and measures exact UTF-8 bytes', () => { + const encodedSample = 'ASCII · 😀 · \ud800'; + expect(utf8ByteLength(encodedSample)).toBe( + new TextEncoder().encode(encodedSample).byteLength + ); + + expect(() => + serializeOneNoteMarkdown(minimalSnapshot(), { + limits: { maxOutputBytes: 8 * 1024 }, + }) + ).toThrowError( + expect.objectContaining>({ + code: 'limit-exceeded', + message: expect.stringContaining('Projected standalone'), + }) + ); + }); + + it('includes resource members in the static ZIP output budget', () => { + const resourceBytes = new Uint8Array(64); + const snapshot = minimalSnapshot( + [], + [ + { + sectionId: 's', + id: 'attachment', + kind: 'attachment', + filename: 'attachment.bin', + extension: 'bin', + mediaType: 'application/octet-stream', + browserRenderable: false, + bytes: resourceBytes, + }, + ] + ); + const resourceLimits = { + maxResourceBytes: 128, + maxTotalResourceBytes: 128, + }; + const plan = createOneNoteExportPlan(snapshot, { + limits: { ...resourceLimits, maxOutputBytes: 1_000_000 }, + }); + const justTooSmall = + plan.projectedOutputBytes * 6 + resourceBytes.byteLength - 1; + + expect(() => + createOneNoteStaticNotebookZip(snapshot, { + limits: { ...resourceLimits, maxOutputBytes: justTooSmall }, + }) + ).toThrowError( + expect.objectContaining>({ + code: 'limit-exceeded', + message: expect.stringContaining('Projected static notebook'), + }) + ); + }); + + it('never auto-embeds parser-disabled or oversized-pixel images', () => { + const imageResource = ( + id: string, + bytes: Uint8Array, + browserRenderable = true + ): OneNoteExportSnapshot['resources'][number] => ({ + sectionId: 's', + id, + kind: 'image', + filename: `${id}.png`, + extension: 'png', + mediaType: 'image/png', + browserRenderable, + bytes, + }); + const snapshot = minimalSnapshot( + [], + [ + imageResource('parser-disabled', PNG, false), + imageResource('dimension-bomb', pngWithDimensions(16_385, 1)), + imageResource('pixel-bomb', pngWithDimensions(8_193, 8_193)), + ] + ); + + const plan = createOneNoteExportPlan(snapshot); + expect(plan.resources).toHaveLength(0); + expect(plan.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'IMAGE_RESOURCE_NOT_RENDERABLE', + resourceId: 'parser-disabled', + }), + expect.objectContaining({ + code: 'UNSAFE_IMAGE_RESOURCE', + resourceId: 'dimension-bomb', + message: expect.stringContaining('dimension-limit'), + }), + expect.objectContaining({ + code: 'UNSAFE_IMAGE_RESOURCE', + resourceId: 'pixel-bomb', + message: expect.stringContaining('pixel-limit'), + }), + ]) + ); + }); +}); + describe('OneNote export filenames', () => { it('removes traversal, reserved names, controls and directional overrides', () => { expect(sanitizeExportFilename('../../CON')).toBe('_CON'); @@ -451,6 +610,51 @@ function fixtureSnapshot(): OneNoteExportSnapshot { }); } +function minimalSnapshot( + blocks: OneNoteContentBlock[] = [], + resources: OneNoteExportSnapshot['resources'] = [] +): OneNoteExportSnapshot { + return createOneNoteExportSnapshot({ + sourceName: 's.one', + sourceFormat: 'one', + notebookName: 'n', + appName: 'a', + appVersion: '1', + parser: { + name: 'p', + version: '1', + implementation: 't', + supportedVariants: [], + }, + sections: [ + { + id: 's', + name: 's', + pages: [ + { + id: 'p', + title: 'p', + text: '', + textPreview: '', + blocks, + diagnostics: [], + }, + ], + diagnostics: [], + }, + ], + resources, + }); +} + +function pngWithDimensions(width: number, height: number): Uint8Array { + const bytes = PNG.slice(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + function textBlock( id: string, text: string, diff --git a/src/onenote/export/plan.ts b/src/onenote/export/plan.ts index 0e4c384..956d4c1 100644 --- a/src/onenote/export/plan.ts +++ b/src/onenote/export/plan.ts @@ -4,7 +4,15 @@ // SPDX-License-Identifier: MPL-2.0 import type { ParserDiagnostic } from '../model/diagnostics.js'; -import type { OneNoteContentBlock } from '../one/content-model.js'; +import type { OneNoteNotebookNodeDto } from '../model/dto.js'; +import { + DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS, + inspectBrowserImage, +} from '../image-safety.js'; +import type { + OneNoteContentBlock, + OneNoteTextStyle, +} from '../one/content-model.js'; import { filenameExtension, forceFilenameExtension, @@ -61,6 +69,18 @@ export interface OneNoteExportPlan { diagnostics: ParserDiagnostic[]; blockCount: number; pageCount: number; + textCharacterCount: number; + inkPointCount: number; + totalResourceBytes: number; + projectedOutputBytes: number; +} + +interface ExportBudget { + textCharacterCount: number; + inkPointCount: number; + modelEntryCount: number; + tableCellSlotCount: number; + numericArrayValueCount: number; } export function exportResourceKey( @@ -82,6 +102,14 @@ export function createOneNoteExportPlan( const warnings: OneNoteExportWarning[] = []; const unsupportedContent: OneNoteUnsupportedContentRecord[] = []; const diagnostics = collectDiagnostics(snapshot, limits); + const budget: ExportBudget = { + textCharacterCount: 0, + inkPointCount: 0, + modelEntryCount: 1, + tableCellSlotCount: 0, + numericArrayValueCount: 0, + }; + countSnapshotMetadata(snapshot, budget, limits); const sectionIds = new Set(snapshot.sections.map(({ id }) => id)); const sectionNames = new Map(); const sectionAllocator = new SafeExportNameAllocator(); @@ -144,7 +172,14 @@ export function createOneNoteExportPlan( resourceAllocators.get(resource.sectionId) ?? new SafeExportNameAllocator(); resourceAllocators.set(resource.sectionId, allocator); - const planned = planResource(resource, bytes, allocator, index, warnings); + const planned = planResource( + resource, + bytes, + allocator, + index, + warnings, + limits + ); if (!planned) continue; const sectionDirectory = sectionNames.get(resource.sectionId)!; planned.path = `assets/${sectionDirectory}/${planned.filename}`; @@ -179,6 +214,7 @@ export function createOneNoteExportPlan( if (blockCount > limits.maxBlocks) { throw limitError('content block', blockCount, limits.maxBlocks); } + countBlockMetadata(block, budget, limits); inspectBlock( block, section.id, @@ -202,22 +238,340 @@ export function createOneNoteExportPlan( diagnostics, blockCount, pageCount, + textCharacterCount: budget.textCharacterCount, + inkPointCount: budget.inkPointCount, + totalResourceBytes, + projectedOutputBytes: projectedOutputBytes(budget, limits), }; } +function countSnapshotMetadata( + snapshot: OneNoteExportSnapshot, + budget: ExportBudget, + limits: OneNoteExportLimits +): void { + addText( + budget, + limits, + snapshot.app.name, + snapshot.app.version, + snapshot.parser.name, + snapshot.parser.version, + snapshot.parser.implementation, + snapshot.parser.referenceRevision, + snapshot.supportLevel, + snapshot.source.name, + snapshot.source.format, + snapshot.notebookName + ); + for (const variant of snapshot.parser.supportedVariants) { + addModelEntries(budget, 1); + addText(budget, limits, variant); + } + for (const note of snapshot.supportNotes) { + addModelEntries(budget, 1); + addText(budget, limits, note); + } + countDiagnosticsMetadata(snapshot.diagnostics, budget, limits); + + for (const section of snapshot.sections) { + addModelEntries(budget, 1); + addText(budget, limits, section.id, section.name, section.sourcePath); + countDiagnosticsMetadata(section.diagnostics, budget, limits); + for (const page of section.pages) { + addModelEntries(budget, 1); + addText( + budget, + limits, + page.id, + page.title, + page.createdAt, + page.updatedAt, + page.text, + page.textPreview + ); + countDiagnosticsMetadata(page.diagnostics, budget, limits); + } + } + + for (const resource of snapshot.resources) { + addModelEntries(budget, 1); + addText( + budget, + limits, + resource.sectionId, + resource.id, + resource.kind, + resource.filename, + resource.extension, + resource.mediaType + ); + } + + if (snapshot.tree) { + addModelEntries(budget, 1); + addText(budget, limits, snapshot.tree.tocPath); + countTreeNodes( + snapshot.tree.nodes, + 0, + new Set(), + budget, + limits + ); + } +} + +function countTreeNodes( + nodes: readonly OneNoteNotebookNodeDto[], + depth: number, + active: Set, + budget: ExportBudget, + limits: OneNoteExportLimits +): void { + if (depth > limits.maxDepth) { + throw new OneNoteExportError( + 'limit-exceeded', + `Export notebook tree depth exceeds ${limits.maxDepth}` + ); + } + for (const node of nodes) { + if (active.has(node)) { + throw new OneNoteExportError( + 'invalid-input', + 'Export notebook tree contains an object cycle' + ); + } + active.add(node); + addModelEntries(budget, 1); + addText( + budget, + limits, + node.kind, + node.id, + node.displayName, + node.kind === 'section' ? node.sectionId : undefined + ); + if (node.kind === 'section-group') { + countTreeNodes(node.children, depth + 1, active, budget, limits); + } + active.delete(node); + } +} + +function countDiagnosticsMetadata( + diagnostics: readonly ParserDiagnostic[], + budget: ExportBudget, + limits: OneNoteExportLimits +): void { + for (const diagnostic of diagnostics) { + addModelEntries(budget, 1); + addText( + budget, + limits, + diagnostic.severity, + diagnostic.code, + diagnostic.message, + diagnostic.structure, + diagnostic.propertyId + ); + } +} + +function countBlockMetadata( + block: OneNoteContentBlock, + budget: ExportBudget, + limits: OneNoteExportLimits +): void { + addModelEntries(budget, 1); + addText(budget, limits, block.kind, block.id); + switch (block.kind) { + case 'text': + addText( + budget, + limits, + block.sourceText, + block.text, + block.paragraphAlignment + ); + countTextStyle(block.paragraphStyle, budget, limits); + for (const run of block.runs) { + addModelEntries(budget, 1); + addText(budget, limits, run.text, run.href); + countTextStyle(run.style, budget, limits); + } + return; + case 'image': + addText( + budget, + limits, + block.resourceId, + block.filename, + block.altText, + block.ocrText, + block.href + ); + return; + case 'attachment': + addText( + budget, + limits, + block.resourceId, + block.iconResourceId, + block.filename + ); + return; + case 'table': { + addNumericArrayValues( + budget, + block.columnWidths.length + block.lockedColumns.length + ); + let columnCount = 0; + if (block.declaredColumnCount !== undefined) { + if ( + !Number.isSafeInteger(block.declaredColumnCount) || + block.declaredColumnCount < 0 + ) { + throw new OneNoteExportError( + 'invalid-input', + 'Export table column count must be a non-negative safe integer' + ); + } + columnCount = block.declaredColumnCount; + } + for (const row of block.rows) { + addModelEntries(budget, 1); + addText(budget, limits, row.id); + columnCount = Math.max(columnCount, row.cells.length); + for (const cell of row.cells) { + addModelEntries(budget, 1); + addText(budget, limits, cell.id, cell.backgroundColor?.kind); + } + } + addTableCellSlots(budget, columnCount, block.rows.length + 2); + return; + } + case 'outline': + addNumericArrayValues(budget, block.indents.length); + return; + case 'outline-element': + case 'outline-group': + return; + case 'ink': + for (const stroke of block.strokes) { + addModelEntries(budget, 1); + addText(budget, limits, stroke.bias); + addInkPoints(budget, limits, stroke.points.length); + } + return; + case 'unsupported': + addText(budget, limits, block.description); + } +} + +function countTextStyle( + style: OneNoteTextStyle, + budget: ExportBudget, + limits: OneNoteExportLimits +): void { + addText( + budget, + limits, + style.font, + style.fontColor?.kind, + style.highlight?.kind + ); +} + +function addText( + budget: ExportBudget, + limits: OneNoteExportLimits, + ...values: Array +): void { + for (const value of values) { + if (value === undefined) continue; + if (value.length > limits.maxTextCharacters - budget.textCharacterCount) { + throw limitError( + 'text character', + limits.maxTextCharacters + 1, + limits.maxTextCharacters + ); + } + budget.textCharacterCount += value.length; + } +} + +function addInkPoints( + budget: ExportBudget, + limits: OneNoteExportLimits, + count: number +): void { + if (count > limits.maxInkPoints - budget.inkPointCount) { + throw limitError('ink point', limits.maxInkPoints + 1, limits.maxInkPoints); + } + budget.inkPointCount += count; +} + +function addModelEntries(budget: ExportBudget, count: number): void { + budget.modelEntryCount = saturatingAdd(budget.modelEntryCount, count); +} + +function addNumericArrayValues(budget: ExportBudget, count: number): void { + budget.numericArrayValueCount = saturatingAdd( + budget.numericArrayValueCount, + count + ); +} + +function addTableCellSlots( + budget: ExportBudget, + columns: number, + rows: number +): void { + budget.tableCellSlotCount = saturatingAdd( + budget.tableCellSlotCount, + saturatingMultiply(columns, rows) + ); +} + function planResource( resource: OneNoteExportResource, bytes: Uint8Array, allocator: SafeExportNameAllocator, index: number, - warnings: OneNoteExportWarning[] + warnings: OneNoteExportWarning[], + limits: OneNoteExportLimits ): PlannedOneNoteResource | undefined { if (resource.kind === 'image') { - const image = sniffBrowserImage(bytes); - if (!image) { + if (!resource.browserRenderable) { warnings.push({ - code: 'UNSUPPORTED_IMAGE_RESOURCE', - message: 'Only signature-verified PNG and JPEG images are exported', + code: 'IMAGE_RESOURCE_NOT_RENDERABLE', + message: + 'Image resource was omitted because the parser did not mark it safe for browser rendering', + sectionId: resource.sectionId, + resourceId: resource.id, + }); + return undefined; + } + const inspected = inspectBrowserImage(bytes, { + maxBytes: Math.min( + limits.maxResourceBytes, + DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxBytes + ), + maxDimension: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxDimension, + maxPixels: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxPixels, + }); + if ( + !inspected.browserRenderable || + !inspected.extension || + !inspected.mediaType + ) { + const reason = inspected.rejectionReason ?? 'invalid-header'; + warnings.push({ + code: + reason === 'unsupported-format' + ? 'UNSUPPORTED_IMAGE_RESOURCE' + : 'UNSAFE_IMAGE_RESOURCE', + message: `Image resource was omitted by bounded inspection (${reason})`, sectionId: resource.sectionId, resourceId: resource.id, }); @@ -225,11 +579,11 @@ function planResource( } const safe = sanitizeExportFilename( resource.filename, - `image-${index + 1}.${image.extension}` + `image-${index + 1}.${inspected.extension}` ); const filename = allocator.allocate( - forceFilenameExtension(safe, image.extension), - `image-${index + 1}.${image.extension}` + forceFilenameExtension(safe, inspected.extension), + `image-${index + 1}.${inspected.extension}` ); return { sectionId: resource.sectionId, @@ -237,8 +591,8 @@ function planResource( kind: resource.kind, path: '', filename, - extension: image.extension, - mediaType: image.mediaType, + extension: inspected.extension, + mediaType: inspected.mediaType, browserRenderable: true, size: bytes.byteLength, bytes, @@ -382,35 +736,6 @@ function collectDiagnostics( } } -function sniffBrowserImage( - bytes: Uint8Array -): - | { extension: 'png' | 'jpg'; mediaType: 'image/png' | 'image/jpeg' } - | undefined { - if ( - bytes.length >= 8 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 && - bytes[4] === 0x0d && - bytes[5] === 0x0a && - bytes[6] === 0x1a && - bytes[7] === 0x0a - ) { - return { extension: 'png', mediaType: 'image/png' }; - } - if ( - bytes.length >= 3 && - bytes[0] === 0xff && - bytes[1] === 0xd8 && - bytes[2] === 0xff - ) { - return { extension: 'jpg', mediaType: 'image/jpeg' }; - } - return undefined; -} - function safeMediaType(value: string): string { return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu.test( value @@ -419,6 +744,55 @@ function safeMediaType(value: string): string { : 'application/octet-stream'; } +/** Enforce the exact UTF-8 size of one completed standalone member. */ +export function enforceSerializedTextOutput( + value: string, + plan: OneNoteExportPlan, + subject: string +): string { + enforceOutputByteLength(plan, utf8ByteLength(value), subject); + return value; +} + +export function enforceOutputByteLength( + plan: OneNoteExportPlan, + byteLength: number, + subject: string +): void { + if (byteLength > plan.limits.maxOutputBytes) { + throw new OneNoteExportError( + 'limit-exceeded', + `${subject} is ${byteLength} UTF-8 bytes; the export limit is ${plan.limits.maxOutputBytes}` + ); + } +} + +/** Exact TextEncoder-compatible byte length without allocating encoded bytes. */ +export function utf8ByteLength(value: string): number { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if ( + code >= 0xd800 && + code <= 0xdbff && + index + 1 < value.length && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + bytes += 4; + index += 1; + } else { + // TextEncoder replaces unpaired surrogates with U+FFFD (three bytes). + bytes += 3; + } + } + return bytes; +} + function resourceBytes(value: ArrayBuffer | Uint8Array): Uint8Array { return value instanceof Uint8Array ? value : new Uint8Array(value); } @@ -438,6 +812,80 @@ function resolveExportLimits( return result; } +function projectedOutputBytes( + budget: ExportBudget, + limits: OneNoteExportLimits +): number { + let result = 8 * 1024; + result = addProjectedBytes( + result, + budget.textCharacterCount, + 12, + limits.maxOutputBytes + ); + result = addProjectedBytes( + result, + budget.inkPointCount, + 96, + limits.maxOutputBytes + ); + result = addProjectedBytes( + result, + budget.modelEntryCount, + 512, + limits.maxOutputBytes + ); + result = addProjectedBytes( + result, + budget.tableCellSlotCount, + 64, + limits.maxOutputBytes + ); + result = addProjectedBytes( + result, + budget.numericArrayValueCount, + 32, + limits.maxOutputBytes + ); + if (result > limits.maxOutputBytes) { + throw new OneNoteExportError( + 'limit-exceeded', + `Projected standalone export output exceeds ${limits.maxOutputBytes} bytes` + ); + } + return result; +} + +function addProjectedBytes( + current: number, + count: number, + bytesPerItem: number, + limit: number +): number { + if (current > limit || count > Math.floor((limit - current) / bytesPerItem)) { + return Number.POSITIVE_INFINITY; + } + return current + count * bytesPerItem; +} + +function saturatingAdd(left: number, right: number): number { + return !Number.isSafeInteger(right) || + right < 0 || + left > Number.MAX_SAFE_INTEGER - right + ? Number.POSITIVE_INFINITY + : left + right; +} + +function saturatingMultiply(left: number, right: number): number { + return !Number.isSafeInteger(left) || + !Number.isSafeInteger(right) || + left < 0 || + right < 0 || + (left !== 0 && right > Number.MAX_SAFE_INTEGER / left) + ? Number.POSITIVE_INFINITY + : left * right; +} + function limitError( subject: string, actual: number, diff --git a/src/onenote/export/serializers.ts b/src/onenote/export/serializers.ts index 69967f4..b56c287 100644 --- a/src/onenote/export/serializers.ts +++ b/src/onenote/export/serializers.ts @@ -13,6 +13,7 @@ import type { } from '../one/content-model.js'; import { createOneNoteExportPlan, + enforceSerializedTextOutput, exportResourceKey, type OneNoteExportPlan, type PlannedOneNoteResource, @@ -91,7 +92,11 @@ export function serializeStructuredJsonWithPlan( warnings: plan.warnings, unsupportedContent: plan.unsupportedContent, }; - return `${JSON.stringify(value, null, 2)}\n`; + return enforceSerializedTextOutput( + `${JSON.stringify(value, null, 2)}\n`, + plan, + 'Structured JSON export' + ); } export function serializePlainTextWithPlan( @@ -138,10 +143,14 @@ export function serializePlainTextWithPlan( } } appendPlainWarnings(lines, plan); - return `${lines - .join('\n') - .replace(/\n{4,}/gu, '\n\n\n') - .trimEnd()}\n`; + return enforceSerializedTextOutput( + `${lines + .join('\n') + .replace(/\n{4,}/gu, '\n\n\n') + .trimEnd()}\n`, + plan, + 'Plain-text export' + ); } export function serializeMarkdownWithPlan( @@ -192,10 +201,14 @@ export function serializeMarkdownWithPlan( } } appendMarkdownWarnings(lines, plan); - return `${lines - .join('\n') - .replace(/\n{4,}/gu, '\n\n\n') - .trimEnd()}\n`; + return enforceSerializedTextOutput( + `${lines + .join('\n') + .replace(/\n{4,}/gu, '\n\n\n') + .trimEnd()}\n`, + plan, + 'Markdown export' + ); } export function serializeHtmlWithPlan( @@ -214,7 +227,8 @@ export function serializeHtmlWithPlan( .map((section) => htmlSection(section, context)) .join('\n'); const warnings = htmlWarnings(plan); - return ` + return enforceSerializedTextOutput( + ` @@ -246,7 +260,10 @@ ${sections} ${warnings} -`; +`, + plan, + 'Semantic HTML export' + ); } function htmlSection( diff --git a/src/onenote/export/types.ts b/src/onenote/export/types.ts index 21d6903..8004972 100644 --- a/src/onenote/export/types.ts +++ b/src/onenote/export/types.ts @@ -82,8 +82,14 @@ export interface OneNoteExportLimits { maxBlocks: number; maxDepth: number; maxDiagnostics: number; + /** Aggregate UTF-16 code units across all snapshot metadata and content. */ + maxTextCharacters: number; + /** Aggregate point records across all ink strokes. */ + maxInkPoints: number; maxResourceBytes: number; maxTotalResourceBytes: number; + /** UTF-8 bytes for one standalone export or all uncompressed ZIP members. */ + maxOutputBytes: number; } export const DEFAULT_ONENOTE_EXPORT_LIMITS: Readonly = { @@ -92,8 +98,11 @@ export const DEFAULT_ONENOTE_EXPORT_LIMITS: Readonly = { maxBlocks: 1_000_000, maxDepth: 256, maxDiagnostics: 100_000, - maxResourceBytes: 256 * 1024 * 1024, - maxTotalResourceBytes: 512 * 1024 * 1024, + maxTextCharacters: 8_000_000, + maxInkPoints: 500_000, + maxResourceBytes: 64 * 1024 * 1024, + maxTotalResourceBytes: 64 * 1024 * 1024, + maxOutputBytes: 128 * 1024 * 1024, }; export interface OneNoteExportOptions { diff --git a/src/onenote/export/zip.ts b/src/onenote/export/zip.ts index 8fa0fbc..b44ed64 100644 --- a/src/onenote/export/zip.ts +++ b/src/onenote/export/zip.ts @@ -5,14 +5,23 @@ import { strToU8, zipSync, type Zippable } from 'fflate'; import { forceFilenameExtension, sanitizeExportFilename } from './naming.js'; -import { createOneNoteExportPlan, type OneNoteExportPlan } from './plan.js'; +import { + createOneNoteExportPlan, + enforceOutputByteLength, + utf8ByteLength, + type OneNoteExportPlan, +} from './plan.js'; import { serializeHtmlWithPlan, serializeMarkdownWithPlan, serializePlainTextWithPlan, serializeStructuredJsonWithPlan, } from './serializers.js'; -import type { OneNoteExportOptions, OneNoteExportSnapshot } from './types.js'; +import { + OneNoteExportError, + type OneNoteExportOptions, + type OneNoteExportSnapshot, +} from './types.js'; export interface OneNoteStaticNotebookManifest { schemaVersion: number; @@ -83,6 +92,7 @@ export function createOneNoteStaticNotebookZip( options: OneNoteExportOptions = {} ): OneNoteStaticNotebookZipArtifact { const plan = createOneNoteExportPlan(snapshot, options); + enforceProjectedStaticNotebookSize(plan); const manifest = createStaticManifest(snapshot, plan); const warnings: OneNoteExportWarningsManifest = { schemaVersion: snapshot.schemaVersion, @@ -96,17 +106,26 @@ export function createOneNoteStaticNotebookZip( }; const files: Zippable = Object.create(null) as Zippable; - files['index.html'] = strToU8(serializeHtmlWithPlan(snapshot, plan, true)); - files['notebook.json'] = strToU8( + const members: Array<{ path: string; byteLength: number }> = []; + let memberBytes = 0; + addTextMember('index.html', serializeHtmlWithPlan(snapshot, plan, true)); + addTextMember( + 'notebook.json', serializeStructuredJsonWithPlan(snapshot, plan) ); - files['notebook.txt'] = strToU8(serializePlainTextWithPlan(snapshot, plan)); - files['notebook.md'] = strToU8( - serializeMarkdownWithPlan(snapshot, plan, true) + addTextMember('notebook.txt', serializePlainTextWithPlan(snapshot, plan)); + addTextMember('notebook.md', serializeMarkdownWithPlan(snapshot, plan, true)); + addTextMember('manifest.json', jsonText(manifest)); + addTextMember('warnings.json', jsonText(warnings)); + for (const resource of plan.resources) { + addBinaryMember(resource.path, resource.bytes); + } + + enforceOutputByteLength( + plan, + projectedZipByteLength(members), + 'Projected static notebook ZIP' ); - files['manifest.json'] = jsonBytes(manifest); - files['warnings.json'] = jsonBytes(warnings); - for (const resource of plan.resources) files[resource.path] = resource.bytes; const bytes = zipSync(files, { level: 6, @@ -114,6 +133,7 @@ export function createOneNoteStaticNotebookZip( os: 3, attrs: 0o644 << 16, }); + enforceOutputByteLength(plan, bytes.byteLength, 'Static notebook ZIP'); const safeName = sanitizeExportFilename( snapshot.notebookName, 'onenote-export' @@ -124,6 +144,37 @@ export function createOneNoteStaticNotebookZip( bytes, manifest, }; + + function addTextMember(path: string, value: string): void { + ensureMemberCapacity(utf8ByteLength(value)); + const bytes = strToU8(value); + addMember(path, bytes, bytes.byteLength); + } + + function addBinaryMember(path: string, value: Uint8Array): void { + ensureMemberCapacity(value.byteLength); + addMember(path, value, value.byteLength); + } + + function addMember( + path: string, + value: Uint8Array, + byteLength: number + ): void { + ensureMemberCapacity(byteLength); + memberBytes += byteLength; + members.push({ path, byteLength }); + files[path] = value; + } + + function ensureMemberCapacity(byteLength: number): void { + if (byteLength > plan.limits.maxOutputBytes - memberBytes) { + throw new OneNoteExportError( + 'limit-exceeded', + `Static notebook members exceed ${plan.limits.maxOutputBytes} bytes` + ); + } + } } function createStaticManifest( @@ -173,6 +224,48 @@ function createStaticManifest( }; } -function jsonBytes(value: unknown): Uint8Array { - return strToU8(`${JSON.stringify(value, null, 2)}\n`); +function jsonText(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function enforceProjectedStaticNotebookSize(plan: OneNoteExportPlan): void { + const available = plan.limits.maxOutputBytes - plan.totalResourceBytes; + if (available < 0 || plan.projectedOutputBytes > Math.floor(available / 6)) { + throw new OneNoteExportError( + 'limit-exceeded', + `Projected static notebook members exceed ${plan.limits.maxOutputBytes} bytes` + ); + } +} + +function projectedZipByteLength( + members: readonly { path: string; byteLength: number }[] +): number { + let result = 22; + for (const member of members) { + const pathBytes = utf8ByteLength(member.path); + const deflateOverhead = Math.ceil(member.byteLength / 16_384) * 16 + 128; + result = safeByteSum( + result, + member.byteLength, + deflateOverhead, + 76, + pathBytes * 2 + ); + } + return result; +} + +function safeByteSum(...values: number[]): number { + let total = 0; + for (const value of values) { + if (!Number.isSafeInteger(value) || value < 0) { + return Number.POSITIVE_INFINITY; + } + if (total > Number.MAX_SAFE_INTEGER - value) { + return Number.POSITIVE_INFINITY; + } + total += value; + } + return total; } diff --git a/src/worker/export-adapter.test.ts b/src/worker/export-adapter.test.ts index 12d5e12..85b8572 100644 --- a/src/worker/export-adapter.test.ts +++ b/src/worker/export-adapter.test.ts @@ -16,10 +16,44 @@ import { } from './export-adapter.js'; import { workerPageKey, workerResourceKey } from './parser-adapter.js'; -const PNG = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00); +const PNG = Uint8Array.of( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00 +); describe('worker export adapter', () => { - it('builds a partial section snapshot from full session pages and copied resources', () => { + it('builds a partial section snapshot with bounded zero-copy resources', () => { const pageDiagnostic = diagnostic('page-warning', 'page page-1'); const sectionDiagnostic = diagnostic('section-warning'); const source = sectionDto('Unsafe/Section.one', 'Section name', [ @@ -80,21 +114,46 @@ describe('worker export adapter', () => { mediaType: 'image/png', size: PNG.length, }); - expect(new Uint8Array(snapshot.resources[0]!.bytes)).toEqual(PNG); + const imageBytes = snapshot.resources[0]!.bytes; + expect(imageBytes).toBeInstanceOf(Uint8Array); + if (!(imageBytes instanceof Uint8Array)) { + throw new Error('Expected a zero-copy image view'); + } + expect(imageBytes).toEqual(PNG); + expect(imageBytes.buffer).toBe(resourceSource.buffer); expect(snapshot.resources[1]).toMatchObject({ sectionId: 'section', id: 'attachment-1', kind: 'attachment', size: 2, }); - expect(new Uint8Array(snapshot.resources[1]!.bytes)).toEqual( - Uint8Array.of(5, 4) - ); - resourceSource.fill(0); - attachmentSource.fill(0); - expect(new Uint8Array(snapshot.resources[0]!.bytes)).toEqual(PNG); - expect(new Uint8Array(snapshot.resources[1]!.bytes)).toEqual( - Uint8Array.of(5, 4) + const attachmentBytes = snapshot.resources[1]!.bytes; + expect(attachmentBytes).toBeInstanceOf(Uint8Array); + if (!(attachmentBytes instanceof Uint8Array)) { + throw new Error('Expected a zero-copy attachment view'); + } + expect(attachmentBytes).toEqual(Uint8Array.of(5, 4)); + expect(attachmentBytes.buffer).toBe(attachmentSource.buffer); + attachmentSource[1] = 8; + expect(attachmentBytes[0]).toBe(8); + }); + + it('caps synthesized missing-page diagnostics without variadic appends', () => { + const source = sectionDto('Section.one', 'Section', []); + source.pages = [ + { id: 'missing-1', title: 'Missing 1', text: '', textPreview: '' }, + { id: 'missing-2', title: 'Missing 2', text: '', textPreview: '' }, + ]; + + expect(() => + createWorkerExportSnapshot(source, new Map(), new Map(), { + limits: { maxDiagnostics: 1 }, + }) + ).toThrowError( + expect.objectContaining({ + code: 'limit-exceeded', + message: expect.stringContaining('diagnostic count'), + }) ); }); diff --git a/src/worker/export-adapter.ts b/src/worker/export-adapter.ts index 9b3b672..4045ad2 100644 --- a/src/worker/export-adapter.ts +++ b/src/worker/export-adapter.ts @@ -6,7 +6,9 @@ import { createOneNoteExportSnapshot, createOneNoteStaticNotebookZip, + DEFAULT_ONENOTE_EXPORT_LIMITS, forceFilenameExtension, + OneNoteExportError, sanitizeExportFilename, serializeOneNoteMarkdown, serializeOneNotePlainText, @@ -26,11 +28,7 @@ import type { OneNoteSectionDto, } from '../onenote/model/dto.js'; import type { OneNoteResource } from '../onenote/one/content-model.js'; -import { - resourcePayload, - workerPageKey, - workerResourceKey, -} from './parser-adapter.js'; +import { workerPageKey, workerResourceKey } from './parser-adapter.js'; export type WorkerExportFormat = 'static-zip' | 'json' | 'text' | 'markdown' | 'html'; @@ -57,11 +55,13 @@ interface CollectedPages { export function createWorkerExportSnapshot( source: OneNoteSectionDto | OneNotePackageDto, pages: ReadonlyMap, - resources: ReadonlyMap + resources: ReadonlyMap, + options: OneNoteExportOptions = {} ): OneNoteExportSnapshot { + const maxDiagnostics = diagnosticLimit(options); return source.format === 'one' - ? sectionSnapshot(source, pages, resources) - : packageSnapshot(source, pages, resources); + ? sectionSnapshot(source, pages, resources, maxDiagnostics) + : packageSnapshot(source, pages, resources, maxDiagnostics); } /** Serialize one parser session into a browser-downloadable export artifact. */ @@ -72,13 +72,18 @@ export function createWorkerExportArtifact( format: WorkerExportFormat, options: OneNoteExportOptions = {} ): WorkerExportArtifact { - const snapshot = createWorkerExportSnapshot(source, pages, resources); + const snapshot = createWorkerExportSnapshot( + source, + pages, + resources, + options + ); if (format === 'static-zip') { const artifact = createOneNoteStaticNotebookZip(snapshot, options); return { filename: artifact.filename, mediaType: artifact.mediaType, - bytes: copyToArrayBuffer(artifact.bytes), + bytes: transferableArrayBuffer(artifact.bytes), }; } @@ -90,7 +95,7 @@ export function createWorkerExportArtifact( return { filename, mediaType: serialization.mediaType, - bytes: copyToArrayBuffer( + bytes: transferableArrayBuffer( new TextEncoder().encode(serialization.serialize(snapshot, options)) ), }; @@ -132,9 +137,16 @@ const TEXT_EXPORTS: Record< function sectionSnapshot( source: OneNoteSectionDto, sessionPages: ReadonlyMap, - sessionResources: ReadonlyMap + sessionResources: ReadonlyMap, + maxDiagnostics: number ): OneNoteExportSnapshot { - const collected = collectPages(source.pages, sessionPages); + const collected = collectPages( + source.pages, + sessionPages, + undefined, + undefined, + maxDiagnostics + ); const context: ExportedSectionContext = { parserSection: source, exportSection: { @@ -170,13 +182,23 @@ function sectionSnapshot( function packageSnapshot( source: OneNotePackageDto, sessionPages: ReadonlyMap, - sessionResources: ReadonlyMap + sessionResources: ReadonlyMap, + maxDiagnostics: number ): OneNoteExportSnapshot { const contexts: ExportedSectionContext[] = []; - const globalDiagnostics = [ - ...source.diagnostics, - ...source.sections.flatMap((section) => section.diagnostics), - ]; + const globalDiagnostics: ParserDiagnostic[] = []; + appendDiagnosticsBounded( + globalDiagnostics, + source.diagnostics, + maxDiagnostics + ); + for (const section of source.sections) { + appendDiagnosticsBounded( + globalDiagnostics, + section.diagnostics, + maxDiagnostics + ); + } let missingPageCount = 0; for (const packaged of source.sections) { @@ -185,9 +207,14 @@ function packageSnapshot( packaged.section.pages, sessionPages, packaged.id, - packaged.path + packaged.path, + maxDiagnostics + ); + appendDiagnosticsBounded( + globalDiagnostics, + collected.diagnostics, + maxDiagnostics ); - globalDiagnostics.push(...collected.diagnostics); missingPageCount += collected.missingCount; contexts.push({ sessionSectionId: packaged.id, @@ -261,7 +288,8 @@ function collectPages( summaries: readonly OneNotePageSummaryDto[], sessionPages: ReadonlyMap, sessionSectionId?: string, - sectionPath?: string + sectionPath?: string, + maxDiagnostics = DEFAULT_ONENOTE_EXPORT_LIMITS.maxDiagnostics ): CollectedPages { const fullPages: OneNotePageDto[] = []; const diagnostics: ParserDiagnostic[] = []; @@ -271,13 +299,17 @@ function collectPages( fullPages.push(page); continue; } - diagnostics.push({ - severity: 'warning', - code: 'export-page-unavailable', - message: `Page ${summary.title || summary.id} was omitted because its full parsed content is no longer available in this session.`, - structure: `${sectionPath ? `${sectionPath}/` : ''}page ${summary.id}`, - recoverable: true, - }); + appendDiagnosticBounded( + diagnostics, + { + severity: 'warning', + code: 'export-page-unavailable', + message: `Page ${summary.title || summary.id} was omitted because its full parsed content is no longer available in this session.`, + structure: `${sectionPath ? `${sectionPath}/` : ''}page ${summary.id}`, + recoverable: true, + }, + maxDiagnostics + ); } return { pages: fullPages, @@ -291,9 +323,11 @@ function subtractNestedDiagnostics( pages: readonly OneNotePageDto[] ): ParserDiagnostic[] { const nested = new Map(); - for (const diagnostic of pages.flatMap((page) => page.diagnostics)) { - const key = diagnosticKey(diagnostic); - nested.set(key, (nested.get(key) ?? 0) + 1); + for (const page of pages) { + for (const diagnostic of page.diagnostics) { + const key = diagnosticKey(diagnostic); + nested.set(key, (nested.get(key) ?? 0) + 1); + } } return sectionDiagnostics.filter((diagnostic) => { const key = diagnosticKey(diagnostic); @@ -304,6 +338,43 @@ function subtractNestedDiagnostics( }); } +function diagnosticLimit(options: OneNoteExportOptions): number { + const value = + options.limits?.maxDiagnostics ?? + DEFAULT_ONENOTE_EXPORT_LIMITS.maxDiagnostics; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new OneNoteExportError( + 'invalid-input', + 'maxDiagnostics must be a positive safe integer' + ); + } + return value; +} + +function appendDiagnosticsBounded( + target: ParserDiagnostic[], + values: readonly ParserDiagnostic[], + limit: number +): void { + for (const diagnostic of values) { + appendDiagnosticBounded(target, diagnostic, limit); + } +} + +function appendDiagnosticBounded( + target: ParserDiagnostic[], + diagnostic: ParserDiagnostic, + limit: number +): void { + if (target.length >= limit) { + throw new OneNoteExportError( + 'limit-exceeded', + `Export diagnostic count exceeds ${limit}` + ); + } + target.push(diagnostic); +} + function diagnosticKey(diagnostic: ParserDiagnostic): string { return JSON.stringify([ diagnostic.severity, @@ -329,23 +400,43 @@ function collectResources( if (sessionSectionId === INVALID_RESOURCE_SECTION) continue; const context = sectionBySessionId.get(sessionSectionId); if (!context) continue; - const payload = resourcePayload(resource); - if (!payload) continue; + const blob = boundedResourceBlob(resource); + if (!blob) continue; result.push({ sectionId: context.exportSection.id, - id: payload.id, - kind: payload.kind, - filename: payload.filename, - extension: payload.extension, - mediaType: payload.mediaType, - browserRenderable: payload.browserRenderable, - size: payload.size, - bytes: payload.bytes, + id: resource.id, + kind: resource.kind, + filename: resource.filename, + extension: resource.extension, + mediaType: resource.mediaType, + browserRenderable: resource.browserRenderable, + size: blob.byteLength, + // The export planner applies per-resource and aggregate limits before + // any serializer or ZIP compressor copies this bounded source view. + bytes: blob, }); } return result; } +function boundedResourceBlob( + resource: OneNoteResource +): Uint8Array | undefined { + const blob = resource.blob; + if ( + resource.availability !== 'available' || + !blob || + !Number.isSafeInteger(blob.offset) || + !Number.isSafeInteger(blob.size) || + blob.offset < 0 || + blob.size < 0 || + blob.offset > blob.source.byteLength - blob.size + ) { + return undefined; + } + return blob.source.subarray(blob.offset, blob.offset + blob.size); +} + const INVALID_RESOURCE_SECTION = Symbol('invalid-resource-section'); function resourceSectionId( @@ -390,7 +481,14 @@ function unique(values: readonly string[]): string[] { return [...new Set(values.filter((value) => value.trim() !== ''))]; } -function copyToArrayBuffer(bytes: Uint8Array): ArrayBuffer { +function transferableArrayBuffer(bytes: Uint8Array): ArrayBuffer { + if ( + bytes.buffer instanceof ArrayBuffer && + bytes.byteOffset === 0 && + bytes.byteLength === bytes.buffer.byteLength + ) { + return bytes.buffer; + } const result = new ArrayBuffer(bytes.byteLength); new Uint8Array(result).set(bytes); return result;