From 89252f1f35de91494bd49460b1645d8c09cc6d33 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:07:43 +0000 Subject: [PATCH] fix(evidence): report missing evidence files instead of permission advice Reading a nonexistent Evidence bundle or snapshot, or exporting into a directory that does not exist, failed as reason "io" with "Evidence file could not be accessed. Check file permissions", and with no path. In `rea compare` the caller could not tell which of the two files failed. Map ENOENT and ENOTDIR to a new "missing" reason with read- and write-specific advice. Every EvidenceFileError now carries the resolved caller path in its projected details. Permission and other I/O failures keep the existing advice. Co-Authored-By: Claude Opus 5.5 --- src/application/JsonFiles.ts | 53 +++++++++++++++---- src/domain/analysisErrorPresentation.ts | 12 +++-- src/domain/analysisErrorProjection.ts | 6 ++- src/domain/evidenceErrors.ts | 8 ++- .../filesystem/evidenceBundleFiles.test.ts | 27 ++++++++++ .../contracts/analysisErrorProjection.test.ts | 2 +- 6 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index 23a3514ddc..4fea078a86 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -10,10 +10,14 @@ import { err, ok, type Result } from "../domain/result.js"; export const readJsonFile = async ( path: string, ): Promise> => { + const requestedPath = resolve(path); try { - const canonicalPath = await realpath(resolve(path)); + const canonicalPath = await realpath(requestedPath); const stats = await lstat(canonicalPath); - if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file")); + if (!stats.isFile()) + return err( + new EvidenceFileError("read", "not-file", { path: requestedPath }), + ); const encoded = await readFile(canonicalPath); let decoded: unknown; try { @@ -23,11 +27,21 @@ export const readJsonFile = async ( ), ); } catch (cause: unknown) { - return err(new EvidenceFileError("read", "invalid-json", { cause })); + return err( + new EvidenceFileError("read", "invalid-json", { + cause, + path: requestedPath, + }), + ); } return ok(decoded); } catch (cause: unknown) { - return err(new EvidenceFileError("read", "io", { cause })); + return err( + new EvidenceFileError("read", missingOrIo(cause), { + cause, + path: requestedPath, + }), + ); } }; @@ -40,8 +54,8 @@ export const writeTextFile = async ( Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError> > => { const bytes = Buffer.byteLength(encoded, "utf8"); + const requestedPath = resolve(path); try { - const requestedPath = resolve(path); const canonicalParent = await realpath(dirname(requestedPath)); const destination = resolve(canonicalParent, basename(requestedPath)); const existing = await lstat(destination).catch((cause: unknown) => { @@ -49,9 +63,14 @@ export const writeTextFile = async ( throw cause; }); if (existing !== undefined) { - if (!overwrite) return err(new EvidenceFileError("write", "exists")); + if (!overwrite) + return err( + new EvidenceFileError("write", "exists", { path: requestedPath }), + ); if (!existing.isFile() || existing.isSymbolicLink()) - return err(new EvidenceFileError("write", "not-file")); + return err( + new EvidenceFileError("write", "not-file", { path: requestedPath }), + ); } if (overwrite) await writeFileAtomic(destination, encoded, { @@ -63,11 +82,27 @@ export const writeTextFile = async ( return ok({ path: requestedPath, bytes }); } catch (cause: unknown) { if (!overwrite && fileErrorCode(cause) === "EEXIST") - return err(new EvidenceFileError("write", "exists", { cause })); - return err(new EvidenceFileError("write", "io", { cause })); + return err( + new EvidenceFileError("write", "exists", { + cause, + path: requestedPath, + }), + ); + return err( + new EvidenceFileError("write", missingOrIo(cause), { + cause, + path: requestedPath, + }), + ); } }; +/** A missing path or parent is a selection error, not a permission failure. */ +const missingOrIo = (cause: unknown): "missing" | "io" => { + const code = fileErrorCode(cause); + return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "io"; +}; + const publishNewFile = async ( destination: string, encoded: string, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 48a1f0860a..76cd82b8f7 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -162,8 +162,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { : `Evidence ${error.evidenceId} does not match the requested reference (${error.reason}). Check the expected and actual identity in the diagnostic details.`; if (error instanceof EvidenceIntegrityError) return "Evidence is invalid or has changed. Recreate or re-import it, then try again."; - if (error instanceof EvidenceFileError) - return evidenceFileMessage(error.reason); + if (error instanceof EvidenceFileError) return evidenceFileMessage(error); if (error instanceof UnknownRegistryError && error.reason === "not-found") return "The requested residual unknown does not exist in this session. Check the unknown_id and try again."; if (error instanceof UnknownRegistryError) @@ -258,7 +257,14 @@ const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { return "Artifact could not be read or written. Check file access and try again."; }; -const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => { +const evidenceFileMessage = ({ + operation, + reason, +}: EvidenceFileError): string => { + if (reason === "missing") + return operation === "read" + ? "Evidence file does not exist at the selected path. Check the path and try again." + : "Evidence output directory does not exist. Choose an existing directory and try again."; if (reason === "not-file") return "Evidence path does not point to a regular file. Choose a file and try again."; if (reason === "exists") diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 6ba8cfac37..bd8e7e9c9b 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -259,7 +259,11 @@ const artifactStateErrorDetails = ( }; if (error instanceof UnknownRegistryError) return { reason: error.reason }; if (error instanceof EvidenceFileError) - return { operation: error.operation, reason: error.reason }; + return { + operation: error.operation, + reason: error.reason, + ...(error.path === undefined ? {} : { path: error.path }), + }; return undefined; }; diff --git a/src/domain/evidenceErrors.ts b/src/domain/evidenceErrors.ts index 2e84cd83d4..f6d0b98a09 100644 --- a/src/domain/evidenceErrors.ts +++ b/src/domain/evidenceErrors.ts @@ -25,11 +25,15 @@ export class EvidenceReferenceError extends EvidenceIntegrityError { export class EvidenceFileError extends AnalysisError { readonly _tag = "EvidenceFileError"; + /** Caller-selected path that failed, resolved against the working directory. */ + readonly path: string | undefined; + constructor( readonly operation: "read" | "write", - readonly reason: "not-file" | "exists" | "invalid-json" | "io", - options?: ErrorOptions, + readonly reason: "not-file" | "exists" | "invalid-json" | "missing" | "io", + options?: ErrorOptions & { readonly path?: string }, ) { super(`Evidence bundle ${operation} failed: ${reason}`, options); + this.path = options?.path; } } diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index e19ceee379..4474a91d1d 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -10,6 +10,7 @@ import { writeEvidenceBundle, } from "../../../src/application/EvidenceBundleFiles.js"; import { compareEvidenceBundlesCommand } from "../../../src/application/EvidenceBundleCommands.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle, @@ -116,6 +117,32 @@ describe("evidence bundle filesystem adapter", () => { }); }); + it("reports a missing file or output directory with the selected path", async () => { + const directory = await createTestTempDirectory("rea-evidence-"); + const absent = join(directory, "absent.json"); + const read = await readEvidenceBundle(absent); + expect(read).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "missing", path: absent }, + }); + if (read.ok) throw new Error("Expected a missing-file failure"); + expect(projectAnalysisError(read.error)).toMatchObject({ + message: expect.stringContaining("does not exist at the selected path"), + details: { operation: "read", reason: "missing", path: absent }, + }); + + const orphan = join(directory, "absent", "bundle.json"); + const written = await writeEvidenceBundle(bundle(), orphan, false); + expect(written).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "missing", path: orphan }, + }); + if (written.ok) throw new Error("Expected a missing-directory failure"); + expect(projectAnalysisError(written.error).message).toContain( + "output directory does not exist", + ); + }); + it("rejects malformed and tampered input", async () => { const directory = await createTestTempDirectory("rea-evidence-"); const malformed = join(directory, "malformed.json"); diff --git a/tests/conformance/contracts/analysisErrorProjection.test.ts b/tests/conformance/contracts/analysisErrorProjection.test.ts index 74c6622cdc..8eb58e5a31 100644 --- a/tests/conformance/contracts/analysisErrorProjection.test.ts +++ b/tests/conformance/contracts/analysisErrorProjection.test.ts @@ -25,7 +25,7 @@ describe("analysis error projection contract", () => { ).map( (reason) => new ArtifactOperationError("inventory_artifact", reason), ), - ...(["not-file", "exists", "invalid-json", "io"] as const).map( + ...(["not-file", "exists", "invalid-json", "missing", "io"] as const).map( (reason) => new EvidenceFileError("read", reason), ), ...(