diff --git a/docs/design/limits-and-recovery.md b/docs/design/limits-and-recovery.md index 638590b1ba..63134f404a 100644 --- a/docs/design/limits-and-recovery.md +++ b/docs/design/limits-and-recovery.md @@ -390,11 +390,11 @@ recovery to the resource kind. The domain owner is It has three variants. Each one projects under `details.refusal` with the `operation` the error is attributed to: -| Variant | Producer fact | Projected fields | -| -------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- | -| `fixed_ceiling` | `fixedAllowanceOptions(name, kind, { limit, unit, narrowing })` | `allowance`, `allowance_kind`, `limit`, `unit`, `narrowing` | -| `adjustable_budget` | an `AnalysisSettingLimit` bound as `limit` | `control`, `setting`, `effective_value`, `unit`, `range` | -| `ambiguous_selector` | `AnalysisInputError` option `ambiguousSelector: { field, candidates }` | `field`, `candidates` | +| Variant | Producer fact | Projected fields | +| -------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `fixed_ceiling` | `fixedAllowanceOptions(name, kind, { limit, unit, narrowing })` | `allowance`, `allowance_kind`, `limit`, `unit`, `narrowing` | +| `adjustable_budget` | an `AnalysisSettingLimit` bound as `limit` | `control`, `setting`, `effective_value`, `unit`, `range`, optional `required_value` | +| `ambiguous_selector` | `AnalysisInputError` option `ambiguousSelector: { field, candidates }` | `field`, `candidates` | Presentation is total over these variants. Authored remediation still wins. Without it, the variant names the recovery: fixed ceilings and their narrowing diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index fd8f8a7f65..9e52d9fa9b 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -488,14 +488,9 @@ "src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts": 2 }, "local-limit-error-class": { - "src/artifacts/apple/InterfaceBuilderDecodeBudget.ts": 1, "src/browser/WebSourceMapFetcher.ts": 1, "src/captures/parseFieldDiscovery.ts": 1, - "src/domain/analysisView/EnvironmentStateExtraction.ts": 1, - "src/domain/native/nativeAotPe.ts": 1, "src/firmware/devicetree/DevicetreeParser.ts": 1, - "src/native/elf/ElfNoteParser.ts": 1, - "src/process/OwnedMcpStdioTransport.ts": 1, "src/signals/SigrokRecordingProvider.ts": 1, "src/signals/parseDecoderDiscovery.ts": 1, "src/v8/V8CodeCache.errors.ts": 1 diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 48dba6e4a4..aa339aa645 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -331,13 +331,16 @@ checkCounted( // A limit a parser stopped at is a ParseFailureError<"limit"> (decodeLimit in // src/domain/parseFailure); a refusal is an AnalysisRefusal. A local limit -// class on plain Error hides the limit's name and maximum from both. +// class on plain Error hides the limit's name and maximum from both; a limit +// subclass of BinaryResourceFailure keeps a second limit field beside the +// allowance its LimitRef already names. The environment-state extraction's private retention stop is control flow that +// its own entry point catches and turns into `null`; it is never reported. checkCounted( - "local limit class extending plain Error (use decodeLimit from src/domain/parseFailure or an AnalysisRefusal)", + "local limit class (use decodeLimit from src/domain/parseFailure, a named BinaryResourceFailure allowance, or an AnalysisRefusal)", "local-limit-error-class", files, localLimitErrorClassCount, - [], + ["src/domain/analysisView/EnvironmentStateExtraction.ts"], ); // A resource allowance is charged and refunded through `Budget` in diff --git a/scripts/verify/limit-class-guard.mjs b/scripts/verify/limit-class-guard.mjs index 1a39b39e2b..d4ef392c6c 100644 --- a/scripts/verify/limit-class-guard.mjs +++ b/scripts/verify/limit-class-guard.mjs @@ -1,9 +1,11 @@ -// Counts local limit classes declared on plain Error: a class whose name has -// Limit, Budget, Exhausted or Exceeded and that extends Error directly. Type -// parameters and line breaks between tokens are allowed, as a formatter or a -// generic declaration may write them. +// Counts local limit classes: a class whose name has Limit, Budget, Exhausted +// or Exceeded and that extends plain Error, or that extends +// BinaryResourceFailure, which already names its allowance in its LimitRef +// (a subclass would keep a second, parallel limit field). Type parameters and +// line breaks between tokens are allowed, as a formatter or a generic +// declaration may write them. const localLimitClass = - /\bclass\s+\w*(?:Limit|Budget|Exhausted|Exceeded)\w*\s*(?:<[^{]*?>)?\s*extends\s+Error\b/gu; + /\bclass\s+\w*(?:Limit|Budget|Exhausted|Exceeded)\w*\s*(?:<[^{]*?>)?\s*extends\s+(?:Error|BinaryResourceFailure)\b/gu; /** @param {string} content */ export const localLimitErrorClassCount = (content) => diff --git a/src/android/JadxFailure.ts b/src/android/JadxFailure.ts index 68f8723555..96033989d6 100644 --- a/src/android/JadxFailure.ts +++ b/src/android/JadxFailure.ts @@ -26,6 +26,7 @@ import { import { androidSnapshotCopy } from "./AndroidTargetSnapshot.js"; import type { JadxSession } from "./JadxSession.js"; import type { JadxMcpTransport } from "./JadxMcpTransport.js"; +import type { OwnedMcpTransportFailure } from "../process/OwnedMcpStdioTransport.js"; import { jadxHeapExhausted } from "./JadxProtocol.js"; import { JADX_HEAP_REMEDIATION } from "./JadxRelease.js"; @@ -46,6 +47,13 @@ type DiagnosticFailure = { | { readonly kind: "startup"; readonly command: string } | { readonly kind: "execution" } | { readonly kind: "output"; readonly reason: string } + | { + readonly kind: "output_limit"; + readonly limit: Extract< + OwnedMcpTransportFailure, + { readonly kind: "output_limit" } + >; + } | { readonly kind: "cancelled" } | { readonly kind: "timeout"; readonly timeoutMs: number } ); @@ -239,6 +247,13 @@ const observeJadxFailure = ( kind: "output", reason: "JADX response violated the pinned upstream protocol", }; + if (observed?.kind === "output_limit") + return { + ...diagnostic, + cause: observed.cause, + kind: "output_limit", + limit: observed, + }; if (observed?.kind === "protocol") return { ...diagnostic, @@ -330,6 +345,24 @@ const presentDiagnosticFailure = ( }); if (failure.kind === "output") return new AnalysisOutputError(failure.operation, failure.reason, options); + if (failure.kind === "output_limit") { + const { scope, ceiling } = failure.limit; + return new AnalysisResourceConstraintError( + failure.operation, + "transport", + `${failure.limit.cause.message}; no partial Evidence was returned.`, + { + [scope === "frame" ? "max_response_bytes" : "max_operation_bytes"]: + ceiling.limit, + }, + { + ...options, + // REA bounds JADX stdio output at fixed sizes with no runtime override. + retryable: false, + fixedAllowance: ceiling, + }, + ); + } if (failure.kind === "startup") { // Before the handshake the retained stderr is the only evidence of why the // launcher stopped; a heap shortage there is a resource limit, not setup. diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 369fa1c67b..4b69e71c52 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -44,7 +44,7 @@ import { } from "./PropertyListDecodeBudget.js"; import { InterfaceBuilderDecodeBudget, - InterfaceBuilderDecodeBudgetExceeded, + interfaceBuilderDecodeBudgetExceeded, } from "./InterfaceBuilderDecodeBudget.js"; import { ArtifactReaderFailure, @@ -287,15 +287,13 @@ const decodeInterfaceBuilderBundle = async ( if (cause instanceof ArtifactReaderFailure) throw cause; // A broken internal assumption is a REA defect, not an invalid archive. if (cause instanceof InternalInvariantError) throw cause; - if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) { - // A document rejected while decoding, e.g. beyond the shared depth - // limit, retains no representation; later documents keep its share. - recordOmittedArchive(entry.path, cause.reason); - continue; - } - // A plist decode budget or nesting limit is not evidence the archive - // is malformed; it is omitted against the shared budget. - if (cause instanceof ParseFailureError && cause.kind === "limit") { + // A decode budget, nesting limit or host stack/capacity limit is not + // evidence the archive is malformed. The rejected document retains no + // representation, so later documents keep its share of the budget. + if ( + (cause instanceof ParseFailureError && cause.kind === "limit") || + isHostLimitFailure(cause) + ) { recordOmittedArchive(entry.path, "aggregate_decode_budget_exhausted"); continue; } @@ -1046,9 +1044,10 @@ const readEntry = async ( maxInputBytes: number, ): Promise => { if (entry.declaredSize !== null && entry.declaredSize > maxInputBytes) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", + throw interfaceBuilderDecodeBudgetExceeded( + "interface-builder", "archive exceeds the aggregate Interface Builder decode budget", + maxInputBytes, ); const stream = await reader.open(entry, signal); const chunks: Buffer[] = []; @@ -1058,9 +1057,10 @@ const readEntry = async ( length += bytes.length; if (length > maxInputBytes) { stream.destroy(); - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", + throw interfaceBuilderDecodeBudgetExceeded( + "interface-builder", "archive exceeded the aggregate Interface Builder decode budget", + maxInputBytes, ); } chunks.push(bytes); @@ -1068,25 +1068,6 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; -/** - * The shared parser bounds plist value nesting before its recursive decode and - * reports the limit as a `limit` parse failure, which the document loop omits. - * A host stack or capacity limit is likewise an omitted archive, not a - * malformed one. - */ -const parseXmlWithinDecodeBudget = (text: string) => { - try { - return parseXmlPropertyList(text); - } catch (cause: unknown) { - if (cause instanceof Error && isHostLimitFailure(cause)) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", - cause.message, - ); - throw cause; - } -}; - /** Project decoded plist data and dates while retaining scalar coverage. */ const decodePlist = ( bytes: Buffer, @@ -1105,7 +1086,7 @@ const decodePlist = ( : undefined; const { value, numberLiterals, duplicateKeys } = binary ? decodeBinaryPropertyList(bytes, scalarPlan, binaryScalarMetadata) - : parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes)); + : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); const projected = projectPlistValue(value); return { value: projected.value, diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts index 0f0a115156..7a713c7e9e 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -1,16 +1,24 @@ -/** Reason reported when an archive would exceed the shared decoding budget. */ -export type InterfaceBuilderBudgetReason = "aggregate_decode_budget_exhausted"; +import { + decodeLimit, + type ParseFailureError, +} from "../../domain/parseFailure.js"; -/** A named resource-limit failure that keeps budget exhaustion out of decode errors. */ -export class InterfaceBuilderDecodeBudgetExceeded extends Error { - constructor( - readonly reason: InterfaceBuilderBudgetReason, - message: string, - ) { - super(message); - this.name = "InterfaceBuilderDecodeBudgetExceeded"; - } -} +/** Limit name of the shared Interface Builder representation byte budget. */ +const AGGREGATE_DECODE_LIMIT = "aggregate-decode-bytes"; + +/** + * An archive would exceed the shared decoding budget of `maximumBytes`. The + * archive is omitted, not shown to be malformed. + */ +export const interfaceBuilderDecodeBudgetExceeded = ( + format: string, + detail: string, + maximumBytes: number, +): ParseFailureError<"limit"> => + decodeLimit(format, detail, { + name: AGGREGATE_DECODE_LIMIT, + maximum: maximumBytes, + }); /** Shared byte budget for decoder records and projected archive data. */ export class InterfaceBuilderDecodeBudget { @@ -39,9 +47,10 @@ export class InterfaceBuilderDecodeBudget { bytes < 0 || bytes > this.#remainingBytes ) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", + throw interfaceBuilderDecodeBudgetExceeded( + "interface-builder", message, + this.#initialBytes, ); this.#remainingBytes -= bytes; } diff --git a/src/artifacts/apple/InterfaceBuilderHostLimit.test.ts b/src/artifacts/apple/InterfaceBuilderHostLimit.test.ts new file mode 100644 index 0000000000..f4523d5404 --- /dev/null +++ b/src/artifacts/apple/InterfaceBuilderHostLimit.test.ts @@ -0,0 +1,56 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { buildBinary } from "plist"; +import { expect, it, vi } from "vitest"; + +import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +const { stackOverflow } = vi.hoisted(() => ({ + stackOverflow: (): never => { + throw new RangeError("Maximum call stack size exceeded"); + }, +})); + +// A host stack limit says nothing about the archive bytes, whichever decoder +// reached it. +vi.mock("../../domain/propertyListKeys.js", async (importOriginal) => ({ + ...(await importOriginal()), + parseXmlPropertyList: stackOverflow, +})); +vi.mock("./BinaryPlistMaterializer.js", async (importOriginal) => ({ + ...(await importOriginal()), + decodeBinaryPropertyList: stackOverflow, +})); + +it.each([ + ["XML", Buffer.from('')], + ["binary", Buffer.from(buildBinary({}))], +] as const)( + "omits a %s archive that reached a host stack limit instead of calling it invalid", + async (_format, bytes) => { + const root = await createTestTempDirectory("rea-ib-host-limit-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle); + await writeFile(join(bundle, "Deep.nib"), bytes); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + + expect(result.documents).toEqual([]); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "aggregate_decode_budget_exhausted", + omitted: 1, + }), + ); + expect(result.limitations).toContain( + "Deep.nib: omitted because aggregate decode budget exhausted.", + ); + }, +); diff --git a/src/artifacts/apple/NibArchive.test.ts b/src/artifacts/apple/NibArchive.test.ts index 9545089e02..2096751920 100644 --- a/src/artifacts/apple/NibArchive.test.ts +++ b/src/artifacts/apple/NibArchive.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { decodeNibArchive } from "./NibArchive.js"; import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js"; -import { InterfaceBuilderDecodeBudgetExceeded } from "./InterfaceBuilderDecodeBudget.js"; import { ParseFailureError } from "../../domain/parseFailure.js"; const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => { @@ -178,10 +177,15 @@ describe("NIBArchive decoder", () => { } catch (cause: unknown) { thrown = cause; } - expect(thrown).toBeInstanceOf(InterfaceBuilderDecodeBudgetExceeded); + expect(thrown).toBeInstanceOf(ParseFailureError); expect(thrown).toHaveProperty( - "message", - expect.stringMatching(/projected field ranges/u), + "failure", + expect.objectContaining({ + kind: "limit", + format: "nib-archive", + detail: expect.stringMatching(/projected field ranges/u), + limit: { name: "aggregate-decode-bytes", maximum: expect.any(Number) }, + }), ); }); }); diff --git a/src/artifacts/apple/NibArchive.ts b/src/artifacts/apple/NibArchive.ts index 90fbe476a7..00570bc166 100644 --- a/src/artifacts/apple/NibArchive.ts +++ b/src/artifacts/apple/NibArchive.ts @@ -8,7 +8,7 @@ import { } from "../../domain/parseFailure.js"; import { InterfaceBuilderDecodeBudget, - InterfaceBuilderDecodeBudgetExceeded, + interfaceBuilderDecodeBudgetExceeded, } from "./InterfaceBuilderDecodeBudget.js"; import { strictUtf8Decoder } from "../../domain/strictUtf8.js"; @@ -276,9 +276,10 @@ const preflightProjectedFieldCount = ( maxBytes: number, ): number => { if (objectCount * 96 > maxBytes) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", + throw interfaceBuilderDecodeBudgetExceeded( + "nib-archive", "NIBArchive object count exceeds the aggregate Interface Builder decode budget", + maxBytes, ); const cursor = { offset: start }; let projectedFields = 0; @@ -300,9 +301,10 @@ const preflightProjectedFieldCount = ( !Number.isSafeInteger(projectedFields) || projectedFields * 96 > maxBytes - objectCount * 96 ) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", + throw interfaceBuilderDecodeBudgetExceeded( + "nib-archive", "NIBArchive projected field ranges exceed the aggregate Interface Builder decode budget", + maxBytes, ); } return projectedFields; diff --git a/src/domain/analysisRefusal.test.ts b/src/domain/analysisRefusal.test.ts index 141f8cf4b7..610429944d 100644 --- a/src/domain/analysisRefusal.test.ts +++ b/src/domain/analysisRefusal.test.ts @@ -242,3 +242,42 @@ describe("typed selector refusals", () => { } }); }); + +describe("adjustable budget minimum", () => { + it("names the measured minimum that admits the request", () => { + const limit = analysisSettingLimit( + "asar.max_listing_bytes", + 1024, + "runtime", + ); + const projected = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect_artifact", + "memory", + "Budget hit.", + null, + { limit: { ...limit, required_value: 4096 } }, + ), + ); + + expect(projected.remediation.action).toContain( + "Raise asar.max_listing_bytes with update_analysis_settings (at least 4096 bytes; range", + ); + expect(projected.details?.refusal).toMatchObject({ + kind: "adjustable_budget", + required_value: 4096, + }); + + const unreachable = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect_artifact", + "memory", + "Budget hit.", + null, + { limit: { ...limit, required_value: limit.maximum + 1 } }, + ), + ).remediation.action; + expect(unreachable).toContain("above the asar.max_listing_bytes maximum"); + expect(unreachable).not.toContain("update_analysis_settings"); + }); +}); diff --git a/src/domain/analysisRefusal.ts b/src/domain/analysisRefusal.ts index bf913bc900..c9413254ea 100644 --- a/src/domain/analysisRefusal.ts +++ b/src/domain/analysisRefusal.ts @@ -26,7 +26,9 @@ export interface AmbiguousSelector { * - `fixed_ceiling`: a release-fixed allowance (`FixedAllowance`) with its * optional limit, unit and narrowing step. * - `adjustable_budget`: an agent-adjustable setting or per-call parameter - * (`AnalysisSettingLimit`) with its control, unit and range. + * (`AnalysisSettingLimit`) with its control, unit, range and, when the + * producer measured it, the minimum value (`required_value`) that admits + * the request. * - `ambiguous_selector`: a selector that matched several candidates. */ export type AnalysisRefusal = @@ -55,7 +57,13 @@ const adjustableBudgetRemediation = ( budget: AnalysisSettingLimit, callerLifetime: AnalysisCallerLifetime, ): string => { - const { unit, minimum, maximum, effective_value: value } = budget; + const { + unit, + minimum, + maximum, + effective_value: value, + required_value: required, + } = budget; const control = budget.parameter ?? budget.setting; const range = `${String(minimum)}..${String(maximum)} ${unit}`; const effective = @@ -64,6 +72,8 @@ const adjustableBudgetRemediation = ( : `${operation} reached its ${control} budget of ${String(value)} ${unit}${budget.source === undefined ? "" : ` (source ${budget.source})`}`; if (value !== null && value >= maximum) return `${effective}. ${control} is already at its maximum (${range}); narrow the request or select a smaller input. ${UNCHANGED}`; + if (required !== undefined && required > maximum) + return `${effective}. The request requires ${String(required)} ${unit}, above the ${control} maximum (${range}); narrow the request or select a smaller input. ${UNCHANGED}`; const environment = analysisSettingDefinition(budget.setting).environment; const change = budget.parameter !== undefined && @@ -75,7 +85,9 @@ const adjustableBudgetRemediation = ( : budget.update_mode === "runtime" ? `Raise ${budget.setting} with update_analysis_settings` : `Set ${environment ?? budget.setting} in the environment that starts REA, then restart REA`; - return `${effective}. ${change} (range ${range}), then retry.`; + const atLeast = + required === undefined ? "" : `at least ${String(required)} ${unit}; `; + return `${effective}. ${change} (${atLeast}range ${range}), then retry.`; }; /** @@ -144,6 +156,9 @@ export const analysisRefusalDetails = ( effective_value: budget.effective_value, unit: budget.unit, range: { minimum: budget.minimum, maximum: budget.maximum }, + ...(budget.required_value === undefined + ? {} + : { required_value: budget.required_value }), }; } case "ambiguous_selector": diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index 60d9e784ea..4c63988dee 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -8,6 +8,7 @@ import { AnalysisResourceConstraintError, } from "../analysisErrorCore.js"; import { allKeyed, keyedBy, uniqueKeyed } from "../keyedCollection.js"; +import { decodeLimit, ParseFailureError } from "../parseFailure.js"; import type { NativeMetadataRecovery } from "./nativeMetadataRecovery.js"; import { NativeAotAdmissionError, @@ -121,29 +122,55 @@ export class NativeAotSnapshotIdentityError extends Error { } } +const NATIVE_AOT_FORMAT = "native-aot-pe"; + +/** Limit names of the two hydration budgets, as `decodeLimit` reports them. */ +const NATIVE_AOT_BUDGET_LIMIT = { + work: "work-units", + "working-memory": "working-memory-bytes", +} as const; + /** Hydration stops by throwing; other scans record truncation and continue. */ -class NativeAotParserBudgetError extends Error { - readonly code = "parser_budget" as const; - - constructor(readonly budget: "work" | "working-memory") { - super(budget); - this.name = "NativeAotParserBudgetError"; - } -} - const parserBudgetExhausted = ( - budget: NativeAotParserBudgetError["budget"], + budget: NativeAotParserBudget, + kind: keyof typeof NATIVE_AOT_BUDGET_LIMIT, ): never => { - throw new NativeAotParserBudgetError(budget); + const maximum = + kind === "work" + ? budget.limits.workUnits + : budget.limits.workingMemoryBytes; + throw decodeLimit( + NATIVE_AOT_FORMAT, + `NativeAOT hydration stopped at its ${String(maximum)} ${kind === "work" ? "work-unit" : "working-memory byte"} budget`, + { name: NATIVE_AOT_BUDGET_LIMIT[kind], maximum }, + ); +}; + +/** Which hydration budget a cause reports, if it is one. */ +const exhaustedParserBudget = ( + cause: unknown, +): keyof typeof NATIVE_AOT_BUDGET_LIMIT | undefined => { + if ( + !(cause instanceof ParseFailureError) || + cause.failure.kind !== "limit" || + cause.failure.format !== NATIVE_AOT_FORMAT + ) + return undefined; + const { name } = cause.failure.limit; + if (name === NATIVE_AOT_BUDGET_LIMIT.work) return "work"; + if (name === NATIVE_AOT_BUDGET_LIMIT["working-memory"]) + return "working-memory"; + return undefined; }; const nativeAotHydrationFailure = ( cause: unknown, budget: NativeAotParserBudget, ): NativeAotHydrationFacts => { - if (cause instanceof NativeAotParserBudgetError) + const exhausted = exhaustedParserBudget(cause); + if (exhausted !== undefined) return nativeAotHydrationFacts( - cause.budget === "work" ? "work_budget" : "working_memory_budget", + exhausted === "work" ? "work_budget" : "working_memory_budget", ); if ( cause instanceof NativeAotAdmissionError && @@ -1079,7 +1106,7 @@ const hydrate = ( budget.limits.workingMemoryBytes - budget.workingMemoryBytes, ); if (!budget.reserveWorkingMemory(initialCapacity)) - parserBudgetExhausted("working-memory"); + parserBudgetExhausted(budget, "working-memory"); let output = Buffer.alloc(initialCapacity); let outputLength = 0; let cursor = 4; @@ -1110,7 +1137,7 @@ const hydrate = ( ), ); if (!budget.reserveWorkingMemory(capacity)) - parserBudgetExhausted("working-memory"); + parserBudgetExhausted(budget, "working-memory"); const expanded = Buffer.allocUnsafe(capacity); output.copy(expanded, 0, 0, outputLength); budget.releaseWorkingMemory(output.byteLength); @@ -1120,15 +1147,16 @@ const hydrate = ( return output.subarray(start, required); }; const append = (chunk: Buffer): void => { - if (!budget.consumeWork(chunk.length)) parserBudgetExhausted("work"); + if (!budget.consumeWork(chunk.length)) + parserBudgetExhausted(budget, "work"); chunk.copy(reserveOutput(chunk.length)); }; const appendZero = (length: number): void => { - if (!budget.consumeWork(length)) parserBudgetExhausted("work"); + if (!budget.consumeWork(length)) parserBudgetExhausted(budget, "work"); reserveOutput(length).fill(0); }; const appendRel = (target: bigint): void => { - if (!budget.consumeWork(4)) parserBudgetExhausted("work"); + if (!budget.consumeWork(4)) parserBudgetExhausted(budget, "work"); const delta = target - (base + BigInt(outputLength)); if (delta < -0x80000000n || delta > 0x7fffffffn) throw new NativeAotAdmissionError({ @@ -1138,7 +1166,7 @@ const hydrate = ( reserveOutput(4).writeInt32LE(Number(delta), 0); }; const appendPtr = (target: bigint): void => { - if (!budget.consumeWork(8)) parserBudgetExhausted("work"); + if (!budget.consumeWork(8)) parserBudgetExhausted(budget, "work"); // writeBigUInt64LE throws RangeError outside the unsigned 64-bit domain. // That is a malformed fixup, not an unexpected parser failure. if (target < 0n || target > 0xffffffffffffffffn) @@ -1160,7 +1188,7 @@ const hydrate = ( }; try { while (cursor < first.length) { - if (!budget.consumeWork(1)) parserBudgetExhausted("work"); + if (!budget.consumeWork(1)) parserBudgetExhausted(budget, "work"); const encoded = first[cursor]; if (encoded === undefined) break; cursor++; @@ -1199,7 +1227,8 @@ const hydrate = ( appendPtr(readFixup(payload)); break; case 4: - if (!budget.consumeWork(payload * 4)) parserBudgetExhausted("work"); + if (!budget.consumeWork(payload * 4)) + parserBudgetExhausted(budget, "work"); if ( payload > Math.floor((first.length - cursor) / 4) || payload > Math.floor((outputLimit - outputLength) / 4) @@ -1224,7 +1253,8 @@ const hydrate = ( } break; case 5: - if (!budget.consumeWork(payload * 4)) parserBudgetExhausted("work"); + if (!budget.consumeWork(payload * 4)) + parserBudgetExhausted(budget, "work"); if ( payload > Math.floor((first.length - cursor) / 4) || payload > Math.floor((outputLimit - outputLength) / 8) diff --git a/src/domain/parseFailure.ts b/src/domain/parseFailure.ts index 5e974775b2..b1b06cc660 100644 --- a/src/domain/parseFailure.ts +++ b/src/domain/parseFailure.ts @@ -25,6 +25,17 @@ interface ParseFailureFacts { readonly field?: string; } +/** + * The limit a parse stopped at: its stable name, the maximum in force, and, + * when the parser measured it, the smallest value that would admit the input. + * `required` is a bigint when the measured size exceeds a safe integer. + */ +export interface LimitRef { + readonly name: string; + readonly maximum: number; + readonly required?: number | bigint; +} + /** A parse outcome the parser established, carried with its facts. */ export type ParseFailure = ParseFailureFacts & ( @@ -37,7 +48,7 @@ export type ParseFailure = ParseFailureFacts & } | { readonly kind: "limit"; - readonly limit: { readonly name: string; readonly maximum: number }; + readonly limit: LimitRef; } ); @@ -106,7 +117,7 @@ export const unsupportedInput = ( export const decodeLimit = ( format: string, detail: string, - limit: { readonly name: string; readonly maximum: number }, + limit: LimitRef, { cause, ...at }: ParseFailureSite = {}, ): ParseFailureError<"limit"> => failureWith({ kind: "limit", format, detail, ...at, limit }, cause); diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index b775f770e3..72d96a0eab 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -23,7 +23,6 @@ import { snapshotEnvironment, } from "../process/snapshotEnvironment.js"; import { - OwnedMcpOutputLimitError, OwnedMcpStdioTransport, type OwnedMcpLauncher, } from "../process/OwnedMcpStdioTransport.js"; @@ -59,7 +58,6 @@ import { AnalysisResourceConstraintError, cancelledBy, } from "../domain/analysisErrorCore.js"; -import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { McpHttpUtf8Error, @@ -573,14 +571,11 @@ export const createIdaMcpConnection = ( operation = "ida_mcp_response", requestTimeoutMs = config.timeoutMs, ): never => { - // The owned transport records the exceeded frame limit before it closes + // The owned transport records the exceeded output ceiling before it closes // the connection, so the SDK's closed-request error is not the reason. const observed = stdio?.observedFailure(); - const frameLimit = - observed?.kind === "protocol" && - observed.cause instanceof OwnedMcpOutputLimitError - ? observed.cause.limitBytes - : undefined; + const outputLimit = + observed?.kind === "output_limit" ? observed : undefined; if (cause instanceof IdaDiagnosticResourceError) throw cause; if (cause instanceof McpHttpUtf8Error) throw cause; // The Windows command lookup already classified its own deadline. @@ -628,28 +623,26 @@ export const createIdaMcpConnection = ( config.timeoutMs, ), }); - if (frameLimit !== undefined) + if (outputLimit !== undefined) { + const { limit } = outputLimit.ceiling; throw new AnalysisResourceConstraintError( operation, "transport", - `Upstream IDA MCP reply exceeded the ${String(frameLimit)}-byte transport limit; no partial Evidence was returned.`, - { max_response_bytes: frameLimit }, + `Upstream IDA MCP reply exceeded the ${String(limit)}-byte transport limit; no partial Evidence was returned.`, + { max_response_bytes: limit }, { - cause: observed?.kind === "protocol" ? observed.cause : cause, + cause: outputLimit.cause, capturedOutput, // REA bounds each stdio message at a fixed size with no runtime override. - ...fixedAllowanceOptions( - `The upstream IDA MCP reply allowance of ${String(frameLimit)} bytes`, - "bytes", - { - limit: frameLimit, - unit: "bytes", - narrowing: - "Request a smaller result from IDA, such as a narrower address range or fewer items.", - }, - ), + retryable: false, + fixedAllowance: { + ...outputLimit.ceiling, + narrowing: + "Request a smaller result from IDA, such as a narrower address range or fewer items.", + }, }, ); + } if (stderrBytes === 0) throw failure; throw new ProviderAdapterError("ida", "transport", { cause: failure, diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 40f8cf0202..23fe677c01 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -21,10 +21,7 @@ import { gnuBuildIdNote, gnuPropertyNote, } from "./ElfNotes.fixture.js"; -import { - ElfNoteSectionNameBudgetFailure, - parseElfNotes, -} from "./ElfNoteParser.js"; +import { elfNoteSectionNameBudget, parseElfNotes } from "./ElfNoteParser.js"; const defaults = inspectElfNotesInputSchema.parse({ path: "/artifacts/app", @@ -266,10 +263,9 @@ it.each([1, 2])( } catch (cause) { failure = cause; } - expect(failure).toBeInstanceOf(ElfNoteSectionNameBudgetFailure); - expect(failure).toMatchObject({ - minimumMaxFileBytes: BigInt(minimumMaxFileBytes), - selectedMaxFileBytes: bytes.length + 1, + expect(elfNoteSectionNameBudget(failure)).toMatchObject({ + required: BigInt(minimumMaxFileBytes), + selected: bytes.length + 1, }); const admitted = parse( diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 35464a9005..2ebdb61c5c 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -29,6 +29,7 @@ import { gnuNoteTypeName, } from "../../domain/native/elfNotes.js"; import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; +import { decodeLimit, ParseFailureError } from "../../domain/parseFailure.js"; import { decodeElfSectionNameTable } from "../metadata/ElfSectionNameTable.js"; const PT_NOTE = 4; @@ -49,20 +50,49 @@ interface TextBudget { readonly scans: Map; } -/** Caller can raise max_file_bytes to admit the decoded section-name table. */ -export class ElfNoteSectionNameBudgetFailure extends Error { - readonly _tag = "ElfNoteSectionNameBudgetFailure"; +/** Limit name of the caller's file budget, which also bounds decoded section names. */ +const SECTION_NAME_BUDGET_LIMIT = "max_file_bytes"; - constructor( - readonly minimumMaxFileBytes: bigint, - readonly selectedMaxFileBytes: number, - ) { - super( - `ELF source and decoded section names require ${String(minimumMaxFileBytes)} bytes, above the selected max_file_bytes (${String(selectedMaxFileBytes)}).`, - ); - this.name = "ElfNoteSectionNameBudgetFailure"; - } -} +/** Caller can raise max_file_bytes to admit the decoded section-name table. */ +const sectionNameBudgetFailure = ( + required: bigint, + selected: number, +): ParseFailureError<"limit"> => + decodeLimit( + "elf", + `ELF source and decoded section names require ${String(required)} bytes, above the selected max_file_bytes (${String(selected)}).`, + { name: SECTION_NAME_BUDGET_LIMIT, maximum: selected, required }, + ); + +/** + * The section-name budget stop's limit: the selected max_file_bytes and the + * size the source plus decoded names require. + */ +export const elfNoteSectionNameBudget = ( + cause: unknown, +): + | { + readonly selected: number; + readonly required: bigint; + readonly detail: string; + } + | undefined => { + if ( + !(cause instanceof ParseFailureError) || + cause.failure.kind !== "limit" || + cause.failure.format !== "elf" || + cause.failure.limit.name !== SECTION_NAME_BUDGET_LIMIT + ) + return undefined; + const { maximum, required } = cause.failure.limit; + return required === undefined + ? undefined + : { + selected: maximum, + required: BigInt(required), + detail: cause.failure.detail, + }; +}; const fail = (message: string): never => { throw new ArtifactReaderFailure("format", message); @@ -785,7 +815,7 @@ export const parseElfNotes = ( ); if (decoded.state === "malformed") return fail(decoded.message); if (decoded.state === "limit") - throw new ElfNoteSectionNameBudgetFailure( + throw sectionNameBudgetFailure( BigInt(bytes.length) + decoded.requiredBytes, input.max_file_bytes, ); diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index d6158d03ab..cbcbfcfa14 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -28,10 +28,7 @@ import { } from "../../domain/native/elfNotes.js"; import { err, ok } from "../../domain/result.js"; import { ELF_NOTES_PROVIDER } from "../../domain/investigationProviders.js"; -import { - ElfNoteSectionNameBudgetFailure, - parseElfNotes, -} from "./ElfNoteParser.js"; +import { elfNoteSectionNameBudget, parseElfNotes } from "./ElfNoteParser.js"; const OPERATION = "inspect_elf_notes"; @@ -96,31 +93,32 @@ const inputBudgetRemediation = ( ? `The image exceeds the ${String(MAX_INSPECT_ELF_NOTE_ENTRIES)}-entry note-table ceiling this operation accepts; select a smaller artifact.` : `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected note entry budget.`; -const sectionNameBudgetRemediation = ( - cause: ElfNoteSectionNameBudgetFailure, -): string => - cause.minimumMaxFileBytes <= BigInt(MAX_INSPECT_ELF_NOTE_FILE_BYTES) - ? `Raise max_file_bytes to at least ${String(cause.minimumMaxFileBytes)} bytes to admit the source and decoded ELF section names.` - : `The source and decoded ELF section names require ${String(cause.minimumMaxFileBytes)} bytes, above this operation's ${String(MAX_INSPECT_ELF_NOTE_FILE_BYTES)}-byte maximum; select an image with a smaller section-name table.`; +const sectionNameBudgetRemediation = (required: bigint): string => + required <= BigInt(MAX_INSPECT_ELF_NOTE_FILE_BYTES) + ? `Raise max_file_bytes to at least ${String(required)} bytes to admit the source and decoded ELF section names.` + : `The source and decoded ELF section names require ${String(required)} bytes, above this operation's ${String(MAX_INSPECT_ELF_NOTE_FILE_BYTES)}-byte maximum; select an image with a smaller section-name table.`; const elfNotesFailurePrimary = ( facts: ArtifactFilePrimaryFacts, input: InspectElfNotesInput, ) => { const { cause, origin } = facts; - if (cause instanceof ElfNoteSectionNameBudgetFailure) + const sectionNameBudget = elfNoteSectionNameBudget(cause); + if (sectionNameBudget !== undefined) return new AnalysisResourceConstraintError( OPERATION, "memory", - cause.message, + sectionNameBudget.detail, { - max_file_bytes: cause.selectedMaxFileBytes, - minimum_required_bytes: cause.minimumMaxFileBytes.toString(), + max_file_bytes: sectionNameBudget.selected, + minimum_required_bytes: sectionNameBudget.required.toString(), }, { cause, retryable: false, - remediationAction: sectionNameBudgetRemediation(cause), + remediationAction: sectionNameBudgetRemediation( + sectionNameBudget.required, + ), }, ); const formatFailure = parseFailureAnalysisError(OPERATION, input.path, cause); diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index aaed2262e4..4085302722 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -9,7 +9,6 @@ import { import * as nativeExportContracts from "../../domain/native/nativeExports.js"; import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { - NativeExportLimitFailure, parseNativeExports, parseNativeExportsSteps, } from "./NativeExportParser.js"; @@ -573,9 +572,14 @@ describe("PE export directories", () => { const cause = refusal( peExportImage({ functions: [], declaredFunctions: 0x40001 }), ); - expect(cause).toBeInstanceOf(NativeExportLimitFailure); expect(cause).toMatchObject({ - limit: "rows", + failure: { + kind: "limit", + limit: { + name: "rows", + maximum: nativeExportContracts.MAX_NATIVE_EXPORT_ROWS, + }, + }, boundary: "structure", maximum_bytes: nativeExportContracts.MAX_NATIVE_EXPORT_ROWS, message: expect.stringContaining("262144-entry export inventory"), @@ -1081,9 +1085,11 @@ describe("export inventory review regressions", () => { expect(bytes.length).toBeLessThan(100_000); const cause = refusal(bytes); - expect(cause).toBeInstanceOf(NativeExportLimitFailure); expect(cause).toMatchObject({ - limit: "retained_text", + failure: { + kind: "limit", + limit: { name: "retained_text", maximum: 16_777_216 }, + }, boundary: "structure", maximum_bytes: 16_777_216, message: expect.stringContaining("16777216-byte retained-text"), diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 708f890bb7..4d302dce98 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -52,27 +52,25 @@ const retainedTextBytes = (...strings: readonly (string | null)[]): number => ); /** Which fixed export inventory allowance refused the image. */ -export type NativeExportLimit = "rows" | "retained_text"; +export const NATIVE_EXPORT_LIMITS = ["rows", "retained_text"] as const; /** - * A fixed inventory allowance was exhausted. `limit` identifies the exhausted - * allowance so consumers derive its units from the producer, not from text: + * A fixed inventory allowance an image can exhaust. The failure's `LimitRef` + * names it so consumers derive its units from the producer, not from text: * `rows` is an entry count and `retained_text` a byte budget. */ -export class NativeExportLimitFailure extends BinaryResourceFailure { - constructor( - readonly limit: NativeExportLimit, - allowance: number, - message: string, - ) { - super("structure", allowance, message); - this.name = "NativeExportLimitFailure"; - } -} +export type NativeExportLimit = (typeof NATIVE_EXPORT_LIMITS)[number]; + +const nativeExportLimit = ( + limit: NativeExportLimit, + allowance: number, + message: string, +): BinaryResourceFailure => + new BinaryResourceFailure("structure", allowance, message, limit); const checkRetainedText = (bytes: number): void => { if (bytes > MAXIMUM_RETAINED_TEXT_BYTES) - throw new NativeExportLimitFailure( + throw nativeExportLimit( "retained_text", MAXIMUM_RETAINED_TEXT_BYTES, `Export names exceed the ${String(MAXIMUM_RETAINED_TEXT_BYTES)}-byte retained-text allowance`, @@ -101,7 +99,7 @@ const hex = (value: bigint | number) => `0x${value.toString(16)}`; const rowBudget = (count: number, label: string): void => { if (count > MAX_NATIVE_EXPORT_ROWS) - throw new NativeExportLimitFailure( + throw nativeExportLimit( "rows", MAX_NATIVE_EXPORT_ROWS, `${label} declares ${String(count)} entries, above the ${String(MAX_NATIVE_EXPORT_ROWS)}-entry export inventory allowance`, diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index bdb446426b..8870a1d4d2 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -31,9 +31,12 @@ import { import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { err, ok } from "../../domain/result.js"; import { NATIVE_EXPORTS_PROVIDER } from "../../domain/investigationProviders.js"; -import { BinaryResourceFailure } from "../metadata/BinaryContainer.js"; import { - NativeExportLimitFailure, + BinaryResourceFailure, + binaryResourceAllowance, +} from "../metadata/BinaryContainer.js"; +import { + NATIVE_EXPORT_LIMITS, type NativeExportLimit, parseNativeExports, } from "./NativeExportParser.js"; @@ -129,7 +132,7 @@ const nativeExportsFailurePrimary = ( { cause, ...limitRecovery( - cause instanceof NativeExportLimitFailure ? cause.limit : null, + binaryResourceAllowance(cause, NATIVE_EXPORT_LIMITS), cause.maximum_bytes, ), }, diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index 21faf80481..a529605458 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -50,23 +50,36 @@ export class BinaryMappingFailure extends BinaryFormatFailure { } } -/** Resource boundary exceeded while decoding metadata or native container structure. */ +/** + * Resource boundary exceeded while decoding metadata or native container + * structure. `allowance` names the exhausted allowance in the failure's + * `LimitRef`; it defaults to the boundary when a decoder has only one. + */ export class BinaryResourceFailure extends ParseFailureError<"limit"> { constructor( readonly boundary: "build-info" | "structure" | "search", readonly maximum_bytes: number, message: string, + allowance: string = boundary, ) { super({ kind: "limit", format: "native-binary", detail: message, - limit: { name: boundary, maximum: maximum_bytes }, + limit: { name: allowance, maximum: maximum_bytes }, }); this.name = "BinaryResourceFailure"; } } +/** The failure's allowance when it is one of `allowances`, else `null`. */ +export const binaryResourceAllowance = ( + failure: BinaryResourceFailure, + allowances: readonly Allowance[], +): Allowance | null => + allowances.find((allowance) => allowance === failure.failure.limit.name) ?? + null; + /** Limit name of the fixed per-string NUL-termination read allowance. */ const STRING_READ_LIMIT = "string-read-bytes"; diff --git a/src/native/pe/PeBinaryFailure.ts b/src/native/pe/PeBinaryFailure.ts index 22dab2236d..f81b055bac 100644 --- a/src/native/pe/PeBinaryFailure.ts +++ b/src/native/pe/PeBinaryFailure.ts @@ -20,7 +20,10 @@ import { carriedReason, } from "../../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; -import { BinaryResourceFailure } from "../metadata/BinaryContainer.js"; +import { + BinaryResourceFailure, + binaryResourceAllowance, +} from "../metadata/BinaryContainer.js"; interface PeBinaryInput { readonly path: string; @@ -30,7 +33,8 @@ interface PeBinaryFailureOptions { readonly operation: string; readonly providerId: string; readonly fileSizeRemediation: () => string; - readonly limit: (cause: BinaryResourceFailure) => Limit | null; + /** The allowances the parser names in its `BinaryResourceFailure`. */ + readonly limits: readonly Limit[]; readonly limitRecovery: (limit: Limit | null) => ResourceConstraintRecovery; } @@ -59,7 +63,7 @@ const peBinaryFailurePrimary = ( carriedReason(artifactFileCancellationCause(cause)), ); if (cause instanceof BinaryResourceFailure) { - const limit = options.limit(cause); + const limit = binaryResourceAllowance(cause, options.limits); return new AnalysisResourceConstraintError( operation, "capacity", diff --git a/src/native/pe/PeImportParser.test.ts b/src/native/pe/PeImportParser.test.ts index 85eb6b5946..0dc813cfe6 100644 --- a/src/native/pe/PeImportParser.test.ts +++ b/src/native/pe/PeImportParser.test.ts @@ -14,7 +14,7 @@ import { peImportNameAtRva, peWithoutImportTableFixture, } from "../../../tests/fixtures/binaryDiagnostics/peImports.js"; -import { parsePeImports, PeImportLimitFailure } from "./PeImportParser.js"; +import { parsePeImports } from "./PeImportParser.js"; const input = inspectPeImportsInputSchema.parse({ path: "/artifacts/app.exe", @@ -558,7 +558,11 @@ it("fails closed when the DLL budget is exhausted", async () => { }), ), ).rejects.toThrow( - expect.objectContaining({ limit: "dlls", maximum_bytes: 1 }), + expect.objectContaining({ + failure: expect.objectContaining({ + limit: { name: "dlls", maximum: 1 }, + }), + }), ); }); @@ -581,7 +585,11 @@ it("fails closed when the function budget is exhausted", async () => { }), ), ).rejects.toThrow( - expect.objectContaining({ limit: "functions", maximum_bytes: 1 }), + expect.objectContaining({ + failure: expect.objectContaining({ + limit: { name: "functions", maximum: 1 }, + }), + }), ); }); @@ -597,13 +605,12 @@ it("fails closed with a structured tag when retained name text is exhausted", as ], }); const failure = parse(bytes); - await expect(failure).rejects.toThrow(PeImportLimitFailure); - await expect(failure).rejects.toThrow( - expect.objectContaining({ - limit: "retained_text", - maximum_bytes: 16 * 1024 * 1024, - }), - ); + await expect(failure).rejects.toMatchObject({ + failure: { + kind: "limit", + limit: { name: "retained_text", maximum: 16 * 1024 * 1024 }, + }, + }); }); it("computes a pefile import hash for oleaut32 ordinal names", async () => { diff --git a/src/native/pe/PeImportParser.ts b/src/native/pe/PeImportParser.ts index 7fe54e8fb7..6508d03a44 100644 --- a/src/native/pe/PeImportParser.ts +++ b/src/native/pe/PeImportParser.ts @@ -55,26 +55,23 @@ const PE_LIMITATIONS = [ "Bound import snapshots, the import address table as loaded, and export-forward resolution in another DLL remain unobserved.", ]; -/** The parser-owned allowance an image exhausted. */ -export type PeImportLimit = "dlls" | "functions" | "retained_text"; +/** The parser-owned allowances an image can exhaust. */ +export const PE_IMPORT_LIMITS = ["dlls", "functions", "retained_text"] as const; +export type PeImportLimit = (typeof PE_IMPORT_LIMITS)[number]; -/** Structured parser allowance refusal; consumers branch on `limit`, never on prose. */ -export class PeImportLimitFailure extends BinaryResourceFailure { - constructor( - readonly limit: PeImportLimit, - readonly allowance: number, - message: string, - ) { - super("structure", allowance, message); - this.name = "PeImportLimitFailure"; - } -} +/** Consumers read the exhausted allowance with `binaryResourceAllowance`. */ +const peImportLimit = ( + limit: PeImportLimit, + allowance: number, + message: string, +): BinaryResourceFailure => + new BinaryResourceFailure("structure", allowance, message, limit); const hex = (value: bigint | number): string => `0x${value.toString(16)}`; const dllBudget = (count: number, max: number): void => { if (count > max) - throw new PeImportLimitFailure( + throw peImportLimit( "dlls", max, `Import inventory declares ${String(count)} descriptors, above the ${String(max)}-entry DLL allowance`, @@ -83,7 +80,7 @@ const dllBudget = (count: number, max: number): void => { const functionBudget = (count: number, max: number): void => { if (count > max) - throw new PeImportLimitFailure( + throw peImportLimit( "functions", max, `Import inventory declares ${String(count)} functions, above the ${String(max)}-entry function allowance`, @@ -95,7 +92,7 @@ const createRetention = () => { return (value: string): void => { text += value.length; if (text > MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES) - throw new PeImportLimitFailure( + throw peImportLimit( "retained_text", MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES, `Import names exceed the fixed ${String(MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES)}-byte retained-text allowance`, diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 6402a7ccd0..80593baf46 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -25,7 +25,7 @@ import { err, ok } from "../../domain/result.js"; import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js"; import { parsePeImports, - PeImportLimitFailure, + PE_IMPORT_LIMITS, type PeImportLimit, } from "./PeImportParser.js"; @@ -131,8 +131,7 @@ const peImportsFailure = ( input.max_file_bytes, MAX_INSPECT_PE_IMPORT_FILE_BYTES, ), - limit: (failure) => - failure instanceof PeImportLimitFailure ? failure.limit : null, + limits: PE_IMPORT_LIMITS, limitRecovery: (limit) => limitRecovery(limit, input), }, snapshot, diff --git a/src/native/pe/PeLoadConfigParser.test.ts b/src/native/pe/PeLoadConfigParser.test.ts index 7d57dbdabe..8e44ffc465 100644 --- a/src/native/pe/PeLoadConfigParser.test.ts +++ b/src/native/pe/PeLoadConfigParser.test.ts @@ -5,10 +5,7 @@ import { peLoadConfigSchema, type PeLoadConfigField, } from "../../domain/native/peLoadConfig.js"; -import { - parsePeLoadConfig, - PeLoadConfigLimitFailure, -} from "./PeLoadConfigParser.js"; +import { parsePeLoadConfig } from "./PeLoadConfigParser.js"; import { peLoadConfigImage, peManySectionsImage, @@ -100,9 +97,9 @@ describe.each([32, 64] as const)("PE%s load configuration", (bits) => { expect( (await parse({ bits, callbackCount: 1 }, 1)).examined_callbacks, ).toBe(1); - await expect(parse({ bits }, 1)).rejects.toBeInstanceOf( - PeLoadConfigLimitFailure, - ); + await expect(parse({ bits }, 1)).rejects.toMatchObject({ + failure: { kind: "limit", limit: { name: "callbacks", maximum: 1 } }, + }); }); it("reports absent directories", async () => { expect(await parse({ bits, tls: false, loadConfig: false })).toMatchObject({ diff --git a/src/native/pe/PeLoadConfigParser.ts b/src/native/pe/PeLoadConfigParser.ts index 593abe4698..b481bdfbb8 100644 --- a/src/native/pe/PeLoadConfigParser.ts +++ b/src/native/pe/PeLoadConfigParser.ts @@ -51,20 +51,9 @@ const optionalFileOffset = ( } }; -/** The parser-owned allowance an image exhausted. */ -export type PeLoadConfigLimit = "callbacks"; - -/** Structured parser allowance refusal; consumers branch on `limit`, never on prose. */ -export class PeLoadConfigLimitFailure extends BinaryResourceFailure { - constructor( - readonly limit: PeLoadConfigLimit, - readonly allowance: number, - message: string, - ) { - super("structure", allowance, message); - this.name = "PeLoadConfigLimitFailure"; - } -} +/** The parser-owned allowances an image can exhaust. */ +export const PE_LOAD_CONFIG_LIMITS = ["callbacks"] as const; +export type PeLoadConfigLimit = (typeof PE_LOAD_CONFIG_LIMITS)[number]; const hex = (value: bigint | number): string => `0x${value.toString(16)}`; @@ -343,10 +332,11 @@ const readTls = async ({ consume(callbackArrayLocations, slotOffset, pointerWidth); if (value === 0n) break; if (callbacks.length >= maxCallbacks) - throw new PeLoadConfigLimitFailure( - "callbacks", + throw new BinaryResourceFailure( + "structure", maxCallbacks, `TLS callback array exceeds the ${String(maxCallbacks)}-entry callback allowance`, + "callbacks" satisfies PeLoadConfigLimit, ); callbacks.push(locate(value)); slot += BigInt(pointerWidth); diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index 6477df6661..9cf50ac520 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -23,7 +23,7 @@ import { err, ok } from "../../domain/result.js"; import { PE_LOAD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js"; import { parsePeLoadConfig, - PeLoadConfigLimitFailure, + PE_LOAD_CONFIG_LIMITS, type PeLoadConfigLimit, } from "./PeLoadConfigParser.js"; @@ -107,8 +107,7 @@ const peLoadConfigFailure = ( input.max_file_bytes, MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES, ), - limit: (failure) => - failure instanceof PeLoadConfigLimitFailure ? failure.limit : null, + limits: PE_LOAD_CONFIG_LIMITS, limitRecovery: (limit) => limitRecovery(limit, input), }, snapshot, diff --git a/src/process/OwnedMcpStdioTransport.ts b/src/process/OwnedMcpStdioTransport.ts index 0c7320341b..743d310a9f 100644 --- a/src/process/OwnedMcpStdioTransport.ts +++ b/src/process/OwnedMcpStdioTransport.ts @@ -14,6 +14,7 @@ import type { SpawnedOwnedProviderProcess, } from "./ProviderProcess.js"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; +import type { FixedAllowance } from "../domain/fixedAllowanceRemediation.js"; import { ProviderProcessSupervisor, spawnOwnedProviderProcess, @@ -40,20 +41,18 @@ export type OwnedMcpTransportFailure = | { readonly kind: "process"; readonly cause: unknown } | { readonly kind: "exit" } | { readonly kind: "protocol"; readonly cause: Error } + | { + /** + * The protocol process exceeded a fixed frame or per-operation byte + * ceiling; the adapter attributes the refusal to its operation. + */ + readonly kind: "output_limit"; + readonly scope: "frame" | "operation"; + readonly ceiling: FixedAllowance & { readonly limit: number }; + readonly cause: Error; + } | { readonly kind: "interrupted"; readonly reason: unknown }; -/** A frame or per-operation byte budget the protocol process exceeded. */ -export class OwnedMcpOutputLimitError extends Error { - constructor( - readonly scope: "frame" | "operation", - readonly limitBytes: number, - message: string, - ) { - super(message); - this.name = "OwnedMcpOutputLimitError"; - } -} - /** SDK-framed stdio with REA-owned process cleanup rather than an unowned child. */ export class OwnedMcpStdioTransport implements Transport { onclose: Transport["onclose"]; @@ -359,12 +358,10 @@ export class OwnedMcpStdioTransport implements Transport { if (limit === undefined) return true; this.#bytes += bytes; if (this.#bytes <= limit) return true; - this.#fail( - new OwnedMcpOutputLimitError( - "operation", - limit, - `${this.settings.label} operation output exceeds ${limit} bytes; no complete result is available`, - ), + this.#failLimit( + "operation", + limit, + `${this.settings.label} operation output exceeds ${limit} bytes; no complete result is available`, ); return false; } @@ -379,12 +376,14 @@ export class OwnedMcpStdioTransport implements Transport { const end = newline === -1 ? bytes.length : newline; const fragment = bytes.subarray(offset, end); const frameBytes = this.#frameLength + fragment.length; - if (frameBytes > this.settings.maxFrameBytes) - throw new OwnedMcpOutputLimitError( + if (frameBytes > this.settings.maxFrameBytes) { + this.#failLimit( "frame", this.settings.maxFrameBytes, `${this.settings.label} MCP frame exceeds ${this.settings.maxFrameBytes} bytes`, ); + return; + } this.#appendFrame(fragment, frameBytes); if (newline === -1) return; // Preserve a leading BOM so the JSON-RPC parser can reject it. @@ -418,4 +417,24 @@ export class OwnedMcpStdioTransport implements Transport { this.#failure ??= { kind: "protocol", cause: error }; this.#error(error); } + /** Record a fixed output ceiling the process exceeded, then fail the session. */ + #failLimit( + scope: "frame" | "operation", + limit: number, + message: string, + ): void { + const cause = new Error(message); + this.#failure ??= { + kind: "output_limit", + scope, + ceiling: { + name: `The ${this.settings.label} ${scope === "frame" ? "frame" : "operation output"} allowance of ${String(limit)} bytes`, + kind: "bytes", + limit, + unit: "bytes", + }, + cause, + }; + this.#error(cause); + } } diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 69b8d5d762..c4fd7decf0 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -1079,11 +1079,25 @@ it.each(["tool-error", "frame-overflow", "body-error", "heap-decoy"])( : "manifest decoder rejected malformed binary XML", }, }); - else + else { expect(outcome.error).toMatchObject({ - _tag: "AnalysisOutputError", + _tag: "AnalysisResourceConstraintError", reason: expect.stringContaining("MCP frame exceeds"), }); + // The refusal is built after finalization, so it keeps the output + // the transport retained. + expect(outcome.error).toHaveProperty( + "capturedOutput", + expect.objectContaining({ truncated: expect.any(Boolean) }), + ); + // A fixed frame ceiling, not an output defect: recovery names it. + expect(outcome.error).toHaveProperty("fixedAllowance", { + name: "The JADX frame allowance of 8388608 bytes", + kind: "bytes", + limit: 8 * 1024 * 1024, + unit: "bytes", + }); + } await provider.close(); await verifyCleanup(launches); }, diff --git a/tests/evaluation/limitClassGuard.test.ts b/tests/evaluation/limitClassGuard.test.ts index bd0a5424ee..f5836cca24 100644 --- a/tests/evaluation/limitClassGuard.test.ts +++ b/tests/evaluation/limitClassGuard.test.ts @@ -20,6 +20,14 @@ it("counts plain-Error limit classes in every layout", () => { ).toBe(1); }); +it("counts limit subclasses of BinaryResourceFailure, which names its allowance", () => { + expect( + localLimitErrorClassCount( + "export class ProbeLimitFailure extends BinaryResourceFailure {}", + ), + ).toBe(1); +}); + it("ignores classes on the shared owners and unrelated names", () => { expect( localLimitErrorClassCount(