feat: bind evidence trees to registry revisions
This commit is contained in:
@@ -154,6 +154,22 @@ export class GitWorkspaceRepository {
|
||||
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);
|
||||
@@ -249,7 +265,11 @@ export class GitWorkspaceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private async git(args: string[], env: NodeJS.ProcessEnv = {}): Promise<string> {
|
||||
private async git(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
invalidObjectMessage?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
@@ -258,6 +278,10 @@ export class GitWorkspaceRepository {
|
||||
);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
if (invalidObjectMessage && typeof error === "object" && error !== null
|
||||
&& "code" in error && typeof error.code === "number") {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", invalidObjectMessage);
|
||||
}
|
||||
throw this.sanitizeGitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,9 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local);
|
||||
if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local);
|
||||
if (request.action !== "delete") {
|
||||
await this.assertEvidenceContext(request.workspace, status.head!);
|
||||
}
|
||||
|
||||
const yamlPath = workspacePath(id);
|
||||
const docPaths = this.documentationPaths(id);
|
||||
@@ -472,6 +475,20 @@ export class WorkspaceRegistry {
|
||||
));
|
||||
}
|
||||
|
||||
private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise<void> {
|
||||
if (!isCanonicalWorkspace(workspace)) return;
|
||||
if (workspace.evidence?.source.type !== "filesystem") return;
|
||||
// P6 owns recursive containment. Here we deliberately validate only the declared root object.
|
||||
await this.repository.assertTreeAtRevision(revision, workspace.evidence.source.uri);
|
||||
}
|
||||
|
||||
private async assertSnapshotEvidenceContexts(state: ActiveState): Promise<void> {
|
||||
for (const revision of state.revisions) {
|
||||
const workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
||||
await this.assertEvidenceContext(workspace, revision.commit);
|
||||
}
|
||||
}
|
||||
|
||||
private async activate(commit: string): Promise<void> {
|
||||
const safeHead = safeCommit(commit);
|
||||
const files = await this.repository.workspacePaths();
|
||||
@@ -495,6 +512,7 @@ export class WorkspaceRegistry {
|
||||
if (workspace.workspace.id !== id) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
||||
}
|
||||
await this.assertEvidenceContext(workspace, safeHead);
|
||||
let snapshotSource = source;
|
||||
const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace)
|
||||
? "operational"
|
||||
@@ -721,6 +739,7 @@ export class WorkspaceRegistry {
|
||||
`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`,
|
||||
]);
|
||||
await this.assertManifestFiles(directory, manifest.files, legacyExpected);
|
||||
await this.assertSnapshotEvidenceContexts(state);
|
||||
const expected = this.expectedSnapshotFiles(state);
|
||||
const files = Object.fromEntries(expected.map((name) => [name, manifest.files[name]]));
|
||||
await this.writeSnapshotManifest(directory, { ...state, files });
|
||||
@@ -757,6 +776,7 @@ export class WorkspaceRegistry {
|
||||
throw new Error("manifest revisions do not match active state");
|
||||
}
|
||||
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
|
||||
await this.assertSnapshotEvidenceContexts(state);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
||||
|
||||
Reference in New Issue
Block a user