mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
refactor: report NativeAOT, Interface Builder and owned-MCP output limits through the shared limit owners (#11194)
* refactor: report NativeAOT, Interface Builder and owned-MCP output limits through the shared limit owners * refactor(errors): carry a parser's required value on its limit reference and in adjustable-budget refusals (#11201) * fix(android): keep the JADX output-limit refusal's retained process output * refactor(native): name PE and native-export allowances in the BinaryResourceFailure limit (#11253) PeImportLimitFailure, PeLoadConfigLimitFailure and NativeExportLimitFailure kept a parallel `limit` field beside the LimitRef their BinaryResourceFailure base already carries (named only "structure"). The allowance name now lives in the LimitRef, consumers read it with binaryResourceAllowance against the parser's exported allowance list, and the three subclasses are removed. The local-limit-error-class guard also counts limit subclasses of BinaryResourceFailure.
This commit is contained in:
1 parent
f7706a199c
commit
e09bbcaf5f
32 files changed
+517
-266
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Buffer> => {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<object>()),
|
||||
parseXmlPropertyList: stackOverflow,
|
||||
}));
|
||||
vi.mock("./BinaryPlistMaterializer.js", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
decodeBinaryPropertyList: stackOverflow,
|
||||
}));
|
||||
|
||||
it.each([
|
||||
["XML", Buffer.from('<?xml version="1.0"?><plist><dict/></plist>')],
|
||||
["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.",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-22
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 = <Allowance extends string>(
|
||||
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";
|
||||
|
||||
|
||||
@@ -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<Limit extends string> {
|
||||
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 = <Limit extends string>(
|
||||
carriedReason(artifactFileCancellationCause(cause)),
|
||||
);
|
||||
if (cause instanceof BinaryResourceFailure) {
|
||||
const limit = options.limit(cause);
|
||||
const limit = binaryResourceAllowance(cause, options.limits);
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"capacity",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user