refactor: make workspace repository strictly read only
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { mkdir } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { promisify, TextDecoder } from "node:util";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
|
||||
@@ -387,55 +387,6 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write only a validated API-owned 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 });
|
||||
}
|
||||
|
||||
/** Create a descriptor only when no filesystem entry exists at its exact path. */
|
||||
async createRegistryFile(path: string, source: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor path is invalid");
|
||||
}
|
||||
const target = join(this.repoPath, path);
|
||||
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await writeFile(target, source, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
} catch {
|
||||
throw new WorkspaceRegistryError("workspace_curator_owned", "Workspace descriptor is curator-owned");
|
||||
}
|
||||
}
|
||||
|
||||
async removeRegistryFile(path: string): Promise<void> {
|
||||
this.assertRegistryArtifactPath(path);
|
||||
await rm(join(this.repoPath, path), { force: true });
|
||||
}
|
||||
|
||||
private pendingPublicationPaths: string[] = [];
|
||||
|
||||
/** 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");
|
||||
}
|
||||
this.pendingPublicationPaths = [...paths];
|
||||
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", [
|
||||
@@ -448,17 +399,6 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private isRegistryArtifactPath(path: string): boolean {
|
||||
return /^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.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");
|
||||
@@ -481,37 +421,6 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
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}`]);
|
||||
// Remove only the exact untracked files this publication created, never curated content.
|
||||
const untracked = this.pendingPublicationPaths.filter((path) => {
|
||||
try {
|
||||
lstatSync(join(this.repoPath, path));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (untracked.length > 0) {
|
||||
await this.git(["clean", "-fd", "--", ...untracked]);
|
||||
}
|
||||
this.pendingPublicationPaths = [];
|
||||
} 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 = {},
|
||||
|
||||
Reference in New Issue
Block a user