/** * A name that can be written on every platform a member saves to * (docs/MESHBAY_DESIGN.md §10). * * A node serves a file under the name its own disk gave it, and a name that is * fine on ext4 can be impossible on Windows or on an exFAT drive: reserved * characters, a trailing dot or space, `CON` or `aux.txt`. The node never * rewrites a name — it is the string that opens the file — so the client does, * at the moment it saves, and says so. * * The same rules as `meshbay_common.paths.sanitize_for_download`, and held to * them by `test_portable_name_parity.py`: two copies of a rule that differ * decide differently which files get renamed. */ const WINDOWS_RESERVED = new Set([ 'CON', 'PRN', 'AUX', 'NUL', ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `COM${i}`), ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `LPT${i}`), ]); const RESERVED_CHARS = new Set('<>:"/\\|?*'); const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32; function isPortable(name) { if (!name || name === '.' || name === '..') return false; for (const c of name) if (reserved(c)) return false; if (name.endsWith(' ') || name.endsWith('.')) return false; return !WINDOWS_RESERVED.has(name.split('.', 1)[0].toUpperCase()); } /** `name` if it can be written everywhere, otherwise the nearest name that can. */ export function portableName(name, replacement = '_') { const text = String(name ?? ''); if (isPortable(text)) return text; let out = Array.from(text).map((c) => (reserved(c) ? replacement : c)).join(''); out = out.replace(/[ .]+$/, ''); const dot = out.indexOf('.'); let stem = dot === -1 ? out : out.slice(0, dot); const rest = dot === -1 ? '' : out.slice(dot); if (WINDOWS_RESERVED.has(stem.toUpperCase())) stem += replacement; out = stem + rest; return out || 'unnamed'; } /** Every segment of a relative path made portable, keeping `/` between them. */ export function portablePath(path) { return String(path ?? '').split('/') .map((segment) => (segment ? portableName(segment) : segment)).join('/'); }