mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(errors): name fixed plist decode allowances instead of host memory in recovery (#5860)
fix(errors): derive fixed-allowance recovery from a typed refusal field
inspect_plist refused a binary plist above its fixed 64 MiB decode
estimate, or nested deeper than 128 levels, with resource "memory" and no
recovery, so the shared presentation told the caller to add worker or
host memory. Neither raises that allowance. The 64 MiB source-size refusal
likewise got the worker file-size advice for evidence replies.
AnalysisResourceConstraintError now carries a typed fixedAllowance
{ name, kind }. fixedAllowanceOptions sets it instead of a prebuilt
string, and the presentation derives the recovery from it, so every
existing fixed-allowance producer moves to the same field. Operation
re-attribution, IDA startup re-attribution and explicit redaction copy the
field through one resourceConstraintRecovery helper; redaction still
applies to the allowance name.
The plist decode, nesting and input refusals use the field. A nesting
refusal now reports maximum_nesting_depth instead of the decode byte
limit it did not exceed.
Fixes #5847
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -302,8 +302,13 @@ Binary plist inspection reads the original bytes through an owned file descripto
|
||||
so conversion cannot erase control characters, fractional dates, negative zero,
|
||||
or the high word of a 128-bit integer. It accepts at most 64 MiB of input and
|
||||
reserves an estimated 64 MiB decode budget before parsing; this estimate is not
|
||||
a hard process-memory bound. Binary-source provenance records the `file`
|
||||
classification without claiming a `plutil` conversion.
|
||||
a hard process-memory bound. A refused input reports non-retryable
|
||||
`resource_constraint` with `reported_limits.maximum_input_bytes`,
|
||||
`maximum_decoded_bytes` or, for nesting deeper than 128 levels,
|
||||
`maximum_nesting_depth`. These are fixed allowances: worker limits and host
|
||||
memory do not raise them, and the unchanged request fails the same way.
|
||||
Binary-source provenance records the `file` classification without claiming a
|
||||
`plutil` conversion.
|
||||
|
||||
A plist explicitly selected through the path input or as the active plist target
|
||||
reports an unreadable or malformed selection as `invalid_request`. A missing or
|
||||
|
||||
@@ -122,6 +122,9 @@ function* xmlPreflightTokens(xml: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deepest binary plist value nesting the reference walk admits. */
|
||||
export const MAX_BINARY_PLIST_NESTING_DEPTH = 128;
|
||||
|
||||
/** Resource or structural failure found while preflighting a plist. */
|
||||
export type BinaryPlistExpansionFailure = "cycle" | "depth" | "expansion";
|
||||
|
||||
@@ -299,7 +302,7 @@ export const estimateBinaryPlistExpansion = (
|
||||
continue;
|
||||
}
|
||||
pendingObjectCount -= 1;
|
||||
if (entry.depth > 128)
|
||||
if (entry.depth > MAX_BINARY_PLIST_NESTING_DEPTH)
|
||||
throw fail("depth", `binary plist nesting exceeds ${budget}`);
|
||||
if (active.has(entry.object))
|
||||
throw fail("cycle", `binary plist reference cycle exceeds ${budget}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { FixedAllowance } from "./fixedAllowanceRemediation.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisErrorOptions,
|
||||
@@ -33,6 +34,12 @@ export const ANALYSIS_RESOURCE_KINDS = [
|
||||
export class AnalysisResourceConstraintError extends AnalysisError {
|
||||
readonly _tag = "AnalysisResourceConstraintError";
|
||||
readonly remediationAction: string | undefined;
|
||||
/**
|
||||
* The fixed format, decoder, or capture allowance that refused the request.
|
||||
* Worker limits and host resources do not raise it, so recovery derives from
|
||||
* this allowance rather than from `resource`.
|
||||
*/
|
||||
readonly fixedAllowance: FixedAllowance | undefined;
|
||||
/**
|
||||
* Whether retrying the same request can succeed. A caller-selected bound is
|
||||
* deterministic for the same input, so it is not retryable even when its
|
||||
@@ -45,17 +52,53 @@ export class AnalysisResourceConstraintError extends AnalysisError {
|
||||
readonly resource: (typeof ANALYSIS_RESOURCE_KINDS)[number],
|
||||
readonly reason: string,
|
||||
readonly reportedLimits: Readonly<Record<string, JsonValue>> | null,
|
||||
options?: AnalysisErrorOptions & {
|
||||
readonly remediationAction?: string;
|
||||
readonly retryable?: boolean;
|
||||
},
|
||||
options?: AnalysisErrorOptions & ResourceConstraintRecovery,
|
||||
) {
|
||||
super(`Resource constraint during ${operation}: ${reason}`, options);
|
||||
this.remediationAction = options?.remediationAction;
|
||||
this.fixedAllowance = options?.fixedAllowance;
|
||||
this.retryable = options?.retryable;
|
||||
}
|
||||
}
|
||||
|
||||
/** Carry one refusal's recovery to an error re-attributed or redacted elsewhere. */
|
||||
export const resourceConstraintRecovery = (
|
||||
error: AnalysisResourceConstraintError,
|
||||
text: (value: string) => string = (value) => value,
|
||||
): ResourceConstraintRecovery =>
|
||||
error.fixedAllowance !== undefined
|
||||
? {
|
||||
fixedAllowance: {
|
||||
name: text(error.fixedAllowance.name),
|
||||
kind: error.fixedAllowance.kind,
|
||||
},
|
||||
...retryable(error),
|
||||
}
|
||||
: {
|
||||
...(error.remediationAction === undefined
|
||||
? {}
|
||||
: { remediationAction: text(error.remediationAction) }),
|
||||
...retryable(error),
|
||||
};
|
||||
|
||||
const retryable = (
|
||||
error: AnalysisResourceConstraintError,
|
||||
): { readonly retryable?: boolean } =>
|
||||
error.retryable === undefined ? {} : { retryable: error.retryable };
|
||||
|
||||
/** Producer-specific recovery, or a fixed allowance whose recovery is derived. */
|
||||
export type ResourceConstraintRecovery =
|
||||
| {
|
||||
readonly remediationAction?: string;
|
||||
readonly fixedAllowance?: never;
|
||||
readonly retryable?: boolean;
|
||||
}
|
||||
| {
|
||||
readonly fixedAllowance: FixedAllowance;
|
||||
readonly remediationAction?: never;
|
||||
readonly retryable?: boolean;
|
||||
};
|
||||
|
||||
/** Provider-neutral invalid analysis input or output at an application boundary. */
|
||||
export class AnalysisProtocolError extends AnalysisError {
|
||||
readonly _tag = "AnalysisProtocolError";
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ProviderSelectionError } from "./providerSelectionError.js";
|
||||
import { ProviderAdapterError } from "./providerAdapterError.js";
|
||||
import { UnknownRegistryError } from "./unknownRegistryError.js";
|
||||
import { providerRetryAction } from "./providerOperationHealth.js";
|
||||
import { fixedAllowanceRemediation } from "./fixedAllowanceRemediation.js";
|
||||
import {
|
||||
resolvedAnalysisError,
|
||||
type AnalysisError,
|
||||
@@ -67,15 +68,20 @@ export const analysisErrorRemediationAction = (
|
||||
if (error instanceof AnalysisResourceConstraintError)
|
||||
return (
|
||||
error.remediationAction ??
|
||||
(error.resource === "capacity"
|
||||
? "Wait for an active analysis to complete, then retry within the reported admission limits."
|
||||
: error.resource === "transport"
|
||||
? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable."
|
||||
: error.resource === "cpu"
|
||||
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
|
||||
: error.resource === "file-size"
|
||||
? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits."
|
||||
: "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.")
|
||||
(error.fixedAllowance !== undefined
|
||||
? fixedAllowanceRemediation(
|
||||
error.fixedAllowance.name,
|
||||
error.fixedAllowance.kind,
|
||||
)
|
||||
: error.resource === "capacity"
|
||||
? "Wait for an active analysis to complete, then retry within the reported admission limits."
|
||||
: error.resource === "transport"
|
||||
? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable."
|
||||
: error.resource === "cpu"
|
||||
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
|
||||
: error.resource === "file-size"
|
||||
? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits."
|
||||
: "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.")
|
||||
);
|
||||
if (
|
||||
error instanceof AnalysisTimeoutError &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AnalysisOutputError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisResourceConstraintError,
|
||||
resourceConstraintRecovery,
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "./analysisErrorCore.js";
|
||||
@@ -183,15 +184,7 @@ const redactFailure = (
|
||||
error.resource,
|
||||
text(error.reason),
|
||||
error.reportedLimits === null ? null : diagnostics(error.reportedLimits),
|
||||
{
|
||||
...context,
|
||||
...(error.remediationAction === undefined
|
||||
? {}
|
||||
: { remediationAction: text(error.remediationAction) }),
|
||||
...(error.retryable === undefined
|
||||
? {}
|
||||
: { retryable: error.retryable }),
|
||||
},
|
||||
{ ...context, ...resourceConstraintRecovery(error, text) },
|
||||
);
|
||||
if (error instanceof ProviderCleanupError)
|
||||
return new ProviderCleanupError(
|
||||
|
||||
@@ -39,11 +39,18 @@ export const fixedAllowanceRemediation = (
|
||||
}
|
||||
};
|
||||
|
||||
/** A named deterministic allowance and the recovery family it belongs to. */
|
||||
export interface FixedAllowance {
|
||||
/** Display name, such as "The tshark JSON decoding allowance". */
|
||||
readonly name: string;
|
||||
readonly kind: FixedAllowanceKind;
|
||||
}
|
||||
|
||||
/** Non-retryable options for a fixed allowance. Occupied capacity stays retryable. */
|
||||
export const fixedAllowanceOptions = (
|
||||
allowance: string,
|
||||
kind: FixedAllowanceKind,
|
||||
): { readonly retryable: false; readonly remediationAction: string } => ({
|
||||
): { readonly retryable: false; readonly fixedAllowance: FixedAllowance } => ({
|
||||
retryable: false,
|
||||
remediationAction: fixedAllowanceRemediation(allowance, kind),
|
||||
fixedAllowance: { name: allowance, kind },
|
||||
});
|
||||
|
||||
+14
-11
@@ -14,6 +14,7 @@ import {
|
||||
AnalysisInputError,
|
||||
AnalysisTimeoutError,
|
||||
AnalysisResourceConstraintError,
|
||||
resourceConstraintRecovery,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
@@ -530,33 +531,35 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
...(cause.userMessage === undefined
|
||||
? {}
|
||||
: { userMessage: cause.userMessage }),
|
||||
...(cause.remediationAction === undefined
|
||||
? {}
|
||||
: { remediationAction: cause.remediationAction }),
|
||||
diagnostics: {
|
||||
...cause.diagnostics,
|
||||
startup_operation: cause.operation,
|
||||
},
|
||||
};
|
||||
const action =
|
||||
cause.remediationAction === undefined
|
||||
? {}
|
||||
: { remediationAction: cause.remediationAction };
|
||||
// Startup is shared, but each accepted operation owns its attribution.
|
||||
// Keep the original cached failure unchanged for later callers.
|
||||
const failure =
|
||||
cause instanceof AnalysisTimeoutError
|
||||
? new AnalysisTimeoutError(operation, cause.timeoutMs, options)
|
||||
? new AnalysisTimeoutError(operation, cause.timeoutMs, {
|
||||
...options,
|
||||
...action,
|
||||
})
|
||||
: cause instanceof AnalysisResourceConstraintError
|
||||
? new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
cause.resource,
|
||||
cause.reason,
|
||||
cause.reportedLimits,
|
||||
{
|
||||
...options,
|
||||
...(cause.retryable === undefined
|
||||
? {}
|
||||
: { retryable: cause.retryable }),
|
||||
},
|
||||
{ ...options, ...resourceConstraintRecovery(cause) },
|
||||
)
|
||||
: new ProviderAdapterError(cause.providerId, operation, options);
|
||||
: new ProviderAdapterError(cause.providerId, operation, {
|
||||
...options,
|
||||
...action,
|
||||
});
|
||||
throw failure
|
||||
.bindCleanupDependencies([cause])
|
||||
.bindCleanupResourceOwner(cause);
|
||||
|
||||
@@ -33,7 +33,11 @@ import {
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
import { MAX_NATIVE_PLIST_BYTES } from "./parsers/plist.js";
|
||||
import {
|
||||
MAX_NATIVE_PLIST_BYTES,
|
||||
NATIVE_PLIST_INPUT_ALLOWANCE,
|
||||
} from "./parsers/plist.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
|
||||
/** Resolved lookup identity and selection intent, independent of file readiness. */
|
||||
export interface NativePlistSelection {
|
||||
@@ -214,15 +218,25 @@ export const nativePlistFailure = (
|
||||
);
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") {
|
||||
const resource = cause.boundary ?? "memory";
|
||||
return new AnalysisResourceConstraintError(
|
||||
"inspect_plist",
|
||||
resource,
|
||||
cause.message,
|
||||
resource === "file-size"
|
||||
? { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }
|
||||
: null,
|
||||
{ cause, capturedOutput, retryable: false },
|
||||
);
|
||||
return resource === "file-size"
|
||||
? new AnalysisResourceConstraintError(
|
||||
"inspect_plist",
|
||||
resource,
|
||||
cause.message,
|
||||
{ maximum_input_bytes: MAX_NATIVE_PLIST_BYTES },
|
||||
{
|
||||
cause,
|
||||
capturedOutput,
|
||||
...fixedAllowanceOptions(NATIVE_PLIST_INPUT_ALLOWANCE, "input"),
|
||||
},
|
||||
)
|
||||
: new AnalysisResourceConstraintError(
|
||||
"inspect_plist",
|
||||
resource,
|
||||
cause.message,
|
||||
null,
|
||||
{ cause, capturedOutput, retryable: false },
|
||||
);
|
||||
}
|
||||
if (selection === undefined)
|
||||
return new ProviderAdapterError(IDENTITY.id, "inspect_plist", {
|
||||
|
||||
@@ -34,14 +34,22 @@ import {
|
||||
} from "../../artifacts/apple/PlistScalars.js";
|
||||
import {
|
||||
estimateBinaryPlistExpansion,
|
||||
MAX_BINARY_PLIST_NESTING_DEPTH,
|
||||
PropertyListDecodeBudgetFailure,
|
||||
} from "../../artifacts/apple/PropertyListDecodeBudget.js";
|
||||
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
|
||||
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
|
||||
|
||||
/** Input ceiling matches native command output; projection is bounded separately. */
|
||||
export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const mebibytes = (bytes: number): string =>
|
||||
`${String(bytes / (1024 * 1024))} MiB`;
|
||||
|
||||
/** Recovery name for the fixed source-size ceiling. */
|
||||
export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} native plist input allowance`;
|
||||
|
||||
const plistObject = z.record(z.string(), z.unknown());
|
||||
|
||||
/** Stable bundle metadata projected from one plist value. */
|
||||
@@ -287,8 +295,18 @@ export const parsePlistBinary = (
|
||||
"inspect_plist",
|
||||
"memory",
|
||||
cause.message,
|
||||
{ maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES },
|
||||
{ cause, retryable: false },
|
||||
cause.kind === "depth"
|
||||
? { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH }
|
||||
: { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES },
|
||||
{
|
||||
cause,
|
||||
...fixedAllowanceOptions(
|
||||
cause.kind === "depth"
|
||||
? `The ${String(MAX_BINARY_PLIST_NESTING_DEPTH)}-level native binary plist nesting allowance`
|
||||
: `The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} native binary plist decode allowance`,
|
||||
"parser",
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
return err(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisResourceConstraintError,
|
||||
resourceConstraintRecovery,
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
@@ -237,15 +238,7 @@ export const analysisClientFailureForOperation = (
|
||||
cause.resource,
|
||||
cause.reason,
|
||||
cause.reportedLimits,
|
||||
{
|
||||
...context,
|
||||
...(cause.retryable === undefined
|
||||
? {}
|
||||
: { retryable: cause.retryable }),
|
||||
...(cause.remediationAction === undefined
|
||||
? {}
|
||||
: { remediationAction: cause.remediationAction }),
|
||||
},
|
||||
{ ...context, ...resourceConstraintRecovery(cause) },
|
||||
);
|
||||
else
|
||||
result = new ProviderAdapterError(providerId, operation, {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createDeferred } from "../../fixtures/binarySession.js";
|
||||
import { Cli } from "incur";
|
||||
import { registerCaptureCommands } from "../../../src/cli/captureCommands.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js";
|
||||
|
||||
// A command-boundary simulator: real process ownership/filesystem, not real TShark coverage.
|
||||
const fixture = async (mode = "ok", failCleanup = false) => {
|
||||
@@ -623,10 +624,10 @@ it.each([
|
||||
reportedLimits: { output_bytes: 32 * 1024 * 1024 },
|
||||
retryable: false,
|
||||
});
|
||||
expect(result.error.remediationAction).toContain(
|
||||
expect(analysisErrorRemediationAction(result.error)).toContain(
|
||||
"The retained command-output allowance",
|
||||
);
|
||||
expect(result.error.remediationAction).not.toMatch(
|
||||
expect(analysisErrorRemediationAction(result.error)).not.toMatch(
|
||||
/Review the reported worker memory limits/u,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildBinary } from "plist";
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -248,3 +249,77 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native plist CLI fixed allowances",
|
||||
() => {
|
||||
const hostMemoryAdvice = /available host memory|worker file-size limits/u;
|
||||
|
||||
cliTest(
|
||||
"names the binary plist decode allowance instead of host memory",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-plist-decode-limit-");
|
||||
const path = join(root, "large.plist");
|
||||
// The decode estimate exceeds 64 MiB below the 64 MiB input ceiling.
|
||||
const source = buildBinary({
|
||||
data: Buffer.alloc(22 * 1024 * 1024, 0x78),
|
||||
});
|
||||
await writeFile(path, source);
|
||||
const runs = [];
|
||||
for (let attempt = 0; attempt < 2; attempt += 1)
|
||||
runs.push(
|
||||
await cli.run({
|
||||
arguments: ["inspect-plist", path, "--json"],
|
||||
environment,
|
||||
}),
|
||||
);
|
||||
const [first, second] = runs;
|
||||
expect(first?.exitCode).toBe(1);
|
||||
expect(second?.json).toEqual(first?.json);
|
||||
expect(first?.json).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action:
|
||||
"The 64 MiB native binary plist decode allowance is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.",
|
||||
},
|
||||
details: {
|
||||
operation: "inspect_plist",
|
||||
resource: "memory",
|
||||
reported_limits: { maximum_decoded_bytes: 64 * 1024 * 1024 },
|
||||
},
|
||||
});
|
||||
expect(Buffer.compare(await readFile(path), source)).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"names the binary plist input allowance instead of worker file-size limits",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-plist-input-limit-");
|
||||
const path = join(root, "huge.plist");
|
||||
await writeFile(path, "bplist00");
|
||||
await truncate(path, 64 * 1024 * 1024 + 1);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-plist", path, "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action: expect.stringContaining(
|
||||
"Select a suitable complete artifact within the fixed 64 MiB native plist input allowance.",
|
||||
),
|
||||
},
|
||||
details: {
|
||||
resource: "file-size",
|
||||
reported_limits: { maximum_input_bytes: 64 * 1024 * 1024 },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.json)).not.toMatch(hostMemoryAdvice);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
type Il2cppMetadata,
|
||||
} from "../../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js";
|
||||
|
||||
const type = (
|
||||
name: string,
|
||||
@@ -188,12 +189,16 @@ it("bounds folded-name retention against the actual Ghidra file admission", () =
|
||||
if (!(caught instanceof AnalysisResourceConstraintError))
|
||||
throw new Error("Expected seed-file allowance refusal");
|
||||
expect(caught.message).toMatch(/byte admission/);
|
||||
expect(caught.remediationAction).toContain(
|
||||
expect(analysisErrorRemediationAction(caught)).toContain(
|
||||
"The IL2CPP Ghidra seed-file allowance",
|
||||
);
|
||||
expect(caught.remediationAction).toContain("fixed byte allowance");
|
||||
expect(caught.remediationAction).not.toContain("fixed count");
|
||||
expect(caught.remediationAction).not.toContain("seed-name allowance");
|
||||
expect(analysisErrorRemediationAction(caught)).toContain(
|
||||
"fixed byte allowance",
|
||||
);
|
||||
expect(analysisErrorRemediationAction(caught)).not.toContain("fixed count");
|
||||
expect(analysisErrorRemediationAction(caught)).not.toContain(
|
||||
"seed-name allowance",
|
||||
);
|
||||
expect(caught.retryable).toBe(false);
|
||||
});
|
||||
it("refuses cyclic type ancestry before emitting a seed", () => {
|
||||
@@ -229,10 +234,12 @@ it("refuses names the real Ghidra seed parser cannot admit", () => {
|
||||
if (!(caught instanceof AnalysisResourceConstraintError))
|
||||
throw new Error("Expected seed-name allowance refusal");
|
||||
expect(caught.message).toMatch(/character admission/);
|
||||
expect(caught.remediationAction).toContain(
|
||||
expect(analysisErrorRemediationAction(caught)).toContain(
|
||||
"The IL2CPP Ghidra seed-name allowance",
|
||||
);
|
||||
expect(caught.remediationAction).toContain("fixed count");
|
||||
expect(caught.remediationAction).not.toContain("fixed byte allowance");
|
||||
expect(analysisErrorRemediationAction(caught)).toContain("fixed count");
|
||||
expect(analysisErrorRemediationAction(caught)).not.toContain(
|
||||
"fixed byte allowance",
|
||||
);
|
||||
expect(caught.retryable).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js";
|
||||
import {
|
||||
fixedAllowanceOptions,
|
||||
fixedAllowanceRemediation,
|
||||
} from "../../../src/domain/fixedAllowanceRemediation.js";
|
||||
import { analysisClientFailureForOperation } from "../../../src/providers/AnalysisClientFailure.js";
|
||||
|
||||
const refusal = () =>
|
||||
new AnalysisResourceConstraintError(
|
||||
"provider_stage",
|
||||
"memory",
|
||||
"decoded representation exceeds the fixture allowance",
|
||||
{ maximum_decoded_bytes: 64 },
|
||||
fixedAllowanceOptions(
|
||||
"The explicit-secret fixture decode allowance",
|
||||
"parser",
|
||||
),
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
boundary: "operation attribution",
|
||||
copy: (error: AnalysisError) =>
|
||||
analysisClientFailureForOperation(error, "fixture", "inspect_fixture"),
|
||||
allowance: "The explicit-secret fixture decode allowance",
|
||||
},
|
||||
{
|
||||
boundary: "explicit redaction",
|
||||
copy: (error: AnalysisError) =>
|
||||
redactExplicitFailure(error, ["explicit-secret"]),
|
||||
allowance: "The [REDACTED] fixture decode allowance",
|
||||
},
|
||||
])(
|
||||
"keeps a fixed allowance's derived recovery through $boundary",
|
||||
({ copy, allowance }) => {
|
||||
const copied = copy(refusal());
|
||||
expect(copied).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(copied).toMatchObject({
|
||||
fixedAllowance: { name: allowance, kind: "parser" },
|
||||
remediationAction: undefined,
|
||||
retryable: false,
|
||||
});
|
||||
expect(projectAnalysisError(copied)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
remediation: { action: fixedAllowanceRemediation(allowance, "parser") },
|
||||
details: { reported_limits: { maximum_decoded_bytes: 64 } },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js";
|
||||
import { scanBinaryPlistScalars } from "../../../../src/artifacts/apple/PlistScalars.js";
|
||||
import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js";
|
||||
import { buildBinary } from "plist";
|
||||
import { buildBinary, type PlistValue } from "plist";
|
||||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import { MAX_NATIVE_PLIST_BYTES } from "../../../../src/native/parsers/plist.js";
|
||||
import { MAX_BINARY_PLIST_NESTING_DEPTH } from "../../../../src/artifacts/apple/PropertyListDecodeBudget.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
nativeMachoTarget,
|
||||
@@ -62,10 +64,76 @@ it("reports a binary source-size refusal as a typed resource limit", async () =>
|
||||
resource: "file-size",
|
||||
reportedLimits: { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES },
|
||||
retryable: false,
|
||||
fixedAllowance: { kind: "input" },
|
||||
});
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
remediation: {
|
||||
action: expect.stringContaining(
|
||||
"within the fixed 64 MiB native plist input allowance",
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(projected.remediation.action).not.toMatch(/worker file-size limits/u);
|
||||
expect(runner.tools).toEqual(["file"]);
|
||||
});
|
||||
|
||||
const nested = (depth: number): PlistValue =>
|
||||
depth === 0 ? 1 : [nested(depth - 1)];
|
||||
|
||||
it.each([
|
||||
{
|
||||
budget: "decode",
|
||||
// Three times the source length exceeds the 64 MiB decode estimate while
|
||||
// the source stays below the separate 64 MiB input ceiling.
|
||||
value: { data: Buffer.alloc(22 * 1024 * 1024, 0x78) },
|
||||
reason: "binary plist object table exceeds the native plist decode budget",
|
||||
limits: { maximum_decoded_bytes: 64 * 1024 * 1024 },
|
||||
allowance: "The 64 MiB native binary plist decode allowance",
|
||||
},
|
||||
{
|
||||
budget: "nesting",
|
||||
value: nested(MAX_BINARY_PLIST_NESTING_DEPTH + 1),
|
||||
reason: "binary plist nesting exceeds the native plist decode budget",
|
||||
limits: { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH },
|
||||
allowance: "The 128-level native binary plist nesting allowance",
|
||||
},
|
||||
])(
|
||||
"names the fixed binary plist $budget allowance instead of host memory",
|
||||
async ({ value, reason, limits, allowance }) => {
|
||||
const { root, path } = await binaryPath();
|
||||
await writeFile(path, buildBinary(value));
|
||||
const runner = new BinaryNativeRunner({
|
||||
file: "Apple binary property list\n",
|
||||
});
|
||||
const client = binaryClient(root, runner);
|
||||
|
||||
const result = await client.execute("inspect_plist", { path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected decode allowance refusal");
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(result.error).toMatchObject({
|
||||
resource: "memory",
|
||||
reason,
|
||||
reportedLimits: limits,
|
||||
retryable: false,
|
||||
fixedAllowance: { name: allowance, kind: "parser" },
|
||||
});
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
details: { resource: "memory", reported_limits: limits },
|
||||
});
|
||||
expect(projected.remediation.action).toBe(
|
||||
`${allowance} is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.`,
|
||||
);
|
||||
expect(runner.tools).toEqual(["file"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a malformed binary plist as a selected-input failure", async () => {
|
||||
const { root, path } = await binaryPath();
|
||||
await writeFile(path, Buffer.from("bplist00bad"));
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createDeferred } from "../../fixtures/binarySession.js";
|
||||
import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { Cli } from "incur";
|
||||
@@ -736,10 +737,10 @@ it.each([
|
||||
resource: "memory",
|
||||
retryable: false,
|
||||
});
|
||||
expect(result.error.remediationAction).toContain(
|
||||
expect(analysisErrorRemediationAction(result.error)).toContain(
|
||||
"The retained command-output allowance",
|
||||
);
|
||||
expect(result.error.remediationAction).not.toMatch(
|
||||
expect(analysisErrorRemediationAction(result.error)).not.toMatch(
|
||||
/Review the reported worker memory limits/u,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user