// 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(); 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 ); }