import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { lstatSync, mkdirSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join } from "node:path"; import { promisify } from "node:util"; import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; const execFileAsync = promisify(execFile); export interface GitStatus { branch: string; head?: string; ahead: number; behind: number; degraded: boolean; lastError?: WorkspaceErrorCode; } export class WorkspaceRegistryError extends Error { constructor(readonly code: WorkspaceErrorCode, message: string) { super(message); this.name = "WorkspaceRegistryError"; } } function isMissing(path: string): boolean { try { lstatSync(path); return false; } catch { return true; } } function assertDirectory(path: string): void { const entry = lstatSync(path); if (!entry.isDirectory() || entry.isSymbolicLink()) { throw new WorkspaceRegistryError("git_unavailable", "Workspace registry path is unavailable"); } } function gitErrorCode(error: unknown): WorkspaceErrorCode { const detail = [ error instanceof Error ? error.message : "", typeof error === "object" && error !== null && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : "", ].join("\n").toLowerCase(); if (/authentication failed|could not read username|permission denied|publickey/.test(detail)) { return "git_auth_failed"; } if (/non-fast-forward|not possible to fast-forward|fast-forward/.test(detail)) { return "git_non_fast_forward"; } if (/remote rejected|pre-receive hook declined|push.*rejected/.test(detail)) { return "git_push_rejected"; } return "git_unavailable"; } /** * A persistent checkout that executes Git only through fixed argument vectors. Git's stdout and * stderr are intentionally never exposed: they can contain remote URLs or credential hints. */ export class GitWorkspaceRepository { readonly root: string; readonly repoPath: string; readonly snapshotsPath: string; readonly statePath: string; readonly locksPath: string; private readonly hooksPath: string; constructor(readonly config: WorkspaceRegistryConfig) { if (!isAbsolute(config.root)) { throw new WorkspaceRegistryError("git_unavailable", "Workspace registry root is unavailable"); } this.root = config.root; this.repoPath = join(this.root, "repo"); this.snapshotsPath = join(this.root, "snapshots"); this.statePath = join(this.root, "state"); this.locksPath = join(this.root, "locks"); this.hooksPath = join(this.locksPath, "empty-hooks"); } async ensureLayout(): Promise { try { for (const path of [this.root, this.snapshotsPath, this.statePath, this.locksPath, this.hooksPath]) { await mkdir(path, { recursive: true, mode: 0o700 }); assertDirectory(path); } } catch (error) { if (error instanceof WorkspaceRegistryError) throw error; throw new WorkspaceRegistryError("git_unavailable", "Workspace registry storage is unavailable"); } } async bootstrap(): Promise { await this.ensureLayout(); if (isMissing(this.repoPath)) { if (!this.config.remoteUrl) { throw new WorkspaceRegistryError("git_unavailable", "Workspace registry remote is unavailable"); } await this.clone(); } else { assertDirectory(this.repoPath); await this.refresh(); } return await this.status(); } async pull(): Promise { await this.ensureLayout(); if (isMissing(this.repoPath)) return await this.bootstrap(); assertDirectory(this.repoPath); await this.refresh(); return await this.status(); } async status(): Promise { const head = (await this.git(["rev-parse", "HEAD"])).trim(); const tracking = await this.gitOptional(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]); const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : []; return { branch: this.config.branch, head, ahead: Number(ahead), behind: Number(behind), degraded: false, }; } private validateRevision(revision: string): void { if (!/^[0-9a-f]{40}$/.test(revision)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); } async parentOf(revision: string): Promise { this.validateRevision(revision); return (await this.gitOptional(["rev-parse", `${revision}^`]))?.trim(); } async workspacePathsAt(revision: string): Promise { this.validateRevision(revision); const output = await this.git(["ls-tree", "-r", "--name-only", revision, "--", "workspaces"]); const paths = output.trim() === "" ? [] : output.trim().split("\n"); for (const path of paths) if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); return paths; } async readWorkspaceAt(revision: string, path: string): Promise { this.validateRevision(revision); if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); return this.git(["show", `${revision}:${path}`]); } async blobAt(revision: string, path: string): Promise { this.validateRevision(revision); if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); return (await this.git(["rev-parse", `${revision}:${path}`])).trim(); } async fetchExact(revision: string): Promise { this.validateRevision(revision); if (!this.config.remoteUrl) throw new WorkspaceRegistryError("git_unavailable", "Workspace registry remote is unavailable"); await this.git(["fetch", "--no-tags", "origin", revision]); } async ensureRunRef(runId: string, revision: string): Promise { this.validateRevision(revision); if (!/^[0-9a-f]{32}$/.test(runId)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace publication run is invalid"); const ref = `refs/thoth/addressed-runs/${runId}/target`; const current = await this.gitOptional(["rev-parse", "--verify", ref]); if (current !== undefined && current.trim() !== revision) throw new WorkspaceRegistryError("workspace_conflict", "Workspace publication target conflicts"); if (current === undefined) await this.git(["update-ref", ref, revision]); } async runRef(runId: string): Promise { if (!/^[0-9a-f]{32}$/.test(runId)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace publication run is invalid"); return (await this.gitOptional(["rev-parse", "--verify", `refs/thoth/addressed-runs/${runId}/target`]))?.trim(); } async workspacePaths(): Promise { const output = await this.git(["ls-tree", "-r", "--name-only", "HEAD", "--", "workspaces"]); const paths = output.trim() === "" ? [] : output.trim().split("\n"); for (const path of paths) { if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); } } return paths; } async readWorkspace(path: string): Promise { if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } return await this.git(["show", `HEAD:${path}`]); } async blob(path: string): Promise { if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } return (await this.git(["rev-parse", `HEAD:${path}`])).trim(); } /** Assert that a canonical Evidence root is a Git tree at an exact commit. */ async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision) || !/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(repoRelativePath)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence revision is invalid"); } const type = (await this.git( ["cat-file", "-t", `${revision}:${repoRelativePath}`], {}, "Workspace Evidence root is invalid", )).trim(); if (type !== "tree") { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid"); } } /** Write only a validated registry artifact below the checked-out repository. */ async writeRegistryFile(path: string, source: string): Promise { this.assertRegistryArtifactPath(path); const target = join(this.repoPath, path); await mkdir(dirname(target), { recursive: true, mode: 0o700 }); await writeFile(target, source, { encoding: "utf8", mode: 0o600 }); } async removeRegistryFile(path: string): Promise { this.assertRegistryArtifactPath(path); await rm(join(this.repoPath, path), { force: true }); } /** Push an already-created commit by its exact object ID. Repeating this is idempotent. */ async pushExact(revision: string): Promise { this.validateRevision(revision); try { await this.git(["push", "origin", `${revision}:refs/heads/${this.config.branch}`]); } catch (error) { // `git` already returns a sanitized WorkspaceRegistryError. Preserve it rather // than mapping its public code a second time (notably git_push_rejected). await this.restoreFailedPublication(); if (error instanceof WorkspaceRegistryError) throw error; throw this.sanitizeGitError(error); } } /** Create a local publication commit without contacting the remote. */ async commitOnly(paths: readonly string[], message: string): Promise { if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); try { await this.git(["add", "--", ...paths]); await this.git(["commit", "-m", message], this.publicationIdentity()); return await this.status(); } catch (error) { // A failed prepare must not leave staged or generated artifacts in the // long-lived author checkout for the next request. await this.restoreFailedPublication(); throw error; } } /** Commit and push a fixed set of validated artifact paths without exposing Git output. */ async commitAndPush(paths: readonly string[], message: string): Promise { if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } try { await this.git(["add", "--", ...paths]); await this.git(["commit", "-m", message], this.publicationIdentity()); await this.git(["push", "origin", `HEAD:${this.config.branch}`]); return await this.status(); } catch (error) { // A failed commit leaves staged/working changes; a failed push leaves an ahead commit. // Restore the last fetched remote revision so the next refresh or explicit retry starts clean. await this.restoreFailedPublication(); throw error; } } private async clone(): Promise { try { await execFileAsync("git", [ "-c", `core.hooksPath=${this.hooksPath}`, "clone", "--branch", this.config.branch, "--single-branch", "--", this.config.remoteUrl!, this.repoPath, ], { cwd: this.root, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); assertDirectory(this.repoPath); } catch (error) { throw this.sanitizeGitError(error); } } private isRegistryArtifactPath(path: string): boolean { return /^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path) || /^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path); } private assertRegistryArtifactPath(path: string): void { if (!this.isRegistryArtifactPath(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } } private async refresh(): Promise { if ((await this.git(["status", "--porcelain"])).trim() !== "") { throw new WorkspaceRegistryError("workspace_stale", "Workspace checkout has local changes"); } if (this.config.remoteUrl) { await this.git(["remote", "set-url", "origin", "--", this.config.remoteUrl]); } await this.git(["fetch", "--no-tags", "origin", this.config.branch]); const remoteHead = (await this.git(["rev-parse", "FETCH_HEAD"])).trim(); const localHead = (await this.git(["rev-parse", "HEAD"])).trim(); if (localHead !== remoteHead) { const commonAncestor = (await this.git(["merge-base", "HEAD", "FETCH_HEAD"])).trim(); if (commonAncestor !== localHead) { throw new WorkspaceRegistryError("git_non_fast_forward", "Workspace checkout diverged from remote"); } await this.git(["merge", "--ff-only", "FETCH_HEAD"]); } if ((await this.git(["rev-parse", "HEAD"])).trim() !== remoteHead) { throw new WorkspaceRegistryError("git_non_fast_forward", "Workspace checkout does not match remote"); } } private publicationIdentity(): NodeJS.ProcessEnv { return { GIT_AUTHOR_NAME: this.config.gitAuthorName, GIT_AUTHOR_EMAIL: this.config.gitAuthorEmail, GIT_COMMITTER_NAME: this.config.gitAuthorName, GIT_COMMITTER_EMAIL: this.config.gitAuthorEmail, }; } private async restoreFailedPublication(): Promise { try { await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]); await this.git(["clean", "-fd", "--", "workspaces", "workspace-docs"]); } catch { // Keep the original sanitized publish failure. A future refresh will surface any recovery // problem without leaking the Git failure details through the API. } } private async git( args: string[], env: NodeJS.ProcessEnv = {}, invalidObjectMessage?: string, ): Promise { try { const { stdout } = await execFileAsync( "git", ["-c", `core.hooksPath=${this.hooksPath}`, ...args], { cwd: this.repoPath, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } }, ); return stdout; } catch (error) { const stderr = typeof error === "object" && error !== null && "stderr" in error && typeof error.stderr === "string" ? error.stderr : ""; if (invalidObjectMessage && /^fatal: path '[^']+' does not exist in '[0-9a-f]{40}'\s*$/u.test(stderr)) { throw new WorkspaceRegistryError("workspace_invalid", invalidObjectMessage); } throw this.sanitizeGitError(error); } } private async gitOptional(args: string[]): Promise { try { return await this.git(args); } catch (error) { if (error instanceof WorkspaceRegistryError && error.code === "git_unavailable") return undefined; throw error; } } private sanitizeGitError(error: unknown): WorkspaceRegistryError { return new WorkspaceRegistryError(gitErrorCode(error), "Workspace Git operation failed"); } } export class WorkspaceRepositoryLock { private queue = Promise.resolve(); constructor(private readonly locksPath: string) {} async run(operation: () => Promise): Promise { const previous = this.queue; let releaseQueue!: () => void; this.queue = new Promise((resolve) => { releaseQueue = resolve; }); await previous; let holder: ChildProcessWithoutNullStreams | undefined; const lockPath = join(this.locksPath, "repository.lock"); try { try { mkdirSync(this.locksPath, { recursive: true, mode: 0o700 }); assertDirectory(this.locksPath); } catch (error) { throw new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable"); } try { holder = await this.acquire(lockPath); } catch (error) { throw this.lockError(error); } return await operation(); } finally { try { if (holder !== undefined) await this.release(holder); } finally { releaseQueue(); } } } private async acquire(lockPath: string): Promise { try { const entry = lstatSync(lockPath); if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("invalid lock path"); } catch (error) { if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) { throw error; } } const holder = spawn("python3", ["-c", WorkspaceRepositoryLock.HOLDER_PROGRAM, lockPath], { stdio: ["pipe", "pipe", "pipe"], }); await new Promise((resolve, reject) => { let output = ""; const fail = (error: WorkspaceRegistryError) => { holder.stdout.removeAllListeners("data"); reject(error); }; holder.once("error", () => fail(new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable"))); holder.once("exit", (code) => { fail(new WorkspaceRegistryError( code === 73 ? "workspace_stale" : "git_unavailable", code === 73 ? "Workspace registry is busy" : "Workspace registry lock is unavailable", )); }); holder.stdout.on("data", (chunk: Buffer) => { output += chunk.toString("utf8"); if (output === "locked\n") { holder.stdout.removeAllListeners("data"); resolve(); } }); }); return holder; } private async release(holder: ChildProcessWithoutNullStreams): Promise { if (!holder.stdin.destroyed) holder.stdin.end(); await new Promise((resolve) => holder.once("exit", () => resolve())); } private lockError(error: unknown): WorkspaceRegistryError { if (error instanceof WorkspaceRegistryError) return error; if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") { return new WorkspaceRegistryError("workspace_stale", "Workspace registry is busy"); } return new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable"); } private static readonly HOLDER_PROGRAM = [ "import fcntl, os, sys", "fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT | getattr(os, 'O_NOFOLLOW', 0), 0o600)", "try:", " fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)", "except BlockingIOError:", " sys.exit(73)", "sys.stdout.write('locked\\n')", "sys.stdout.flush()", "sys.stdin.buffer.read()", ].join("\n"); }