3a6c80558d
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>
131 lines
5.2 KiB
TypeScript
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 };
|
|
};
|