// Config/cache persistence: the plugin owns two files under `/playnite/`, created 0700. // `config.json` is operator-editable and atomically rewritten (temp + rename); the plugin refuses a // group/world-writable `config.json` (the same sshd rule the runner and hooks enforce, since the UI // can rewrite it and the launch commands it produces run as the host user). `cache.json` is disposable // derived state. import * as fs from "node:fs"; import * as path from "node:path"; import { type Config, type RawConfig, resolveConfig } from "./config.js"; import { cachePath, configPath, pluginDir } from "./paths.js"; export interface Cache { /** Fingerprint + count of the last reconcile — lets a re-read skip an unchanged PUT. */ lastSync?: { fingerprint: string; count: number; at: number }; } export const emptyCache = (): Cache => ({}); /** Create the plugin dir 0700 if absent (idempotent). */ const ensureDir = (): void => { fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 }); }; /** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */ const assertNotWorldWritable = (file: string): void => { if (process.platform === "win32") return; let mode: number; try { mode = fs.statSync(file).mode; } catch { return; // absent — nothing to guard } if ((mode & 0o022) !== 0) { throw new Error( `refusing ${file}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`, ); } }; /** Atomic write: temp file in the same dir, then rename. */ const atomicWrite = (file: string, data: string): void => { ensureDir(); const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmp, data, { mode: 0o600 }); fs.renameSync(tmp, file); }; /** Load the authored config (defaults filled). A missing file yields the empty config. */ export const loadConfig = (): Config => { const file = configPath(); let raw = ""; try { raw = fs.readFileSync(file, "utf8"); } catch { return resolveConfig({}); } assertNotWorldWritable(file); const parsed = JSON.parse(raw) as RawConfig; return resolveConfig(parsed); }; /** Persist a config (atomic). */ export const saveConfig = (config: Config): void => { atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`); }; /** Save the raw (operator-authored) config verbatim — the shape the UI edits. */ export const saveRawConfig = (raw: RawConfig): void => { atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`); }; export const loadCache = (): Cache => { try { return JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache; } catch { return emptyCache(); } }; export const saveCache = (cache: Cache): void => { atomicWrite(cachePath(), JSON.stringify(cache)); }; /** Absolute paths, exported for the UI/CLI status view. */ export const statePaths = () => ({ dir: pluginDir(), config: configPath(), cache: cachePath(), relConfig: path.basename(configPath()), });