Files
ThothII/backend/src/workspaces/git-repository.ts
T

462 lines
18 KiB
TypeScript

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<void> {
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<GitStatus> {
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<GitStatus> {
await this.ensureLayout();
if (isMissing(this.repoPath)) return await this.bootstrap();
assertDirectory(this.repoPath);
await this.refresh();
return await this.status();
}
async status(): Promise<GitStatus> {
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<string | undefined> {
this.validateRevision(revision);
return (await this.gitOptional(["rev-parse", `${revision}^`]))?.trim();
}
async workspacePathsAt(revision: string): Promise<string[]> {
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<string> {
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<string> {
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<void> {
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<void> {
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<string | undefined> {
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<string[]> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
this.validateRevision(revision);
try {
await this.git(["push", "origin", `${revision}:refs/heads/${this.config.branch}`]);
} catch (error) {
throw this.sanitizeGitError(error);
}
}
/** Create a local publication commit without contacting the remote. */
async commitOnly(paths: readonly string[], message: string): Promise<GitStatus> {
if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
await this.git(["add", "--", ...paths]);
await this.git(["commit", "-m", message], this.publicationIdentity());
return await this.status();
}
/** Commit and push a fixed set of validated artifact paths without exposing Git output. */
async commitAndPush(paths: readonly string[], message: string): Promise<GitStatus> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string | undefined> {
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<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.queue;
let releaseQueue!: () => void;
this.queue = new Promise<void>((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<ChildProcessWithoutNullStreams> {
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<void>((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<void> {
if (!holder.stdin.destroyed) holder.stdin.end();
await new Promise<void>((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");
}