import { execFile } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { GitWorkspaceRepository } from "../../../src/workspaces/git-repository.js"; import { materializeEvidenceTree } from "../../../src/workspaces/evidence/materialization.js"; import type { WorkspaceRegistryConfig } from "../../../src/workspaces/types.js"; const runFile = promisify(execFile); const temporaryRoots: string[] = []; afterEach(() => { temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); }); async function git(cwd: string, args: string[]): Promise { const { stdout } = await runFile("git", args, { cwd }); return stdout.trim(); } function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { return { root, remoteUrl, branch: "main", gitAuthorName: "Evidence Materializer Test", gitAuthorEmail: "evidence-materializer@example.invalid", installationId: "test", secretRoots: [], maxImportBytes: 1024, maxImportEntries: 1, }; } async function fixture(): Promise<{ root: string; remote: string; commit: string }> { const root = mkdtempSync(join(tmpdir(), "thoth-evidence-materializer-")); temporaryRoots.push(root); const remote = join(root, "remote.git"); const source = join(root, "source"); await git(root, ["init", "--bare", "--initial-branch=main", remote]); mkdirSync(source); await git(source, ["init", "--initial-branch=main"]); await git(source, ["config", "user.name", "Evidence Materializer Test"]); await git(source, ["config", "user.email", "evidence-materializer@example.invalid"]); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: research, name: Research}]\n"); mkdirSync(join(source, "research", "evidence", "nested"), { recursive: true }); writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 3\n id: research\n"); writeFileSync(join(source, "research", "evidence", "guide.md"), "# guide\n"); writeFileSync(join(source, "research", "evidence", "nested", "deep.md"), "# deep\n"); await git(source, ["add", "-A"]); await git(source, ["commit", "-m", "initial"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); const commit = await git(source, ["rev-parse", "HEAD"]); return { root, remote, commit }; } async function repo(fixture: { root: string; remote: string }): Promise { const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); return repository; } test("materializes the tree, hashes every file, and writes a bounded manifest", async () => { const fixtureValue = await fixture(); const repository = await repo(fixtureValue); const target = mkdtempSync(join(tmpdir(), "thoth-evidence-target-")); temporaryRoots.push(target); const result = await materializeEvidenceTree({ repository, revision: fixtureValue.commit, id: "research", targetDirectory: target, }); expect(readFileSync(join(result.root, "guide.md"), "utf8")).toBe("# guide\n"); expect(readFileSync(join(result.root, "nested", "deep.md"), "utf8")).toBe("# deep\n"); expect(result.manifest).toMatchObject({ schemaVersion: 1, workspace: "research", commit: fixtureValue.commit, entryCount: 2, }); expect(Object.keys(result.manifest.files).sort()).toEqual(["guide.md", "nested/deep.md"]); expect(result.manifest.files["guide.md"]!.digest).toMatch(/^sha256:[0-9a-f]{64}$/); expect(result.manifestDigest).toMatch(/^[0-9a-f]{64}$/); expect(readFileSync(result.manifestPath, "utf8")).toContain('"entryCount":2'); }); test("refuses symlink-containing trees and bound violations without publishing", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-evidence-unsafe-")); temporaryRoots.push(root); const remote = join(root, "remote.git"); const source = join(root, "source"); await git(root, ["init", "--bare", "--initial-branch=main", remote]); mkdirSync(source); await git(source, ["init", "--initial-branch=main"]); await git(source, ["config", "user.name", "E"]); await git(source, ["config", "user.email", "e@e.invalid"]); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: research, name: Research}]\n"); mkdirSync(join(source, "research", "evidence"), { recursive: true }); writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 3\n id: research\n"); writeFileSync(join(source, "research", "outside.md"), "# outside\n"); symlinkSync("../outside.md", join(source, "research", "evidence", "link.md")); await git(source, ["add", "-A"]); await git(source, ["commit", "-m", "symlink"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); const commit = await git(source, ["rev-parse", "HEAD"]); const repository = await repo({ root, remote }); const target = mkdtempSync(join(tmpdir(), "thoth-evidence-unsafe-target-")); temporaryRoots.push(target); await expect(materializeEvidenceTree({ repository, revision: commit, id: "research", targetDirectory: target })) .rejects.toThrow(); expect(readdirSync(target)).toEqual([]); // A valid tree but a per-file bound of 1 byte must also refuse. const fixtureValue = await fixture(); const repository2 = await repo(fixtureValue); const target2 = mkdtempSync(join(tmpdir(), "thoth-evidence-bound-target-")); temporaryRoots.push(target2); await expect(materializeEvidenceTree({ repository: repository2, revision: fixtureValue.commit, id: "research", targetDirectory: target2, limits: { maxFileBytes: 1 }, })).rejects.toThrow(); expect(readdirSync(target2)).toEqual([]); });