Files
punktfunk-plugin-rom-manager/src/quote.ts
T
enricobuehler 3a6c80558d
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled
feat: initial ROM & emulator manager plugin (M0–M5)
A punktfunk-plugin-* package that scans ROM directories, maps them to
emulators, fetches box art, and reconciles them into the host game library
under provider id `rom-manager` — with a console-hosted web UI.

Engine (pure, unit-tested core):
- Table-driven platform registry (~25 consoles) + emulator registry with
  best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch
  core discovery.
- Scanner with disc folding (m3u/cue/gdi), archive gating, excludes.
- No-Intro title parsing + optional per-platform region dedupe.
- Security-critical quoting seam: POSIX single-quote + Windows double-quote
  with hostile-name refusal; ROM filenames never reach a shell un-quoted.
- Pure desired-state reconcile (stable external_ids, scale guard, fingerprint
  skip) → full-replace PUT /library/provider/rom-manager.

Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/
header, fuzzy match, operator API key) behind a provider seam, with keyless
libretro-thumbnails as the zero-setup fallback (`auto` default).

UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a
plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators /
Games / Sync). Standalone password-gated fallback for host-only installs.

CLI: scan / detect / preview / sync / uninstall / set-password.

48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end:
scan → detect → reconcile PUT, fingerprint idempotence, and the standalone
UI serving SPA + REST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:09:18 +02:00

131 lines
5.2 KiB
TypeScript

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