import { execFile as callback } from "node:child_process"; import { promisify } from "node:util"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { retentionTagForCommit } from "../resource-lock/index.js"; import { materializeWorkspaceSources, workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js"; const exec = promisify(callback); type SourceBackup = { formatVersion: 1; root: WorkspaceSourcePin; repositories: Array<{ repository: string; commits: string[]; bundle: string; sha256: string }>; }; async function hash(filename: string) { const digest = createHash("sha256"); for await (const chunk of createReadStream(filename)) digest.update(chunk); return digest.digest("hex"); } /** Source-only backup: no Graph state, checkout instructions or credentials. * Git bundles retain exact commits and their ancestors without needing a forge. * Storage/retention of the resulting directory belongs to the operator. */ export async function backupWorkspaceSources(options: { source: WorkspaceSourcePin; directory: string; output: string; locations?: Record; }): Promise { const output = path.resolve(options.output); if ( await lstat(output).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; return null; }) ) throw Error("Source backup destination must not exist"); const closure = await materializeWorkspaceSources(options); const grouped = new Map>(); for (const entry of closure.resources) { const commits = grouped.get(entry.repository) ?? new Map(); commits.set(entry.commit, path.join(path.resolve(options.directory), entry.directory)); grouped.set(entry.repository, commits); } await mkdir(path.dirname(output), { recursive: true }); const staging = await mkdtemp(path.join(path.dirname(output), ".source-backup-")); const manifest: SourceBackup = { formatVersion: 1, root: closure.root, repositories: [] }; try { for (const [repository, commits] of [...grouped].sort(([a], [b]) => a.localeCompare(b))) { const bundle = createHash("sha256").update(repository).digest("hex") + ".bundle"; const gitDirectory = await mkdtemp(path.join(staging, ".git-")); try { await exec("git", ["init", "--bare", gitDirectory]); const refs: string[] = []; for (const [commit, checkout] of commits) { const ref = retentionTagForCommit(commit); await exec("git", ["-C", gitDirectory, "fetch", "--no-tags", "--", checkout, `${ref}:${ref}`]); refs.push(ref); } await exec("git", ["-C", gitDirectory, "bundle", "create", path.join(staging, bundle), ...refs]); } finally { await rm(gitDirectory, { recursive: true, force: true }); } manifest.repositories.push({ repository, commits: [...commits.keys()].sort(), bundle, sha256: await hash(path.join(staging, bundle)), }); } await writeFile(path.join(staging, "backup.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); await rename(staging, output); return manifest; } finally { await rm(staging, { recursive: true, force: true }); } } export async function restoreWorkspaceSources(options: { backup: string; directory: string }) { const backup = path.resolve(options.backup); const filename = path.join(backup, "backup.json"); const info = await lstat(filename); if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw Error("Invalid source backup manifest"); const manifest = JSON.parse(await readFile(filename, "utf8")) as SourceBackup; if ( manifest.formatVersion !== 1 || !Array.isArray(manifest.repositories) || !manifest.repositories.length || manifest.repositories.length > 256 ) throw Error("Invalid source backup manifest"); const root = workspaceSourcePin(manifest.root); const locations: Record = {}; for (const entry of manifest.repositories) { if (!entry || !Array.isArray(entry.commits) || !entry.commits.length || entry.commits.length > 256) throw Error("Invalid source backup entry"); for (const commit of entry.commits) workspaceSourcePin({ repository: entry.repository, commit }); if (Object.hasOwn(locations, entry.repository)) throw Error("Duplicate source backup repository"); const expected = createHash("sha256").update(entry.repository).digest("hex") + ".bundle"; if (entry.bundle !== expected || !/^[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid source backup bundle identity"); const bundle = path.join(backup, expected); const stat = await lstat(bundle); if (!stat.isFile() || stat.isSymbolicLink() || (await hash(bundle)) !== entry.sha256) throw Error("Source backup bundle integrity mismatch"); locations[entry.repository] = pathToFileURL(bundle).href; } if ( !manifest.repositories.some((entry) => entry.repository === root.repository && entry.commits.includes(root.commit)) ) throw Error("Source backup does not retain its root commit"); // Every discovered dependency must use the backup. Never fall through to the // original forge if a manifest omits a required repository. return materializeWorkspaceSources({ source: root, directory: options.directory, locations, requireLocations: true }); }