feat: add safe OneNote export serializers

This commit is contained in:
2026-07-22 19:36:20 +02:00
parent f0f6e48188
commit ece969e269
7 changed files with 2328 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
// 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
const WINDOWS_RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/iu;
const MAX_FILENAME_CODEPOINTS = 120;
const ACTIVE_EXTENSIONS = new Set([
'app',
'application',
'bat',
'cjs',
'cmd',
'com',
'desktop',
'exe',
'hta',
'htm',
'html',
'jar',
'js',
'lnk',
'mjs',
'ps1',
'scr',
'svg',
'svgz',
'url',
'vbs',
'wasm',
'xhtml',
'xml',
]);
/** Convert untrusted display metadata into one safe ZIP path segment. */
export function sanitizeExportFilename(
value: string | undefined,
fallback = 'untitled'
): string {
let result = cleanFilenameSegment(value);
if (!result || result === '.' || result === '..') {
result = cleanFilenameSegment(fallback);
}
if (!result || result === '.' || result === '..') result = 'untitled';
if (WINDOWS_RESERVED.test(result.split('.')[0] ?? result)) {
result = `_${result}`;
}
result = truncateFilename(result, MAX_FILENAME_CODEPOINTS);
return result || 'untitled';
}
function cleanFilenameSegment(value: string | undefined): string {
const basename = (value ?? '').split(/[\\/]/u).at(-1) ?? '';
let result = '';
for (const character of basename.normalize('NFC')) {
const code = character.codePointAt(0)!;
if (isControlOrDirectional(code)) continue;
result += '<>:"/\\|?*'.includes(character) ? '_' : character;
}
result = result
.replace(/\s+/gu, ' ')
.replace(/\.{2,}/gu, '.')
.replace(/^[ .]+|[ .]+$/gu, '');
return result;
}
export function normalizeExportExtension(
value: string | undefined
): string | undefined {
const extension = value
?.split('\0')
.join('')
.trim()
.replace(/^\.+/u, '')
.toLowerCase();
return extension && /^[a-z0-9][a-z0-9._+-]{0,15}$/u.test(extension)
? extension
: undefined;
}
/** Make browser/script/executable attachment suffixes download-inert. */
export function neutralizeActiveExtension(filename: string): {
filename: string;
neutralized: boolean;
} {
const extension = filenameExtension(filename);
if (!extension || !ACTIVE_EXTENSIONS.has(extension)) {
return { filename, neutralized: false };
}
return { filename: `${filename}.bin`, neutralized: true };
}
export function forceFilenameExtension(
filename: string,
extension: string
): string {
const safeExtension = normalizeExportExtension(extension) ?? 'bin';
const index = filename.lastIndexOf('.');
const stem = index > 0 ? filename.slice(0, index) : filename;
return truncateFilename(`${stem}.${safeExtension}`, MAX_FILENAME_CODEPOINTS);
}
export function filenameExtension(filename: string): string | undefined {
const index = filename.lastIndexOf('.');
return index > 0 && index < filename.length - 1
? filename.slice(index + 1).toLowerCase()
: undefined;
}
export class SafeExportNameAllocator {
private readonly used = new Set<string>();
allocate(value: string | undefined, fallback = 'untitled'): string {
const safe = sanitizeExportFilename(value, fallback);
if (this.reserve(safe)) return safe;
const extension = filenameExtension(safe);
const stem = extension ? safe.slice(0, -(extension.length + 1)) : safe;
for (let suffix = 2; suffix < 1_000_000; suffix += 1) {
const candidate = truncateFilename(
`${stem}-${suffix}${extension ? `.${extension}` : ''}`,
MAX_FILENAME_CODEPOINTS
);
if (this.reserve(candidate)) return candidate;
}
throw new Error('Could not allocate a unique export filename');
}
private reserve(value: string): boolean {
const key = value.toLocaleLowerCase('en-US');
if (this.used.has(key)) return false;
this.used.add(key);
return true;
}
}
function truncateFilename(value: string, maxCodepoints: number): string {
const characters = [...value];
if (characters.length <= maxCodepoints) return value;
const extension = filenameExtension(value);
const suffix =
extension && [...extension].length <= 16 ? `.${extension}` : '';
const suffixLength = [...suffix].length;
return `${characters.slice(0, Math.max(1, maxCodepoints - suffixLength)).join('')}${suffix}`.replace(
/[ .]+$/gu,
''
);
}
function isControlOrDirectional(code: number): boolean {
return (
code <= 0x1f ||
(code >= 0x7f && code <= 0x9f) ||
(code >= 0x202a && code <= 0x202e) ||
(code >= 0x2066 && code <= 0x2069) ||
code === 0xfeff
);
}