import { randomUUID } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import { lock } from "proper-lockfile"; interface StoredConfig { allowedMutations?: string[]; [key: string]: unknown; } async function readText(path: string): Promise { try { return await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } function parseConfig(text: string | undefined, path: string): StoredConfig { if (text === undefined) return {}; let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { throw new Error( `invalid JSON in OneDev toolkit config ${path}: ${error instanceof Error ? error.message : String(error)}`, ); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error(`OneDev toolkit config is not a JSON object: ${path}`); } return parsed as StoredConfig; } function allowedMutations(config: StoredConfig): string[] { if (!Array.isArray(config.allowedMutations)) return []; return config.allowedMutations.filter( (value): value is string => typeof value === "string", ); } export async function readAllowedMutationScopes(path: string): Promise { return allowedMutations(parseConfig(await readText(path), path)); } async function removeIfPresent(path: string): Promise { try { await rm(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } export async function saveAllowedMutationScope( path: string, scope: string, ): Promise { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); let compromised: Error | undefined; const release = await lock(path, { realpath: false, stale: 10_000, update: 5_000, retries: { retries: 200, factor: 1.1, minTimeout: 10, maxTimeout: 100, randomize: true, }, onCompromised: (error) => { compromised = error; }, }); try { const original = await readText(path); const config = parseConfig(original, path); const allowed = allowedMutations(config); if (!allowed.includes(scope)) allowed.push(scope); config.allowedMutations = allowed; const temporary = resolve( dirname(path), `.${basename(path)}.${randomUUID()}.tmp`, ); await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600, }); try { if (compromised) throw compromised; await rename(temporary, path); } finally { await removeIfPresent(temporary); } } finally { try { await release(); } catch { // A compromised lock is handled before rename; cleanup failure must not kill Pi. } } }