feat: add safe OneNote export serializers
This commit is contained in:
158
src/onenote/export/naming.ts
Normal file
158
src/onenote/export/naming.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user