diff --git a/src/application/AnalysisSettings.test.ts b/src/application/AnalysisSettings.test.ts index d8214ffe84..114f3a684f 100644 --- a/src/application/AnalysisSettings.test.ts +++ b/src/application/AnalysisSettings.test.ts @@ -14,6 +14,7 @@ import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSna import { runtimeAnalysisConfig, runtimeAsarHeaderAdmission, + runtimeCaptureDecoderHeap, } from "../config/runtimeAnalysisSettings.js"; import { parseConfig } from "../config/parseConfig.js"; @@ -391,3 +392,75 @@ it("keeps the Hopper startup deadline source through a runtime override", () => expect(live.hopperStartupTimeoutSource).toBe("runtime"); }); }); + +it("retains invalid ASAR seeds across long-lived views and override resets", async () => { + const environment = { + REA_ASAR_MAX_HEADER_BYTES: "invalid", + REA_ASAR_MAX_LISTING_BYTES: "invalid", + }; + const store = new AnalysisSettings(environment); + const admission = runtimeAsarHeaderAdmission(environment, store.read); + expect(() => admission.maximumBytes).toThrow("REA_ASAR_MAX_HEADER_BYTES"); + expect(() => admission.listing?.source).toThrow("REA_ASAR_MAX_LISTING_BYTES"); + store.update({ + changes: [ + { setting: "asar.max_header_bytes", action: "set", value: 8192 }, + { setting: "asar.max_listing_bytes", action: "set", value: 4096 }, + ], + }); + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const accepted = store.run(async () => { + await barrier; + expect(admission).toMatchObject({ + maximumBytes: 8192, + source: "runtime", + listing: { maximumBytes: 4096, source: "runtime" }, + }); + }); + store.update({ + changes: [ + { setting: "asar.max_header_bytes", action: "reset" }, + { setting: "asar.max_listing_bytes", action: "reset" }, + ], + }); + release(); + await accepted; + for (const read of [() => admission.maximumBytes, () => admission.source]) + expect(read).toThrow("REA_ASAR_MAX_HEADER_BYTES"); + for (const read of [ + () => admission.listing?.maximumBytes, + () => admission.listing?.source, + ]) + expect(read).toThrow("REA_ASAR_MAX_LISTING_BYTES"); +}); + +it("retains an invalid capture heap seed through override and reset", () => { + const environment = { REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "invalid" }; + const store = new AnalysisSettings(environment); + const heap = runtimeCaptureDecoderHeap(environment, store.read); + expect(() => heap.oldGenerationMiB).toThrow( + "REA_CAPTURE_DECODER_OLD_GENERATION_MIB", + ); + store.update({ + changes: [ + { + setting: "capture.decoder_old_generation_mib", + action: "set", + value: 64, + }, + ], + }); + expect(heap).toMatchObject({ oldGenerationMiB: 64, source: "runtime" }); + store.update({ + changes: [ + { setting: "capture.decoder_old_generation_mib", action: "reset" }, + ], + }); + expect(() => heap.oldGenerationMiB).toThrow( + "REA_CAPTURE_DECODER_OLD_GENERATION_MIB", + ); + expect(() => heap.source).toThrow("REA_CAPTURE_DECODER_OLD_GENERATION_MIB"); +}); diff --git a/src/config/runtimeAnalysisSettings.ts b/src/config/runtimeAnalysisSettings.ts index 87d6782b15..1299acbf79 100644 --- a/src/config/runtimeAnalysisSettings.ts +++ b/src/config/runtimeAnalysisSettings.ts @@ -11,17 +11,13 @@ import { DEFAULT_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION, DEFAULT_GHIDRA_VALUE_FLOW_MAX_EDGES, } from "../domain/analysisResourceLimits.js"; +import { parseAsarListingAdmission } from "./asarListingAdmission.js"; import { - parseAsarListingAdmission, - DEFAULT_ASAR_LISTING_ADMISSION, -} from "./asarListingAdmission.js"; -import { - DEFAULT_ASAR_HEADER_ADMISSION, parseAsarHeaderAdmission, type AsarHeaderAdmission, } from "./asarHeaderAdmission.js"; import { - resolveCaptureDecoderHeap, + parseCaptureDecoderHeap, type CaptureDecoderHeap, } from "./captureDecoderHeap.js"; import { @@ -172,78 +168,70 @@ export const runtimeAsarHeaderAdmission = ( const parsed = parseAsarHeaderAdmission( environment.REA_ASAR_MAX_HEADER_BYTES, ); - const headerOverride = - read?.().overrides["asar.max_header_bytes"] ?? - currentAnalysisSettingOverride("asar.max_header_bytes"); - if (!parsed.ok && headerOverride === undefined) throw parsed.error; const listing = parseAsarListingAdmission( environment.REA_ASAR_MAX_LISTING_BYTES, ); - const listingOverride = - read?.().overrides["asar.max_listing_bytes"] ?? - currentAnalysisSettingOverride("asar.max_listing_bytes"); - if (!listing.ok && listingOverride === undefined) throw listing.error; - const baseline = parsed.ok ? parsed.value : DEFAULT_ASAR_HEADER_ADMISSION; - const listingBaseline = listing.ok - ? listing.value - : DEFAULT_ASAR_LISTING_ADMISSION; - return read === undefined - ? { - maximumBytes: headerOverride ?? baseline.maximumBytes, - source: headerOverride === undefined ? baseline.source : "runtime", - listing: { - maximumBytes: listingOverride ?? listingBaseline.maximumBytes, - source: - listingOverride === undefined ? listingBaseline.source : "runtime", - }, - } - : { - listing: { - get maximumBytes() { - return ( - read().overrides["asar.max_listing_bytes"] ?? - listingBaseline.maximumBytes - ); - }, - get source() { - return read().overrides["asar.max_listing_bytes"] === undefined - ? listingBaseline.source - : "runtime"; - }, - }, - get maximumBytes() { - return ( - read().overrides["asar.max_header_bytes"] ?? baseline.maximumBytes - ); - }, - get source() { - return read().overrides["asar.max_header_bytes"] === undefined - ? baseline.source - : "runtime"; - }, - }; + const header = () => { + const override = + read?.().overrides["asar.max_header_bytes"] ?? + currentAnalysisSettingOverride("asar.max_header_bytes"); + if (override !== undefined) + return { maximumBytes: override, source: "runtime" as const }; + if (!parsed.ok) throw parsed.error; + return parsed.value; + }; + const expandedListing = () => { + const override = + read?.().overrides["asar.max_listing_bytes"] ?? + currentAnalysisSettingOverride("asar.max_listing_bytes"); + if (override !== undefined) + return { maximumBytes: override, source: "runtime" as const }; + if (!listing.ok) throw listing.error; + return listing.value; + }; + if (read === undefined) return { ...header(), listing: expandedListing() }; + return { + get maximumBytes() { + return header().maximumBytes; + }, + get source() { + return header().source; + }, + listing: { + get maximumBytes() { + return expandedListing().maximumBytes; + }, + get source() { + return expandedListing().source; + }, + }, + }; }; export const runtimeCaptureDecoderHeap = ( environment: Readonly>, read?: AnalysisSettingsReader, ): CaptureDecoderHeap => { - const baseline = resolveCaptureDecoderHeap(environment); - return read === undefined - ? baseline - : { - get oldGenerationMiB() { - return ( - read().overrides["capture.decoder_old_generation_mib"] ?? - baseline.oldGenerationMiB - ); - }, - get source() { - return read().overrides["capture.decoder_old_generation_mib"] === - undefined - ? baseline.source - : "runtime"; - }, - }; + const parsed = parseCaptureDecoderHeap( + environment.REA_CAPTURE_DECODER_OLD_GENERATION_MIB, + ); + const effective = () => { + const override = + read?.().overrides["capture.decoder_old_generation_mib"] ?? + currentAnalysisSettingOverride("capture.decoder_old_generation_mib"); + if (override !== undefined) + return { oldGenerationMiB: override, source: "runtime" as const }; + if (!parsed.ok) throw parsed.error; + return parsed.value; + }; + if (read === undefined) return effective(); + return { + get oldGenerationMiB() { + return effective().oldGenerationMiB; + }, + get source() { + return effective().source; + }, + }; }; /** * Resolve the accepted operation's allowance on each call. An invalid launch diff --git a/tests/acceptance/analysisSettingsMcp.test.ts b/tests/acceptance/analysisSettingsMcp.test.ts index 16511db276..53f2be0b9e 100644 --- a/tests/acceptance/analysisSettingsMcp.test.ts +++ b/tests/acceptance/analysisSettingsMcp.test.ts @@ -428,3 +428,93 @@ it("lowers, restores and seeds the native matching allowance over MCP", async () await invalid.client.close(); } }, 30000); + +it("starts with invalid runtime seeds and recovers ASAR and capture work on the same connection", async () => { + const root = await createTestTempDirectory("rea-invalid-runtime-seeds-"); + const archive = join(root, "small.asar"); + const capture = join(root, "capture.har"); + const payload = Buffer.from("answer"); + await writeFile( + archive, + Buffer.concat([ + asarHeaderBytes( + JSON.stringify({ + files: { "answer.txt": { size: payload.length, offset: "0" } }, + }), + ), + payload, + ]), + ); + await writeFile( + capture, + JSON.stringify({ + log: { + version: "1.2", + creator: { name: "test", version: "1" }, + entries: [], + }, + }), + ); + const { client, call } = await connectLocalToolsMcp({ + REA_ASAR_MAX_HEADER_BYTES: "invalid", + REA_ASAR_MAX_LISTING_BYTES: "invalid", + REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "invalid", + }); + try { + const changes = [ + { setting: "asar.max_header_bytes", action: "set", value: 8192 }, + { setting: "asar.max_listing_bytes", action: "set", value: 4096 }, + { + setting: "capture.decoder_old_generation_mib", + action: "set", + value: 64, + }, + ]; + expect( + (await call("update_analysis_settings", { changes })).isError, + ).not.toBe(true); + expect((await call("open_binary", { path: archive })).isError).not.toBe( + true, + ); + expect((await call("inspect_artifact", {})).isError).not.toBe(true); + const inspected = await call("inspect_web_network_capture", { + capture_path: capture, + format: "har", + }); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + expect( + ( + await call("update_analysis_settings", { + changes: changes.map(({ setting }) => ({ setting, action: "reset" })), + }) + ).isError, + ).not.toBe(true); + const refusedArchive = await call("inspect_artifact", {}); + expect(refusedArchive.isError).toBe(true); + expect(JSON.stringify(refusedArchive)).toContain( + "REA_ASAR_MAX_HEADER_BYTES", + ); + const refused = await call("inspect_web_network_capture", { + capture_path: capture, + format: "har", + }); + expect(refused.isError).toBe(true); + expect(JSON.stringify(refused)).toContain( + "REA_CAPTURE_DECODER_OLD_GENERATION_MIB", + ); + expect( + (await call("update_analysis_settings", { changes })).isError, + ).not.toBe(true); + expect((await call("inspect_artifact", {})).isError).not.toBe(true); + expect( + ( + await call("inspect_web_network_capture", { + capture_path: capture, + format: "har", + }) + ).isError, + ).not.toBe(true); + } finally { + await client.close(); + } +}, 30000);