473 lines
19 KiB
TypeScript
473 lines
19 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) {
|
|
// `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<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());
|
|
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<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");
|
|
}
|