diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index f104a7e57d..07c5433fd3 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -411,16 +411,16 @@ const writeTextPartsOwned = async ( }), cause.lease, ); + const failure = classifyWriteFailure(cause.outcome.error); return stagedCleanupFailure( - new EvidenceFileError( - "write", - fileAccessFailureReason(cause.outcome.error), - { - cause, - path: requestedPath, - cleanup, - }, - ), + new EvidenceFileError("write", failure.reason, { + cause, + path: requestedPath, + cleanup, + ...(failure.systemCode === undefined + ? {} + : { systemCode: failure.systemCode }), + }), cause.lease, ); } @@ -437,15 +437,32 @@ const writeTextPartsOwned = async ( path: requestedPath, }), ); + const failure = classifyWriteFailure(cause); return err( - new EvidenceFileError("write", fileAccessFailureReason(cause), { + new EvidenceFileError("write", failure.reason, { cause, path: requestedPath, + ...(failure.systemCode === undefined + ? {} + : { systemCode: failure.systemCode }), }), ); } }; +/** Recognize only host codes that establish a publication resource refusal. */ +const classifyWriteFailure = ( + cause: unknown, +): { + readonly reason: EvidenceFileError["reason"]; + readonly systemCode?: "EFBIG" | "ENOSPC" | "EDQUOT"; +} => { + const code = fileErrorCode(cause); + return code === "EFBIG" || code === "ENOSPC" || code === "EDQUOT" + ? { reason: "storage-limit", systemCode: code } + : { reason: fileAccessFailureReason(cause) }; +}; + /** The retained staging lease clears this error only when a retry confirms release. */ const stagedCleanupFailure = < Failure extends EvidenceFileError | AnalysisCancelledError, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index fcab0bb0fd..bf20bac1ed 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -260,7 +260,10 @@ export const analysisErrorCategory = ( return "unavailable"; if (error instanceof ArtifactOperationError) return artifactErrorCategory(error.reason); - if (error instanceof EvidenceFileError && error.reason === "path-limit") + if ( + error instanceof EvidenceFileError && + (error.reason === "path-limit" || error.reason === "storage-limit") + ) return "resource_constraint"; if ( error instanceof EvidenceFileError && @@ -565,11 +568,18 @@ const evidenceFileMessage = ({ operation, reason, published, + systemCode, }: EvidenceFileError): string => { if (published !== undefined) return "The output file was published, but staging cleanup remains incomplete."; if (reason === "path-limit") return "Filesystem path-length constraints prevented reading or atomic publication at the selected path. Choose a shorter local path."; + if (reason === "storage-limit") + return systemCode === "EFBIG" + ? "The host file-size limit prevented Evidence publication. Increase the inherited file-size allowance and retry." + : systemCode === undefined + ? "A host filesystem resource limit prevented Evidence publication. Check the file-size allowance, filesystem space and quota before retrying." + : "The filesystem lacks space or quota for Evidence publication. Free space or increase quota and retry."; if (reason === "symlink-traversal") return "The selected path cannot be resolved because its symbolic-link chain loops or exceeds the host traversal limit. Repair the chain or select another path."; if (reason === "missing") diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index dcf0a984ed..576cd7de92 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -236,7 +236,8 @@ const evidenceFileCode = ( reason: EvidenceFileError["reason"], ): AnalysisErrorProjection["code"] => { if (reason === "invalid-json") return "evidence_integrity_mismatch"; - if (reason === "path-limit") return "resource_constraint"; + if (reason === "path-limit" || reason === "storage-limit") + return "resource_constraint"; // A missing, non-file or already-existing path is the caller's selection. if (reason === "io") return "execution_failure"; return "invalid_request"; @@ -418,6 +419,9 @@ const artifactStateErrorDetails = ( operation: error.operation, reason: error.reason, ...(error.reason === "path-limit" ? { boundary: "filesystem-path" } : {}), + ...(error.systemCode === undefined + ? {} + : { system_code: error.systemCode }), ...(error.path === undefined ? {} : { path: error.path }), ...(error.contentIssues === undefined ? {} diff --git a/src/domain/evidenceErrors.ts b/src/domain/evidenceErrors.ts index 167de2a6b7..c8db5350a5 100644 --- a/src/domain/evidenceErrors.ts +++ b/src/domain/evidenceErrors.ts @@ -60,6 +60,7 @@ export class EvidenceFileError extends AnalysisError { readonly published: PublishedEvidenceFileObservation | undefined; /** Schema issues relative to the selected file's JSON root, never the request envelope. */ readonly contentIssues: readonly AnalysisInputIssue[] | undefined; + readonly systemCode: "EFBIG" | "ENOSPC" | "EDQUOT" | undefined; constructor( readonly operation: "read" | "write", @@ -70,6 +71,7 @@ export class EvidenceFileError extends AnalysisError { | "invalid-json" | "missing" | "path-limit" + | "storage-limit" | "symlink-traversal" | "io", options?: AnalysisErrorOptions & { @@ -77,6 +79,7 @@ export class EvidenceFileError extends AnalysisError { readonly published?: PublishedEvidenceFileObservation; readonly userMessage?: string; readonly contentIssues?: readonly AnalysisInputIssue[]; + readonly systemCode?: "EFBIG" | "ENOSPC" | "EDQUOT"; }, ) { super(`Evidence bundle ${operation} failed: ${reason}`, options); @@ -84,5 +87,6 @@ export class EvidenceFileError extends AnalysisError { this.published = options?.published; this.userMessage = options?.userMessage; this.contentIssues = options?.contentIssues; + this.systemCode = options?.systemCode; } } diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 67babfcc73..3237aa96f8 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -189,6 +189,10 @@ const redactFailure = ( const publishedPath = identity(error.published?.path); return new EvidenceFileError(error.operation, error.reason, { ...context, + ...(error.systemCode === undefined || + text(error.systemCode) !== error.systemCode + ? {} + : { systemCode: error.systemCode }), ...(retainedPath === undefined ? {} : { path: retainedPath }), ...(error.userMessage === undefined ? {} diff --git a/tests/boundary/filesystem/jsonOutputResourceFailure.test.ts b/tests/boundary/filesystem/jsonOutputResourceFailure.test.ts new file mode 100644 index 0000000000..50b4a95f8e --- /dev/null +++ b/tests/boundary/filesystem/jsonOutputResourceFailure.test.ts @@ -0,0 +1,136 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const failure = vi.hoisted(() => ({ code: "" })); +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + mkdtemp: async (...args: Parameters) => { + if (failure.code !== "") + throw Object.assign(new Error("controlled staging refusal"), { + code: failure.code, + }); + return filesystem.mkdtemp(...args); + }, + }; +}); +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { writeTextParts } from "../../../src/application/JsonFiles.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js"; + +it.each([ + ["EFBIG", "file-size allowance"], + ["ENOSPC", "space or quota"], + ["EDQUOT", "space or quota"], +] as const)( + "preserves the %s host resource refusal through redaction", + async (code, guidance) => { + const root = await createTestTempDirectory("rea-write-resource-"); + const resources = new ArtifactResourceScope(); + onTestFinished(() => resources.close()); + failure.code = code; + try { + const result = await writeTextParts( + ["bytes"], + join(root, "out.json"), + false, + resources, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError( + redactExplicitFailure(result.error, ["unrelated-secret"]), + ); + expect(projected).toMatchObject({ + code: "resource_constraint", + category: "resource_constraint", + retryable: false, + details: { + operation: "write", + reason: "storage-limit", + system_code: code, + path: join(root, "out.json"), + }, + }); + expect(projected.remediation.action).toContain(guidance); + const excluded = projectAnalysisError( + redactExplicitFailure(result.error, [code]), + ); + expect(excluded.details).not.toHaveProperty("system_code"); + expect(excluded.remediation.action).toContain("file-size allowance"); + } + expect(await readdir(root)).toEqual([]); + } finally { + failure.code = ""; + } + }, +); + +/** Fail after the first staged part has been written, before publication. */ +function* limitedParts(): Generator { + yield "staged bytes"; + throw Object.assign(new Error("file size limit"), { code: "EFBIG" }); +} + +it.each([false, true])( + "retains a staged EFBIG and cleans or retries its owner (cleanup fails: %s)", + async (cleanupFails) => { + const root = await createTestTempDirectory("rea-staged-resource-"); + const output = join(root, "out.json"); + const resources = new ArtifactResourceScope(); + const { OwnedStagedOutput } = + await import("../../../src/filesystem/OwnedStagedOutput.js"); + const close = vi.spyOn(OwnedStagedOutput.prototype, "close"); + if (cleanupFails) + close.mockRejectedValueOnce(new Error("controlled cleanup refusal")); + try { + const result = await writeTextParts( + limitedParts(), + output, + false, + resources, + ); + if (result.ok) throw new Error("Expected resource refusal"); + const redacted = redactExplicitFailure(result.error, [ + "unrelated-secret", + ]); + const projected = projectAnalysisError(redacted); + expect(projected).toMatchObject({ + code: cleanupFails ? "cleanup_incomplete" : "resource_constraint", + details: { + reason: "storage-limit", + system_code: "EFBIG", + path: output, + }, + }); + const staging = (await readdir(root)).filter((name) => + name.startsWith(".rea-write-"), + ); + expect(staging).toHaveLength(cleanupFails ? 1 : 0); + expect((await readdir(root)).includes("out.json")).toBe(false); + if (cleanupFails) { + expect(resources.hasPending).toBe(true); + expect(redacted.cleanupResources).toContain(join(root, staging[0]!)); + await resources.retryCleanup(); + expect(resources.hasPending).toBe(false); + expect(redacted.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(redacted)).toMatchObject({ + code: "resource_constraint", + details: { + reason: "storage-limit", + system_code: "EFBIG", + path: output, + }, + }); + expect(await readdir(root)).toEqual([]); + } + } finally { + close.mockRestore(); + await resources.close(); + } + }, +);