#!/usr/bin/env node import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { chmod, open, readFile, rename, unlink, } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; /** Semantic hash of the previously installed personal policy snapshot. */ export const LEGACY_PERMISSION_POLICY_HASHES = new Set([ "9abf020e80fceae8c52de3e8b758822dfc86cb90e9083381e2569dde1e29400a", ]); function stripJsonComments(text) { let output = ""; let inString = false; let escaped = false; let lineComment = false; let blockComment = false; for (let index = 0; index < text.length; index += 1) { const character = text[index]; const next = text[index + 1]; if (lineComment) { if (character === "\n") { lineComment = false; output += character; } continue; } if (blockComment) { if (character === "*" && next === "/") { blockComment = false; index += 1; } continue; } if (inString) { output += character; if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === '"') inString = false; continue; } if (character === '"') { inString = true; output += character; } else if (character === "/" && next === "/") { lineComment = true; index += 1; } else if (character === "/" && next === "*") { blockComment = true; index += 1; } else { output += character; } } return output; } function stripTrailingCommas(text) { let output = ""; let inString = false; let escaped = false; for (let index = 0; index < text.length; index += 1) { const character = text[index]; if (inString) { output += character; if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === '"') inString = false; continue; } if (character === '"') { inString = true; output += character; continue; } if (character === ",") { let lookahead = index + 1; while (/\s/.test(text[lookahead] ?? "")) lookahead += 1; if (text[lookahead] === "}" || text[lookahead] === "]") continue; } output += character; } return output; } export function parseJsonc(text, label = "policy.jsonc") { try { return JSON.parse(stripTrailingCommas(stripJsonComments(text))); } catch (error) { throw new Error(`${label} is invalid JSONC: ${error instanceof Error ? error.message : String(error)}`); } } function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value !== null && typeof value === "object") { return Object.fromEntries( Object.keys(value) .sort() .map((key) => [key, sortJson(value[key])]), ); } return value; } export function permissionPolicySemanticHash(text) { const parsed = parseJsonc(text); const canonical = JSON.stringify(sortJson(parsed)); return createHash("sha256").update(canonical).digest("hex"); } function backupTimestamp(now) { return now.toISOString().replace(/[:.]/g, "-"); } async function readFileSnapshot(path) { const noFollow = constants.O_NOFOLLOW ?? 0; const handle = await open(path, constants.O_RDONLY | noFollow); try { const info = await handle.stat(); if (!info.isFile()) throw new Error(`policy path is not a regular file: ${path}`); return { content: await handle.readFile("utf8"), dev: info.dev, ino: info.ino }; } finally { await handle.close(); } } async function writeExclusive(path, content) { const handle = await open( path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600, ); try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close(); } await chmod(path, 0o600); } export async function migratePermissionPolicy( policyPath, seedPath, options = {}, ) { const now = options.now ?? new Date(); const legacyHashes = new Set( options.legacyHashes ?? LEGACY_PERMISSION_POLICY_HASHES, ); let original; try { original = await readFileSnapshot(policyPath); } catch (error) { if (error?.code === "ENOENT") return { status: "missing" }; throw error; } const seedContent = await readFile(seedPath, "utf8"); parseJsonc(seedContent, "bundled permission policy seed"); const currentHash = permissionPolicySemanticHash(original.content); const seedHash = permissionPolicySemanticHash(seedContent); if (currentHash === seedHash) { await chmod(policyPath, 0o600); return { status: "current" }; } if (!legacyHashes.has(currentHash)) return { status: "custom" }; const backupPath = `${policyPath}.bak.${backupTimestamp(now)}`; const temporaryPath = join( dirname(policyPath), `.${basename(policyPath)}.tmp.${process.pid}.${backupTimestamp(now)}`, ); await writeExclusive(backupPath, original.content); let temporaryCreated = false; try { await writeExclusive(temporaryPath, seedContent); temporaryCreated = true; if (options.beforeCommit) await options.beforeCommit(); const current = await readFileSnapshot(policyPath); if ( current.dev !== original.dev || current.ino !== original.ino || current.content !== original.content ) { throw new Error( "permission policy changed during migration; original file was preserved", ); } await rename(temporaryPath, policyPath); temporaryCreated = false; await chmod(policyPath, 0o600); return { status: "migrated", backupPath }; } finally { if (temporaryCreated) await unlink(temporaryPath).catch(() => undefined); } } async function main() { const [policyPath, seedPath] = process.argv.slice(2); if (!policyPath || !seedPath) { console.error( "usage: migrate-permission-policy.mjs ", ); process.exitCode = 2; return; } try { const result = await migratePermissionPolicy(policyPath, seedPath); console.log( result.status === "migrated" ? `migrated:${result.backupPath}` : result.status, ); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } } function isMainModule() { return process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; } if (isMainModule()) await main();