diff --git a/docs/python-packaged-applications.md b/docs/python-packaged-applications.md index f33713addd..7937ddd9f4 100644 --- a/docs/python-packaged-applications.md +++ b/docs/python-packaged-applications.md @@ -120,6 +120,10 @@ Outer-compressed PYZ images have a separate per-reader capacity of 128 private files, bounding retained inodes and image identities. The complete image count is admitted before any temporary file is created; excess images return a capacity failure. This is a resource policy, not a PyInstaller format limit. +Artifact errors retain this distinction as `details.boundary: "capacity"`, with +the actual allowance in the diagnosis and `details.detail`. Changing output +detail does not reduce that allowance's input scope; these operations do not +select a narrower member path during inventory. Only a zero-byte read establishes EOF; short successful descriptor reads fill the bounded range. Compressed members must consume their complete declared input span; trailing junk and concatenated streams are refused. diff --git a/src/artifacts/ArtifactOperationFailure.test.ts b/src/artifacts/ArtifactOperationFailure.test.ts new file mode 100644 index 0000000000..2e7c6b3c35 --- /dev/null +++ b/src/artifacts/ArtifactOperationFailure.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; + +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; + +it.each(["capacity", "file-size", "memory"] as const)( + "retains the producing %s limit through public error projection", + (boundary) => { + const failure = new ArtifactReaderFailure( + "limit", + "The specific reader allowance was exceeded", + { boundary }, + ); + const result = projectAnalysisError( + translateArtifactFailure("inventory_artifact", failure), + ); + expect(result).toMatchObject({ + code: "truncated", + retryable: false, + message: failure.message, + details: { boundary, detail: failure.message }, + }); + }, +); diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index aa2f718b10..e484f8afb2 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -29,6 +29,7 @@ export const translateArtifactFailure = ( ...(cause.admission === undefined ? {} : { admission: cause.admission }), + ...(cause.boundary === undefined ? {} : { boundary: cause.boundary }), }, ); bindArtifactFileCleanup(cause, error); diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index e08d4b0b20..d3890435d6 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -1,4 +1,7 @@ -import type { ArtifactAdmissionLimit } from "../domain/artifactOperationError.js"; +import type { + ArtifactAdmissionLimit, + ArtifactLimitBoundary, +} from "../domain/artifactOperationError.js"; import type { ArtifactIntegrityClaim, ArtifactIntegrityDetails, @@ -18,9 +21,6 @@ import type { AnalysisPartialObservation, } from "../domain/analysisErrorBase.js"; -/** Which bound a `limit` failure hit, so callers need not infer it. */ -export type ArtifactReaderBoundary = "file-size" | "capacity" | "memory"; - export interface ArtifactReaderFailureOptions extends ErrorOptions { readonly cancellation?: { readonly reason: unknown } | undefined; readonly cleanup?: AnalysisCleanupObservation | undefined; @@ -28,7 +28,7 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions { readonly cleanupLeases?: readonly AnalysisCleanupLease[] | undefined; readonly partialObservation?: AnalysisPartialObservation | undefined; /** The constrained resource bound for a limit failure. */ - readonly boundary?: ArtifactReaderBoundary; + readonly boundary?: ArtifactLimitBoundary; /** Named admission whose recovery must not be inferred from other limit text. */ readonly admission?: ArtifactAdmissionLimit; } @@ -198,7 +198,7 @@ export class ArtifactReaderFailure extends Error { readonly cleanupLeases: readonly AnalysisCleanupLease[] | undefined; readonly partialObservation: AnalysisPartialObservation | undefined; /** The constrained bound for a `limit` reason, when the reader knows it. */ - readonly boundary: ArtifactReaderBoundary | undefined; + readonly boundary: ArtifactLimitBoundary | undefined; /** Named admission for a limit whose public recovery is owned by the producer. */ readonly admission: ArtifactAdmissionLimit | undefined; diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 7105480fca..09b4d50791 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -106,6 +106,12 @@ export const analysisErrorRemediationAction = ( ) return error.remediationAction; if (error instanceof AnalysisTimeoutError) return timeoutRemediation(error); + if ( + error instanceof ArtifactOperationError && + error.reason === "limit" && + error.boundary === "capacity" + ) + return "Use an artifact within the capacity reported in details.detail. Changing output detail or retrying the unchanged input does not remove this boundary."; if (error instanceof HopperTimeoutError) return error.operation === undefined ? "Review details.launcher and inspect Hopper for ongoing initial analysis or a pending loader, demo, or license dialog. For a large target, increase REA_HOPPER_STARTUP_TIMEOUT_MS in the environment that starts REA and allow at least that long in the MCP client. Resolve retained cleanup before reopening." @@ -471,7 +477,10 @@ const artifactMessage = ({ if (reason === "cancelled") return "Artifact operation was cancelled. Start it again when ready."; if (reason === "limit") - return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact."; + return ( + detail ?? + "Artifact processing reached a resource limit. Select an artifact within the reported limits and retry." + ); if (reason === "path") return ( detail ?? diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index ac38b7788d..30539b649b 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -381,6 +381,7 @@ const artifactStateErrorDetails = ( reason: error.reason, ...(error.reason === "limit" ? { truncated: true } : {}), ...(error.detail === undefined ? {} : { detail: error.detail }), + ...(error.boundary === undefined ? {} : { boundary: error.boundary }), ...(error.admission === undefined ? {} : { diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 1e71945d1b..ab9d3d2d1b 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -7,6 +7,9 @@ import { type AnalysisErrorOptions, } from "./analysisErrorBase.js"; +/** The resource class that refused an artifact operation. */ +export type ArtifactLimitBoundary = "file-size" | "capacity" | "memory"; + /** Admission named by the producing reader, with its effective byte ceiling and its source. */ export interface ArtifactAdmissionLimit { readonly kind: "asar-header"; @@ -19,6 +22,7 @@ export class ArtifactOperationError extends AnalysisError { readonly _tag = "ArtifactOperationError"; override readonly userMessage: string | undefined; readonly admission: ArtifactAdmissionLimit | undefined; + readonly boundary: ArtifactLimitBoundary | undefined; constructor( readonly operation: @@ -52,6 +56,7 @@ export class ArtifactOperationError extends AnalysisError { options?: AnalysisErrorOptions & { readonly userMessage?: string; readonly admission?: ArtifactAdmissionLimit; + readonly boundary?: ArtifactLimitBoundary; }, ) { super( @@ -61,6 +66,7 @@ export class ArtifactOperationError extends AnalysisError { options, ); this.admission = options?.admission; + this.boundary = options?.boundary; this.userMessage = options?.userMessage ?? (options?.admission === undefined ? undefined : detail); diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 271b67d68c..0cd3f83f21 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import type { ArtifactReaderBoundary } from "../../artifacts/ArtifactReader.js"; +import type { ArtifactLimitBoundary } from "../../domain/artifactOperationError.js"; import { jsonParts } from "../../domain/jsonSerialization.js"; import type { InspectPeResourcesInput, @@ -22,7 +22,7 @@ type OccupiedRange = Range & { /** Complete PE resource result retained before a refusal. */ export const PE_RESOURCE_METADATA_BYTES = 8 * 1024 * 1024; const METADATA_BYTES = PE_RESOURCE_METADATA_BYTES; -const limit = (message: string, boundary: ArtifactReaderBoundary): never => { +const limit = (message: string, boundary: ArtifactLimitBoundary): never => { throw new ArtifactReaderFailure("limit", message, { boundary }); }; /** Count bytes through the canonical bounded JSON writer. */ diff --git a/tests/boundary/filesystem/asarHeaderAdmission.test.ts b/tests/boundary/filesystem/asarHeaderAdmission.test.ts index 497c1c6af2..f7fb0d30a0 100644 --- a/tests/boundary/filesystem/asarHeaderAdmission.test.ts +++ b/tests/boundary/filesystem/asarHeaderAdmission.test.ts @@ -250,9 +250,7 @@ it("keeps unrelated artifact limit recovery distinct from header admission", () "Directory traversal reached its examined-entry budget", ), ).message, - ).toBe( - "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.", - ); + ).toBe("Directory traversal reached its examined-entry budget"); }); afterEach(() => { diff --git a/tests/boundary/providers/native/artifactProvider.capacity.test.ts b/tests/boundary/providers/native/artifactProvider.capacity.test.ts new file mode 100644 index 0000000000..54d2412233 --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.capacity.test.ts @@ -0,0 +1,68 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { + cArchiveExecutable, + pyzArchive, + PYTHON_39_MAGIC, +} from "../../../../src/artifacts/pyinstaller/CArchive.fixture.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +it.each([1, 129])( + "reports the actual private-image capacity for %i compressed PYZ images", + async (count) => { + const root = await createTestTempDirectory("rea-artifact-capacity-"); + const path = join(root, "app.exe"); + const bytes = cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 309, + members: Array.from({ length: count }, (_, index) => ({ + name: `archive-${index}.pyz`, + typeCode: "z", + payload: pyzArchive({ magic: PYTHON_39_MAGIC, modules: [] }), + compress: true, + })), + }); + await writeFile(path, bytes); + const client = new ArtifactProvider({}).createClient({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }); + try { + const result = await client.execute("inventory_artifact", {}); + if (count === 1) { + expect(result.ok).toBe(true); + return; + } + if (result.ok) throw new Error("Expected capacity refusal"); + expect(bytes.length).toBeLessThan(16_384); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "truncated", + retryable: false, + message: + "PyInstaller compressed PYZ images exceed the 128-file private storage budget", + details: { boundary: "capacity" }, + remediation: { + action: expect.stringContaining( + "capacity reported in details.detail", + ), + }, + }); + expect(projected.remediation.action).not.toContain( + "Narrow the requested path", + ); + } finally { + await client.close(); + } + }, +);