// The security-critical seam (design §10.1). ROM *filenames* are untrusted input that ends up inside // a `sh -c` / `cmd.exe /c` string executed as the host user. Every path that reaches a // `LaunchSpec.value` MUST go through here — never string-concatenate an un-quoted path. // // - POSIX (`sh -c`): single-quote wrapping is *fully general* — inside single quotes every byte is // literal, and the one escape needed is the single quote itself (`'` → `'\''`). Nothing is ever // refused (a NUL can't appear in an argv anyway; we reject it defensively). // - Windows (`cmd.exe /c`): there is **no** fully-safe general quoting, so we double-quote AND // refuse a hostile-character list (`" % ! ^ & | < >` + control chars). A refused ROM is skipped // with a loud warning rather than risking command injection (design §6). export type Os = "linux" | "windows"; /** The current host OS in this module's terms (macOS is not a punktfunk host, so anything non-win32 is POSIX). */ export const currentOs = (): Os => process.platform === "win32" ? "windows" : "linux"; export type QuoteResult = | { ok: true; value: string } | { ok: false; reason: string }; /** POSIX single-quote escaping — total, never refuses (except an impossible embedded NUL). */ export const quotePosix = (s: string): QuoteResult => { if (s.includes("\0")) return { ok: false, reason: "path contains a NUL byte" }; // Close the quote, emit an escaped literal quote, reopen: 'it'\''s' → it's return { ok: true, value: `'${s.replace(/'/g, "'\\''")}'` }; }; // `cmd.exe /c` metacharacters we cannot neutralise by quoting: env expansion (`%`), delayed // expansion (`!`), the escape char (`^`), redirection/piping (`& | < >`), and the quote itself. const WINDOWS_HOSTILE = /["%!^&|<>]/; /** True if `s` contains any C0 control character (< 0x20) — refused on Windows command lines. */ const hasControlChar = (s: string): boolean => { for (let i = 0; i < s.length; i++) { if (s.charCodeAt(i) < 0x20) return true; } return false; }; /** * Windows double-quoting with a hostile-name refusal list. Returns `{ok:false}` for any name that * `cmd.exe` could interpret — the caller skips that ROM and surfaces the reason. */ export const quoteWindows = (s: string): QuoteResult => { if (hasControlChar(s)) { return { ok: false, reason: "path contains control characters" }; } const m = WINDOWS_HOSTILE.exec(s); if (m) { return { ok: false, reason: `path contains a character unsafe for cmd.exe (${JSON.stringify(m[0])})`, }; } // A run of backslashes immediately before the closing quote must be doubled, or the CRT argv // parser reads `\"` as an escaped quote and the argument never terminates (MSDN "Parsing C // Command-Line Arguments"). Paths rarely end in `\`, but be correct anyway. const trailing = /\\+$/.exec(s); const body = trailing ? s.slice(0, -trailing[0].length) + trailing[0].repeat(2) : s; return { ok: true, value: `"${body}"` }; }; /** The quoting function for a given host OS. */ export const quoteFor = (os: Os): ((s: string) => QuoteResult) => os === "windows" ? quoteWindows : quotePosix; export type RenderResult = | { ok: true; command: string } | { ok: false; reason: string }; export interface RenderVars { /** * The launcher token, inserted **verbatim** (already a valid, trusted command fragment): either a * per-OS-quoted absolute path or a wrapper like `flatpak run org.libretro.RetroArch`. Produced by * the emulator detector (§ `emulators.ts`), never from untrusted ROM data — so it is not re-quoted * here (quoting a multi-word `flatpak run …` would break it). */ exe: string; /** The ROM path — **untrusted** filename data; quoted per-OS (or refused on Windows). */ rom: string; /** The core path (RetroArch) — semi-trusted; quoted per-OS. */ core?: string; } /** * Render a launch command from a template with `{exe}`, `{core}`, `{rom}` placeholders. `{rom}` and * `{core}` are quoted per-OS (a hostile Windows value fails the whole render → the ROM is skipped); * `{exe}` is a trusted pre-formed token inserted verbatim. `extraArgs` is operator-typed trusted * input, appended verbatim (design §6). */ export const renderCommand = ( template: string, vars: RenderVars, os: Os, extraArgs?: string, ): RenderResult => { const quote = quoteFor(os); const subs = new Map(); subs.set("exe", vars.exe); // verbatim — trusted launcher token for (const [key, value] of [ ["rom", vars.rom], ["core", vars.core], ] as const) { if (value === undefined) continue; const q = quote(value); if (!q.ok) return { ok: false, reason: `${key}: ${q.reason}` }; subs.set(key, q.value); } // Every `{placeholder}` in the template must resolve — an unknown one is a template bug, not a // runtime skip. for (const match of template.matchAll(/\{(\w+)\}/g)) { const key = match[1]; if (key !== undefined && !subs.has(key)) { return { ok: false, reason: `template placeholder {${key}} has no value`, }; } } const command = template.replace( /\{(\w+)\}/g, (_all, key: string) => subs.get(key) ?? "", ); const extra = extraArgs?.trim(); return { ok: true, command: extra ? `${command} ${extra}` : command }; };