import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { fail } from "../errors.ts"; import type { DocxOperation } from "../contracts.ts"; export const WORKSPACE_ROOT = path.join(os.tmpdir(), "pi-extension-docx"); export type RevisionManifest = { schemaVersion: "1.0"; revisionId: string; createdAt: string; expiresAt: string; sourcePath: string; sourceSha256: string; stagedPath: string; stagedSha256: string; operations: DocxOperation[]; expectedChangedParts: string[]; changedParts: string[]; validation: Record }; export async function createWorkspace(ttlMs = 24 * 60 * 60 * 1000): Promise<{ id: string; dir: string; expiresAt: string }> { await fs.mkdir(WORKSPACE_ROOT, { recursive: true, mode: 0o700 }); const id = randomUUID(), dir = path.join(WORKSPACE_ROOT, id); await fs.mkdir(dir, { mode: 0o700 }); return { id, dir, expiresAt: new Date(Date.now() + ttlMs).toISOString() }; } export async function saveRevision(manifest: RevisionManifest): Promise { const dir = path.join(WORKSPACE_ROOT, manifest.revisionId); await fs.mkdir(dir, { recursive: true, mode: 0o700 }); const manifestPath = path.join(dir, "revision.json"); await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { encoding: "utf8", mode: 0o600 }); return manifestPath; } export async function loadRevision(revisionId: string): Promise { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(revisionId)) fail("REVISION_NOT_FOUND", "Invalid revision id."); const directory = path.join(WORKSPACE_ROOT, revisionId), manifestPath = path.join(directory, "revision.json"), raw = await fs.readFile(manifestPath, "utf8").catch(() => undefined); if (!raw) fail("REVISION_NOT_FOUND", `Staged revision not found: ${revisionId}.`); let manifest: RevisionManifest; try { manifest = JSON.parse(raw) as RevisionManifest; } catch { fail("REVISION_STALE", "Staged revision manifest is malformed."); } const hash = /^[a-f0-9]{64}$/i, expectedStagedPath = path.join(directory, "candidate.docx"); if (manifest.schemaVersion !== "1.0" || manifest.revisionId !== revisionId || !path.isAbsolute(manifest.sourcePath) || path.extname(manifest.sourcePath).toLowerCase() !== ".docx" || path.resolve(manifest.stagedPath) !== expectedStagedPath || !hash.test(manifest.sourceSha256) || !hash.test(manifest.stagedSha256) || !Array.isArray(manifest.operations) || !Array.isArray(manifest.expectedChangedParts) || !Array.isArray(manifest.changedParts) || !manifest.validation || typeof manifest.validation !== "object") fail("REVISION_STALE", "Staged revision manifest failed integrity checks."); const expires = Date.parse(manifest.expiresAt); if (!Number.isFinite(expires) || expires <= Date.now()) fail("REVISION_STALE", `Staged revision expired: ${revisionId}.`); return manifest; } export async function cleanupExpiredWorkspaces(now = Date.now()): Promise { await fs.mkdir(WORKSPACE_ROOT, { recursive: true, mode: 0o700 }); let removed = 0; for (const entry of await fs.readdir(WORKSPACE_ROOT, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = path.join(WORKSPACE_ROOT, entry.name), file = path.join(dir, "revision.json"); const raw = await fs.readFile(file, "utf8").catch(() => undefined); const expires = raw ? Date.parse((JSON.parse(raw) as { expiresAt?: string }).expiresAt ?? "") : NaN; const stat = await fs.stat(dir); if ((Number.isFinite(expires) && expires <= now) || (!Number.isFinite(expires) && now - stat.mtimeMs > 24 * 60 * 60 * 1000)) { await fs.rm(dir, { recursive: true, force: true }); removed++; } } return removed; }