fix: bound OneNote export output

This commit is contained in:
2026-07-22 20:03:55 +02:00
parent 45880fcf2b
commit 572a67148a
7 changed files with 1049 additions and 121 deletions

View File

@@ -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 = '<img src=x onerror=alert(1)><script>bad()</script>';
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<Partial<OneNoteExportError>>({
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<Partial<OneNoteExportError>>({
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<Partial<OneNoteExportError>>({
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<Partial<OneNoteExportError>>({
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,