Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ project adheres to [Semantic Versioning](https://semver.org/).
- Playwright setup note in the Quick start section of `README.md`.

### Fixed
- Comparison and case summaries now refuse a `runs` directory that is a
symlink, matching list, export, and prune. The comparison path used to
follow the link and read the target tree. The GUI compare route returns
HTTP 422 for that refusal.
- Verify saved case now returns HTTP 422 when the saved case is structurally
invalid, the same distinction inspect already makes with 404 and 422. The
route used to report that failure as HTTP 500.
Expand Down
11 changes: 10 additions & 1 deletion bin/aas-gui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,16 @@ export function createGuiServer({
sendJson(response, 400, { error: "Compare requires two valid run ids." });
return;
}
const comparison = compareRuns(left, right, { outputRoot });
let comparison;
try {
comparison = compareRuns(left, right, { outputRoot });
} catch (error) {
if (/regular directory/.test(error?.message ?? "")) {
sendJson(response, 422, { error: "Runs directory must be a regular directory." });
return;
}
throw error;
}
if (url.searchParams.get("format") === "markdown") {
response.writeHead(200, { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-disposition": 'attachment; filename="case-comparison.md"' });
response.end(renderComparisonMarkdown(comparison)); return;
Expand Down
6 changes: 6 additions & 0 deletions bin/aas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,9 @@ function readStageArtifact(bundleDir, manifest, name) {
* component revisions only.
*/
export function summarizeRun(runId, { outputRoot = DEFAULT_PATHS.outputRoot } = {}) {
// Same store rule as list, export, and prune. Otherwise a symlinked runs
// directory is followed and comparison reads the link target.
assertRunsDirectory(outputRoot);
const { bundleDir, manifest } = readRunManifest(runsDirectory(outputRoot), runId);
validateSavedManifest(manifest, bundleDir);
const report = readRunReport(bundleDir, manifest);
Expand Down Expand Up @@ -1344,6 +1347,9 @@ const COMPARED_FIELDS = [
* identical evidence.
*/
export function compareRuns(leftId, rightId, { outputRoot = DEFAULT_PATHS.outputRoot } = {}) {
// summarizeRun reports a bad id as not-comparable. A symlinked store is a
// precondition failure, same as list and export, and must not be swallowed.
assertRunsDirectory(outputRoot);
let left = null;
let right = null;
const errors = [];
Expand Down
38 changes: 37 additions & 1 deletion test/gui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import { createHash } from "node:crypto";
import { request } from "node:http";
import { existsSync, readFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
Expand Down Expand Up @@ -639,6 +639,42 @@ test("GUI history and compare endpoints serve summaries and classifications", as
}
});

test("GUI compare refuses a symlinked runs directory", (t) => {
const outputRoot = mkdtempSync(join(tmpdir(), "aas-gui-runs-link-"));
const outside = mkdtempSync(join(tmpdir(), "aas-gui-runs-outside-"));
const runId = "2026-09-06T050000000Z-outside";
writeCase(outside, runId, {
report: {
run_id: runId,
flow: "decide -> act",
domain: "inventory",
policy_id: "outside-only-policy",
component_provenance: [],
stages: { decide: { status: "passed", policy_id: "outside-only-policy" }, act: { status: "skipped" }, prove: { status: "skipped" } },
},
});
try {
symlinkSync(join(outside, "runs"), join(outputRoot, "runs"));
} catch (error) {
if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) {
t.skip("symlinks are unavailable on this platform");
return;
}
throw error;
}
const server = createGuiServer({ outputRoot });
return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)).then(async () => {
try {
const compared = await requestServer(server, `/api/compare?a=${runId}&b=${runId}`);
assert.equal(compared.status, 422);
assert.match(compared.body, /regular directory/);
assert.doesNotMatch(compared.body, /outside-only-policy/);
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
});

test("GUI exposes a domain selector defaulting to refund", () => {
const page = renderPage();
assert.match(page, /<select id="domain">/);
Expand Down
2 changes: 2 additions & 0 deletions test/stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,8 @@ test("a symlinked runs directory is not used as the case store", (t) => {
assert.throws(() => listRuns({ outputRoot }), /regular directory/);
assert.throws(() => pruneRuns({ outputRoot, keep: 1 }), /regular directory/);
assert.throws(() => exportRunBundle(runId, { outputRoot }), /regular directory/);
assert.throws(() => summarizeRun(runId, { outputRoot }), /regular directory/);
assert.throws(() => compareRuns(runId, runId, { outputRoot }), /regular directory/);
assert.equal(existsSync(join(outsideParent, "runs", runId, "manifest.json")), true);
});

Expand Down
Loading