159 lines
4.5 KiB
TypeScript
159 lines
4.5 KiB
TypeScript
// 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
|
|
);
|
|
}
|