import { createStoreFileLock, removeStaleStoreFileLock, type StoreFileLock, } from "./StoreFileLock.js"; import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { lstat, mkdir, open, realpath, type FileHandle, } from "node:fs/promises"; import { dirname } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; import { PERMISSION_CAPABILITIES, type PermissionGrant, } from "../domain/permissionPolicy.js"; import { err, ok, type Result } from "../domain/result.js"; const grantSchema = z.object({ grant_id: z.string().min(1), capability: z.enum(PERMISSION_CAPABILITIES), roots: z.array(z.string()), executables: z.array(z.string()), environment_names: z.array(z.string()), origins: z.array(z.string()).optional(), network: z.enum(["none", "loopback", "external"]), mount: z.boolean(), lifetime: z.literal("project"), operation_identity: z.string().nullable(), expires_at: z.iso.datetime().nullable(), }); const storeSchema = z.object({ project_id: z.string().regex(/^project_[a-f0-9]{64}$/u), project_root: z.string(), grants: z.array(grantSchema).max(1_000), }); export type ProjectPermissionStore = z.infer; /** Owner-only project policy persistence failure. */ export class ProjectPermissionStoreError extends Error { readonly _tag = "ProjectPermissionStoreError" as const; constructor( readonly reason: | "project_not_found" | "not_owner_only" | "invalid" | "locked" | "io", options?: ErrorOptions, ) { super(`Project permission store failed: ${reason}`, options); } } /** Derive relocation-explicit project identity from its canonical root. */ const identifyPermissionProject = async ( projectRoot: string, ): Promise< Result< { readonly id: string; readonly root: string }, ProjectPermissionStoreError > > => { try { const root = await realpath(projectRoot); return ok({ id: `project_${createHash("sha256").update(root).digest("hex")}`, root, }); } catch (cause: unknown) { return err(new ProjectPermissionStoreError("project_not_found", { cause })); } }; /** Read and validate an owner-only store bound to the requested project. */ export const readProjectPermissionStore = async ( path: string, projectRoot: string, ): Promise< Result > => { const project = await identifyPermissionProject(projectRoot); if (!project.ok) return project; if (process.getuid === undefined) return err(new ProjectPermissionStoreError("not_owner_only")); let handle: FileHandle | undefined; try { const opened = await openPrivateStore(path); if (!opened.ok) return opened; if (opened.value === null) return ok(null); handle = opened.value; const encoded = await handle.readFile("utf8"); let decoded: unknown; try { decoded = JSON.parse(encoded); } catch (cause: unknown) { return err(new ProjectPermissionStoreError("invalid", { cause })); } const parsed = storeSchema.safeParse(decoded); if ( !parsed.success || parsed.data.project_id !== project.value.id || parsed.data.project_root !== project.value.root ) return err(new ProjectPermissionStoreError("invalid")); return ok(parsed.data); } catch (cause: unknown) { if (isNotFound(cause)) return ok(null); return err(new ProjectPermissionStoreError("io", { cause })); } finally { await handle?.close().catch(() => undefined); } }; /** Atomically replace explicit project grants with owner-only permissions. */ export const writeProjectPermissionStore = async ( path: string, projectRoot: string, grants: readonly PermissionGrant[], ): Promise> => { const project = await identifyPermissionProject(projectRoot); if (!project.ok) return project; const candidate = storeSchema.safeParse({ project_id: project.value.id, project_root: project.value.root, grants, }); if (!candidate.success) return err( new ProjectPermissionStoreError("invalid", { cause: candidate.error }), ); if (process.getuid === undefined) return err(new ProjectPermissionStoreError("not_owner_only")); return withPermissionStoreLock(path, () => writePreparedPermissionStore(path, projectRoot, candidate.data), ); }; const writePreparedPermissionStore = async ( path: string, projectRoot: string, candidate: ProjectPermissionStore, ): Promise> => { await writeFileAtomic(path, `${JSON.stringify(candidate, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); const verified = await readProjectPermissionStore(path, projectRoot); if (!verified.ok) return verified; if ( verified.value === null || JSON.stringify(verified.value) !== JSON.stringify(candidate) ) return err(new ProjectPermissionStoreError("invalid")); return ok(candidate); }; /** Revoke one grant while serializing the complete read-modify-write cycle. */ export const revokeProjectPermissionGrant = async ( path: string, projectRoot: string, grantId: string, ): Promise> => withPermissionStoreLock(path, async () => { const current = await readProjectPermissionStore(path, projectRoot); if (!current.ok) return current; if (current.value === null) return ok(false); const retained = current.value.grants.filter( ({ grant_id }) => grant_id !== grantId, ); if (retained.length === current.value.grants.length) return ok(false); const written = await writePreparedPermissionStore(path, projectRoot, { ...current.value, grants: retained, }); return written.ok ? ok(true) : written; }); const withPermissionStoreLock = async ( path: string, action: () => Promise>, ): Promise> => { let lock: StoreFileLock | undefined; try { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const acquired = await acquirePermissionStoreLock(path); if (!acquired.ok) return acquired; lock = acquired.value; return await action(); } catch (cause: unknown) { return err(new ProjectPermissionStoreError("io", { cause })); } finally { if (lock !== undefined) await lock.release(); } }; const acquirePermissionStoreLock = async ( destination: string, ): Promise> => { const path = `${destination}.lock`; for (let attempt = 0; attempt < 100; attempt += 1) { try { return ok(await createStoreFileLock(path)); } catch (cause: unknown) { if (!isAlreadyExists(cause)) return err(new ProjectPermissionStoreError("io", { cause })); if (await removeStaleStoreFileLock(path)) continue; if (attempt === 99) return err(new ProjectPermissionStoreError("locked", { cause })); await delay(10); } } return err(new ProjectPermissionStoreError("locked")); }; const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT"; const openPrivateStore = async ( path: string, ): Promise> => { let handle: FileHandle | undefined; try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); const metadata = await handle.stat(); if (!privateRegularFile(metadata)) { await handle.close(); return err(new ProjectPermissionStoreError("not_owner_only")); } return ok(handle); } catch (cause: unknown) { await handle?.close().catch(() => undefined); if (isNotFound(cause)) return ok(null); return err( new ProjectPermissionStoreError( isSymlinkRefusal(cause) ? "not_owner_only" : "io", { cause }, ), ); } }; const privateRegularFile = ( metadata: Awaited>, ): boolean => metadata.isFile() && !metadata.isSymbolicLink() && (Number(metadata.mode) & 0o077) === 0 && process.getuid !== undefined && metadata.uid === process.getuid(); const isAlreadyExists = (cause: unknown): boolean => errorCode(cause) === "EEXIST"; const errorCode = (cause: unknown): unknown => typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined; const isSymlinkRefusal = (cause: unknown): boolean => errorCode(cause) === "ELOOP" || errorCode(cause) === "EMLINK";