import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { writePrivateAtomicJson } from "./atomic-json.ts"; import { currentCompletionOwnerId } from "./completion-owner.ts"; import { processStartKey } from "./process-identity.ts"; /** * On-disk identity of one parent process, keyed by its `completionOwnerId`. Detached background runners outlive * their parent, so a later process needs this record to prove the owner of a finished result is really gone. * Records are deliberately NOT removed on shutdown: a cleanly exited parent is exactly the case where its * runners finish later and a resumed process must take the results over. */ export interface OwnerRecord { version: 1; completionOwnerId: string; pid: number; startKey?: string; hostname: string; createdAt: number; } const MAX_ENCODED_SEGMENT_LENGTH = 200; /** Collision-free, traversal-safe file stem for an id that may come from an untrusted result file. */ export function ownerFileStem(id: string): string | undefined { if (typeof id !== "string" || !id) return undefined; const encoded = encodeURIComponent(id); if (encoded.length <= MAX_ENCODED_SEGMENT_LENGTH) return encoded; return `h-${createHash("sha256").update(id).digest("hex")}`; } export function ownerRecordPath(ownersDir: string, completionOwnerId: string): string | undefined { const stem = ownerFileStem(completionOwnerId); return stem ? path.join(ownersDir, `${stem}.json`) : undefined; } export function parseOwnerRecord(value: unknown): OwnerRecord | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; const record = value as Record; if (record.version !== 1) return undefined; if (typeof record.completionOwnerId !== "string" || !record.completionOwnerId) return undefined; if (!Number.isSafeInteger(record.pid) || (record.pid as number) <= 0) return undefined; if (typeof record.hostname !== "string" || !record.hostname) return undefined; if (typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt)) return undefined; return { version: 1, completionOwnerId: record.completionOwnerId, pid: record.pid as number, ...(typeof record.startKey === "string" && record.startKey ? { startKey: record.startKey } : {}), hostname: record.hostname, createdAt: record.createdAt, }; } /** Returns undefined for a missing, unreadable, or malformed record; callers must treat that as "unknown". */ export function readOwnerRecord(ownersDir: string, completionOwnerId: string): OwnerRecord | undefined { const file = ownerRecordPath(ownersDir, completionOwnerId); if (!file) return undefined; try { const record = parseOwnerRecord(JSON.parse(fs.readFileSync(file, "utf-8"))); return record?.completionOwnerId === completionOwnerId ? record : undefined; } catch { return undefined; } } export interface EnsureOwnerRecordOptions { ownersDir: string; completionOwnerId?: string; pid?: number; hostname?: string; startKey?: () => string | undefined; now?: () => number; } /** * Writes this process's owner record unless an identical one is already on disk (an extension reload in the same * process shares the owner id and must not rewrite it). Returns true when a file was written. */ export function ensureOwnerRecord(options: EnsureOwnerRecordOptions): boolean { const completionOwnerId = options.completionOwnerId ?? currentCompletionOwnerId(); const pid = options.pid ?? process.pid; const hostname = options.hostname ?? os.hostname(); const file = ownerRecordPath(options.ownersDir, completionOwnerId); if (!file) return false; const existing = readOwnerRecord(options.ownersDir, completionOwnerId); if (existing && existing.pid === pid && existing.hostname === hostname) return false; const startKey = (options.startKey ?? (() => processStartKey(pid)))(); const record: OwnerRecord = { version: 1, completionOwnerId, pid, ...(startKey ? { startKey } : {}), hostname, createdAt: (options.now ?? Date.now)(), }; fs.mkdirSync(options.ownersDir, { recursive: true, mode: 0o700 }); try { fs.chmodSync(options.ownersDir, 0o700); } catch { // Best effort (no-op on Windows); the records themselves are written 0600. } writePrivateAtomicJson(file, record); return true; }