import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { lstatSync, mkdirSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { promisify, TextDecoder } from "node:util"; import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; const execFileAsync = promisify(execFile); export interface GitStatus { branch: string; repository?: WorkspaceRepositoryIdentity; head?: string; ahead: number; behind: number; degraded: boolean; lastError?: WorkspaceErrorCode; } export interface WorkspaceRepositoryIdentity { host: string; repository: string; transport: "https" | "ssh" | "local"; } export interface EvidenceTreeObject { mode: "100644" | "100755"; oid: string; posixPath: string; } export class WorkspaceRegistryError extends Error { constructor(readonly code: WorkspaceErrorCode, message: string) { super(message); this.name = "WorkspaceRegistryError"; } } function invalidRemote(): never { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Git remote is invalid"); } function safeRepositoryPath(raw: string): string { let decoded: string; try { decoded = decodeURIComponent(raw).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, ""); } catch { return invalidRemote(); } if ( decoded.length === 0 || decoded.includes("\\") || decoded.split("/").some((part) => part === "" || part === "." || part === "..") || /[\p{Cc}\s?#]/u.test(decoded) ) return invalidRemote(); return decoded; } /** Convert a configured remote to the only repository identity safe for API/UI responses. */ export function normalizeRepositoryIdentity(remote: string): WorkspaceRepositoryIdentity { if (remote.length === 0 || remote.trim() !== remote || remote.includes("\0")) return invalidRemote(); if (isAbsolute(remote) || remote.startsWith("file://")) { return { host: "local", repository: "configured-repository", transport: "local" }; } const scp = /^git@([^:/\s]+):(.+)$/.exec(remote); if (scp) { return { host: scp[1].toLowerCase(), repository: safeRepositoryPath(scp[2]), transport: "ssh" }; } let parsed: URL; try { parsed = new URL(remote); } catch { return invalidRemote(); } if (parsed.search || parsed.hash || !parsed.hostname || parsed.port) return invalidRemote(); if (parsed.protocol === "https:") { if (parsed.username || parsed.password) return invalidRemote(); return { host: parsed.hostname.toLowerCase(), repository: safeRepositoryPath(parsed.pathname), transport: "https", }; } if (parsed.protocol === "ssh:") { if (parsed.password || (parsed.username !== "" && parsed.username !== "git")) return invalidRemote(); return { host: parsed.hostname.toLowerCase(), repository: safeRepositoryPath(parsed.pathname), transport: "ssh", }; } return invalidRemote(); } 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 isValidUtf8(buffer: Buffer): boolean { try { new TextDecoder("utf-8", { fatal: true }).decode(buffer); return true; } catch { return false; } } 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"; } 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; private readonly identity?: WorkspaceRepositoryIdentity; constructor(private 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"); this.identity = config.remoteUrl === undefined ? undefined : normalizeRepositoryIdentity(config.remoteUrl); } 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, ...(this.identity ? { repository: this.identity } : {}), head, ahead: Number(ahead), behind: Number(behind), degraded: false, }; } async workspaceDirectories(): Promise { const output = await this.git(["ls-tree", "-d", "--name-only", "HEAD"]); const directories = output.trim() === "" ? [] : output.trim().split("\n"); for (const id of directories) { if (id === "workspace-docs") continue; if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); } } return directories.filter((id) => id !== "workspace-docs").sort(); } async workspacePaths(): Promise { const paths: string[] = []; for (const id of await this.workspaceDirectories()) { const path = `${id}/workspace.yaml`; const type = await this.gitOptional(["cat-file", "-t", `HEAD:${path}`]); if (type === undefined) continue; if (type.trim() !== "blob") { throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is invalid"); } paths.push(path); } return paths.sort(); } async readCatalog(revision = "HEAD"): Promise { return await this.git(["show", `${revision}:thoth-workspaces.yaml`], {}, "Workspace catalog is invalid"); } async catalogBlob(revision = "HEAD"): Promise { return (await this.git(["rev-parse", `${revision}:thoth-workspaces.yaml`], {}, "Workspace catalog is invalid")).trim(); } async readWorkspace(path: string, revision = "HEAD"): Promise { if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } return await this.git(["show", `${revision}:${path}`]); } async blob(path: string, revision = "HEAD"): Promise { if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } return (await this.git(["rev-parse", `${revision}:${path}`])).trim(); } /** Read-only object type at an exact revision, or undefined when absent. */ async gitObjectType(revision: string, path: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); } if (!/^[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } return await this.gitOptional(["cat-file", "-t", `${revision}:${path}`]); } /** Read a generated-doc blob at an exact revision, or undefined when absent. */ async readObjectOrAbsent(revision: string, path: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); } if (!/^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } const output = await this.gitOptional(["show", `${revision}:${path}`]); return output === undefined ? undefined : output; } /** List committed generated-doc paths at an exact revision. */ async workspaceDocsPaths(revision: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid"); } const output = await this.git(["ls-tree", "-r", "--name-only", revision, "--", "workspace-docs"]); if (output.trim() === "") return []; const paths = output.trim().split("\n"); for (const path of paths) { if (!/^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid docs path"); } } return paths; } /** 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) || !/^[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"); } } /** Read the curated FK annotations object at an exact commit, or undefined when absent. */ async annotationsObject(revision: string, id: string): Promise<{ blobId: string; contents: Buffer } | undefined> { if (!/^[0-9a-f]{40}$/.test(revision)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace annotations revision is invalid"); } if (!/^[a-z][a-z0-9-]{2,62}$/.test(id) || id === "workspace-docs") { throw new WorkspaceRegistryError("workspace_invalid", "Workspace annotations path is invalid"); } const path = `${id}/schema/annotations.yaml`; // ls-tree -z reports the exact object at the path (or its children when the path is a tree). const listing = await this.git(["ls-tree", "-z", "--full-tree", revision, "--", path]); const entries = listing.split("\0").filter((entry) => entry.length > 0); if (entries.length === 0) return undefined; const exact = entries.find((entry) => entry.slice(entry.lastIndexOf("\t") + 1) === path); if (exact === undefined) { // The path resolves to a tree (its children are listed) or another non-blob object. throw new WorkspaceRegistryError("workspace_invalid", "Workspace annotations object is invalid"); } const match = /^([0-9]{6})\s+(blob|tree|commit)\s+([0-9a-f]{40})\t/.exec(exact); // Only regular Git blobs are accepted: symlinks (120000) and gitlinks (160000) are refused. if (match === null || match[2] !== "blob" || (match[1] !== "100644" && match[1] !== "100755")) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace annotations object is invalid"); } const blobId = match[3]; const contents = await this.gitBlobBytes(blobId, 16 * 1024 * 1024, "annotations"); if (!isValidUtf8(contents)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace annotations object is not valid UTF-8"); } return { blobId, contents }; } /** * Recursively enumerate a canonical `/evidence` tree at an exact commit as regular Git blobs. * Symlinks (120000), gitlinks (160000), non-regular modes, non-blob types, traversal/absolute/ * duplicate/cross-namespace paths, and NUL/newline-bearing names are refused. */ async evidenceTreeObjects(revision: string, id: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence revision is invalid"); } if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence path is invalid"); } const prefix = `${id}/evidence`; const listing = await this.git(["ls-tree", "-r", "-z", "--full-tree", revision, "--", prefix]); const entries = listing.split("\0").filter((entry) => entry.length > 0); const seen = new Set(); const objects: EvidenceTreeObject[] = []; for (const entry of entries) { const tab = entry.lastIndexOf("\t"); if (tab < 0) throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence object is invalid"); const name = entry.slice(tab + 1); const meta = entry.slice(0, tab); const match = /^([0-9]{6}) (blob|commit|tree) ([0-9a-f]{40})$/.exec(meta); if (match === null || match[2] !== "blob" || (match[1] !== "100644" && match[1] !== "100755")) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence object is invalid"); } if (name === prefix) { // The Evidence root resolves to a single regular blob (or symlink/gitlink already refused above). throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid"); } if (!name.startsWith(`${prefix}/`)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence object escapes its namespace"); } const rel = name.slice(prefix.length + 1); if (rel.length === 0 || rel.includes("\0") || rel.includes("\n") || rel.includes("\r")) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence path is invalid"); } const segments = rel.split("/"); if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence path is invalid"); } if (seen.has(rel)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence path is duplicated"); seen.add(rel); objects.push({ mode: match[1] as "100644" | "100755", oid: match[3], posixPath: rel }); } return objects; } /** Read one Evidence blob with a per-object byte bound. */ evidenceBlobBytes(objectId: string, maxBytes: number): Promise { return this.gitBlobBytes(objectId, maxBytes, "Evidence"); } /** Return the 40-hex tree id of a canonical Evidence root at an exact commit. */ async evidenceTreeId(revision: string, id: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision) || !/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence revision is invalid"); } const objectId = (await this.git(["rev-parse", `${revision}:${id}/evidence`])).trim(); const type = (await this.git(["cat-file", "-t", objectId])).trim(); if (!/^[0-9a-f]{40}$/.test(objectId) || type !== "tree") { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid"); } return objectId; } /** Return the byte size of one Git object without reading its contents. */ async gitObjectSize(objectId: string): Promise { if (!/^[0-9a-f]{40}$/.test(objectId)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence object is invalid"); } const raw = (await this.git(["cat-file", "-s", objectId])).trim(); const size = Number(raw); if (!Number.isSafeInteger(size) || size < 0) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence object size is invalid"); } return size; } private async gitBlobBytes(objectId: string, maxBytes: number, label: string): Promise { if (!/^[0-9a-f]{40}$/.test(objectId)) { throw new WorkspaceRegistryError("workspace_invalid", `Workspace ${label} object is invalid`); } try { const { stdout } = await execFileAsync( "git", ["-c", `core.hooksPath=${this.hooksPath}`, "cat-file", "blob", objectId], { cwd: this.repoPath, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, encoding: "buffer", maxBuffer: maxBytes + 1024 * 1024, }, ); if (stdout.length > maxBytes) { throw new WorkspaceRegistryError("workspace_invalid", `Workspace ${label} object is too large`); } return stdout; } catch (error) { if (error instanceof WorkspaceRegistryError) throw error; const detail = error instanceof Error ? error.message : ""; if (/maxBuffer|stdout maxBuffer/i.test(detail)) { throw new WorkspaceRegistryError("workspace_invalid", `Workspace ${label} object is too large`); } throw this.sanitizeGitError(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 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 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"); }