diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs index 54c6ddc82b..66247aa380 100644 --- a/scripts/bundle-runtime.mjs +++ b/scripts/bundle-runtime.mjs @@ -5,7 +5,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"; /** * Reduce filesystem resolution during CLI and MCP startup. Keep dependencies - * external, dynamic imports lazy, and original compiled worker entrypoints. + * external and original compiled worker entrypoints. Dynamic imports can split + * into lazy chunks unless the same module is also statically reachable. */ export const bundleRuntime = async (root) => { const compiledRoot = resolve(root, "dist"); diff --git a/scripts/ci/job-timings.mjs b/scripts/ci/job-timings.mjs index 4d96f91b0f..60c6f78582 100644 --- a/scripts/ci/job-timings.mjs +++ b/scripts/ci/job-timings.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index 5723280266..4222ca5e27 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -1,4 +1,3 @@ -import { deepStrictEqual } from "node:assert"; import { lstat, mkdir, @@ -13,7 +12,6 @@ import { dirname, isAbsolute, join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; -import { PRODUCT_IDENTITY } from "../dist/identity.js"; import { pathExists } from "./lib/verify-package-core.mjs"; import { parse as parseJsonc } from "jsonc-parser"; import { skillReferenceIssues } from "./lib/docs-facts.mjs"; diff --git a/scripts/verify-real-ghidra-dol.mjs b/scripts/verify-real-ghidra-dol.mjs index eaa355351d..d92d6ea452 100644 --- a/scripts/verify-real-ghidra-dol.mjs +++ b/scripts/verify-real-ghidra-dol.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; import { readFile, mkdtemp, mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,7 +12,6 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { readGhidraLanguageCatalog } from "../dist/ghidra/GhidraLanguageCatalog.js"; import { parseEvidence } from "../dist/domain/evidence.js"; -import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildGameCubeDolFixture } from "../tests/conformance/ghidra/dol-fixture.mjs"; @@ -96,8 +94,6 @@ async function languageInstalled(languageId) { } } -const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); - /** One MCP session plus CLI check. `languageOverride` is set only for the Gekko pass. */ async function runPass({ fixture, languageOverride, label, layout }) { const env = { ...baseEnv }; diff --git a/scripts/verify/rea-numeric-limits.mjs b/scripts/verify/rea-numeric-limits.mjs index 8d5e65d59b..60cfdf21af 100644 --- a/scripts/verify/rea-numeric-limits.mjs +++ b/scripts/verify/rea-numeric-limits.mjs @@ -185,7 +185,7 @@ const stringBindings = (sourceFile, symbols, unresolved) => { ) return undefined; if ((declaration.parent.flags & ts.NodeFlags.Const) === 0) { - unknown(identifier, "mutable setting-key or value alias"); + unknown(declaration.name, "mutable setting-key or value alias"); return undefined; } if (declaration.pos >= identifier.pos) { @@ -919,7 +919,7 @@ if (direct) { const entries = parseNumericLimitRegistry(registry); const listed = new Map(); for (const entry of entries) { - const { name, policy, read, reason } = entry; + const { name, read, reason } = entry; if (listed.has(name)) failures.push(`${name} is listed twice`); listed.set(name, entry); if (reason.trim().length < 40) diff --git a/src/adb/AdbCommand.capture.test.ts b/src/adb/AdbCommand.capture.test.ts index 41b87d2cbb..2b2c8a7312 100644 --- a/src/adb/AdbCommand.capture.test.ts +++ b/src/adb/AdbCommand.capture.test.ts @@ -20,6 +20,7 @@ it.each([ source: 'process.stdout.write("partial-雪"); process.stderr.write("waiting\\n"); setInterval(() => {}, 1000)', code: "provider_timeout", + timeoutMs: 500, stdout: "partial-雪", stderr: "waiting\n", }, @@ -28,6 +29,7 @@ it.each([ source: 'process.stdout.write("partial-雪"); process.stderr.write("adb: error: device offline\\n"); process.exitCode = 1', code: "unsupported_target", + expectedPath: "caller-device", stdout: "partial-雪", stderr: "adb: error: device offline\n", }, @@ -36,6 +38,7 @@ it.each([ source: 'process.stdout.write("partial-雪"); process.stderr.write("adb: error: cannot stat \'/host/token file\': Permission denied\\n"); process.exitCode = 1', code: "unsupported_target", + expectedPath: "/host/token file", stdout: "partial-雪", stderr: "adb: error: cannot stat '/host/token file': Permission denied\n", }, @@ -54,7 +57,7 @@ it.each([ }, ["-e", testCase.source], { - timeoutMs: testCase.name === "timeout" ? 500 : 5_000, + timeoutMs: testCase.timeoutMs ?? 5_000, maxOutputBytes: testCase.maxOutputBytes ?? 1024, }, undefined, @@ -75,12 +78,34 @@ it.each([ }, }); if (testCase.code === "unsupported_target") - expect(projected.details?.path).toBe( - testCase.name === "local path refusal" - ? "/host/token file" - : "caller-device", - ); + expect(projected.details?.path).toBe(testCase.expectedPath); + if (testCase.code === "provider_timeout") { + expect(projected.details?.timeout_ms).toBe(testCase.timeoutMs); + expect(projected.details?.limit).toBeUndefined(); + } expect(JSON.stringify(projected)).not.toContain( "ambient-value-must-not-be-persisted", ); }); + +it.each([true, false])( + "projects a truncated adb capture as retryable only for volatile output (%s)", + async (volatileOutput) => { + const result = await runAdbCapture( + volatileOutput ? "read_adb_logcat" : "install_adb_package", + { device: "device" }, + { binary: process.execPath, pathSource: "environment", environment: {} }, + ["-e", 'process.stdout.write("X".repeat(4096))'], + { timeoutMs: 5_000, maxOutputBytes: 1024, volatileOutput }, + undefined, + ); + if (result.ok) throw new Error("Expected a capture allowance refusal"); + const projected = projectAnalysisError(result.error); + expect(projected.retryable).toBe(volatileOutput); + expect(projected).toMatchObject({ + code: "resource_constraint", + details: { reported_limits: { max_output_bytes: 1024 } }, + }); + expect(projected.details?.limit).toBeUndefined(); + }, +); diff --git a/src/adb/AdbCommand.test.ts b/src/adb/AdbCommand.test.ts index 3664389ded..e82b247bd5 100644 --- a/src/adb/AdbCommand.test.ts +++ b/src/adb/AdbCommand.test.ts @@ -1,5 +1,4 @@ import { expect, it, vi } from "vitest"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ExecFileOutputCancelledError, execFileBytes, @@ -212,65 +211,6 @@ it("does not infer cancellation from a foreign ABORT_ERR code", async () => { }); }); -it.each([true, false])( - "projects a truncated adb capture as retryable only for volatile output (%s)", - async (volatileOutput) => { - vi.mocked(execFileBytes).mockRejectedValueOnce( - Object.assign(new Error("too much"), { - code: 1, - stdout: "", - stderr: "", - signal: null, - killed: false, - outputTruncated: true, - }), - ); - const result = await runAdbCapture( - volatileOutput ? "read_adb_logcat" : "install_adb_package", - { device: "device" }, - { binary: "/host/adb", pathSource: "environment", environment: {} }, - volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"], - { timeoutMs: 100, maxOutputBytes: 1024, volatileOutput }, - undefined, - ); - if (result.ok) throw new Error("Expected a capture allowance refusal"); - const projected = projectAnalysisError(result.error); - expect(projected.retryable).toBe(volatileOutput); - expect(projected).toMatchObject({ - code: "resource_constraint", - details: { reported_limits: { max_output_bytes: 1024 } }, - }); - expect(projected.details?.limit).toBeUndefined(); - }, -); - -it("reports the effective fixed adb deadline", async () => { - vi.mocked(execFileBytes).mockRejectedValueOnce( - Object.assign(new Error("deadline"), { - code: "ETIMEDOUT", - stdout: "", - stderr: "", - signal: null, - killed: true, - }), - ); - const result = await runAdbCapture( - "pull_adb_file", - { device: "device" }, - { binary: "/host/adb", pathSource: "environment", environment: {} }, - ["pull", "/remote"], - { timeoutMs: 600_000, maxOutputBytes: 1024 }, - undefined, - ); - if (result.ok) throw new Error("Expected an adb deadline"); - const projected = projectAnalysisError(result.error); - expect(projected).toMatchObject({ - code: "provider_timeout", - details: { timeout_ms: 600_000 }, - }); - expect(projected.details?.limit).toBeUndefined(); -}); - it("keeps a physical failure even when the later abort reason is that same error", async () => { const controller = new AbortController(); const failure = new Error("physical command failure"); diff --git a/src/adb/AdbVersionProbe.test.ts b/src/adb/AdbVersionProbe.test.ts index d45b3bafca..6b1c70c263 100644 --- a/src/adb/AdbVersionProbe.test.ts +++ b/src/adb/AdbVersionProbe.test.ts @@ -47,19 +47,32 @@ it("reports the fixed adb version-probe deadline", async () => { }); it("reports the fixed adb version-output cap", async () => { - vi.mocked(execFileOutput).mockRejectedValueOnce( - Object.assign(new Error("too much"), { - code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", - stdout: "", - stderr: "", - signal: null, - killed: false, - outputTruncated: true, - }), - ); - const failure = await inspectAdbClient(selection).catch( - (cause: unknown) => cause, - ); + let selected: string = selection.binary; + if (process.platform === "win32") { + vi.mocked(execFileOutput).mockRejectedValueOnce( + Object.assign(new Error("too much"), { + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + stdout: "", + stderr: "", + signal: null, + killed: false, + outputTruncated: true, + }), + ); + } else { + const root = await mkdtemp(join(tmpdir(), "rea-adb-version-cap-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + selected = join(root, "adb"); + await writeFile( + selected, + `#!${process.execPath}\nprocess.stdout.write("x".repeat(100_000));\n`, + { mode: 0o755 }, + ); + } + const failure = await inspectAdbClient({ + ...selection, + binary: selected, + }).catch((cause: unknown) => cause); const projected = projectAnalysisError( failure as Parameters[0], ); @@ -74,25 +87,28 @@ it("reports the fixed adb version-output cap", async () => { expect(projected.details?.limit).toBeUndefined(); }); -it("distinguishes execute denial from missing or unreadable version output", async () => { - vi.mocked(execFileOutput).mockRejectedValueOnce( - Object.assign(new Error("denied"), { - code: "EACCES", - spawnFailed: true, - stdout: "", - stderr: "", - signal: null, - killed: false, - }), - ); - await expect(inspectAdbClient(selection)).rejects.toMatchObject({ - code: "binary_not_executable", - diagnostics: { - selected_binary: selection.binary, - filesystem_error: "EACCES", - }, - }); -}); +it.skipIf(process.platform !== "win32")( + "classifies an EACCES execute refusal on Windows", + async () => { + vi.mocked(execFileOutput).mockRejectedValueOnce( + Object.assign(new Error("denied"), { + code: "EACCES", + spawnFailed: true, + stdout: "", + stderr: "", + signal: null, + killed: false, + }), + ); + await expect(inspectAdbClient(selection)).rejects.toMatchObject({ + code: "binary_not_executable", + diagnostics: { + selected_binary: selection.binary, + filesystem_error: "EACCES", + }, + }); + }, +); it("names an EPERM execute refusal separately from missing permission", async () => { vi.mocked(execFileOutput).mockRejectedValueOnce( diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index b17e5a9d3d..3de0a13bbf 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -313,10 +313,10 @@ export class JadxProvider implements AndroidAnalysisPort { : AbortSignal.any([signal, this.#shutdown.signal]), }); this.#inspections.add(pending); - void pending.then( - () => this.#inspections.delete(pending), - () => this.#inspections.delete(pending), - ); + const remove = (): void => { + this.#inspections.delete(pending); + }; + void pending.then(remove, remove); return pending; } diff --git a/src/apktool/ApktoolConfiguration.test.ts b/src/apktool/ApktoolConfiguration.test.ts index b9730251c8..a3c4f49c1b 100644 --- a/src/apktool/ApktoolConfiguration.test.ts +++ b/src/apktool/ApktoolConfiguration.test.ts @@ -1,5 +1,6 @@ import * as commandOwner from "../process/OwnedCommand.js"; import { failedInspectionDescriptor } from "../../tests/fixtures/processInspectionCleanup.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { AnalysisError, primaryAnalysisError, @@ -486,6 +487,7 @@ it("cancels a pending filesystem lookup and owns its eventual rejection", async const pendingStat = new Promise((_resolve, reject) => { rejectLookup = reject; }); + void pendingStat.catch(() => undefined); const observed = new Promise((resolve) => { started = resolve; }); @@ -502,14 +504,20 @@ it("cancels a pending filesystem lookup and owns its eventual rejection", async "win32", controller.signal, ); - await observed; - controller.abort(); - await expect(pending).rejects.toMatchObject({ - _tag: "AnalysisCancelledError", - }); - rejectLookup(new Error("filesystem completed after cancellation")); - await Promise.resolve(); - expect(lookup.stat).toHaveBeenCalledTimes(1); + try { + await awaitAcknowledgement(observed, pending, "apktool filesystem lookup"); + controller.abort(); + await expect(pending).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + rejectLookup(new Error("filesystem completed after cancellation")); + await Promise.resolve(); + expect(lookup.stat).toHaveBeenCalledTimes(1); + } finally { + controller.abort(); + rejectLookup(new Error("fixture cleanup")); + await pending.catch(() => undefined); + } }); it("bounds the entire pending PATH lookup by the selection deadline", async () => { diff --git a/src/application/AnalysisContextQueries.test.ts b/src/application/AnalysisContextQueries.test.ts index f39e884da8..49fd81ec6b 100644 --- a/src/application/AnalysisContextQueries.test.ts +++ b/src/application/AnalysisContextQueries.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { getNavigationContext, @@ -500,7 +501,16 @@ it("waits for late facet cleanup after cancellation and retains its owner", asyn settled = true; return result; }); - await started.promise; + onTestFinished(async () => { + controller.abort(); + late.resolve(err(failure)); + try { + await pending; + } finally { + await lease.release(); + } + }); + await awaitAcknowledgement(started.promise, pending, "facet execution"); controller.abort(); await Promise.resolve(); expect(settled).toBe(false); @@ -530,7 +540,12 @@ it("reports cancellation after a late successful facet that ignored its signal", { address: "0x401000", document: "fixture" }, controller.signal, ); - await started.promise; + onTestFinished(async () => { + controller.abort(); + late.resolve(ok(createAnalysisExecution("finished", provider))); + await pending; + }); + await awaitAcknowledgement(started.promise, pending, "facet execution"); controller.abort(); late.resolve(ok(createAnalysisExecution("finished", provider))); const result = await pending; @@ -586,7 +601,11 @@ it("preserves a provider's cancellation for the same signal", async () => { { address: "0x401000", document: "fixture" }, controller.signal, ); - await started.promise; + onTestFinished(async () => { + controller.abort(reason); + await pending; + }); + await awaitAcknowledgement(started.promise, pending, "provider execution"); controller.abort(reason); const result = await pending; if (result.ok) throw new Error("expected cancellation"); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 26684e8b51..08ccf5126b 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -67,10 +67,7 @@ import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import { profilelessNativeDispatchProfile } from "./binary/ProfilelessNativeWorkflowSnapshot.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; -import type { - AnalysisExecution, - ProviderIdentity, -} from "./AnalysisProvider.js"; +import type { AnalysisExecution } from "./AnalysisProvider.js"; import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js"; import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js"; diff --git a/src/application/GuidanceAccess.test.ts b/src/application/GuidanceAccess.test.ts index cbdf5168a7..17b2c88ce2 100644 --- a/src/application/GuidanceAccess.test.ts +++ b/src/application/GuidanceAccess.test.ts @@ -1,7 +1,6 @@ import { expect, it } from "vitest"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; -import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { MANAGED_SKILL_FILES } from "./SetupSkill.js"; import { searchGuidanceOutputSchema } from "../contracts/guidanceContracts.js"; import { RetainedRegularFileCleanupFailure } from "./RegularFileRead.js"; diff --git a/src/application/McpClientRegistration.test.ts b/src/application/McpClientRegistration.test.ts index 5074fcae42..e916e55c6d 100644 --- a/src/application/McpClientRegistration.test.ts +++ b/src/application/McpClientRegistration.test.ts @@ -12,6 +12,7 @@ import { join, sep } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { ok } from "../domain/result.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; let resources: ArtifactResourceScope; @@ -612,17 +613,22 @@ describe("Registration cancellation and sibling outcomes", () => { entered(); await blocked; }); - await admitted; - const controller = new AbortController(); - const queued = register(path, launcher, controller.signal); - controller.abort("queued cancellation"); - unblock(); - await first; - expect(await queued).toMatchObject({ - ok: false, - error: { _tag: "AnalysisCancelledError" }, - }); - expect(await readFile(path, "utf8")).toBe("{}"); + try { + await awaitAcknowledgement(admitted, first, "registration transaction"); + const controller = new AbortController(); + const queued = register(path, launcher, controller.signal); + controller.abort("queued cancellation"); + unblock(); + await first; + expect(await queued).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(await readFile(path, "utf8")).toBe("{}"); + } finally { + unblock(); + await first; + } }); it("keeps successful siblings after ordinary failures and marks work not started after cancellation", async () => { diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index aac93940c3..54922c686a 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -23,7 +23,6 @@ import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js"; import { DEFAULT_CATALOG_IDENTITY } from "../catalogIdentity.js"; import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js"; import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; -import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Recording setup host for service-level planning and recovery tests. */ export class FakeSetupHost implements SetupHost { diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 70b196b7ed..97566e5f0f 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -31,7 +31,6 @@ import type { } from "../AnalysisProvider.js"; import { AnalysisSnapshotCache, - SNAPSHOT_CACHE_ENTRY_CEILING, isSnapshotCacheable, isSourceEvidenceRetainable, } from "./AnalysisSnapshotCache.js"; @@ -595,7 +594,7 @@ describe("analysis snapshot cache capacity", () => { parameters: { query: "existing" }, execution, }; - for (let index = 0; index < SNAPSHOT_CACHE_ENTRY_CEILING - 1; index += 1) + for (let index = 0; index < 9_999; index += 1) cache.recordWorkflow({ ...workflowInput, parameters: { query: String(index) }, @@ -620,9 +619,7 @@ describe("analysis snapshot cache capacity", () => { }); cache.recordWorkflow(workflowInput); expect(cache.entries()).toHaveLength(1); - expect(cache.workflowEntries()).toHaveLength( - SNAPSHOT_CACHE_ENTRY_CEILING - 1, - ); + expect(cache.workflowEntries()).toHaveLength(9_999); cache.record({ ...directInput, @@ -669,9 +666,7 @@ describe("analysis snapshot cache capacity", () => { ), "a capacity-refused workflow binding is not counted", ).toBe(0); - expect(cache.workflowEntries()).toHaveLength( - SNAPSHOT_CACHE_ENTRY_CEILING - 1, - ); + expect(cache.workflowEntries()).toHaveLength(9_999); const otherTarget = { ...target, sha256: "b".repeat(64) }; cache.recordWorkflow({ diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index e89abf080a..88156bffc1 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -87,7 +87,7 @@ const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet = new Set( ); /** Maximum duplicated result bindings retained across direct and workflow queries. */ -export const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000; +const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000; const replayDocumentSelected = ( operation: AnalysisOperation, diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index e9159e596c..05199df91e 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -1754,22 +1754,16 @@ export class BinarySession this.#transitionPending += 1; // Later calls wait for this transition and must not be included in its drain. const admittedThrough = this.#transitionGeneration++; - const result = this.#transition.then( - () => operation(admittedThrough), - () => operation(admittedThrough), - ); + const perform = (): Promise => operation(admittedThrough); + const result = this.#transition.then(perform, perform); this.#transition = result.then( () => undefined, () => undefined, ); - void result.then( - () => { - this.#transitionPending -= 1; - }, - () => { - this.#transitionPending -= 1; - }, - ); + const finish = (): void => { + this.#transitionPending -= 1; + }; + void result.then(finish, finish); return result; } @@ -1864,32 +1858,19 @@ export class BinarySession ); }; signal.addEventListener("abort", onAbort, { once: true }); - transition.then( - () => { - signal.removeEventListener("abort", onAbort); - resolve( - signal.aborted - ? err( - new AnalysisCancelledError(operation, { - cause: signal.reason, - }), - ) - : ok(undefined), - ); - }, - () => { - signal.removeEventListener("abort", onAbort); - resolve( - signal.aborted - ? err( - new AnalysisCancelledError(operation, { - cause: signal.reason, - }), - ) - : ok(undefined), - ); - }, - ); + const finish = (): void => { + signal.removeEventListener("abort", onAbort); + resolve( + signal.aborted + ? err( + new AnalysisCancelledError(operation, { + cause: signal.reason, + }), + ) + : ok(undefined), + ); + }; + transition.then(finish, finish); }); } } diff --git a/src/application/binaryDiagnostics/AppleIpsService.test.ts b/src/application/binaryDiagnostics/AppleIpsService.test.ts index 1784b7dc70..72829a6442 100644 --- a/src/application/binaryDiagnostics/AppleIpsService.test.ts +++ b/src/application/binaryDiagnostics/AppleIpsService.test.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import { parseAppleIps } from "../../native/apple/AppleIpsParser.js"; +import { inspectAppleIpsInputSchema } from "../../domain/native/appleIps.js"; import { parseEvidence } from "../../domain/evidence.js"; import { expect, it } from "vitest"; import { AppleIpsService } from "./AppleIpsService.js"; @@ -10,52 +13,19 @@ import { } from "../../domain/analysisErrorCore.js"; const identity = APPLE_IPS_PROVIDER; -const report: AppleIps = { - artifact: { - path: "/artifacts/report.ips", - sha256: "a".repeat(64), - bytes: 128, +const bytes = Buffer.from( + '{"bug_type":"309"}\n{"procName":"rea-crash-fixture","threads":[],"usedImages":[]}', +); +const path = "/artifacts/report.ips"; +const report = parseAppleIps( + bytes, + { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, }, - metadata: { - bug_type: "309", - timestamp: null, - os_version: null, - bundle_id: null, - incident_id: null, - name: null, - build_version: null, - platform: null, - unlisted_keys: [], - }, - process: { - name: "rea-crash-fixture", - path: null, - pid: null, - parent: null, - parent_pid: null, - cpu_type: null, - translated: null, - os_version: null, - }, - exception: null, - termination: null, - faulting_thread: null, - threads: [], - images: [], - classification: { status: "unclassified", matches: [] }, - coverage: { - status: "complete", - reason: null, - threads: 0, - frames: 0, - images: 0, - registers: 0, - type_mismatches: [], - }, - unlisted_body_keys: [], - join: null, - limitations: ["Opened Mach-O and dSYM UUID joins remain unobserved."], -}; + inspectAppleIpsInputSchema.parse({ path }), +); it.each([0, 5])( "rejects an unresolved faulting thread %s without its mismatch", diff --git a/src/application/binaryDiagnostics/BinaryLayoutPort.ts b/src/application/binaryDiagnostics/BinaryLayoutPort.ts index 55fe16c8ed..54c9c3066e 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutPort.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutPort.ts @@ -1,11 +1,13 @@ -import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { BinaryLayout, InspectBinaryLayoutInput, } from "../../domain/native/binaryLayout.js"; import type { Result } from "../../domain/result.js"; -import type { ProviderIdentity } from "../AnalysisProvider.js"; /** Replaceable offline binary-layout provider; owns its parser and process protocol. */ export interface BinaryLayoutPort { diff --git a/src/application/binaryDiagnostics/ByteEntropyService.test.ts b/src/application/binaryDiagnostics/ByteEntropyService.test.ts index 0fa02b0660..0d0ed6d371 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.test.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.test.ts @@ -5,10 +5,7 @@ import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { byteEntropySchema } from "../../domain/native/byteEntropy.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; -import { - ByteEntropyService, - MAX_BYTE_ENTROPY_BLOCKS, -} from "./ByteEntropyService.js"; +import { ByteEntropyService } from "./ByteEntropyService.js"; it("keeps whole-file identity and entropy while selecting unaligned blocks and a partial tail", async () => { const root = await createTestTempDirectory("rea-entropy-"); @@ -421,7 +418,7 @@ it("reports a changed artifact when its parent directory moves during streaming" it("refuses a block series above the output-row limit before scanning", async () => { const root = await createTestTempDirectory("rea-entropy-block-limit-"); const path = join(root, "bytes"); - await writeFile(path, Buffer.alloc(MAX_BYTE_ENTROPY_BLOCKS * 256 + 1)); + await writeFile(path, Buffer.alloc(32_768 * 256 + 1)); let reads = 0; const service = new ByteEntropyService({ stat, @@ -443,8 +440,8 @@ it("refuses a block series above the output-row limit before scanning", async () _tag: "AnalysisResourceConstraintError", resource: "capacity", reportedLimits: { - max_blocks: MAX_BYTE_ENTROPY_BLOCKS, - requested_blocks: MAX_BYTE_ENTROPY_BLOCKS + 1, + max_blocks: 32_768, + requested_blocks: 32_769, }, }, }); diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts index ae5cc1f564..55e0fe1c99 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -44,7 +44,7 @@ import { completeProviderEvidence } from "../ProviderOperationExecution.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; /** Bound retained result rows before constructing the Evidence representation. */ -export const MAX_BYTE_ENTROPY_BLOCKS = 32_768; +const MAX_BYTE_ENTROPY_BLOCKS = 32_768; /** Generated files such as Linux /proc entries report size 0 but still hold bytes. */ const ZERO_SIZE_LIMITATION = diff --git a/src/application/binaryDiagnostics/ElfNotesService.test.ts b/src/application/binaryDiagnostics/ElfNotesService.test.ts index f71883719e..a682610ab6 100644 --- a/src/application/binaryDiagnostics/ElfNotesService.test.ts +++ b/src/application/binaryDiagnostics/ElfNotesService.test.ts @@ -2,7 +2,10 @@ import { parseEvidence } from "../../domain/evidence.js"; import { expect, it } from "vitest"; import { ElfNotesService } from "./ElfNotesService.js"; import { ELF_NOTES_PROVIDER } from "../../domain/investigationProviders.js"; -import type { ElfNotes } from "../../domain/native/elfNotes.js"; +import { createHash } from "node:crypto"; +import { inspectElfNotesInputSchema } from "../../domain/native/elfNotes.js"; +import { elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js"; +import { parseElfNotes } from "../../native/elf/ElfNoteParser.js"; import { ok } from "../../domain/result.js"; import { AnalysisCancelledError, @@ -11,22 +14,17 @@ import { } from "../../domain/analysisErrorCore.js"; const identity = ELF_NOTES_PROVIDER; -const report: ElfNotes = { - artifact: { - path: "/artifacts/app", - sha256: "a".repeat(64), - bytes: 512, +const path = "/artifacts/app"; +const bytes = elfNoteFixture({ ownership: "none" }); +const report = parseElfNotes( + bytes, + { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, }, - format: "elf64", - byte_order: "little", - machine: 62, - type: 3, - type_name: "shared", - tables: [], - entries: [], - coverage: { status: "complete", examined_tables: 0, examined_entries: 0 }, - limitations: ["No PT_NOTE program header or SHT_NOTE section was declared."], -}; + inspectElfNotesInputSchema.parse({ path }), +); it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; diff --git a/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts b/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts index 422233a1a4..b3b0cacb45 100644 --- a/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts +++ b/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts @@ -27,57 +27,6 @@ const undecodableHeaderPointer = (): Buffer => { bytes[Number(bytes.readBigUInt64LE(64 + 56 + 8)) + 1] = 0x0f; return bytes; }; -const report: ElfUnwindRanges = { - artifact: { - path: "/artifacts/app", - sha256: "a".repeat(64), - bytes: 512, - }, - format: "elf64", - byte_order: "little", - machine: 62, - type: 3, - type_name: "shared", - eh_frame: { status: "absent", source: null, location: null }, - eh_frame_hdr: { - status: "absent", - location: null, - version: null, - eh_frame_pointer_encoding: null, - fde_count_encoding: null, - table_encoding: null, - eh_frame_pointer: null, - fde_count: null, - table_entries: 0, - }, - debug_frame: { status: "absent", location: null }, - cies: [], - ranges: [], - page: { - offset: 0, - limit: 256, - returned: 0, - total: 0, - next_offset: null, - }, - coverage: { - status: "complete", - total_ranges: 0, - total_cies: 0, - ranges_with_symbols: 0, - ranges_without_symbols: 0, - eh_frame_hdr_agreement: "unavailable", - examined_records: 0, - }, - limitations: [ - "No .eh_frame section or PT_GNU_EH_FRAME segment was declared.", - "No .eh_frame_hdr section or PT_GNU_EH_FRAME segment was declared.", - "CFA instructions are counted but not evaluated.", - ".debug_frame is absent; only .eh_frame was examined.", - "Symbol names, section names, and augmentation strings are target-derived and untrusted.", - ], -}; - const parsed = ( bytes: Buffer, raw: Readonly> = {}, @@ -97,6 +46,8 @@ const parsed = ( ); }; +const report = parsed(elfUnwindAbsentFixture()); + const accept = async ( value: ElfUnwindRanges, raw: Readonly> = {}, diff --git a/src/application/binaryDiagnostics/PeCertificatesService.test.ts b/src/application/binaryDiagnostics/PeCertificatesService.test.ts index fa73b22f77..e1264077a1 100644 --- a/src/application/binaryDiagnostics/PeCertificatesService.test.ts +++ b/src/application/binaryDiagnostics/PeCertificatesService.test.ts @@ -1,7 +1,10 @@ import { parseEvidence } from "../../domain/evidence.js"; import { createHash } from "node:crypto"; import { inspectPeCertificatesInputSchema } from "../../domain/native/peCertificates.js"; -import { peCertificateFixture } from "../../native/pe/PeCertificates.fixture.js"; +import { + peCertificateFixture, + peWithoutCertificateTableFixture, +} from "../../native/pe/PeCertificates.fixture.js"; import { parsePeCertificates } from "../../native/pe/PeCertificateParser.js"; import { expect, it } from "vitest"; import { PeCertificatesService } from "./PeCertificatesService.js"; @@ -15,19 +18,17 @@ import { } from "../../domain/analysisErrorCore.js"; const identity = PE_CERTIFICATES_PROVIDER; -const report: PeCertificates = { - artifact: { - path: "/artifacts/app.exe", - sha256: "a".repeat(64), - bytes: 512, +const path = "/artifacts/app.exe"; +const bytes = peWithoutCertificateTableFixture(); +const report = parsePeCertificates( + bytes, + { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, }, - format: "pe32", - machine: 0x14c, - table: null, - entries: [], - coverage: { status: "complete", examined_entries: 0, certificates: 0 }, - limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."], -}; + inspectPeCertificatesInputSchema.parse({ path }), +); it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; diff --git a/src/application/binaryDiagnostics/PeImportsService.test.ts b/src/application/binaryDiagnostics/PeImportsService.test.ts index c641bf5385..9810201edf 100644 --- a/src/application/binaryDiagnostics/PeImportsService.test.ts +++ b/src/application/binaryDiagnostics/PeImportsService.test.ts @@ -1,9 +1,15 @@ +import { createHash } from "node:crypto"; +import { peWithoutImportTableFixture } from "../../../tests/fixtures/binaryDiagnostics/peImports.js"; +import { parsePeImports } from "../../native/pe/PeImportParser.js"; +import { + inspectPeImportsInputSchema, + peImportsSchema, + type PeImports, +} from "../../domain/native/peImports.js"; import { parseEvidence } from "../../domain/evidence.js"; import { expect, it } from "vitest"; import { PeImportsService } from "./PeImportsService.js"; import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js"; -import type { PeImports } from "../../domain/native/peImports.js"; -import { peImportsSchema } from "../../domain/native/peImports.js"; import { ok } from "../../domain/result.js"; import { AnalysisCancelledError, @@ -12,35 +18,17 @@ import { } from "../../domain/analysisErrorCore.js"; const identity = PE_IMPORTS_PROVIDER; -const report: PeImports = { - artifact: { - path: "/artifacts/app.exe", - sha256: "a".repeat(64), - bytes: 512, +const path = "/artifacts/app.exe"; +const bytes = peWithoutImportTableFixture(); +const report = await parsePeImports( + bytes, + { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, }, - format: "pe32-plus", - machine: 0x8664, - bits: 64, - architecture: "amd64", - import_directory: null, - delay_import_directory: null, - imports: [], - delay_imports: [], - imphash: { - algorithm: "pefile", - md5: null, - reason: "IMAGE_DIRECTORY_ENTRY_IMPORT is absent.", - }, - coverage: { - status: "complete", - reason: null, - examined_dlls: 0, - imports: 0, - delay_imports: 0, - }, - issues: [], - limitations: ["IMAGE_DIRECTORY_ENTRY_IMPORT is absent."], -}; + inspectPeImportsInputSchema.parse({ path }), +); it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; @@ -189,7 +177,7 @@ it("publishes every consumed directory span as its own Evidence location", async size: 40, locations: [ { offset: 0x100, bytes: 20 }, - { offset: 0x1f8, bytes: 20 }, + { offset: report.artifact.bytes - 8, bytes: 20 }, ], }, }), diff --git a/src/application/binaryDiagnostics/PeResourcesService.ts b/src/application/binaryDiagnostics/PeResourcesService.ts index c0e331519c..e559ee9a50 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.ts @@ -11,7 +11,7 @@ import { type PeResourceIdentity, } from "../../domain/native/peResources.js"; import { peSubjectArchitecture } from "../../domain/peMachine.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; diff --git a/src/application/binaryDiagnostics/WindowsShortcutService.test.ts b/src/application/binaryDiagnostics/WindowsShortcutService.test.ts index 4aa8be43bd..ba9b98b000 100644 --- a/src/application/binaryDiagnostics/WindowsShortcutService.test.ts +++ b/src/application/binaryDiagnostics/WindowsShortcutService.test.ts @@ -2,7 +2,9 @@ import { parseEvidence } from "../../domain/evidence.js"; import { expect, it } from "vitest"; import { WindowsShortcutService } from "./WindowsShortcutService.js"; import { WINDOWS_SHORTCUT_PROVIDER } from "../../domain/investigationProviders.js"; -import type { WindowsShortcut } from "../../domain/native/windowsShortcut.js"; +import { createHash } from "node:crypto"; +import { inspectWindowsShortcutInputSchema } from "../../domain/native/windowsShortcut.js"; +import { parseWindowsShortcut } from "../../native/windows/WindowsShortcutParser.js"; import { ok } from "../../domain/result.js"; import { AnalysisCancelledError, @@ -12,64 +14,20 @@ import { const identity = WINDOWS_SHORTCUT_PROVIDER; -const report: WindowsShortcut = { - artifact: { - path: "/artifacts/shortcut.lnk", - sha256: "a".repeat(64), - bytes: 128, +const path = "/artifacts/shortcut.lnk"; +const bytes = Buffer.alloc(80); +bytes.writeUInt32LE(76, 0); +Buffer.from("0114020000000000c000000000000046", "hex").copy(bytes, 4); +bytes.writeUInt32LE(1, 60); +const report = await parseWindowsShortcut( + bytes, + { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, }, - header: { - location: { offset: 0, bytes: 76 }, - header_size: 76, - clsid: "00021401-0000-0000-C000-000000000046", - link_flags: { raw: 0, decoded: [] }, - file_attributes: { raw: 0, decoded: [] }, - creation_time: { raw: "0", iso8601: null }, - access_time: { raw: "0", iso8601: null }, - write_time: { raw: "0", iso8601: null }, - file_size: 0, - icon_index: 0, - show_command: { raw: 1, decoded: "normal" }, - hotkey: { - raw: 0, - modifiers: [], - virtual_key: null, - virtual_key_name: null, - }, - }, - id_list: null, - link_info: null, - string_data: { - name: null, - relative_path: null, - working_dir: null, - arguments: null, - icon_location: null, - }, - extra_data: { blocks: [], terminal_offset: null }, - observations: [ - { kind: "arguments_present", evidence_kind: "derived", value: false }, - { kind: "argument_length", evidence_kind: "derived", characters: 0 }, - { kind: "target_extension", evidence_kind: "derived", extension: null }, - { kind: "show_command_minimized", evidence_kind: "derived", value: false }, - { - kind: "icon_location_differs_from_target", - evidence_kind: "derived", - value: null, - }, - ], - coverage: { - status: "complete", - reason: null, - examined_id_list_items: 0, - examined_extra_data_blocks: 0, - failing_offset: null, - }, - issues: [], - limitations: [ - "Rows reproduce the selected Shell Link bytes; they do not resolve, open, or execute the link target.", - ], -}; + inspectWindowsShortcutInputSchema.parse({ path }), +); it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; diff --git a/src/application/desktop/DesktopBuildExport.ts b/src/application/desktop/DesktopBuildExport.ts index 5975916209..78f025a10c 100644 --- a/src/application/desktop/DesktopBuildExport.ts +++ b/src/application/desktop/DesktopBuildExport.ts @@ -7,7 +7,6 @@ import { READER_RUNNER_SLACK_S, readerTimeout, } from "../../domain/desktop/appTaskReaders.js"; -import { BUILD_REA_TIMEOUT_S } from "../../desktop/BuildComputerController.js"; import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; @@ -32,6 +31,7 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js"; import { + BUILD_REA_TIMEOUT_S, fetchHomeFolder, type ControlledComputer, } from "../../desktop/BuildComputerController.js"; diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts index 7a23c862fb..5862b903a3 100644 --- a/src/application/electron/ElectronFuseService.ts +++ b/src/application/electron/ElectronFuseService.ts @@ -8,7 +8,7 @@ import { } from "../../domain/electron/electronFuses.js"; import type { Evidence } from "../../domain/evidence.js"; import { createElectronFuseEvidence } from "../../domain/electron/electronFuseEvidence.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts index 29f21d8065..2bf05f09ad 100644 --- a/src/application/environment/environmentBuild.ts +++ b/src/application/environment/environmentBuild.ts @@ -14,7 +14,6 @@ import { TEMPLATE_RULES, type TemplateRule, } from "../../domain/analysisView/environmentManifestRules.js"; -import type { Evidence } from "../../domain/evidence.js"; import type { EnvironmentBuildReport } from "../../domain/environmentBundleExport.js"; import { err, ok, type Result } from "../../domain/result.js"; import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; diff --git a/src/application/environment/environmentBuildReport.ts b/src/application/environment/environmentBuildReport.ts index c183968231..4ea6ec4054 100644 --- a/src/application/environment/environmentBuildReport.ts +++ b/src/application/environment/environmentBuildReport.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import { z } from "zod"; import { basename } from "node:path"; import { isIP } from "node:net"; @@ -83,9 +84,6 @@ const SCAN_BODY_LIMIT_BYTES = 4 * 1024 * 1024; /** Pointers the report lists; the count covers every match. */ const REPORTED_POINTERS = 20; -const pointerToken = (name: string): string => - name.replaceAll("~", "~0").replaceAll("/", "~1"); - /** A held value: a non-empty string, or an array holding one. */ const holdsValue = (value: unknown): boolean => (typeof value === "string" && value !== "") || @@ -198,7 +196,7 @@ const credentialTally = () => { const entries = Object.entries(value); for (let index = entries.length - 1; index >= 0; index--) { const [name, child] = entries[index]!; - const path = `${pointer}/${pointerToken(name)}`; + const path = `${pointer}/${escapeJsonPointerToken(name)}`; field(`${label} ${path}`, name, child); stack.push([child, path]); } @@ -209,7 +207,7 @@ const credentialTally = () => { else if (scanned?.kind === "json") walk(scanned.value, label); else if (scanned?.kind === "form") for (const [name, value] of scanned.fields) - field(`${label} /${pointerToken(name)}`, name, value); + field(`${label} /${escapeJsonPointerToken(name)}`, name, value); }; return { field, diff --git a/src/application/environment/environmentCompareDiff.ts b/src/application/environment/environmentCompareDiff.ts index 020db4ced3..a596f133eb 100644 --- a/src/application/environment/environmentCompareDiff.ts +++ b/src/application/environment/environmentCompareDiff.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js"; import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js"; import { matchesClockFormat } from "../../domain/analysisView/environmentStateWriteClockFormats.js"; @@ -74,9 +75,6 @@ const shown = (value: JsonValue | undefined): string => { return text.length > VALUE_CHARS ? `${text.slice(0, VALUE_CHARS)}…` : text; }; -const escapePointer = (key: string): string => - key.replaceAll("~", "~0").replaceAll("/", "~1"); - const formatKey = (format: ClockFormat): string => format.kind === "epoch" ? `epoch-${format.unit}` @@ -427,7 +425,7 @@ class Differ { ? { agreed: new Set(), fields: new Map() } : undefined; for (const [key, value] of Object.entries(live)) { - const child = `${pointer}/${escapePointer(key)}`; + const child = `${pointer}/${escapeJsonPointerToken(key)}`; const other = envKey(key); // A translated key and a verbatim key may name the same entry; an // environment member can account for only one live member. @@ -460,7 +458,7 @@ class Differ { ? undefined : { ...generation, - pointer: `${generation.pointer}/${escapePointer(key)}`, + pointer: `${generation.pointer}/${escapeJsonPointerToken(key)}`, }, ); if (this.differences.length === before) composed?.agreed.add(key); @@ -489,7 +487,7 @@ class Differ { } else this.add({ category: "value", - pointer: `${pointer}/${escapePointer(rule.field)}`, + pointer: `${pointer}/${escapeJsonPointerToken(rule.field)}`, live: shown(live[rule.field]), env: shown(env[other]), }); @@ -499,7 +497,7 @@ class Differ { if (!matched.has(key)) this.add({ category: "extra-field", - pointer: `${pointer}/${escapePointer(key)}`, + pointer: `${pointer}/${escapeJsonPointerToken(key)}`, env: shown(value), }); } diff --git a/src/application/environment/environmentCompareIds.ts b/src/application/environment/environmentCompareIds.ts index ecab1767ac..8862fdb512 100644 --- a/src/application/environment/environmentCompareIds.ts +++ b/src/application/environment/environmentCompareIds.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js"; import type { BodyAddress } from "../../domain/analysisView/environmentStateWriteRules.js"; import { isUuid } from "../../domain/analysisView/environmentStateWriteValues.js"; @@ -315,7 +316,7 @@ const idPair = ( : undefined; const childPointer = (at: string, key: string | number): string => - `${at}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`; + `${at}/${escapeJsonPointerToken(String(key))}`; const learnAt = ( live: JsonValue | undefined, diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 1e235795a3..8c1b1b6f68 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -1,3 +1,4 @@ +import { jsonPointerTokens } from "../../domain/jsonPointer.js"; import { createdFields, documentBodyProblem, @@ -53,7 +54,6 @@ import { collectionMatch, decodedSegment, isObject, - pointerTokens, scalarText, type JsonObject, type StateEntity, @@ -435,7 +435,7 @@ export const writeAnswer = ( // A member the call sent and the plan stores as sent is answered as sent; any other keeps the recorded answer. unloaded: (plan.observation?.read?.unloaded ?? []).filter( (path) => { - const [field] = pointerTokens(path); + const [field] = jsonPointerTokens(path); return !( rendered.sent?.includes(field!) === true && plan.rules.some( diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index 11691c479f..41f61f1841 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -1,3 +1,4 @@ +import { jsonPointerTokens } from "../../domain/jsonPointer.js"; import { ownMember, isObject, @@ -5,7 +6,6 @@ import { isSafeHttpMethod, listItems, locateList, - pointerTokens, type JsonObject, } from "../../domain/analysisView/environmentStateRules.js"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; @@ -170,7 +170,7 @@ const describeJson = (value: JsonValue): string => { const pointer = locateList(value, undefined); const items = pointer === undefined ? [] : (listItems(value, pointer) ?? []); if (pointer !== undefined && items.length > 0) { - const [member] = pointerTokens(pointer); + const [member] = jsonPointerTokens(pointer); return member === undefined ? `a JSON array of objects ${withFields(items)}` : `a JSON object whose ${describeText(member, "member", "capture Evidence", JSON.stringify)} lists objects ${withFields(items)}`; diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index fad0217392..d7cee78469 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import type { AnalysisSettingsReader } from "../../domain/analysisSettings.js"; import { ASAR_HEADER_ADMISSION_SETTING, @@ -145,7 +146,8 @@ export const analyzeJavaScriptApplicationValidated = async ( }); assertJavaScriptAnalysisActive(options.signal); return ok(evidence); - } catch (cause: unknown) { + } catch (caught: unknown) { + let cause = caught; if ( cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" && @@ -246,13 +248,10 @@ const describeResultSchemaFailure = (cause: z.ZodError): string => { const pointer = path.length === 0 ? "/" - : `/${path.map((part) => escapePointerPart(String(part))).join("/")}`; + : `/${path.map((part) => escapeJsonPointerToken(String(part))).join("/")}`; return `Result schema rejected ${String(cause.issues.length)} ${cause.issues.length === 1 ? "issue" : "issues"} at ${pointer} (${issue.code})`; }; -const escapePointerPart = (part: string): string => - part.replaceAll("~", "~0").replaceAll("/", "~1"); - const FILESYSTEM_ERROR_CODES = new Set([ "ENOENT", "EACCES", diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 21e68c15e1..ba833987be 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -214,8 +214,11 @@ export const reconstructJavaScriptArtifact = async ( ).length, { scope }, ); - let outcome; - let cleanupFailure: AnalysisError | undefined; + let outcome!: Awaited< + ReturnType + >; + let analysisFailure: { readonly cause: unknown } | undefined; + let cleanupAttempt: Awaited>; try { outcome = await analyzeAndAdoptJavaScriptArtifactFiles( files, @@ -250,18 +253,28 @@ export const reconstructJavaScriptArtifact = async ( preloads.paths, preloads.limitations, ); + } catch (cause: unknown) { + analysisFailure = { cause }; } finally { - try { - const cleanup = await scope.release({ - kind: "javascript-source-analysis", - analysis: worker, - resource: `JavaScript source analyzer for ${path}`, - }); - if (cleanup.kind === "failed") throw cleanup.cause; - } catch (cause: unknown) { - if (!(cause instanceof AnalysisError)) throw cause; - cleanupFailure = cause; - } + cleanupAttempt = await scope.release({ + kind: "javascript-source-analysis", + analysis: worker, + resource: `JavaScript source analyzer for ${path}`, + }); + } + if (analysisFailure !== undefined) { + if (cleanupAttempt.kind === "failed") + throw scope.cleanupFailure( + { kind: "failed", cause: analysisFailure.cause }, + [cleanupAttempt], + ); + throw analysisFailure.cause; + } + let cleanupFailure: AnalysisError | undefined; + if (cleanupAttempt.kind === "failed") { + if (!(cleanupAttempt.cause instanceof AnalysisError)) + throw cleanupAttempt.cause; + cleanupFailure = cleanupAttempt.cause; } const analysis = outcome.analysis; let interruption = outcome.interruption; diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts index 6e92841437..6227f907da 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts @@ -1,10 +1,7 @@ import { expect, it } from "vitest"; import { BrowserObservationError } from "../../domain/browserObservationError.js"; -import { - AnalysisCancelledError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; import { parseEvidence } from "../../domain/evidence.js"; import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js"; diff --git a/src/application/ntfs/NtfsJournalPaging.test.ts b/src/application/ntfs/NtfsJournalPaging.test.ts index 6981e6dda0..e377937f30 100644 --- a/src/application/ntfs/NtfsJournalPaging.test.ts +++ b/src/application/ntfs/NtfsJournalPaging.test.ts @@ -16,6 +16,7 @@ import { parseEvidence } from "../../domain/evidence.js"; import { ntfsJournalResultSchema } from "../../domain/windows/ntfsJournalQuery.js"; import { inspectNtfsJournalEvidence } from "./NtfsJournalService.js"; import { createDeferred } from "../../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; const acquisitions = async () => { @@ -170,7 +171,11 @@ it("keeps an active acquisition independent of a cancelled peer, bounds concurre }); try { const active = inspectNtfsJournalEvidence(fixture.input, resources); - await entered.promise; + await awaitAcknowledgement( + entered.promise, + active, + "provider lifecycle boundary", + ); const cancelled = new AbortController(); cancelled.abort("peer cancelled"); const peer = await inspectNtfsJournalEvidence( diff --git a/src/artifacts/ArtifactFileSource.test.ts b/src/artifacts/ArtifactFileSource.test.ts index 2850354ea9..d472c5b32e 100644 --- a/src/artifacts/ArtifactFileSource.test.ts +++ b/src/artifacts/ArtifactFileSource.test.ts @@ -2,6 +2,7 @@ import { open, writeFile, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; it("retries an admission cancelled after opening and closing the selected descriptor", async () => { @@ -132,7 +133,7 @@ it("lets another caller continue when shared admission is cancelled", async () = }, (cause: unknown) => cause, ); - await started; + await awaitAcknowledgement(started, first, "artifact source admission"); const second = source.read(); await Promise.resolve(); controller.abort(reason); diff --git a/src/artifacts/ArtifactOperationFailure.test.ts b/src/artifacts/ArtifactOperationFailure.test.ts index d05af1481c..0834c54523 100644 --- a/src/artifacts/ArtifactOperationFailure.test.ts +++ b/src/artifacts/ArtifactOperationFailure.test.ts @@ -5,6 +5,17 @@ import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js"; import { constants as bufferConstants } from "node:buffer"; +import { imageFileSizeRemediation } from "./StableArtifactFailure.js"; + +// The provider ceiling test uses a sparse file and is skipped on Windows. +it.skipIf(process.platform !== "win32")( + "names the fixed image ceiling when the selected budget is already maximal", + () => { + expect(imageFileSizeRemediation(1024, 1024)).toContain( + "no runtime override", + ); + }, +); it.each([ { effective: 1, required: bufferConstants.MAX_STRING_LENGTH + 1 }, @@ -94,6 +105,18 @@ it.each(["capacity", "file-size", "memory"] as const)( message: failure.message, details: { boundary, detail: failure.message }, }); + const wrapped = ArtifactReaderFailure.withCleanup(failure, { + reason: "close failed", + resources: ["handle"], + }); + expect( + projectAnalysisError( + translateArtifactFailure("inventory_artifact", wrapped), + ), + ).toMatchObject({ + code: "cleanup_incomplete", + details: { boundary, cleanup: "incomplete", resources: ["handle"] }, + }); }, ); diff --git a/src/artifacts/ArtifactResourceScope.test.ts b/src/artifacts/ArtifactResourceScope.test.ts index 821c91cafb..5f6678c2b6 100644 --- a/src/artifacts/ArtifactResourceScope.test.ts +++ b/src/artifacts/ArtifactResourceScope.test.ts @@ -1,6 +1,7 @@ import { open, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { withArtifactFileHandle, bindArtifactFileCleanup, @@ -36,25 +37,33 @@ it("waits for admitted operations and shares concurrent close", async () => { started.resolve(); await finish.promise; }); - await started.promise; + try { + await awaitAcknowledgement(started.promise, operation, "scope admission"); + const close = scope.close(); + expect(scope.close()).toBe(close); + await expect(scope.run(async () => undefined)).rejects.toMatchObject({ + reason: "unavailable", + message: expect.stringContaining("closed"), + }); + let closed = false; + void close.then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); - const close = scope.close(); - expect(scope.close()).toBe(close); - await expect(scope.run(async () => undefined)).rejects.toMatchObject({ - reason: "unavailable", - message: expect.stringContaining("closed"), - }); - let closed = false; - void close.then(() => { - closed = true; - }); - await Promise.resolve(); - expect(closed).toBe(false); - - finish.resolve(); - await operation; - await close; - expect(closed).toBe(true); + finish.resolve(); + await operation; + await close; + expect(closed).toBe(true); + } finally { + finish.resolve(); + try { + await operation; + } finally { + await scope.close(); + } + } }); it("stops a close hook even when retained cleanup is still failing", async () => { diff --git a/src/artifacts/HexRecordArtifactReader.test.ts b/src/artifacts/HexRecordArtifactReader.test.ts index 8d1136ea07..de2202b077 100644 --- a/src/artifacts/HexRecordArtifactReader.test.ts +++ b/src/artifacts/HexRecordArtifactReader.test.ts @@ -89,7 +89,13 @@ it("fails closed on over-limit records", async () => { scanCanonicalArtifactInventory( await writeImage("runs.hex", `${runs.join("\n")}\n`), ), - ).rejects.toMatchObject({ reason: "limit", boundary: "capacity" }); + ).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + message: expect.stringContaining( + "more than 65536 address-discontiguous record runs", + ), + }); }); it("leaves an invalid first record to plain file classification", async () => { diff --git a/src/artifacts/MachOSliceArtifactReader.test.ts b/src/artifacts/MachOSliceArtifactReader.test.ts index 43eea98dd8..9852872836 100644 --- a/src/artifacts/MachOSliceArtifactReader.test.ts +++ b/src/artifacts/MachOSliceArtifactReader.test.ts @@ -1,5 +1,6 @@ import { EventEmitter, once } from "node:events"; import { PassThrough } from "node:stream"; +import { buffer } from "node:stream/consumers"; import { truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -14,6 +15,7 @@ import { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import type { NativeCommandRunner } from "../native/CommandRunner.js"; import { NativeCommandFailure } from "../native/CommandRunner.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import type { ArtifactEntry } from "./ArtifactReader.js"; @@ -157,56 +159,60 @@ describe("Mach-O command cleanup lifecycle", () => { const reader = new MachOSliceArtifactReader(binary, {}, runner); const iteration = reader.entries(); const firstEntry = iteration[Symbol.asyncIterator]().next(); - await started; - const closing = reader.close(); - await Promise.resolve(); - expect(cleanupRetries).toBe(0); - finishCommand( - err( - new NativeCommandFailure( - "lipo", - "timeout", - null, - undefined, - { - stdout: "partial lipo output", - stderr: "partial lipo diagnostic", - stdoutBytes: 19, - stderrBytes: 23, - exitCode: null, - signal: null, - truncated: false, - }, - "capture process group remains unverified", - ["capture-group:9999"], - ), + const commandResult = err( + new NativeCommandFailure( + "lipo", + "timeout", + null, + undefined, + { + stdout: "partial lipo output", + stderr: "partial lipo diagnostic", + stdoutBytes: 19, + stderrBytes: 23, + exitCode: null, + signal: null, + truncated: false, + }, + "capture process group remains unverified", + ["capture-group:9999"], ), ); - - await expect(firstEntry).rejects.toMatchObject({ - reason: "unavailable", - cause: expect.objectContaining({ - cleanupFailure: "capture process group remains unverified", - }), - cleanup: { - reason: "capture process group remains unverified", - resources: ["capture-group:9999"], - }, - }); - await expect(closing).rejects.toMatchObject({ - cleanup: { - reason: - "run:first, process-group:1234: first process group remains live; run:second, process-group:5678: second process group remains live", - resources: [ - "run:first", - "process-group:1234", - "run:second", - "process-group:5678", - ], - }, - }); - await reader.close(); - expect(cleanupRetries).toBe(2); + try { + await awaitAcknowledgement(started, firstEntry, "lipo capture start"); + const closing = reader.close(); + await Promise.resolve(); + expect(cleanupRetries).toBe(0); + finishCommand(commandResult); + await expect(firstEntry).rejects.toMatchObject({ + reason: "unavailable", + cause: expect.objectContaining({ + cleanupFailure: "capture process group remains unverified", + }), + cleanup: { + reason: "capture process group remains unverified", + resources: ["capture-group:9999"], + }, + }); + await expect(closing).rejects.toMatchObject({ + cleanup: { + reason: + "run:first, process-group:1234: first process group remains live; run:second, process-group:5678: second process group remains live", + resources: [ + "run:first", + "process-group:1234", + "run:second", + "process-group:5678", + ], + }, + }); + await reader.close(); + expect(cleanupRetries).toBe(2); + } finally { + finishCommand(commandResult); + await firstEntry.catch(() => undefined); + await reader.close().catch(() => undefined); + } }); }); @@ -633,6 +639,7 @@ describe("Mach-O slice stream ownership", () => { const release = createDeferred(); const nativeRead = source.handle.read.bind(source.handle); let reads: { mockRestore(): void } | undefined; + let consumed: Promise | undefined; try { const [entry] = await sliceEntries(reader); reads = vi.spyOn(source.handle, "read").mockImplementation((async ( @@ -643,8 +650,9 @@ describe("Mach-O slice stream ownership", () => { return Reflect.apply(nativeRead, source.handle, args); }) as typeof source.handle.read); const stream = await reader.open(entry!); - stream.read(0); - await reached.promise; + consumed = buffer(stream); + void consumed.catch(() => undefined); + await awaitAcknowledgement(reached.promise, consumed, "slice read"); let closed = false; const closing = reader.close().then(() => { closed = true; @@ -656,9 +664,13 @@ describe("Mach-O slice stream ownership", () => { expect(stream.closed).toBe(true); } finally { release.resolve(); - await reader.close(); - reads?.mockRestore(); - await source.handle.close(); + try { + await reader.close(); + } finally { + await consumed?.catch(() => undefined); + reads?.mockRestore(); + await source.handle.close(); + } } }); @@ -729,7 +741,7 @@ it("joins Mach-O structural enumeration reads admitted before close", async () = const traversal = reader.entries()[Symbol.asyncIterator](); const next = traversal.next(); try { - await reached.promise; + await awaitAcknowledgement(reached.promise, next, "slice enumeration read"); let closed = false; const closing = reader.close().then(() => { closed = true; diff --git a/src/artifacts/NtfsMftArtifactReader.test.ts b/src/artifacts/NtfsMftArtifactReader.test.ts index 6dc2438c90..5f7be93dba 100644 --- a/src/artifacts/NtfsMftArtifactReader.test.ts +++ b/src/artifacts/NtfsMftArtifactReader.test.ts @@ -7,8 +7,11 @@ import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { NtfsMftArtifactReader } from "./NtfsMftArtifactReader.js"; -import { MFT_DEFAULT_RECORD_BYTES } from "../domain/windows/ntfsMft.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; + +// The constructed FILE records intentionally use the 1024-byte default. +const MFT_DEFAULT_RECORD_BYTES = 1024; import { linkedMftFixture } from "../../tests/fixtures/ntfsMftExtension.js"; import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js"; @@ -212,7 +215,7 @@ it("retries a shared preparation cancelled by another traversal", async () => { const cancelledResult = expect(cancelled).rejects.toMatchObject({ reason: "cancelled", }); - await started; + await awaitAcknowledgement(started, cancelled, "NTFS traversal read"); const other = reader.entries()[Symbol.asyncIterator]().next(); controller.abort("cancelled first traversal"); await cancelledResult; @@ -319,7 +322,7 @@ it("does not finish a traversal when close begins during final source verificati const rejected = expect(traversal).rejects.toMatchObject({ reason: "unavailable", }); - await started; + await awaitAcknowledgement(started, traversal, "NTFS close read"); const closing = reader.close(); resume(); await rejected; diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index e89c00e080..14b5a1a0ae 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -201,6 +201,8 @@ vi.mock("node:fs/promises", async () => { const PLANTED_SOURCE = "release-exposure-planted-secret-7c2e9a"; const PLANTED_KEY = "AKIAPLANT9EXPOSUREXX"; const ignoreText = (): void => undefined; +// Keep the marker separate so Vite does not treat fixture text as this file's source map. +const INLINE_MAP_PREFIX = "# sourceMappingURL=data:application/json;base64,"; describe("ReleaseExposureReader review regressions", () => { it("keeps inline CSS source-map coverage unknown in files and packages", async () => { @@ -2067,7 +2069,7 @@ const plistObject = (type: number, length: number, body: Buffer): Buffer => describe("ReleaseExposureReader text encodings", () => { const marker = "-----BEGIN PRIVATE KEY-----\n"; - const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from( + const inlineMap = `//${INLINE_MAP_PREFIX}${Buffer.from( JSON.stringify({ version: 3, sources: ["src/app.ts"], @@ -4328,7 +4330,7 @@ describe("ReleaseExposureReader script roles over a .map name", () => { }); describe("ReleaseExposureReader HTML script references", () => { - const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from( + const inlineMap = `//${INLINE_MAP_PREFIX}${Buffer.from( JSON.stringify({ version: 3, sources: ["src/app.ts"], @@ -4620,7 +4622,7 @@ describe("ReleaseExposureReader cumulative text allowance", () => { describe("ReleaseExposureReader unfollowed map links", () => { const SCRIPT = "const value = 1;\n//# sourceMappingURL=app.js.map\n"; - const EMBEDDED_PAYLOAD = `const value = 1;\n//# sourceMappingURL=data:application/json;base64,${Buffer.from( + const EMBEDDED_PAYLOAD = `const value = 1;\n//${INLINE_MAP_PREFIX}${Buffer.from( JSON.stringify({ version: 3, sources: ["a.ts"], diff --git a/src/artifacts/StableArtifactFailure.ts b/src/artifacts/StableArtifactFailure.ts index 17bd02e64f..a5b9615443 100644 --- a/src/artifacts/StableArtifactFailure.ts +++ b/src/artifacts/StableArtifactFailure.ts @@ -5,6 +5,44 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js"; import { completedStableArtifactObservation } from "./StableArtifactObservation.js"; +import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; +import { + AnalysisAccessDeniedError, + AnalysisInputError, +} from "../domain/analysisErrorCore.js"; + +/** Project filesystem errors that identify a refused or unreadable selected file. */ +export const projectSelectedArtifactFileFailure = ( + cause: unknown, + operation: string, + path: string, + description: string, + field = "path", +): AnalysisError | undefined => { + if (!(cause instanceof Error) || !("code" in cause)) return undefined; + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(operation, { cause }, [ + { + path: [field], + reason: "invalid_value", + message: `Selected ${description} could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + return undefined; +}; + +/** Explain the selected image read budget and its fixed upper bound. */ +export const imageFileSizeRemediation = ( + selectedBytes: number, + maximumBytes: number, +): string => + selectedBytes >= maximumBytes + ? `The image exceeds the ${String(maximumBytes)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` + : `Raise max_file_bytes above ${String(selectedBytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; /** Name an observed regular-file retry that admission will validate again. */ export const regularFileSymlinkRecovery = (resolvedPath: string): string => diff --git a/src/artifacts/UImageArtifactReader.boundaries.test.ts b/src/artifacts/UImageArtifactReader.boundaries.test.ts index 5e594c94d1..3248cb380d 100644 --- a/src/artifacts/UImageArtifactReader.boundaries.test.ts +++ b/src/artifacts/UImageArtifactReader.boundaries.test.ts @@ -103,9 +103,12 @@ it("preserves cancellation during payload CRC admission", async () => { ), ).toBe(true); } finally { - await reader.close(); - reads.mockRestore(); - await admitted.handle.close(); + try { + await reader.close(); + } finally { + reads.mockRestore(); + await admitted.handle.close(); + } } }); @@ -149,9 +152,12 @@ it("admits a multi-image count before scanning the remaining payload", async () results.reduce((sum, result) => sum + result.bytesRead, 0), ).toBeLessThanOrEqual(UIMAGE_HEADER_SIZE + UIMAGE_MULTI_TABLE_WINDOW); } finally { - await reader.close(); - reads.mockRestore(); - await admitted.handle.close(); + try { + await reader.close(); + } finally { + reads.mockRestore(); + await admitted.handle.close(); + } } }); diff --git a/src/artifacts/UnityFsArtifactReader.boundaries.test.ts b/src/artifacts/UnityFsArtifactReader.boundaries.test.ts index 0268cb4128..047b915783 100644 --- a/src/artifacts/UnityFsArtifactReader.boundaries.test.ts +++ b/src/artifacts/UnityFsArtifactReader.boundaries.test.ts @@ -7,6 +7,7 @@ import { setImmediate } from "node:timers/promises"; import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { buildUnityFs } from "../../tests/fixtures/unityfs.js"; import { openRegularFile } from "../filesystem/RegularFile.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; @@ -185,9 +186,12 @@ describe("UnityFS allocation and malformed-input boundaries", () => { }); expect(reads).toHaveBeenCalledTimes(1); // bounded header only } finally { - await reader.close(); - reads.mockRestore(); - await admitted.handle.close(); + try { + await reader.close(); + } finally { + reads.mockRestore(); + await admitted.handle.close(); + } } }, ); @@ -223,6 +227,19 @@ describe("UnityFS allocation and malformed-input boundaries", () => { size, data: index === 0 ? Buffer.alloc(size, 0x61) : Buffer.alloc(0), })); + const control = new UnityFsArtifactReader( + await fixture( + buildUnityFs(members.slice(0, 256), { + blockSize: size, + encodeBlock: repeatedBlock, + }), + ), + ); + try { + expect(await entries(control)).toHaveLength(256); + } finally { + await control.close(); + } const reader = new UnityFsArtifactReader( await fixture( buildUnityFs(members, { @@ -232,7 +249,9 @@ describe("UnityFS allocation and malformed-input boundaries", () => { ), ); try { - await expect(entries(reader)).rejects.toMatchObject({ reason: "limit" }); + await expect(entries(reader)).rejects.toMatchObject({ + reason: "limit", + }); } finally { await reader.close(); } @@ -354,7 +373,7 @@ describe("UnityFS stream ownership and immutable cache", () => { let consumed: Promise | undefined; try { consumed = buffer(await reader.open(entry!, controller.signal)); - await reached.promise; + await awaitAcknowledgement(reached.promise, consumed, "member read"); controller.abort(new Error("caller cancelled")); release.resolve(); await expect(consumed).rejects.toBe(failure); @@ -405,10 +424,12 @@ describe("UnityFS stream ownership and immutable cache", () => { await release.promise; return Reflect.apply(nativeRead, admitted.handle, args); }) as typeof admitted.handle.read); + let consumed: Promise | undefined; try { const stream = await reader.open(entry!); - stream.read(0); - await reached.promise; + consumed = buffer(stream); + void consumed.catch(() => undefined); + await awaitAcknowledgement(reached.promise, consumed, "member read"); let closed = false; const closing = reader.close().then(() => { closed = true; @@ -420,9 +441,13 @@ describe("UnityFS stream ownership and immutable cache", () => { expect(stream.closed).toBe(true); } finally { release.resolve(); - await reader.close(); - reads.mockRestore(); - await admitted.handle.close(); + try { + await reader.close(); + } finally { + await consumed?.catch(() => undefined); + reads.mockRestore(); + await admitted.handle.close(); + } } }); @@ -454,9 +479,12 @@ describe("UnityFS stream ownership and immutable cache", () => { buffer(await reader.open(entry!, controller.signal)), ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); } finally { - await reader.close(); - reads.mockRestore(); - await admitted.handle.close(); + try { + await reader.close(); + } finally { + reads.mockRestore(); + await admitted.handle.close(); + } } }); }); @@ -474,12 +502,19 @@ describe("UnityFS stream capacity and cache ownership", () => { await fixture(buildUnityFs(members, { blockSize: 1 })), ); try { + let completed = 0; await expect( (async () => { - for (const entry of await entries(reader)) + for (const entry of await entries(reader)) { await buffer(await reader.open(entry)); + completed += 1; + } })(), - ).rejects.toMatchObject({ reason: "limit", boundary: "capacity" }); + ).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + }); + expect(completed).toBe(128); } finally { await reader.close(); } diff --git a/src/artifacts/WxapkgArtifactReader.boundaries.test.ts b/src/artifacts/WxapkgArtifactReader.boundaries.test.ts index b580f0469a..f8f82e6ab8 100644 --- a/src/artifacts/WxapkgArtifactReader.boundaries.test.ts +++ b/src/artifacts/WxapkgArtifactReader.boundaries.test.ts @@ -156,7 +156,10 @@ describe("wxapkg allocation and malformed-input boundaries", () => { await fixture(buildWxapkg(members, { aliasOffsets: true })), ); try { - await expect(entries(reader)).rejects.toMatchObject({ reason: "limit" }); + await expect(entries(reader)).rejects.toMatchObject({ + reason: "limit", + message: expect.stringContaining("wxapkg members declare"), + }); } finally { await reader.close(); } @@ -418,29 +421,25 @@ describe("wxapkg stream capacity and freshness", () => { } }); - it("bounds repeated member I/O operations, including empty members", async () => { + it.each([ + { path: "x", data: "b" }, + { path: "empty.wxss", data: "" }, + ])("bounds repeated I/O operations for $path", async (member) => { const reader = new WxapkgArtifactReader( - await fixture( - buildWxapkg([ - { path: "x", data: "b" }, - { path: "empty.wxss", data: "" }, - ]), - ), + await fixture(buildWxapkg([member])), ); try { - const [entry, emptyEntry] = await entries(reader); + const [entry] = await entries(reader); await expect( (async () => { for (let index = 0; index < 5000; index++) await buffer(await reader.open(entry!)); })(), - ).rejects.toMatchObject({ reason: "limit", boundary: "capacity" }); - await expect( - (async () => { - for (let index = 0; index < 5000; index++) - await buffer(await reader.open(emptyEntry!)); - })(), - ).rejects.toMatchObject({ reason: "limit", boundary: "capacity" }); + ).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + message: expect.stringContaining("wxapkg directory-and-chunk I/O work"), + }); } finally { await reader.close(); } diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts index 6895828b65..aeefc7def7 100644 --- a/src/artifacts/ZipArtifactReader.test.ts +++ b/src/artifacts/ZipArtifactReader.test.ts @@ -6,6 +6,7 @@ import { } from "../../tests/fixtures/zipZstandard.js"; import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { once } from "node:events"; import { randomBytes } from "node:crypto"; import { constants, zstdCompressSync } from "node:zlib"; @@ -836,7 +837,7 @@ it("joins an admitted ZIP enumeration before reader close completes", async () = const traversal = reader.entries()[Symbol.asyncIterator](); const next = traversal.next(); try { - await reached.promise; + await awaitAcknowledgement(reached.promise, next, "ZIP enumeration read"); let closed = false; const closing = reader.close().then(() => { closed = true; diff --git a/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts b/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts index 460c2734eb..8ab88e3942 100644 --- a/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts +++ b/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts @@ -9,7 +9,6 @@ import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; -import { DirectoryArtifactEntryAccessDenied } from "../DirectoryArtifactReader.js"; import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 1b08333f78..d18cbdd47e 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -24,8 +24,6 @@ import { versionMinCommand, } from "./MachoImage.fixture.js"; import { - MAX_LAZY_INFO_DEPENDENCIES, - MAX_LAZY_INFO_SYMBOL_REFERENCES, MAX_LOAD_COMMAND_BYTES, readMachoImage, } from "./MachoLoadCommandReader.js"; @@ -35,6 +33,10 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js"; +// Independently exercise the dependency and reference budgets. +const MAX_LAZY_INFO_DEPENDENCIES = 1024; +const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024; + const read = (bytes: Uint8Array) => readMachoImage(readerOf(bytes), bytes.length); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index e36ebd4030..1d0ee2c744 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -19,10 +19,7 @@ import type { MachoImageFacts, MachoSlice, } from "../../domain/apple/dylibResolution.js"; -import { - machoArchitectureName, - machoCpuSubtypeBase, -} from "../../domain/apple/machoCpuIdentity.js"; +import { machoArchitectureName } from "../../domain/apple/machoCpuIdentity.js"; import { legacyMachoPlatform, type MachoVersionMinPlatform, @@ -52,13 +49,13 @@ export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024; * byte cap alone would retain about a million dependency results. Stop before * those results are kept or traced. */ -export const MAX_LAZY_INFO_DEPENDENCIES = 1024; +const MAX_LAZY_INFO_DEPENDENCIES = 1024; /** * Symbol names are validated and then discarded. Distinct one-byte strings * stay under the 16 MiB byte budgets while retaining one cache entry per * offset, so stop before that map and those decodes grow. */ -export const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024; +const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024; const LC_SEGMENT = 0x1; const LC_SEGMENT_64 = 0x19; diff --git a/src/artifacts/bun/BunStandaloneArtifactReader.test.ts b/src/artifacts/bun/BunStandaloneArtifactReader.test.ts index b7ea0895d2..12fdd878ee 100644 --- a/src/artifacts/bun/BunStandaloneArtifactReader.test.ts +++ b/src/artifacts/bun/BunStandaloneArtifactReader.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { buffer } from "node:stream/consumers"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; import * as artifactPaths from "../ArtifactPaths.js"; import { BunStandaloneArtifactReader } from "../BunStandaloneArtifactReader.js"; import { inventoryArtifact } from "../inventory/ArtifactInventory.js"; @@ -284,7 +285,7 @@ it("keeps another caller's in-flight inventory independent of cancelled preparat }, (cause: unknown) => cause, ); - await started; + await awaitAcknowledgement(started, first, "Bun artifact read"); const second = collect(reader.entries()); controller.abort(reason); resume(); diff --git a/src/artifacts/deno/DenoSectionLayout.test.ts b/src/artifacts/deno/DenoSectionLayout.test.ts index 8c7d4614b8..eb8a49ee63 100644 --- a/src/artifacts/deno/DenoSectionLayout.test.ts +++ b/src/artifacts/deno/DenoSectionLayout.test.ts @@ -1,10 +1,6 @@ import { expect, it } from "vitest"; -import { - MAX_DENO_METADATA_JSON_BYTES, - MAX_DENO_TABLE_COUNT, - parseDenoSection, -} from "./DenoSectionLayout.js"; +import { parseDenoSection } from "./DenoSectionLayout.js"; import { buildDenoSection, defaultDenoMetadata, @@ -130,7 +126,7 @@ it("treats a missing opening marker as an unsupported layout", async () => { }); it("refuses metadata JSON above the named allowance before reading it as JSON", async () => { - const claimed = MAX_DENO_METADATA_JSON_BYTES + 1; + const claimed = 8 * 1024 * 1024 + 1; const bytes = Buffer.alloc(DENO_FIXTURE_SECTION_MAGIC.length + 8 + claimed); DENO_FIXTURE_SECTION_MAGIC.copy(bytes, 0); bytes.writeBigUInt64LE(BigInt(claimed), DENO_FIXTURE_SECTION_MAGIC.length); @@ -145,7 +141,7 @@ it("refuses a specifier table above the named record allowance", async () => { parseDenoSectionBytes( buildDenoSection({ specifiers: [], - specifierCount: MAX_DENO_TABLE_COUNT + 1, + specifierCount: 100_001, }), ), ).rejects.toMatchObject({ diff --git a/src/artifacts/deno/DenoSectionLayout.ts b/src/artifacts/deno/DenoSectionLayout.ts index 93d2a7b89c..6bd659091c 100644 --- a/src/artifacts/deno/DenoSectionLayout.ts +++ b/src/artifacts/deno/DenoSectionLayout.ts @@ -9,9 +9,9 @@ import { walkDenoVirtualFs, type DenoVfsWalk } from "./DenoVirtualFs.js"; /** Opening and closing marker of a Deno compile data section. */ export const DENO_SECTION_MAGIC = Buffer.from("d3n0l4nd", "latin1"); -export const MAX_DENO_METADATA_JSON_BYTES = 8 * 1024 * 1024; +const MAX_DENO_METADATA_JSON_BYTES = 8 * 1024 * 1024; export const MAX_DENO_VFS_HEADER_JSON_BYTES = 32 * 1024 * 1024; -export const MAX_DENO_TABLE_COUNT = 100_000; +const MAX_DENO_TABLE_COUNT = 100_000; export const MAX_DENO_SPECIFIER_TEXT_BYTES = 8 * 1024 * 1024; export const MAX_DENO_REMOTE_BODY_BYTES = 256 * 1024 * 1024; diff --git a/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts b/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts index b363fac09d..eb79d760b6 100644 --- a/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts +++ b/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts @@ -5,6 +5,7 @@ import { open, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; import { elfNote, elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js"; import { @@ -19,10 +20,7 @@ import { artifactFormatDescriptor } from "../artifactFormats.js"; import { ArtifactPathRegistry } from "../ArtifactPaths.js"; import { DenoStandaloneArtifactReader } from "../DenoStandaloneArtifactReader.js"; import { cArchiveExecutable } from "../pyinstaller/CArchive.fixture.js"; -import { - locateDenoStandalone, - MAX_DENO_INTEL_TRAILER_SENTINELS, -} from "./DenoStandaloneLocation.js"; +import { locateDenoStandalone } from "./DenoStandaloneLocation.js"; import { buildDenoSection, DENO_FIXTURE_SECTION_MAGIC, @@ -34,9 +32,11 @@ import { denoUniversal, machOWithSegment, } from "./DenoStandalone.fixture.js"; -import { MAX_DENO_PATH_COMPONENT_BYTES } from "./DenoVirtualFs.js"; + import { resolveArtifactReaderLimits } from "../../domain/artifactReaderLimits.js"; +const MAX_DENO_PATH_COMPONENT_BYTES = 255; + it("keeps a confirmed Deno inventory when another pack recognizer is limited", async () => { const original = denoElf(); // Add a valid empty section table without changing the existing PT_NOTE. @@ -1071,7 +1071,7 @@ it("rejects packed Intel sentinels without rereading a chunk per sentinel", asyn it("leaves an x64 Mach-O absent when sentinels pass the allowance", async () => { const packed = Buffer.concat([ denoMachO([], { architecture: "x64" }), - rejectedIntelSentinels(MAX_DENO_INTEL_TRAILER_SENTINELS + 1), + rejectedIntelSentinels(65), ]); let readBytes = 0; const probe = await locateDenoStandalone( @@ -1307,7 +1307,7 @@ it("accepts side-by-side Intel trailers with identical bytes", async () => { it("refuses a confirmed Intel trailer when the allowance ends the outer search", async () => { const bytes = Buffer.concat([ denoMachO([], { architecture: "x64" }), - rejectedIntelSentinels(MAX_DENO_INTEL_TRAILER_SENTINELS), + rejectedIntelSentinels(64), intelTrailerRecord(buildDenoSection()), ]); await expect( @@ -1876,7 +1876,7 @@ it.each(["retry", "independent caller", "physical failure"] as const)( (cause: unknown) => cause, ); pending.push(failure); - await entered; + await awaitAcknowledgement(entered, first, "Deno artifact read"); const concurrent = scenario === "independent caller" ? collect(reader.entries()) diff --git a/src/artifacts/deno/DenoStandaloneLocation.ts b/src/artifacts/deno/DenoStandaloneLocation.ts index f68f409e94..372b5dba20 100644 --- a/src/artifacts/deno/DenoStandaloneLocation.ts +++ b/src/artifacts/deno/DenoStandaloneLocation.ts @@ -64,7 +64,7 @@ const ELF_NOTE_TYPE = 0x53554901; const PE_HEADER_PROBE_BYTES = 64 * 1024; const INTEL_CHUNK_BYTES = 1024 * 1024; /** Trailer candidates examined before the search stops. */ -export const MAX_DENO_INTEL_TRAILER_SENTINELS = 64; +const MAX_DENO_INTEL_TRAILER_SENTINELS = 64; const INTEL_SENTINEL = Buffer.concat([ Buffer.from("<~sui-data~>", "latin1"), Buffer.from([0xef, 0xbe, 0xad, 0xde]), diff --git a/src/artifacts/deno/DenoVirtualFs.test.ts b/src/artifacts/deno/DenoVirtualFs.test.ts index e6668bcd14..a5d4d6edec 100644 --- a/src/artifacts/deno/DenoVirtualFs.test.ts +++ b/src/artifacts/deno/DenoVirtualFs.test.ts @@ -2,15 +2,17 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { - MAX_DENO_LOGICAL_PATH_BYTES, MAX_DENO_LOGICAL_PATH_PARTS, - MAX_DENO_PATH_COMPONENT_BYTES, - MAX_DENO_VFS_DEPTH, - MAX_DENO_VFS_ENTRIES, - MAX_DENO_VFS_NAME_UNITS, walkDenoVirtualFs, } from "./DenoVirtualFs.js"; +// Independent format and admission examples. +const MAX_DENO_VFS_ENTRIES = 100_000; +const MAX_DENO_VFS_NAME_UNITS = 4_096; +const MAX_DENO_VFS_DEPTH = 512; +const MAX_DENO_PATH_COMPONENT_BYTES = 255; +const MAX_DENO_LOGICAL_PATH_BYTES = 512; + const file = ( name: string, offset: number, diff --git a/src/artifacts/deno/DenoVirtualFs.ts b/src/artifacts/deno/DenoVirtualFs.ts index d0d26e23e8..f678f30c7a 100644 --- a/src/artifacts/deno/DenoVirtualFs.ts +++ b/src/artifacts/deno/DenoVirtualFs.ts @@ -4,32 +4,32 @@ import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { normalizeArtifactPath } from "../ArtifactPaths.js"; /** Directory, file, and symlink records admitted from one virtual filesystem. */ -export const MAX_DENO_VFS_ENTRIES = 100_000; +const MAX_DENO_VFS_ENTRIES = 100_000; /** Logical path parts admitted before an indexed fallback. */ export const MAX_DENO_LOGICAL_PATH_PARTS = 256; /** * UTF-16 code units retained for one virtual name. Longer names are indexed * and the original spelling is not copied into members. */ -export const MAX_DENO_VFS_NAME_UNITS = 4_096; +const MAX_DENO_VFS_NAME_UNITS = 4_096; /** * Nested `walkEntries` calls. This is the recursion budget, separate from * the logical-path part fallback. */ -export const MAX_DENO_VFS_DEPTH = 512; +const MAX_DENO_VFS_DEPTH = 512; /** * UTF-16 code units of the source path built for each entry, directories * included, plus retained symlink parts. Each path is rebuilt and normalized * once, so this also bounds the synchronous walk. */ -export const MAX_DENO_VFS_RETAINED_UNITS = 32 * 1024 * 1024; +const MAX_DENO_VFS_RETAINED_UNITS = 32 * 1024 * 1024; /** POSIX NAME_MAX. A longer component makes extraction fail with ENAMETOOLONG. */ -export const MAX_DENO_PATH_COMPONENT_BYTES = 255; +const MAX_DENO_PATH_COMPONENT_BYTES = 255; /** * Natural logical path bytes. Half of the 1,024-byte Darwin PATH_MAX, leaving * the rest for the output root, so a long path does not abort extraction. */ -export const MAX_DENO_LOGICAL_PATH_BYTES = 512; +const MAX_DENO_LOGICAL_PATH_BYTES = 512; /** Logical path suffixes of the regions stored beside an original file. */ export const DENO_CHILD_SUFFIXES = { transpiled: ".deno-transpiled", diff --git a/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts b/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts index 8742984684..291b86f788 100644 --- a/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts +++ b/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts @@ -106,10 +106,13 @@ it.each(cases)( ).toHaveLength(2); expect(scope.hasPending).toBe(false); } finally { - await scope.close(); - observation.mockRestore(); - close.mockRestore(); - release.mockRestore(); + try { + await scope.close(); + } finally { + observation.mockRestore(); + close.mockRestore(); + release.mockRestore(); + } } }, ); @@ -146,8 +149,11 @@ it.each(cases)( await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" }); expect(scope.hasPending).toBe(false); } finally { - await scope.close(); - close.mockRestore(); + try { + await scope.close(); + } finally { + close.mockRestore(); + } } }, ); @@ -295,8 +301,11 @@ it.each(cases)( expect(closes).toBe(2); expect(scope.hasPending).toBe(false); } finally { - await scope.close(); - close.mockRestore(); + try { + await scope.close(); + } finally { + close.mockRestore(); + } } }, ); @@ -378,8 +387,11 @@ it.each( } expect(scope.hasPending).toBe(false); } finally { - await scope.close(); - close.mockRestore(); + try { + await scope.close(); + } finally { + close.mockRestore(); + } } }, ); diff --git a/src/artifacts/fit/FitStructure.test.ts b/src/artifacts/fit/FitStructure.test.ts index 9024f3b111..8792258e03 100644 --- a/src/artifacts/fit/FitStructure.test.ts +++ b/src/artifacts/fit/FitStructure.test.ts @@ -12,13 +12,7 @@ import { fitFixture, type FitFixtureImage, } from "./FitImage.fixture.js"; -import { - FIT_MAX_CONFIGURATION_REFERENCES, - FIT_MAX_CONFIGURATIONS, - FIT_MAX_HASH_NODES_PER_IMAGE, - FIT_MAX_IMAGES, - readFitStructure, -} from "./FitStructure.js"; +import { readFitStructure } from "./FitStructure.js"; const read = (file: Buffer) => readFitStructure( @@ -487,75 +481,74 @@ describe("FIT configurations", () => { describe("FIT fixed limits", () => { it("fails more images than the fixed allowance", () => { - expect( - failureOf( - fitFixture({ - images: Array.from({ length: FIT_MAX_IMAGES + 1 }, (_, index) => ({ - name: `image-${String(index)}`, - data: Buffer.from([index & 0xff]), - })), - }), - ), - ).toMatchObject({ reason: "limit", boundary: "capacity" }); + const make = (count: number) => + fitFixture({ + images: Array.from({ length: count }, (_, index) => ({ + name: `image-${String(index)}`, + data: Buffer.from([index & 0xff]), + })), + }); + expect(read(make(1024)).images).toHaveLength(1024); + expect(failureOf(make(1025))).toMatchObject({ + reason: "limit", + boundary: "capacity", + }); }); it("fails more hash nodes than the fixed allowance", () => { - expect( - failureOf( - fitFixture({ - images: [ - { - ...kernel(), - hashes: Array.from( - { length: FIT_MAX_HASH_NODES_PER_IMAGE + 1 }, - () => "crc32", - ), - }, - ], - }), - ), - ).toMatchObject({ reason: "limit", boundary: "capacity" }); + const make = (count: number) => + fitFixture({ + images: [ + { + ...kernel(), + hashes: Array.from({ length: count }, () => "crc32"), + }, + ], + }); + expect(read(make(8)).images[0]?.hashes).toHaveLength(8); + expect(failureOf(make(9))).toMatchObject({ + reason: "limit", + boundary: "capacity", + }); }); it("fails more configurations than the fixed allowance", () => { - expect( - failureOf( - fitFixture({ - images: [kernel()], - configurations: { - name: "configurations", - children: Array.from( - { length: FIT_MAX_CONFIGURATIONS + 1 }, - (_, index) => ({ name: `conf-${String(index)}` }), - ), - }, - }), - ), - ).toMatchObject({ reason: "limit", boundary: "capacity" }); + const make = (count: number) => + fitFixture({ + images: [kernel()], + configurations: { + name: "configurations", + children: Array.from({ length: count }, (_, index) => ({ + name: `conf-${String(index)}`, + })), + }, + }); + expect(read(make(1024)).metadata.configurations).toHaveLength(1024); + expect(failureOf(make(1025))).toMatchObject({ + reason: "limit", + boundary: "capacity", + }); }); it("bounds configuration references before decoding them", () => { - expect( - failureOf( - fitFixture({ - images: [kernel()], - configurations: { - name: "configurations", - children: [ - { - name: "conf-1", - properties: { - loadables: Buffer.alloc(FIT_MAX_CONFIGURATION_REFERENCES + 1), - }, - }, - ], - }, - }), - ), - ).toMatchObject({ reason: "limit", boundary: "capacity" }); + const make = (count: number) => + fitFixture({ + images: [kernel()], + configurations: { + name: "configurations", + children: [ + { name: "conf-1", properties: { loadables: Buffer.alloc(count) } }, + ], + }, + }); + expect(read(make(16_384)).metadata.configurations).toHaveLength(1); + expect(failureOf(make(16_385))).toMatchObject({ + reason: "limit", + boundary: "capacity", + }); }); - it("maps a parser limit to a fixed allowance without a caller limit", () => { + it("does not attach caller-adjustable admission to a fixed parser limit", () => { let nested: FixtureNode = { name: "leaf" }; for (let depth = 0; depth < 64; depth += 1) nested = { name: `n${String(depth)}`, children: [nested] }; @@ -565,9 +558,12 @@ describe("FIT fixed limits", () => { children: [{ name: "images" }, nested], }), ); - expect(failure).toMatchObject({ reason: "limit", boundary: "capacity" }); - // The parser's caller-limit recovery does not apply to fixed FIT reads. - expect(String((failure as Error).message)).not.toContain("caller-selected"); + expect(failure).toMatchObject({ + reason: "limit", + boundary: "capacity", + readerLimit: undefined, + admission: undefined, + }); }); it("fails a structure without /images", () => { @@ -580,10 +576,7 @@ describe("FIT fixed limits", () => { it("bounds dangling-reference diagnostics while preserving every declared reference", () => { const name = "c".repeat(4096); - const references = Array.from( - { length: FIT_MAX_CONFIGURATION_REFERENCES }, - () => "missing", - ); + const references = Array.from({ length: 16_384 }, () => "missing"); const file = fitFixture({ images: [{ name: "kernel", data: Buffer.from("K") }], configurations: { diff --git a/src/artifacts/fit/FitStructure.ts b/src/artifacts/fit/FitStructure.ts index c8b18f8e1f..06f7e44d20 100644 --- a/src/artifacts/fit/FitStructure.ts +++ b/src/artifacts/fit/FitStructure.ts @@ -18,11 +18,11 @@ import { BinaryFormatFailure } from "../../native/metadata/BinaryContainer.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; // Fixed resource bounds; configurable ceilings are tracked in #6176. -export const FIT_MAX_IMAGES = 1024; -export const FIT_MAX_HASH_NODES_PER_IMAGE = 8; -export const FIT_MAX_CONFIGURATIONS = 1024; +const FIT_MAX_IMAGES = 1024; +const FIT_MAX_HASH_NODES_PER_IMAGE = 8; +const FIT_MAX_CONFIGURATIONS = 1024; /** Bounds configuration references and their limitations across the FIT. */ -export const FIT_MAX_CONFIGURATION_REFERENCES = 16_384; +const FIT_MAX_CONFIGURATION_REFERENCES = 16_384; type FitNode = Devicetree["nodes"][number]; type FitProperty = FitNode["properties"][number]; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 35af41535a..15bfa22513 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -3,12 +3,10 @@ import { nesContainerFormat, parseInesLayout, } from "../../domain/inesContainer.js"; -import type { GodotPckAdmission } from "../GodotPckLocation.js"; import { resolveArtifactReaderLimits, type ArtifactReaderLimits, } from "../../domain/artifactReaderLimits.js"; -import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import type { ArtifactReaderAdmission, ArtifactFormatAdmission, diff --git a/src/artifacts/nodeSea/NodeSea.fixture.ts b/src/artifacts/nodeSea/NodeSea.fixture.ts index d21990d99b..79300bb06f 100644 --- a/src/artifacts/nodeSea/NodeSea.fixture.ts +++ b/src/artifacts/nodeSea/NodeSea.fixture.ts @@ -81,9 +81,7 @@ export const buildNodeSeaBlob = ( return Buffer.concat(parts); }; -export const nodeSeaFuseBytes = ( - state: NodeSeaFuseFixture = "absent", -): Buffer => { +const nodeSeaFuseBytes = (state: NodeSeaFuseFixture = "absent"): Buffer => { if (state === "absent") return Buffer.alloc(0); const sentinel = (digit: string, hash = NODE_SEA_FUSE_HASH): Buffer => Buffer.from(`\0NODE_SEA_FUSE_${hash}:${digit}\0`, "latin1"); diff --git a/src/artifacts/pyinstaller/PyInstallerAdmissionBoundaries.test.ts b/src/artifacts/pyinstaller/PyInstallerAdmissionBoundaries.test.ts index 4242b390f8..2fc7a90a6e 100644 --- a/src/artifacts/pyinstaller/PyInstallerAdmissionBoundaries.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerAdmissionBoundaries.test.ts @@ -1,6 +1,7 @@ import type { FileHandle } from "node:fs/promises"; import { expect, it, vi } from "vitest"; import { withPyInstallerReader } from "../../../tests/fixtures/pyinstallerReader.js"; +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; import { PyInstallerPyzStore } from "./PyInstallerPyzStore.js"; @@ -114,7 +115,11 @@ it.each([false, true])( const opening = reader.open(selected, signal.signal); void opening.catch(() => undefined); try { - await started; + await awaitAcknowledgement( + started, + opening, + "PyInstaller image admission", + ); signal.abort(cancelled); release(); await expect(opening).rejects.toMatchObject({ diff --git a/src/artifacts/pyinstaller/PyInstallerBoundaries.test.ts b/src/artifacts/pyinstaller/PyInstallerBoundaries.test.ts index 524ca9bcb5..c807d3d294 100644 --- a/src/artifacts/pyinstaller/PyInstallerBoundaries.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerBoundaries.test.ts @@ -1,4 +1,5 @@ import { withPyInstallerReader } from "../../../tests/fixtures/pyinstallerReader.js"; +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; import { dirname, join } from "node:path"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { createHash, randomBytes } from "node:crypto"; @@ -392,7 +393,11 @@ it.each([false, true])( void consumed.catch(() => undefined); let closing: Promise | undefined; try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + consumed, + "PyInstaller operation", + ); controller.abort(caller); let closed = false; closing = reader.close().then(() => { @@ -616,7 +621,11 @@ it("joins a late private-file close and retries its failed owner after cancellat .next() .catch((cause: unknown) => cause); try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + listing, + "PyInstaller operation", + ); let settled = false; const closing = reader.close().then(() => { settled = true; @@ -690,7 +699,11 @@ for (const cancel of [false, true]) { (cause: unknown) => ({ ok: false as const, cause }), ); try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + reading, + "PyInstaller operation", + ); expect(output.readableEnded).toBe(false); expect(owner?.handle.fd).toBeGreaterThanOrEqual(0); if (cancel) { @@ -825,7 +838,11 @@ it.each([false, true])( .catch((cause: unknown) => cause); let closing: Promise | undefined; try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + first, + "PyInstaller operation", + ); await setImmediate(); expect(handles.size).toBe(1); caller.abort(reason); @@ -928,7 +945,11 @@ it.each( ); let second: Promise | undefined; try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + first, + "PyInstaller operation", + ); expect(reader.formatMetadata()).toBeUndefined(); if (peer) { second = members(reader); @@ -1031,7 +1052,11 @@ it("keeps a physical parse failure primary despite simultaneous caller cancellat ); let second: Promise | undefined; try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + first, + "PyInstaller operation", + ); second = members(reader).catch((cause: unknown) => cause); release.resolve(); const original = await first; @@ -1042,8 +1067,7 @@ it("keeps a physical parse failure primary despite simultaneous caller cancellat expect(reader.formatMetadata()).toBeUndefined(); } finally { release.resolve(); - await first; - await second; + await Promise.allSettled([first, ...(second ? [second] : [])]); reads.mockRestore(); } }, diff --git a/src/artifacts/pyinstaller/PyzArchive.test.ts b/src/artifacts/pyinstaller/PyzArchive.test.ts index 1adc66e7eb..45f84d44fd 100644 --- a/src/artifacts/pyinstaller/PyzArchive.test.ts +++ b/src/artifacts/pyinstaller/PyzArchive.test.ts @@ -6,7 +6,7 @@ import { open, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { readPyzArchive, MAX_PYZ_TABLE_BYTES } from "./PyzArchive.js"; +import { readPyzArchive } from "./PyzArchive.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { PYTHON_39_MAGIC, pyzArchive } from "./CArchive.fixture.js"; @@ -82,7 +82,7 @@ it.each(["PYZ\0", "BAD\0"])( await writeFile(path, header); const handle = await open(path, "r+"); try { - const size = 12 + MAX_PYZ_TABLE_BYTES + 1; + const size = 12 + 16 * 1024 * 1024 + 1; await handle.truncate(size); expect(await readPyzArchive(handle, 0, size)).toMatchObject({ ok: false, diff --git a/src/artifacts/pyinstaller/PyzArchive.ts b/src/artifacts/pyinstaller/PyzArchive.ts index f0408e1e54..9f88a04967 100644 --- a/src/artifacts/pyinstaller/PyzArchive.ts +++ b/src/artifacts/pyinstaller/PyzArchive.ts @@ -44,7 +44,7 @@ export type PyzArchive = }; /** Bound the transient marshal table allocation independently of image expansion. */ -export const MAX_PYZ_TABLE_BYTES = 16 * 1024 * 1024; +const MAX_PYZ_TABLE_BYTES = 16 * 1024 * 1024; interface PyzHeaderFacts { readonly signatureMatches: boolean; diff --git a/src/browser/BrowserScenarioResponseValues.ts b/src/browser/BrowserScenarioResponseValues.ts index acb69a65de..7ad607e838 100644 --- a/src/browser/BrowserScenarioResponseValues.ts +++ b/src/browser/BrowserScenarioResponseValues.ts @@ -1,3 +1,4 @@ +import { jsonPointerTokens } from "../domain/jsonPointer.js"; import { visit, type JSONPath } from "jsonc-parser"; import { isExactJsonNumber } from "../domain/exactJsonNumber.js"; import { safeParseJson } from "../domain/safeJson.js"; @@ -22,14 +23,6 @@ export interface ResponseValue { readonly value: string | number; } -const pointerTokens = (pointer: string): readonly string[] => - pointer === "" - ? [] - : pointer - .slice(1) - .split("/") - .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")); - /** Keep unrelated fields usable while marking numbers that cannot be selected exactly. */ export const parseScenarioValueJson = (text: string) => safeParseJson(text, (_key, value, context) => @@ -44,7 +37,7 @@ export const responsePointerValue = ( pointer: string, ): unknown => { let current = value; - for (const key of pointerTokens(pointer)) { + for (const key of jsonPointerTokens(pointer)) { if (Array.isArray(current)) { if (!/^(?:0|[1-9][0-9]*)(?![\s\S])/u.test(key)) return undefined; current = current[Number(key)]; @@ -191,7 +184,7 @@ export const redactResponseSource = ( ): Result => { const markers = new Map( values.map(({ entry, value }) => [ - JSON.stringify(pointerTokens(entry.json_pointer)), + JSON.stringify(jsonPointerTokens(entry.json_pointer)), redact(String(value)), ]), ); diff --git a/src/browser/ElectronProcessCleanup.ts b/src/browser/ElectronProcessCleanup.ts index 45238703ac..ac12a1857d 100644 --- a/src/browser/ElectronProcessCleanup.ts +++ b/src/browser/ElectronProcessCleanup.ts @@ -2,11 +2,9 @@ import { cleanupOwnedProcessGroup, cleanupWindowsProcessTree, verifyNoTokenOwnedProcesses, - type OwnedProcessGroup, type PersistentOwnedProcessGroup, type ProcessCleanupResult, type ProcessLineageObservation, - type OwnershipSweepUnverifiedProcess, type ProcessOwnershipHost, } from "../process/ProcessOwnership.js"; import { systemProcessOwnershipHost } from "../process/ProcessOwnershipObservation.js"; diff --git a/src/browser/IsolatedWebBundleAnalyzer.test.ts b/src/browser/IsolatedWebBundleAnalyzer.test.ts index 23a701c95e..bfc2624cb3 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.test.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.test.ts @@ -1,3 +1,4 @@ +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { expect, it } from "vitest"; import { createWebPageInspection } from "../../tests/support/browser/webPageInspection.js"; @@ -233,10 +234,19 @@ it("carries the exact caller cancellation reason and completed prefix facts", as input, controller.signal, ); - await started.promise; - controller.abort(exactReason); - const result = await pending; - analyzer.releaseReservation(); + let result: Awaited; + try { + await awaitAcknowledgement(started.promise, pending, "script analysis"); + controller.abort(exactReason); + result = await pending; + } finally { + controller.abort(exactReason); + try { + await pending; + } finally { + analyzer.releaseReservation(); + } + } if (result.ok) throw new Error("Expected cancellation"); expect(result.error._tag).toBe("AnalysisCancelledError"); diff --git a/src/browser/PlaywrightElectronActiveActions.test.ts b/src/browser/PlaywrightElectronActiveActions.test.ts index e171badb94..65b34439c7 100644 --- a/src/browser/PlaywrightElectronActiveActions.test.ts +++ b/src/browser/PlaywrightElectronActiveActions.test.ts @@ -5,6 +5,7 @@ import { afterEach, expect, it, vi } from "vitest"; import type { ElectronApplication } from "playwright-core"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { electronActiveObservationInputSchema, @@ -791,20 +792,33 @@ it.each(["acquisition", "identity"] as const)( { signal: controller.signal }, resources, ); - await started.promise; - controller.abort(new Error("stop during selected window read")); - await Promise.resolve(); - finish.resolve(); - const outcome = await running; - expect(outcome.actions[0]).toMatchObject({ - status: "cancelled", - target: null, - }); - expect(page.locator).not.toHaveBeenCalled(); - expect(dispose).toHaveBeenCalledOnce(); - expect(resources.hasPending).toBe(false); - await resources.close(); - expect(dispose).toHaveBeenCalledOnce(); + try { + await awaitAcknowledgement( + started.promise, + running, + "selected window read", + ); + controller.abort(new Error("stop during selected window read")); + await Promise.resolve(); + finish.resolve(); + const outcome = await running; + expect(outcome.actions[0]).toMatchObject({ + status: "cancelled", + target: null, + }); + expect(page.locator).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + expect(resources.hasPending).toBe(false); + await resources.close(); + expect(dispose).toHaveBeenCalledOnce(); + } finally { + finish.resolve(); + try { + await running; + } finally { + await resources.close(); + } + } }, ); diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 20854534dc..eb5738f707 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -6,6 +6,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { mkdir, writeFile, rm, access, symlink } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import type { Browser, BrowserContext, @@ -242,34 +243,42 @@ it.each([false, true])( (value) => ({ ok: true as const, value }), (cause: unknown) => ({ ok: false as const, cause }), ); - await started.promise; - if (cancelled) controller.abort(reason); - launched.resolve({ + const context = { close, browser: () => ({}) as Browser, pages: () => [], newPage, - } as unknown as BrowserContext); - const result = await opening; - if (result.ok) { - try { - expect(cancelled).toBe(false); - expect(newPage).toHaveBeenCalledOnce(); - expect(close).not.toHaveBeenCalled(); - expect(result.value.profilePath).toBe(profile); - } finally { - await result.value.cleanup.close(); + } as unknown as BrowserContext; + try { + await awaitAcknowledgement(started.promise, opening, "browser launch"); + if (cancelled) controller.abort(reason); + launched.resolve(context); + const result = await opening; + if (result.ok) { + try { + expect(cancelled).toBe(false); + expect(newPage).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + expect(result.value.profilePath).toBe(profile); + } finally { + await result.value.cleanup.close(); + } + } else { + expect(cancelled).toBe(true); + expect(result.cause).toMatchObject({ reason: "cancelled" }); + if (!(result.cause instanceof AnalysisError)) + throw new Error("Expected typed cancellation"); + expect(result.cause.cause).toBe(reason); + expect(newPage).not.toHaveBeenCalled(); } - } else { - expect(cancelled).toBe(true); - expect(result.cause).toMatchObject({ reason: "cancelled" }); - if (!(result.cause instanceof AnalysisError)) - throw new Error("Expected typed cancellation"); - expect(result.cause.cause).toBe(reason); - expect(newPage).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + await expect(access(profile)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + controller.abort(reason); + launched.resolve(context); + const outcome = await opening; + if (outcome.ok) await outcome.value.cleanup.close(); } - expect(close).toHaveBeenCalledOnce(); - await expect(access(profile)).rejects.toMatchObject({ code: "ENOENT" }); }, ); @@ -428,16 +437,27 @@ it("does not configure a late attached connection after cancellation", async () signal: controller.signal, }, ); - await connectionStarted.promise; - controller.abort(); - connection.resolve(connectedBrowser); + try { + await awaitAcknowledgement( + connectionStarted.promise, + opening, + "CDP connection", + ); + controller.abort(); + connection.resolve(connectedBrowser); - await expect(opening).rejects.toMatchObject({ - _tag: "BrowserObservationError", - reason: "cancelled", - }); - expect(connectedBrowser.contexts).not.toHaveBeenCalled(); - expect(connectedBrowser.close).toHaveBeenCalledOnce(); + await expect(opening).rejects.toMatchObject({ + _tag: "BrowserObservationError", + reason: "cancelled", + }); + expect(connectedBrowser.contexts).not.toHaveBeenCalled(); + expect(connectedBrowser.close).toHaveBeenCalledOnce(); + } finally { + controller.abort(); + connection.resolve(connectedBrowser); + const opened = await opening.catch(() => undefined); + await opened?.cleanup.close(); + } }); it("closes an attached connection when cancellation starts target discovery", async () => { @@ -651,10 +671,19 @@ it("checks a late stop after attached restoration and before transport disconnec }, }); - await restorationStarted.promise; - revision += 1; - finishRestoration.resolve(undefined); - await release; + try { + await awaitAcknowledgement( + restorationStarted.promise, + release, + "browser restoration", + ); + revision += 1; + finishRestoration.resolve(undefined); + await release; + } finally { + finishRestoration.resolve(undefined); + await release.catch(() => undefined); + } expect(order).toEqual([ "restore-started", @@ -738,25 +767,36 @@ it("waits for a cancelled environment mutation before restoring it", async () => }); const controller = new AbortController(); const opening = fixture.open(controller.signal); - await localeStarted.promise; - const cancelled = expect(opening).rejects.toMatchObject({ - _tag: "AnalysisCancelledError", - }); - controller.abort(); - await new Promise((resolve) => setImmediate(resolve)); - expect(fixture.environmentSession.send).not.toHaveBeenCalledWith( - "Emulation.setLocaleOverride", - { locale: "" }, - ); + try { + await awaitAcknowledgement( + localeStarted.promise, + opening, + "locale mutation", + ); + const cancelled = expect(opening).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + expect(fixture.environmentSession.send).not.toHaveBeenCalledWith( + "Emulation.setLocaleOverride", + { locale: "" }, + ); - localeResult.resolve(undefined); - await cancelled; - expect(fixture.environmentSession.send).toHaveBeenCalledWith( - "Emulation.setLocaleOverride", - { locale: "" }, - ); - expect(fixture.environmentSession.detach).toHaveBeenCalledOnce(); - expect(fixture.browser.close).toHaveBeenCalledOnce(); + localeResult.resolve(undefined); + await cancelled; + expect(fixture.environmentSession.send).toHaveBeenCalledWith( + "Emulation.setLocaleOverride", + { locale: "" }, + ); + expect(fixture.environmentSession.detach).toHaveBeenCalledOnce(); + expect(fixture.browser.close).toHaveBeenCalledOnce(); + } finally { + controller.abort(); + localeResult.resolve(undefined); + const opened = await opening.catch(() => undefined); + await opened?.cleanup.close(); + } }); it("releases cleanup ownership when the attached target is destroyed", async () => { @@ -1055,12 +1095,26 @@ it("rechecks the browser release gate after finalization changes its revision", }, ); - await eventsStarted.promise; - finishEvents.resolve(undefined); - await gateStarted.promise; - revision += 1; - finishGate.resolve(undefined); - await release; + try { + await awaitAcknowledgement( + eventsStarted.promise, + release, + "browser event finalization", + ); + finishEvents.resolve(undefined); + await awaitAcknowledgement( + gateStarted.promise, + release, + "browser release gate", + ); + revision += 1; + finishGate.resolve(undefined); + await release; + } finally { + finishEvents.resolve(undefined); + finishGate.resolve(undefined); + await release.catch(() => undefined); + } expect(gateRuns).toEqual([0, 1]); expect(closeBrowser).toHaveBeenCalledTimes(1); @@ -1092,10 +1146,19 @@ it("runs the latest stop gate after attached restoration and before disconnect", }, }); - await restorationStarted.promise; - revision += 1; - finishRestoration.resolve(undefined); - await release; + try { + await awaitAcknowledgement( + restorationStarted.promise, + release, + "browser restoration", + ); + revision += 1; + finishRestoration.resolve(undefined); + await release; + } finally { + finishRestoration.resolve(undefined); + await release.catch(() => undefined); + } expect(gateRevisions).toEqual([1]); expect(disconnected).toBe(true); @@ -1143,19 +1206,32 @@ it.each([false, true])( () => ({ ok: true as const }), (cause: unknown) => ({ ok: false as const, cause }), ); - await gateStarted.promise; - if (changeRevision) revision += 1; - finishGate.resolve(undefined); - await expect(release).resolves.toEqual({ ok: false, cause: failure }); - expect(closeBrowser).toHaveBeenCalledTimes(1); - expect(transportClosed).toBe(false); - expect(owner.browserClosed).toBe(false); - expect(owner.cleanupResources).toContain("browser_transport"); + try { + await awaitAcknowledgement( + gateStarted.promise, + release, + "browser release gate", + ); + if (changeRevision) revision += 1; + finishGate.resolve(undefined); + await expect(release).resolves.toEqual({ ok: false, cause: failure }); + expect(closeBrowser).toHaveBeenCalledTimes(1); + expect(transportClosed).toBe(false); + expect(owner.browserClosed).toBe(false); + expect(owner.cleanupResources).toContain("browser_transport"); - await owner.release(undefined, gate); - expect(closeBrowser).toHaveBeenCalledTimes(2); - expect(transportClosed).toBe(true); - expect(owner.browserClosed).toBe(true); + await owner.release(undefined, gate); + expect(closeBrowser).toHaveBeenCalledTimes(2); + expect(transportClosed).toBe(true); + expect(owner.browserClosed).toBe(true); + } finally { + finishGate.resolve(undefined); + try { + await release; + } finally { + await owner.close(); + } + } }, ); diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts index aa8f24cf6f..3f683f183f 100644 --- a/src/browser/PlaywrightScenarioRequest.test.ts +++ b/src/browser/PlaywrightScenarioRequest.test.ts @@ -1076,6 +1076,12 @@ describe("saved response JSON pointers", () => { vi.unstubAllGlobals(); }); + it("selects the root and decodes escaped response keys once", () => { + const value = { "a/b": { "~1": "literal", "/": "slash" } }; + expect(responsePointerValue(value, "")).toBe(value); + expect(responsePointerValue(value, "/a~1b/~01")).toBe("literal"); + }); + it("does not coerce a terminal newline in an array index", () => { expect(responsePointerValue({ items: ["record-7"] }, "/items/0\n")).toBe( undefined, diff --git a/src/browser/PlaywrightScenarioServiceWorkers.ts b/src/browser/PlaywrightScenarioServiceWorkers.ts index 43aa200834..9c01e3290d 100644 --- a/src/browser/PlaywrightScenarioServiceWorkers.ts +++ b/src/browser/PlaywrightScenarioServiceWorkers.ts @@ -389,14 +389,10 @@ export class PlaywrightScenarioServiceWorkers { // Abort discovery/handshakes and reads, never an already-sent page mutation. this.#setup.abort(); const restoration = (this.#restoration ??= this.restore()); - void restoration.then( - () => { - if (this.#restoration === restoration) this.#restoration = undefined; - }, - () => { - if (this.#restoration === restoration) this.#restoration = undefined; - }, - ); + const clearRestoration = (): void => { + if (this.#restoration === restoration) this.#restoration = undefined; + }; + void restoration.then(clearRestoration, clearRestoration); // Restoration completion does not discharge a stop request that arrived // after that restoration began. Both paths share the owner's stop latch. const completion = restoration.then(() => this.fulfillStopRequest()); @@ -427,14 +423,10 @@ export class PlaywrightScenarioServiceWorkers { this.#loadingStopped = true; })(); this.#stopPromise = stopping; - void stopping.then( - () => { - if (this.#stopPromise === stopping) this.#stopPromise = undefined; - }, - () => { - if (this.#stopPromise === stopping) this.#stopPromise = undefined; - }, - ); + const clearStop = (): void => { + if (this.#stopPromise === stopping) this.#stopPromise = undefined; + }; + void stopping.then(clearStop, clearStop); return stopping; } diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts index 49d3447475..8b66b90819 100644 --- a/src/browser/PlaywrightScenarioSession.test.ts +++ b/src/browser/PlaywrightScenarioSession.test.ts @@ -1,3 +1,4 @@ +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { AnalysisCleanupLeaseGroup, AnalysisCleanupLease, @@ -550,24 +551,34 @@ it("transfers cancellation to a no-stop cleanup already in progress", async () = }); const firstClose = session.close(false); - await started; - cancellation.abort(); - const retry = session.close(); - finishRestoration(); - await expect(Promise.all([firstClose, retry])).resolves.toEqual([ - "disconnected-external", - "disconnected-external", - ]); + try { + await awaitAcknowledgement(started, firstClose, "policy restoration"); + cancellation.abort(); + const retry = session.close(); + finishRestoration(); + await expect(Promise.all([firstClose, retry])).resolves.toEqual([ + "disconnected-external", + "disconnected-external", + ]); - const policyStops = mocks.policySend.mock.calls.filter( - ([method]) => method === "Page.stopLoading", - ).length; - const frameStops = page.evaluate.mock.calls.filter( - ([script]) => script === "globalThis.stop()", - ).length; - expect(policyStops + frameStops).toBe(1); - expect(cleanup.close).toHaveBeenCalledTimes(1); - expect(browserWasOpenAtStop).toBe(true); + const policyStops = mocks.policySend.mock.calls.filter( + ([method]) => method === "Page.stopLoading", + ).length; + const frameStops = page.evaluate.mock.calls.filter( + ([script]) => script === "globalThis.stop()", + ).length; + expect(policyStops + frameStops).toBe(1); + expect(cleanup.close).toHaveBeenCalledTimes(1); + expect(browserWasOpenAtStop).toBe(true); + } finally { + cancellation.abort(); + finishRestoration(); + try { + await firstClose; + } finally { + await session.close(); + } + } }); it("does not repeat a stop confirmed by storage during a late retry", async () => { @@ -591,27 +602,37 @@ it("does not repeat a stop confirmed by storage during a late retry", async () = }); const firstClose = session.close(false); - await started; - cancellation.abort(); - const retry = session.close(); - finishRestoration(); - await expect(Promise.all([firstClose, retry])).resolves.toEqual([ - "disconnected-external", - "disconnected-external", - ]); + try { + await awaitAcknowledgement(started, firstClose, "policy restoration"); + cancellation.abort(); + const retry = session.close(); + finishRestoration(); + await expect(Promise.all([firstClose, retry])).resolves.toEqual([ + "disconnected-external", + "disconnected-external", + ]); - expect(storage?.close).toHaveBeenCalledWith(false); - expect( - mocks.policySend.mock.calls.filter( - ([method]) => method === "Page.stopLoading", - ), - ).toHaveLength(0); - expect( - page.evaluate.mock.calls.filter( - ([script]) => script === "globalThis.stop()", - ), - ).toHaveLength(0); - expect(cleanup.close).toHaveBeenCalledTimes(1); + expect(storage?.close).toHaveBeenCalledWith(false); + expect( + mocks.policySend.mock.calls.filter( + ([method]) => method === "Page.stopLoading", + ), + ).toHaveLength(0); + expect( + page.evaluate.mock.calls.filter( + ([script]) => script === "globalThis.stop()", + ), + ).toHaveLength(0); + expect(cleanup.close).toHaveBeenCalledTimes(1); + } finally { + cancellation.abort(); + finishRestoration(); + try { + await firstClose; + } finally { + await session.close(); + } + } }); it.each(["block", "allow"] as const)( @@ -938,18 +959,19 @@ it("does not await policy initialization after its child target detaches", async }, sessionId: "policy-session", }); - await started; - const closing = session.close(); - mocks.policyEvent?.({ - method: "Target.detachedFromTarget", - params: { sessionId: "child-session" }, - }); try { + await awaitAcknowledgement(started, held, "child policy admission"); + const closing = session.close(); + mocks.policyEvent?.({ + method: "Target.detachedFromTarget", + params: { sessionId: "child-session" }, + }); await expect(closing).resolves.toBe("disconnected-external"); + expect(cleanup.close).toHaveBeenCalledTimes(1); } finally { releaseChild(); + await session.close(); } - expect(cleanup.close).toHaveBeenCalledTimes(1); }); it("skips policy restoration for a child without a committed document", async () => { @@ -1222,7 +1244,11 @@ it.each([false, true])( signal: controller.signal, }); try { - await started.promise; + await awaitAcknowledgement( + started.promise, + result, + "cookie initialization", + ); controller.abort(reason); await new Promise((resolve) => setImmediate(resolve)); expect(cleanup.close).not.toHaveBeenCalled(); @@ -1396,14 +1422,21 @@ it("cancels a stalled policy preflight without allowing late setup", async () => const outcome = expect(opening).rejects.toMatchObject({ _tag: "AnalysisCancelledError", }); - await started; - cancellation.abort(); - await outcome; - expect(cleanup.close).toHaveBeenCalledTimes(1); - expect(cleanup.close.mock.calls[0]).toHaveLength(1); - release(); - await new Promise((resolve) => setImmediate(resolve)); - expect(mocks.policySend).not.toHaveBeenCalled(); + try { + await awaitAcknowledgement(started, opening, "policy preflight"); + cancellation.abort(); + await outcome; + expect(cleanup.close).toHaveBeenCalledTimes(1); + expect(cleanup.close.mock.calls[0]).toHaveLength(1); + release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mocks.policySend).not.toHaveBeenCalled(); + } finally { + cancellation.abort(); + release(); + await opening.catch(() => undefined); + await outcome.catch(() => undefined); + } }); it("waits for a pending registration installation before cancelled cleanup", async () => { @@ -1427,25 +1460,32 @@ it("waits for a pending registration installation before cancelled cleanup", asy const outcome = expect(opening).rejects.toMatchObject({ _tag: "AnalysisCancelledError", }); - await started; - cancellation.abort(); - await new Promise((resolve) => setImmediate(resolve)); - expect(cleanup.close).not.toHaveBeenCalled(); - expect(page.evaluate).toHaveBeenCalledTimes(1); - releaseInstallation(); - await outcome; - const restoration = mocks.policySend.mock.calls.find( - ([method]) => method === "Runtime.evaluate", - )?.[1]; - expect(restoration).toMatchObject({ - expression: expect.stringContaining("policy_owned"), - }); - expect(mocks.policySend).toHaveBeenCalledWith( - "Page.stopLoading", - {}, - "policy-session", - ); - expect(cleanup.close).toHaveBeenCalledTimes(1); + try { + await awaitAcknowledgement(started, opening, "policy installation"); + cancellation.abort(); + await new Promise((resolve) => setImmediate(resolve)); + expect(cleanup.close).not.toHaveBeenCalled(); + expect(page.evaluate).toHaveBeenCalledTimes(1); + releaseInstallation(); + await outcome; + const restoration = mocks.policySend.mock.calls.find( + ([method]) => method === "Runtime.evaluate", + )?.[1]; + expect(restoration).toMatchObject({ + expression: expect.stringContaining("policy_owned"), + }); + expect(mocks.policySend).toHaveBeenCalledWith( + "Page.stopLoading", + {}, + "policy-session", + ); + expect(cleanup.close).toHaveBeenCalledTimes(1); + } finally { + cancellation.abort(); + releaseInstallation(); + await opening.catch(() => undefined); + await outcome.catch(() => undefined); + } }); it("rejects attached block when caller-owned registrations already exist", async () => { @@ -1578,7 +1618,7 @@ it("retains late opening failure cleanup after cancellation until the exact prof const pending = provider.captureScenario(scenario, { signal: caller.signal, }); - await started; + await awaitAcknowledgement(started, pending, "browser launch start"); caller.abort(reason); const result = await pending; if (result.ok) throw new Error("Expected cancelled opening"); @@ -1600,6 +1640,7 @@ it("retains late opening failure cleanup after cancellation until the exact prof }); expect(failure.cleanupIncomplete).toBe(false); } finally { + caller.abort(reason); denied = false; finishOpening(); await provider.close(); diff --git a/src/browser/WebBundleAnalysisWorkerStartup.test.ts b/src/browser/WebBundleAnalysisWorkerStartup.test.ts index 4261c014df..dc0542d6da 100644 --- a/src/browser/WebBundleAnalysisWorkerStartup.test.ts +++ b/src/browser/WebBundleAnalysisWorkerStartup.test.ts @@ -120,11 +120,14 @@ it.for([ } } } finally { - await worker.close(); - preparation.mockRestore(); - child.stdin.destroy(); - child.stdout.destroy(); - child.stderr.destroy(); + try { + await worker.close(); + } finally { + preparation.mockRestore(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + } } }, ); diff --git a/src/browser/WebSourceMapFetcher.test.ts b/src/browser/WebSourceMapFetcher.test.ts index 5a92142691..8f15c7e5d9 100644 --- a/src/browser/WebSourceMapFetcher.test.ts +++ b/src/browser/WebSourceMapFetcher.test.ts @@ -601,84 +601,51 @@ it("does not mistake an independent body AbortError for a falsy caller cancellat ).toBe(true); }); -it("keeps an actual stalled HTTP header deadline local to its map", async () => { - const server = createServer(() => {}); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const address = server.address(); - if (address === null || typeof address === "string") - throw new Error("Expected TCP address"); - const url = `http://127.0.0.1:${address.port}/map`; - try { - const result = await fetchWebSourceMaps( - [{ ...request, declaredUrl: url, fetchUrl: url }], - { - ...input, - allowed_origins: [new URL(url).origin], - source_map_fetch_timeout_ms: 1000, - }, - undefined, - { fetch }, - ); - expect(result).toMatchObject({ - ok: true, - value: { - items: [ - { - status: "fetch_failed", - limitation: expect.stringContaining("deadline"), - }, - ], - }, +it.each(["headers", "body"] as const)( + "keeps an actual stalled HTTP %s deadline local to its map", + async (stage) => { + const server = createServer((_request, response) => { + if (stage === "body") { + response.writeHead(200, { "content-type": "application/json" }); + response.write('{"version":3,'); + } }); - } finally { - server.closeAllConnections(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -}); - -it("keeps an actual stalled HTTP response deadline local to its map", async () => { - const server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "application/json" }); - response.write('{"version":3,'); - }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const address = server.address(); - if (address === null || typeof address === "string") - throw new Error("Expected TCP address"); - const url = `http://127.0.0.1:${address.port}/map`; - try { - const result = await fetchWebSourceMaps( - [{ ...request, declaredUrl: url, fetchUrl: url }], - { - ...input, - allowed_origins: [new URL(url).origin], - source_map_fetch_timeout_ms: 1000, - }, - undefined, - { fetch }, - ); - expect(result).toMatchObject({ - ok: true, - value: { - items: [ - { - status: "fetch_failed", - limitation: expect.stringContaining("deadline"), - }, - ], - }, - }); - } finally { - server.closeAllConnections(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected TCP address"); + const url = `http://127.0.0.1:${address.port}/map`; + try { + const result = await fetchWebSourceMaps( + [{ ...request, declaredUrl: url, fetchUrl: url }], + { + ...input, + allowed_origins: [new URL(url).origin], + source_map_fetch_timeout_ms: 1000, + }, + undefined, + { fetch }, + ); + expect(result).toMatchObject({ + ok: true, + value: { + items: [ + { + status: "fetch_failed", + limitation: expect.stringContaining("deadline"), + }, + ], + }, + }); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, +); it.each([302, 503])( "retains HTTP %s response cleanup without hanging on an independent tee branch", diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index c4e855dadb..b333429f07 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -706,7 +706,8 @@ const fetchOne = async ( budget: budget.decodedRecords, }, ); - } catch (caught: unknown) { + } catch (failure: unknown) { + let caught = failure; if (caught instanceof SourceMapPendingFetch) { if (caught.cause instanceof SourceMapDeadlineError) { // Native fetch reports abort completion asynchronously. Check the next @@ -870,7 +871,8 @@ const readBoundedText = async ( if (value !== undefined) chunks.push(value); bytes += nextBytes; } - } catch (cause: unknown) { + } catch (caught: unknown) { + let cause = caught; // Fetch can error its body with the abort reason before read cancellation. // A confirmed errored stream is already terminal and needs only its lock released. await Promise.resolve(); diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts index 4d85134462..bdd0fa7d66 100644 --- a/src/browser/history/CaptureFailures.test.ts +++ b/src/browser/history/CaptureFailures.test.ts @@ -2,7 +2,6 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { captureDecoderHeapContinuingRecovery, - captureDecoderHeapRecovery, DEFAULT_CAPTURE_DECODER_HEAP, } from "../../config/captureDecoderHeap.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 73b895418d..6e5f3060f6 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -1,3 +1,4 @@ +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { captureBudgets } from "./CaptureBudgets.js"; import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; @@ -36,11 +37,8 @@ import { type CaptureDecoderHeap, } from "../../config/captureDecoderHeap.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; -import { - NODE_CAPTURE_HEAP_ENFORCED, - NODE_CAPTURE_HEAP_LIMITS, - captureDecoderProcessMemoryLimitBytes, -} from "./CaptureRelease.js"; +import { NODE_CAPTURE_HEAP_ENFORCED } from "./CaptureRelease.js"; +import { CAPTURE_DECODER_SEMI_SPACE_MIB } from "../../domain/analysisResourceLimits.js"; const OPERATION = "inspect_web_network_capture"; /** Reason for a decoder whose owned process tree reached its Job Object commit limit. */ @@ -296,7 +294,7 @@ export const captureMemoryFailure = ( cause?: unknown, heap: CaptureDecoderHeap = DEFAULT_CAPTURE_DECODER_HEAP, ): AnalysisResourceConstraintError => { - const processMemoryLimitBytes = captureDecoderProcessMemoryLimitBytes( + const processMemoryLimitBytes = javaScriptWorkerMemoryLimitBytes( heap.oldGenerationMiB, ); const recovery = captureDecoderHeapRecovery(heap); @@ -315,7 +313,7 @@ export const captureMemoryFailure = ( ? { old_generation_heap_mib: heap.oldGenerationMiB, old_generation_heap_source: heap.source, - semi_space_heap_mib: NODE_CAPTURE_HEAP_LIMITS.semiSpaceMiB, + semi_space_heap_mib: CAPTURE_DECODER_SEMI_SPACE_MIB, } : {}), ...(input.format !== "mitmproxy" && processMemoryLimitBytes !== null diff --git a/src/browser/history/CaptureJson.ts b/src/browser/history/CaptureJson.ts index b1703b76f5..b6333843b1 100644 --- a/src/browser/history/CaptureJson.ts +++ b/src/browser/history/CaptureJson.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import { captureBudgets } from "./CaptureBudgets.js"; import { visit } from "jsonc-parser"; import { LosslessNumber } from "lossless-json"; @@ -58,7 +59,7 @@ export const parseCaptureJson = ( return `${parent.pointer}/${parent.items.length}`; return parent.pendingKey === undefined ? parent.pointer - : `${parent.pointer}/${parent.pendingKey.replaceAll("~", "~0").replaceAll("/", "~1")}`; + : `${parent.pointer}/${escapeJsonPointerToken(parent.pendingKey)}`; }; // The document root is container level 1. Admit a container only while // its own level stays within the budget; values inside an admitted diff --git a/src/browser/history/CaptureRedaction.ts b/src/browser/history/CaptureRedaction.ts index 9a505fce04..5a405ce81b 100644 --- a/src/browser/history/CaptureRedaction.ts +++ b/src/browser/history/CaptureRedaction.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import { isSafeNumber, type LosslessNumber } from "lossless-json"; import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js"; import { parseRecordedTransportUrl } from "../../domain/recordedTransportUrl.js"; @@ -149,7 +150,7 @@ export class CaptureRedaction { /** Escape a producer property for an RFC 6901 location. */ export const capturePointer = (parent: string, key: string): string => - `${parent}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`; + `${parent}/${escapeJsonPointerToken(key)}`; /** Structural record guard does not duck-type number/byte sidecar markers. */ export const captureObject = ( diff --git a/src/browser/history/CaptureRelease.ts b/src/browser/history/CaptureRelease.ts index dbb0c5fff6..96fa825ad5 100644 --- a/src/browser/history/CaptureRelease.ts +++ b/src/browser/history/CaptureRelease.ts @@ -1,27 +1,5 @@ -import { - CAPTURE_DECODER_SEMI_SPACE_MIB, - DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB, -} from "../../domain/analysisResourceLimits.js"; import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; -import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; - -/** - * Default V8 heap settings for the owned Node capture decoder process. - * The old generation is `REA_CAPTURE_DECODER_OLD_GENERATION_MIB`; the - * semi-space stays fixed. - */ -export const NODE_CAPTURE_HEAP_LIMITS = { - oldGenerationMiB: DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB, - semiSpaceMiB: CAPTURE_DECODER_SEMI_SPACE_MIB, -} as const; - -/** Windows Job Object commit limit for one resolved old-generation budget. */ -export const captureDecoderProcessMemoryLimitBytes = ( - oldGenerationMiB: number, -): number | null => javaScriptWorkerMemoryLimitBytes(oldGenerationMiB); /** The Node capture decoder runs on this executable; Bun accepts but ignores V8 heap flags. */ export const NODE_CAPTURE_HEAP_ENFORCED = javaScriptRuntime(process.versions).id === "node"; - -/** Fixed offline interpretation profile, not an authenticated producer claim. */ diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index 98e13c8a8f..f8ff159a87 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -1,3 +1,4 @@ +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { captureBudgets, withCaptureBudgets } from "./CaptureBudgets.js"; import type { FileSnapshotPartialObservation } from "../../domain/fileSnapshotObservation.js"; import { @@ -16,10 +17,7 @@ import { resolveCaptureDecoderHeap, type CaptureDecoderHeap, } from "../../config/captureDecoderHeap.js"; -import { - NODE_CAPTURE_HEAP_ENFORCED, - captureDecoderProcessMemoryLimitBytes, -} from "./CaptureRelease.js"; +import { NODE_CAPTURE_HEAP_ENFORCED } from "./CaptureRelease.js"; import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js"; import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; @@ -613,9 +611,7 @@ const nodeMemoryLimitation = (heap: CaptureDecoderHeap): string => { const recovery = captureDecoderHeapRecovery(heap); if (NODE_CAPTURE_HEAP_ENFORCED) return `HAR/SAZ/qlog use a ${String(heap.oldGenerationMiB)} MiB Node old-generation heap (source ${heap.source}). ${recovery}`; - const commitLimit = captureDecoderProcessMemoryLimitBytes( - heap.oldGenerationMiB, - ); + const commitLimit = javaScriptWorkerMemoryLimitBytes(heap.oldGenerationMiB); return commitLimit === null ? `HAR/SAZ/qlog run on Bun, which does not enforce REA's V8 heap flags. ${recovery}` : `HAR/SAZ/qlog run on Bun, which does not enforce REA's V8 heap flags, under a ${String(commitLimit)}-byte Windows Job Object commit limit. ${recovery}`; diff --git a/src/browser/history/NodeCaptureAdapter.test.ts b/src/browser/history/NodeCaptureAdapter.test.ts index 1e224a2ef8..e0857eaac6 100644 --- a/src/browser/history/NodeCaptureAdapter.test.ts +++ b/src/browser/history/NodeCaptureAdapter.test.ts @@ -1,4 +1,4 @@ -import { expect, it, vi } from "vitest"; +import { expect, it } from "vitest"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { NodeCaptureAdapter } from "./NodeCaptureAdapter.js"; @@ -9,17 +9,20 @@ it.each(["har", "qlog"] as const)( }, ); -vi.mock("./CaptureRelease.js", async (original) => ({ - ...(await original()), - NODE_CAPTURE_HEAP_ENFORCED: false, - captureDecoderProcessMemoryLimitBytes: () => null, -})); - it.each(["saz", "qlog"] as const)( - "refuses a %s worker when the runtime has no enforceable memory bound", + "uses an enforceable memory bound for %s on the current runtime", async (format) => { - await expect( - new NodeCaptureAdapter(format).command("/owned/request.json"), - ).rejects.toBeInstanceOf(AnalysisCapabilityUnavailableError); + const adapter = new NodeCaptureAdapter(format); + const bun = process.versions.bun !== undefined; + if (bun && process.platform !== "win32") { + await expect( + adapter.command("/owned/request.json"), + ).rejects.toBeInstanceOf(AnalysisCapabilityUnavailableError); + } else { + const command = await adapter.command("/owned/request.json"); + expect(command.command).toBe(process.execPath); + if (bun) expect(command.memoryLimitBytes).toBeGreaterThan(0); + else expect(command.memoryLimitBytes).toBeUndefined(); + } }, ); diff --git a/src/browser/history/NodeCaptureAdapter.ts b/src/browser/history/NodeCaptureAdapter.ts index 6b0a7abd74..490be0c523 100644 --- a/src/browser/history/NodeCaptureAdapter.ts +++ b/src/browser/history/NodeCaptureAdapter.ts @@ -1,3 +1,4 @@ +import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { fileURLToPath } from "node:url"; import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js"; import { @@ -6,10 +7,7 @@ import { type CaptureDecoderHeap, } from "../../config/captureDecoderHeap.js"; import { CAPTURE_DECODER_SEMI_SPACE_MIB } from "../../domain/analysisResourceLimits.js"; -import { - NODE_CAPTURE_HEAP_ENFORCED, - captureDecoderProcessMemoryLimitBytes, -} from "./CaptureRelease.js"; +import { NODE_CAPTURE_HEAP_ENFORCED } from "./CaptureRelease.js"; import { QLOG_CAPTURE_PROVIDER_IDENTITY, HAR_CAPTURE_PROVIDER_IDENTITY, @@ -54,7 +52,7 @@ export class NodeCaptureAdapter implements HistoricalCaptureFormatAdapter { : []; } async command(requestPath: string) { - const memoryLimitBytes = captureDecoderProcessMemoryLimitBytes( + const memoryLimitBytes = javaScriptWorkerMemoryLimitBytes( this.heap.oldGenerationMiB, ); if ( diff --git a/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts b/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts index cd24739c3d..5e9bc74c2e 100644 --- a/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts +++ b/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts @@ -1,4 +1,5 @@ import { createDeferred } from "../../../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../../../tests/fixtures/lifecycleAcknowledgement.js"; import { readFile, rename, copyFile } from "node:fs/promises"; import { expect, it, vi } from "vitest"; import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js"; @@ -240,7 +241,7 @@ it("bounds active imports without an unbounded queue and releases admission afte ); const first = port.inspect(fixture.path); try { - await reached.promise; + await awaitAcknowledgement(reached.promise, first, "trace import"); const second = await port.inspect(fixture.path); if (second.ok) throw new Error("Expected capacity refusal"); expect(second.error._tag).toBe("AnalysisResourceConstraintError"); diff --git a/src/browser/history/protobuf/ProtobufCapture.ts b/src/browser/history/protobuf/ProtobufCapture.ts index 86cb3c2574..2bdd7f658e 100644 --- a/src/browser/history/protobuf/ProtobufCapture.ts +++ b/src/browser/history/protobuf/ProtobufCapture.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../../../domain/jsonPointer.js"; import { captureBudgets } from "../CaptureBudgets.js"; import { isAbsolute } from "node:path"; import { CaptureFormatError } from "../CaptureFormatError.js"; @@ -63,10 +64,7 @@ const excludeBody = ( sha256: null, }, ); - const parts = pointer - .split("/") - .slice(1) - .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); + const parts = pointer.split("/").slice(1).map(unescapeJsonPointerToken); let parent: JsonValue | undefined = record.reported; for (const part of parts.slice(0, -1)) { if ( diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index cd8d89260d..d8457d62cd 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -13,7 +13,7 @@ import { import { evidenceSchema, type Evidence } from "../domain/evidence.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; -import { err, ok } from "../domain/result.js"; +import { ok } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { OBJDUMP_ARTIFACT_OPERATIONS, diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 45680f2962..866fc03450 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -49,11 +49,7 @@ export const registerUtilityCommands = ( registerReferenceSourceCommand(cli, logger, environment); }; -/** Decimal argv text; the shared deadline field owns the bounds and help. */ -const cliNativeDeadline = (field: typeof nativeUiDeadlineField) => - cliDecimalField(field); - -// Decimal tokens only, for the same reason as cliNativeDeadline. +// Decimal tokens only; shared fields own their bounds and help. const nativeUiScopeOptions = { pid: cliIntegerArgument(1).describe("Existing application PID"), windowId: cliIntegerArgument(1).describe( @@ -70,7 +66,7 @@ const nativeUiScopeOptions = { maxNodes: cliIntegerArgument(1) .default(500) .describe("Maximum accessibility nodes per capture"), - nativeUiDeadlineMs: cliNativeDeadline(nativeUiDeadlineField), + nativeUiDeadlineMs: cliDecimalField(nativeUiDeadlineField), }; const nativeUiScope = (options: { @@ -208,7 +204,7 @@ const registerNativeCommands = ( path: localPathStringSchema.describe("Mach-O or app path"), }), options: z.object({ - nativeCommandTimeoutMs: cliNativeDeadline(nativeCommandTimeoutField), + nativeCommandTimeoutMs: cliDecimalField(nativeCommandTimeoutField), }), run: ({ args, options }) => logCliCommand(logger, command, () => @@ -235,7 +231,7 @@ const registerNativeCommands = ( .describe( "Plist path relative to the app root, or an absolute path (default: the bundle's Info.plist)", ), - nativeCommandTimeoutMs: cliNativeDeadline(nativeCommandTimeoutField), + nativeCommandTimeoutMs: cliDecimalField(nativeCommandTimeoutField), }), run: ({ args, options }) => logCliCommand(logger, "inspect-plist", () => @@ -263,7 +259,7 @@ const registerNativeCommands = ( symbols: swiftSymbolsSchema.describe("Swift mangled symbols to demangle"), }), options: z.object({ - nativeCommandTimeoutMs: cliNativeDeadline(nativeCommandTimeoutField), + nativeCommandTimeoutMs: cliDecimalField(nativeCommandTimeoutField), }), run: ({ args, options }) => logCliCommand(logger, "demangle-swift", () => diff --git a/src/cli/webRuntimeCommands.ts b/src/cli/webRuntimeCommands.ts index ef4a2020c7..d40a70d8da 100644 --- a/src/cli/webRuntimeCommands.ts +++ b/src/cli/webRuntimeCommands.ts @@ -4,7 +4,6 @@ import { createWebRuntimeService } from "../composition/webRuntime.js"; import { cliDomainInput } from "./domainInput.js"; import { observeWebExecutionInputSchema } from "../domain/webExecution.js"; import { inspectWebEventListenersInputSchema } from "../domain/webEventListeners.js"; -import { browserCliError } from "./browserCliError.js"; import { browserScopeOptions, selectedCommandTimeout, diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index fcdc101f47..e90c8b07a6 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -35,7 +35,6 @@ export const parseCliJsonInput = async ( options?: { readonly snapshot?: boolean; readonly stdin?: Readable; - readonly maxStdinBytes?: number; }, ): Promise< | { readonly ok: true; readonly value: unknown } @@ -132,13 +131,12 @@ const readCliJsonStdin = async ( options?: { readonly snapshot?: boolean; readonly stdin?: Readable; - readonly maxStdinBytes?: number; }, ): Promise< Result > => { const stream = options?.stdin ?? process.stdin; - const maxBytes = options?.maxStdinBytes ?? DEFAULT_MAX_STDIN_BYTES; + const maxBytes = DEFAULT_MAX_STDIN_BYTES; const chunks: Buffer[] = []; let totalBytes = 0; const requireHeadroom = (): void => { diff --git a/src/composition/resolveWindowsProviderExecutable.ts b/src/composition/resolveWindowsProviderExecutable.ts index bb5f175882..dc255939ae 100644 --- a/src/composition/resolveWindowsProviderExecutable.ts +++ b/src/composition/resolveWindowsProviderExecutable.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from "node:fs"; +import { statSync } from "node:fs"; import { win32 } from "node:path"; import { windowsPathEntries } from "../process/WindowsExecutable.js"; @@ -11,7 +11,7 @@ export interface WindowsExecutableLookup { const isRegularFile = (path: string): boolean => { try { - return existsSync(path) && statSync(path).isFile(); + return statSync(path).isFile(); } catch { return false; } diff --git a/src/composition/webBundleAnalysisWorkerProcess.ts b/src/composition/webBundleAnalysisWorkerProcess.ts index c10b8203fd..ae0f9b6737 100644 --- a/src/composition/webBundleAnalysisWorkerProcess.ts +++ b/src/composition/webBundleAnalysisWorkerProcess.ts @@ -1,14 +1,8 @@ import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js"; -import { - serveWebBundleAnalysisWorker, - type WebBundleScriptAnalyzer, -} from "../browser/WebBundleAnalysisWorkerServer.js"; - -const analyze: WebBundleScriptAnalyzer = (script, precision) => - analyzeCapturedWebScript(script, precision); +import { serveWebBundleAnalysisWorker } from "../browser/WebBundleAnalysisWorkerServer.js"; try { - await serveWebBundleAnalysisWorker(analyze); + await serveWebBundleAnalysisWorker(analyzeCapturedWebScript); } catch (cause: unknown) { process.stderr.write( `${cause instanceof Error ? cause.message : String(cause)}\n`, diff --git a/src/config/regexWorkerHeapRequest.test.ts b/src/config/regexWorkerHeapRequest.test.ts index ad245270b5..e5fa9c411b 100644 --- a/src/config/regexWorkerHeapRequest.test.ts +++ b/src/config/regexWorkerHeapRequest.test.ts @@ -16,7 +16,6 @@ import { resolveRegexWorkerHeapRequest, REGEX_WORKER_HEAP_REQUEST_SETTING, regexWorkerHeapRequestContinuingRecovery, - regexWorkerHeapRequestRecovery, } from "./regexWorkerHeapRequest.js"; import { MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB, diff --git a/src/contracts/erlangBeamDeliveryContracts.test.ts b/src/contracts/bytecodeDeliveryContracts.test.ts similarity index 61% rename from src/contracts/erlangBeamDeliveryContracts.test.ts rename to src/contracts/bytecodeDeliveryContracts.test.ts index 4a8f4a670e..6b91f4e753 100644 --- a/src/contracts/erlangBeamDeliveryContracts.test.ts +++ b/src/contracts/bytecodeDeliveryContracts.test.ts @@ -4,16 +4,73 @@ import { expect, it } from "vitest"; import { toolContract } from "./toolContracts.js"; import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js"; -const view = { - kind: "beam", - collection: "exports", - fields: ["function"], +const beamView = { kind: "beam", collection: "exports", fields: ["function"] }; +const pycView = { + kind: "python-pyc", + collection: "code_objects", + fields: ["name"], }; -it.each(["draft-2020-12", "draft-07"] as const)( - "advertises BEAM delivery combinations in %s", - (target) => { - const contract = toolContract("inspect_erlang_beam"); +const cases = [ + { + name: "inspect_erlang_beam", + path: "/tmp/module.beam", + view: beamView, + extra: [ + [{ detail: "summary", view: { ...beamView, fields: ["name"] } }, false], + [ + { detail: "summary", view: { ...beamView, modules: ["erlang"] } }, + false, + ], + [ + { + detail: "summary", + view: { ...beamView, collection: "imports", modules: ["erlang"] }, + }, + true, + ], + [ + { + detail: "summary", + view: { ...beamView, collection: "chunks", fields: ["payload"] }, + }, + true, + ], + [ + { + detail: "summary", + view: { ...beamView, collection: "atoms", fields: ["index", "name"] }, + }, + true, + ], + [ + { + detail: "summary", + view: { ...beamView, collection: "lambdas", fields: ["free_vars"] }, + }, + true, + ], + ], + }, + { + name: "inspect_python_pyc", + path: "/tmp/module.pyc", + view: pycView, + extra: [], + }, +] as const; + +it.each( + cases.flatMap((testCase) => + (["draft-2020-12", "draft-07"] as const).map((target) => ({ + ...testCase, + target, + })), + ), +)( + "advertises $name delivery combinations in $target", + ({ name, path, view, extra, target }) => { + const contract = toolContract(name); const projected = toolInputSchemaWithMetadata(contract)[ "~standard" ].jsonSchema.input({ target }); @@ -43,38 +100,9 @@ it.each(["draft-2020-12", "draft-07"] as const)( [{ detail: "summary", view: { ...view, fields: ["missing"] } }, false], [{ detail: "summary", view: { ...view, kind: "native-exports" } }, false], [{ detail: "summary", view, unexpected: true }, false], - [{ detail: "summary", view: { ...view, fields: ["name"] } }, false], - [{ detail: "summary", view: { ...view, modules: ["erlang"] } }, false], - [ - { - detail: "summary", - view: { ...view, collection: "imports", modules: ["erlang"] }, - }, - true, - ], - [ - { - detail: "summary", - view: { ...view, collection: "chunks", fields: ["payload"] }, - }, - true, - ], - [ - { - detail: "summary", - view: { ...view, collection: "atoms", fields: ["index", "name"] }, - }, - true, - ], - [ - { - detail: "summary", - view: { ...view, collection: "lambdas", fields: ["free_vars"] }, - }, - true, - ], + ...extra, ] as const) { - const input = { path: "/tmp/module.beam", ...delivery }; + const input = { path, ...delivery }; expect( contract.inputSchema.safeParse(input).success, JSON.stringify(input), diff --git a/src/contracts/desktopBuildToolContracts.ts b/src/contracts/desktopBuildToolContracts.ts index 813b9cef55..6701c64565 100644 --- a/src/contracts/desktopBuildToolContracts.ts +++ b/src/contracts/desktopBuildToolContracts.ts @@ -10,8 +10,6 @@ import { MAX_BUILD_STATE_PATHS, SEED_CHECKPOINT, } from "../domain/desktop/buildCapture.js"; -import { draftNoteSchema } from "../domain/desktop/appTaskDraftNotes.js"; -import { draftValueSchema } from "../domain/desktop/appTaskDraftValues.js"; import { WITNESS_STAGES } from "../domain/desktop/buildWitness.js"; import { MEASURE_BUILD_TASK_CONTRACT } from "./desktopBuildMeasureContract.js"; import { diff --git a/src/contracts/jsonSchemaReferences.ts b/src/contracts/jsonSchemaReferences.ts index b72267e387..86c2630625 100644 --- a/src/contracts/jsonSchemaReferences.ts +++ b/src/contracts/jsonSchemaReferences.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../domain/jsonPointer.js"; import { schemaArrays, schemaChildren, @@ -231,7 +232,7 @@ export class JsonSchemaReferences { for (const token of fragment.slice(1).split("/")) { if (/~(?:[^01]|$)/u.test(token)) return { kind: "unresolved", reason: "pointer" }; - const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + const key = unescapeJsonPointerToken(token); if ( value === null || typeof value !== "object" || diff --git a/src/contracts/pythonPycDeliveryContracts.test.ts b/src/contracts/pythonPycDeliveryContracts.test.ts deleted file mode 100644 index 3d8074de97..0000000000 --- a/src/contracts/pythonPycDeliveryContracts.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Ajv2020 } from "ajv/dist/2020.js"; -import { Ajv } from "ajv"; -import { expect, it } from "vitest"; -import { toolContract } from "./toolContracts.js"; -import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js"; - -const view = { - kind: "python-pyc", - collection: "code_objects", - fields: ["name"], -}; - -it.each(["draft-2020-12", "draft-07"] as const)( - "advertises pyc delivery combinations in %s", - (target) => { - const contract = toolContract("inspect_python_pyc"); - const projected = toolInputSchemaWithMetadata(contract)[ - "~standard" - ].jsonSchema.input({ target }); - const ajv = - target === "draft-2020-12" - ? new Ajv2020({ strict: false, validateFormats: false }) - : new Ajv({ strict: false, validateFormats: false }); - expect(ajv.validateSchema(projected)).toBe(true); - expect(projected).toMatchObject({ type: "object" }); - for (const key of ["anyOf", "oneOf", "allOf", "x-rea-root-discriminator"]) - expect(projected).not.toHaveProperty(key); - const validate = ajv.compile(projected); - for (const [delivery, valid] of [ - [{}, true], - [{ detail: "complete" }, true], - [{ detail: "summary" }, true], - [{ detail: "summary", view }, true], - [ - { - detail: "summary", - view: { ...view, names: [], fields: [], indices: [] }, - }, - true, - ], - [{ view }, false], - [{ detail: "complete", view }, false], - [{ detail: "summary", view: { ...view, fields: ["missing"] } }, false], - [{ detail: "summary", view: { ...view, kind: "native-exports" } }, false], - [{ detail: "summary", view, unexpected: true }, false], - ] as const) { - const input = { path: "/tmp/module.pyc", ...delivery }; - expect( - contract.inputSchema.safeParse(input).success, - JSON.stringify(input), - ).toBe(valid); - expect( - validate(input), - JSON.stringify({ input, errors: validate.errors }), - ).toBe(valid); - } - }, -); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 087ad12293..8100e805ae 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -106,7 +106,6 @@ import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMe import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestigationGraph.js"; import { addressedEntry, - functionDossierOutput, lifecycleResultOf, nullableText, procedureInfoOutput, diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 457fa3b91e..f482e533c9 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -5,7 +5,6 @@ import { investigationStatisticsSchema } from "../domain/investigationLifecycle. import { utcDateTimeSchema } from "../domain/offsetDateTime.js"; import { analysisCleanupRetrySchema } from "../domain/analysisErrorRecovery.js"; -import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { analysisProfileSchema } from "../domain/analysisProfile.js"; import { evidenceSchema, @@ -783,5 +782,3 @@ export const objcClassDiscoveryOutput = evidenceResultOf( identity_coverage: objcClassDiscoveryCoverageSchema, }), ); - -export const functionDossierOutput = evidenceResultOf(functionDossierSchema); diff --git a/src/desktop/AgentEnvSolveHelper.test.ts b/src/desktop/AgentEnvSolveHelper.test.ts index 4a24e714d8..4b8ce44a45 100644 --- a/src/desktop/AgentEnvSolveHelper.test.ts +++ b/src/desktop/AgentEnvSolveHelper.test.ts @@ -7,11 +7,13 @@ import { writeFile, } from "node:fs/promises"; import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; import { fakeAgentEnvSolves } from "../../tests/fixtures/fakeAgentEnvSolves.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; @@ -22,7 +24,7 @@ import { const SOLVE = { outcome: "passed" as const, checks: {}, turns: 3, usd: 0.1 }; -const until = async (path: string) => { +const until = async (path: string, signal: AbortSignal) => { for (;;) { if ( await access(path).then( @@ -31,7 +33,7 @@ const until = async (path: string) => { ) ) return; - await new Promise((resolve) => setTimeout(resolve, 50)); + await delay(50, undefined, { signal }); } }; @@ -49,10 +51,13 @@ describe("runSolveHelper", () => { const runner = await fakeAgentEnvSolves([SOLVE]); const environment = { ...runner.environment, FAKE_SOLVE_HANGS: "1" }; const stop = new AbortController(); + const polling = new AbortController(); const retained: SolveCleanupOwner[] = []; const started = until( join(runner.environment.FAKE_STATE ?? "", "solves.jsonl"), - ).then(() => stop.abort(new Error("cancelled by the test"))); + polling.signal, + ); + void started.catch(() => undefined); const begun = Date.now(); const solved = runSolveHelper({ @@ -64,16 +69,23 @@ describe("runSolveHelper", () => { // agentenv keeps a sandbox's folder until its teardown is confirmed, so the // emptied private root shows nothing is left up: only the cancellation remains. - const failure: unknown = await solved.then( - () => undefined, - (error: unknown) => error, - ); - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toBe("cancelled by the test"); - expect(failure).not.toHaveProperty("cleanupIncomplete", true); - await started; - expect(retained).toHaveLength(0); - expect(Date.now() - begun).toBeLessThan(15_000); + try { + await awaitAcknowledgement(started, solved, "solve helper start", 10_000); + stop.abort(new Error("cancelled by the test")); + const failure: unknown = await solved.then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe("cancelled by the test"); + expect(failure).not.toHaveProperty("cleanupIncomplete", true); + expect(retained).toHaveLength(0); + expect(Date.now() - begun).toBeLessThan(15_000); + } finally { + polling.abort(); + stop.abort(); + await Promise.allSettled([started, solved]); + } }, 30_000); it("records a helper stopped at its deadline without a report as a failed solve of unknown cost, its cleanup known", async () => { diff --git a/src/desktop/BuildSecretFiles.cleanup.test.ts b/src/desktop/BuildSecretFiles.cleanup.test.ts index 24560149f9..abb9a11615 100644 --- a/src/desktop/BuildSecretFiles.cleanup.test.ts +++ b/src/desktop/BuildSecretFiles.cleanup.test.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, readdir, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -10,7 +10,6 @@ import { DesktopBuildSecrets } from "../application/desktop/DesktopBuildSecrets. // Made-up values, never real credentials. const FIRST = "e-Plum7-Kite-Throwaway-42"; -const SECOND = "e-Quince3-Lantern-Throwaway-77"; const injection = vi.hoisted(() => ({ path: "", diff --git a/src/desktop/DesktopBuildOwnedCommand.test.ts b/src/desktop/DesktopBuildOwnedCommand.test.ts index 0dbc2e2c6c..1c810577a6 100644 --- a/src/desktop/DesktopBuildOwnedCommand.test.ts +++ b/src/desktop/DesktopBuildOwnedCommand.test.ts @@ -65,9 +65,15 @@ it expect(stop).toHaveBeenCalledTimes(processRetained ? 1 : 0); await expect(stat(workspace)).rejects.toMatchObject({ code: "ENOENT" }); } finally { - await chmod(parent, 0o700); - await resources.close(); - command.mockRestore(); + try { + try { + await chmod(parent, 0o700); + } finally { + await resources.close(); + } + } finally { + command.mockRestore(); + } } }, ); diff --git a/src/desktop/DesktopBuildSetupHost.test.ts b/src/desktop/DesktopBuildSetupHost.test.ts index ee0a480c38..7a09af62d6 100644 --- a/src/desktop/DesktopBuildSetupHost.test.ts +++ b/src/desktop/DesktopBuildSetupHost.test.ts @@ -5,6 +5,7 @@ import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { providerSnapshot } from "../../tests/support/reverseEngineeringCompositionFixtures.js"; import * as owned from "../process/OwnedCommand.js"; import { systemDoctorHost } from "../application/Doctor.js"; @@ -89,7 +90,11 @@ it("retains installer cancellation, command outcome and output", async () => { ).installAgentenv(uv, controller.signal); void pending.catch(() => undefined); try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + pending, + "provider lifecycle boundary", + ); controller.abort(reason); const failure: unknown = await pending.catch((cause: unknown) => cause); if (!(failure instanceof AnalysisCancelledError)) throw failure; @@ -103,8 +108,11 @@ it("retains installer cancellation, command outcome and output", async () => { } finally { controller.abort(); await Promise.allSettled([pending]); - await resources.close(); - command.mockRestore(); + try { + await resources.close(); + } finally { + command.mockRestore(); + } } }); diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 624c3e3a36..74a22f0536 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - type AnalysisSnapshot, analysisSnapshotSchema, analysisQueryId, parseAnalysisSnapshot, diff --git a/src/domain/analysisView/analysisViewCycles.test.ts b/src/domain/analysisView/analysisViewCycles.test.ts index 494e8814fb..41e08788b5 100644 --- a/src/domain/analysisView/analysisViewCycles.test.ts +++ b/src/domain/analysisView/analysisViewCycles.test.ts @@ -1,17 +1,11 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; -import { readImportSpecifiers } from "../../../scripts/lib/import-specifiers.mjs"; +import { expectImportCycles } from "../../../tests/fixtures/importCycles.js"; const root = dirname(fileURLToPath(import.meta.url)); -const relativeSpecifiers = (source: string): readonly string[] => - readImportSpecifiers(source) - .map(({ specifier }) => specifier) - .filter((specifier) => specifier.startsWith(".")); - /** * Cycles that already existed outside the analysis-view hub. Membership is * exact so a new edge cannot hide inside the allow-list. @@ -40,97 +34,8 @@ const allowedCycleEdges = [ "environmentStateWriteEffects.ts -> environmentStateWriteAddress.ts", ].sort(); -const productionFiles = (directory: string): string[] => { - const files: string[] = []; - for (const name of readdirSync(directory)) { - const path = join(directory, name); - if (statSync(path).isDirectory()) files.push(...productionFiles(path)); - else if (name.endsWith(".ts") && !name.includes(".test.")) files.push(path); - } - return files; -}; - -const importGraph = (): Map> => { - const files = new Set( - productionFiles(root).map((path) => relative(root, path)), - ); - const graph = new Map>(); - for (const file of files) { - const source = readFileSync(join(root, file), "utf8"); - const edges = new Set(); - for (const spec of relativeSpecifiers(source)) { - const resolved = resolve(dirname(join(root, file)), spec).replace( - /\.js$/, - ".ts", - ); - const target = relative(root, resolved); - if (files.has(target)) edges.add(target); - } - graph.set(file, edges); - } - return graph; -}; - -const stronglyConnected = ( - graph: Map>, -): readonly (readonly string[])[] => { - let index = 0; - const stack: string[] = []; - const onStack = new Set(); - const indices = new Map(); - const low = new Map(); - const components: string[][] = []; - const visit = (node: string): void => { - indices.set(node, index); - low.set(node, index); - index += 1; - stack.push(node); - onStack.add(node); - for (const next of graph.get(node) ?? []) { - if (!indices.has(next)) { - visit(next); - low.set(node, Math.min(low.get(node) ?? index, low.get(next) ?? index)); - } else if (onStack.has(next)) { - low.set( - node, - Math.min(low.get(node) ?? index, indices.get(next) ?? index), - ); - } - } - if (low.get(node) === indices.get(node)) { - const component: string[] = []; - let member = ""; - while (member !== node) { - member = stack.pop() ?? node; - onStack.delete(member); - component.push(member); - } - if (component.length > 1) components.push(component.sort()); - } - }; - for (const node of [...graph.keys()].sort()) { - if (!indices.has(node)) visit(node); - } - return components.sort( - (left, right) => - right.length - left.length || left.join().localeCompare(right.join()), - ); -}; - describe("analysis view import cycles", () => { it("keeps projectors from importing the request and result hub", () => { - const graph = importGraph(); - const cycles = stronglyConnected(graph).map((cycle) => cycle.join("\n")); - expect(cycles).toEqual( - allowedCycles.map((cycle) => [...cycle].sort().join("\n")), - ); - const internalEdges = allowedCycles.flatMap((cycle) => - cycle.flatMap((source) => - [...(graph.get(source) ?? [])] - .filter((target) => cycle.includes(target)) - .map((target) => `${source} -> ${target}`), - ), - ); - expect(internalEdges.sort()).toEqual(allowedCycleEdges); + expectImportCycles(root, allowedCycles, allowedCycleEdges); }); }); diff --git a/src/domain/analysisView/apkSignatureView.ts b/src/domain/analysisView/apkSignatureView.ts index 812c3cf2c7..1d1a2cdf3d 100644 --- a/src/domain/analysisView/apkSignatureView.ts +++ b/src/domain/analysisView/apkSignatureView.ts @@ -194,7 +194,7 @@ const read = (parent: AnalysisViewParent): Result => { function* records( report: ApkSignatureResult, selection: Selection, -): Generator> { +): Generator & { id: string; signer_id?: string }> { const wanted = selection.schemes === undefined ? undefined : new Set(selection.schemes); for (const scheme of schemes) { @@ -317,8 +317,8 @@ export const projectApkSignatureView = ( const rows = ids?.size === 0 || signers?.size === 0 ? [] : records(report, view); for (const row of rows) { - if (ids !== undefined && !ids.has(String(row.id))) continue; - if (signers !== undefined && !signers.has(String(row.signer_id ?? row.id))) + if (ids !== undefined && !ids.has(row.id)) continue; + if (signers !== undefined && !signers.has(row.signer_id ?? row.id)) continue; if (total++ < view.offset || items.length >= view.limit) continue; const item: Record = {}; diff --git a/src/domain/analysisView/environmentStateCounters.ts b/src/domain/analysisView/environmentStateCounters.ts index 2a9fdef81b..bd2764c7fc 100644 --- a/src/domain/analysisView/environmentStateCounters.ts +++ b/src/domain/analysisView/environmentStateCounters.ts @@ -1,3 +1,4 @@ +import { jsonPointerTokens } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; type JsonObject = { [key: string]: JsonValue }; @@ -7,12 +8,7 @@ const isObject = (value: JsonValue | undefined): value is JsonObject => /** A list counter's member names: a top-level member's name not starting with `/`, else a pointer (`/pageInfo/totalRows`, `/~1total`). */ export const counterPath = (counter: string): string[] => - counter.startsWith("/") - ? counter - .slice(1) - .split("/") - .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")) - : [counter]; + counter.startsWith("/") ? jsonPointerTokens(counter) : [counter]; /** A list counter's recorded value, or undefined when the body lacks it. */ export const counterAt = ( diff --git a/src/domain/analysisView/environmentStateListFacts.ts b/src/domain/analysisView/environmentStateListFacts.ts index 22c1df8b7c..e158665d30 100644 --- a/src/domain/analysisView/environmentStateListFacts.ts +++ b/src/domain/analysisView/environmentStateListFacts.ts @@ -1,3 +1,4 @@ +import { jsonPointerTokens } from "../jsonPointer.js"; import { identifierWords } from "./environmentStateWords.js"; import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; @@ -14,7 +15,6 @@ import { listItems, listsIds, pointerTo, - pointerTokens, recordedItemCount, same, scalarText, @@ -52,7 +52,7 @@ export const listFacts = ( if (items.length >= 2) for (const [name, value] of Object.entries(parent)) if ( - name !== pointerTokens(plan.pointer)[0] && + name !== jsonPointerTokens(plan.pointer)[0] && listsIds(value, items, field) ) listing.add(name); @@ -101,7 +101,7 @@ export const itemTotals = ( parent: JsonObject, listPointer: string, ): [counter: string, value: JsonValue | undefined][] => { - const root = pointerTokens(listPointer)[0]; + const root = jsonPointerTokens(listPointer)[0]; const totals: [string, JsonValue | undefined][] = []; for (const [name, value] of Object.entries(parent)) if (isItemTotal(name)) @@ -153,7 +153,7 @@ const continues = (name: string, value: JsonValue | undefined): boolean => /** Continuation facts in the envelope or its sibling paging objects, never inside the list payload. */ const hasContinuation = (parent: JsonObject, listPointer: string): boolean => { - const root = pointerTokens(listPointer)[0]; + const root = jsonPointerTokens(listPointer)[0]; return Object.entries(parent).some( ([name, value]) => continues(name, value) || diff --git a/src/domain/analysisView/environmentStateResponseMembers.ts b/src/domain/analysisView/environmentStateResponseMembers.ts index 51d42ef820..93173334dd 100644 --- a/src/domain/analysisView/environmentStateResponseMembers.ts +++ b/src/domain/analysisView/environmentStateResponseMembers.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../jsonPointer.js"; import { canonicalJson } from "../comparisonSemantics.js"; import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; @@ -183,7 +184,7 @@ export const mintedFor = ( /** The first member a pointer names, which holds the entity rather than the envelope. */ const pointerRoot = (pointer: string): string => - pointer.slice(1).split("/")[0]!.replaceAll("~1", "/").replaceAll("~0", "~"); + unescapeJsonPointerToken(pointer.slice(1).split("/")[0]!); /** Of these names, the top-level members a body carries outside the entity at `pointer`, or anywhere in a document (no pointer); none when the entity is the whole body. */ export const envelopeMembers = ( diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 48a49dacf3..fc979af021 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken, jsonPointerTokens } from "../jsonPointer.js"; import { iterateUrlQueryParameters, parseUrlQueryParameterNames, @@ -575,15 +576,7 @@ export const deeperThan = (value: JsonValue, limit: number): boolean => { }; export const pointerTo = (name: string): string => - `/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`; -/** The unescaped reference tokens of a pointer. */ -export const pointerTokens = (pointer: string): string[] => - pointer === "" - ? [] - : pointer - .slice(1) - .split("/") - .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")); + `/${escapeJsonPointerToken(name)}`; const memberOf = ( body: JsonValue | undefined, @@ -597,7 +590,7 @@ const soleElement = (value: JsonValue | undefined): JsonValue | undefined => /** The value at a pointer: the root, one member, or element 0 of a one-element array at the root or in one member. */ export const at = (body: JsonValue, pointer: string): JsonValue | undefined => { - const [first, second, ...rest] = pointerTokens(pointer); + const [first, second, ...rest] = jsonPointerTokens(pointer); if (first === undefined) return body; if (second !== undefined) return second === "0" && rest.length === 0 @@ -616,7 +609,7 @@ export const replaceAt = ( pointer: string, value: JsonValue, ): JsonValue => { - const [first, second] = pointerTokens(pointer); + const [first, second] = jsonPointerTokens(pointer); if (first === undefined) return value; if (at(body, pointer) === undefined) return body; if (!isObject(body)) return [value]; diff --git a/src/domain/analysisView/environmentStateVariedCalls.test.ts b/src/domain/analysisView/environmentStateVariedCalls.test.ts index f157859c3b..94c8a2424a 100644 --- a/src/domain/analysisView/environmentStateVariedCalls.test.ts +++ b/src/domain/analysisView/environmentStateVariedCalls.test.ts @@ -9,7 +9,6 @@ import { DEFAULT_THEN_NUMBERED_ROWS, DONE_CLOCK_ROWS, DONE_MICRO_ROWS, - NUMBERED_THEN_BLANK_ROWS, OWNER_CLEARED_THEN_NAMED_ROWS, SENT_DELETED_THEN_NUMBERED_ROWS, SENT_IN_ANOTHER_PROJECT_ROWS, diff --git a/src/domain/analysisView/environmentStateWriteApply.test.ts b/src/domain/analysisView/environmentStateWriteApply.test.ts index e2e84fd59f..89c9af7505 100644 --- a/src/domain/analysisView/environmentStateWriteApply.test.ts +++ b/src/domain/analysisView/environmentStateWriteApply.test.ts @@ -136,10 +136,19 @@ it.each<[string, JsonObject | undefined, string]>([ }); it.each<[string, JsonValue | undefined]>([ - ["", { a: [{ b: 1 }, { b: [2, 3] }], "x/y": { "m~n": true }, "": "blank" }], + [ + "", + { + a: [{ b: 1 }, { b: [2, 3] }], + "x/y": { "m~n": true }, + "~1": "literal tilde-one", + "": "blank", + }, + ], ["/a/0/b", 1], ["/a/1/b/1", 3], ["/x~1y/m~0n", true], + ["/~01", "literal tilde-one"], ["/", "blank"], ["/a/2/b", undefined], ["/a/01/b", undefined], @@ -151,6 +160,7 @@ it.each<[string, JsonValue | undefined]>([ const body = { a: [{ b: 1 }, { b: [2, 3] }], "x/y": { "m~n": true }, + "~1": "literal tilde-one", "": "blank", }; expect(valueAt(body, pointer)).toEqual(expected); diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 3386f1a3d0..e64aba21e1 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -1,3 +1,4 @@ +import { jsonPointerTokens } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import { @@ -6,7 +7,6 @@ import { at, isObject, pointerTo, - pointerTokens, replaceAt, same, scalarText, @@ -179,7 +179,7 @@ export const valueAt = ( pointer: string, ): JsonValue | undefined => { let value: JsonValue | undefined = body; - for (const token of pointerTokens(pointer)) { + for (const token of jsonPointerTokens(pointer)) { if (Array.isArray(value)) { // JSON Pointer array indexes are canonical decimal tokens; coercing // arbitrary text would make /01 alias /1 and can turn huge indexes into @@ -343,7 +343,7 @@ const copiedShapes = ( rules.flatMap((rule): [string, JsonValue][] => { if (rule.rule !== "copy" || !Object.hasOwn(observed, rule.field)) return []; - const tokens = pointerTokens(rule.from); + const tokens = jsonPointerTokens(rule.from); return tokens.length === 1 ? [[tokens[0]!, observed[rule.field]!]] : []; }), ); @@ -708,7 +708,7 @@ export const writtenResponse = ( ); const answer = (written.unloaded ?? []).reduce( (rendered, path) => { - const tokens = pointerTokens(path); + const tokens = jsonPointerTokens(path); const kept = memberAt(shown, tokens); if (kept !== undefined) return withMemberAt(rendered, tokens, kept); // A member the answer left out of an object it showed stays out. diff --git a/src/domain/analysisView/environmentStateWriteCopies.ts b/src/domain/analysisView/environmentStateWriteCopies.ts index 44f2dec475..aa162e9d54 100644 --- a/src/domain/analysisView/environmentStateWriteCopies.ts +++ b/src/domain/analysisView/environmentStateWriteCopies.ts @@ -1,7 +1,7 @@ +import { jsonPointerTokens } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { ownMember, - pointerTokens, scalarText, type JsonObject, type StateEntity, @@ -49,7 +49,7 @@ export const storedMembers = ( rules.flatMap((rule) => { if (rule.rule === "echo") return [rule.field]; if (rule.rule === "reference") return [rule.member]; - const tokens = rule.rule === "copy" ? pointerTokens(rule.from) : []; + const tokens = rule.rule === "copy" ? jsonPointerTokens(rule.from) : []; // A member also stored otherwise under its own name is not stored as sent. return tokens.length === 1 && !Object.hasOwn(entity, tokens[0]!) ? tokens diff --git a/src/domain/analysisView/webBundleView.test.ts b/src/domain/analysisView/webBundleView.test.ts index 3c6ed6133e..25639df204 100644 --- a/src/domain/analysisView/webBundleView.test.ts +++ b/src/domain/analysisView/webBundleView.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "vitest"; import type { WebBundleAnalysis } from "../webBundleAnalysis.js"; +import { sampleWebBundleAnalysis } from "../../../tests/fixtures/webBundleAnalysis.js"; import { AnalysisInputError } from "../analysisErrorCore.js"; import { projectAnalysisError } from "../analysisErrorProjection.js"; import { @@ -8,104 +9,6 @@ import { } from "./analysisView.js"; import { analysisViewRequestSchema } from "./analysisViewRequest.js"; -const sampleAnalysis = (): WebBundleAnalysis => ({ - capture: { - target_url: "https://example.test/app", - target_origin: "https://example.test", - scripts_observed: 3, - scripts_analyzed: 2, - source_artifacts: [], - }, - observations: { - chunks: { - nodes: [ - { - script_key: "app.js", - url: "https://example.test/app.js", - artifact_sha256: "a".repeat(64), - bytes: 1200, - }, - { - script_key: "vendor.js", - url: "https://example.test/vendor.js", - artifact_sha256: "b".repeat(64), - bytes: 4500, - }, - ], - edges: [], - }, - routes: [], - endpoints: [ - { - value: "/api/users", - mechanism: "call:fetch", - location: { script_key: "app.js", line: 10, column: 2 }, - }, - { - value: "/api/users", - mechanism: "call:fetch", - location: { script_key: "vendor.js", line: 42, column: 8 }, - }, - { - value: "/api/orders", - mechanism: "call:sendBeacon", - location: { script_key: "app.js", line: 85, column: 4 }, - }, - ], - webmcp_declarations: [], - source_maps: { - status: "not_requested", - requested: 0, - processed: 0, - items: [], - }, - }, - inferences: [ - { - kind: "endpoint_candidate", - value: "/api/dynamic-user", - confidence: "low", - basis: [ - { - script_key: "app.js", - artifact_sha256: "a".repeat(64), - line: 25, - column: 4, - detector: "call:client.get:unverified-network-receiver", - }, - ], - }, - { - kind: "vendor_fingerprint", - value: "React", - confidence: "high", - basis: [ - { - script_key: "vendor.js", - artifact_sha256: "b".repeat(64), - line: 1, - column: 0, - detector: "vendor:react", - }, - ], - }, - ], - unknowns: [ - { - dimension: "network", - reason: "Unverified client receiver", - affected_script_keys: ["app.js"], - }, - ], - completeness: { - status: "partial", - parsed_scripts: 2, - parse_failures: 1, - visited_ast_nodes: 150, - }, - limitations: ["Script evaluation was omitted"], -}); - const makeParent = ( overrides?: Partial, ): AnalysisViewParent => ({ @@ -114,7 +17,7 @@ const makeParent = ( operation: "analyze_web_bundle", limitations: ["Parent run was bounded"], normalizedResult: { - ...sampleAnalysis(), + ...sampleWebBundleAnalysis(), ...overrides, }, }); @@ -134,7 +37,7 @@ const query = ( it.each([undefined, null, "https://example.test"])( "preserves historical omitted and explicit target origins: %s", (origin) => { - const report = sampleAnalysis(); + const report = sampleWebBundleAnalysis(); if (origin === undefined) delete report.capture.target_origin; else report.capture.target_origin = origin; const parent = makeParent(report); @@ -247,7 +150,7 @@ it("pages endpoints and concatenated pages match original records exactly, prese }); const concatenated = [...page1.items, ...page2.items]; - const original = sampleAnalysis().observations.endpoints; + const original = sampleWebBundleAnalysis().observations.endpoints; expect(concatenated).toEqual( original.map((row, source_index) => ({ source_index, ...row })), ); @@ -399,7 +302,7 @@ it("returns typed AnalysisResourceConstraintError when a single record exceeds 1 }; const parent = makeParent({ observations: { - ...sampleAnalysis().observations, + ...sampleWebBundleAnalysis().observations, endpoints: [hugeEndpoint], }, }); @@ -430,7 +333,7 @@ it.each([undefined, ["call:fetch"]])( }); const parent = makeParent({ observations: { - ...sampleAnalysis().observations, + ...sampleWebBundleAnalysis().observations, endpoints: [ largeRecord(1), largeRecord(2), @@ -480,7 +383,7 @@ it("rejects non-web-bundle parent operation with invalid_value", () => { evidenceId: `ev_${"d".repeat(64)}`, operation: "analyze_javascript_application", limitations: [], - normalizedResult: sampleAnalysis(), + normalizedResult: sampleWebBundleAnalysis(), }; const result = projectAnalysisView( @@ -500,7 +403,7 @@ it("rejects non-web-bundle parent operation with invalid_value", () => { }); it("continues filtered pages by matching ordinal while preserving original indices", () => { - const report = sampleAnalysis(); + const report = sampleWebBundleAnalysis(); const row = report.observations.endpoints[0]!; const endpoints = [ { ...row, mechanism: "call:sendBeacon" }, diff --git a/src/domain/android/androidMapping.ts b/src/domain/android/androidMapping.ts index d49c924905..6f24529a85 100644 --- a/src/domain/android/androidMapping.ts +++ b/src/domain/android/androidMapping.ts @@ -6,7 +6,6 @@ import { PROGUARD_MAPPING_LIMITS, proguardResolutionBytes, type ProguardMapping, - type ProguardResolution, resolveProguardClass, resolveProguardField, resolveProguardMethod, diff --git a/src/domain/apiContract/parseApiContract.ts b/src/domain/apiContract/parseApiContract.ts index f08178f2bb..e98446a42d 100644 --- a/src/domain/apiContract/parseApiContract.ts +++ b/src/domain/apiContract/parseApiContract.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import { AnalysisCancelledError, AnalysisInputError, @@ -44,8 +45,6 @@ const record = (value: unknown): Record | undefined => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; -const pointerPart = (value: string): string => - value.replaceAll("~", "~0").replaceAll("/", "~1"); const identity = (prefix: string, facts: unknown) => `${prefix}_${digestCanonicalValue(facts)}`; @@ -240,7 +239,7 @@ function* additionalOperations( if (examined++ % 128 === 0) yield; const admitted = inventory.reserve(512 + method.length * 24); if (!admitted.ok) return admitted; - const source = `${pointer}/${pointerPart(method)}`; + const source = `${pointer}/${escapeJsonPointerToken(method)}`; const reserved = reservedAdditionalMethods.has(method); const added = !httpToken.test(method) || reserved @@ -308,7 +307,7 @@ function* pathItem( if (!admitted.ok) return admitted; const diagnosed = inventory.diagnostic( "unknown-path-item-field", - `${pointer}/${pointerPart(key)}`, + `${pointer}/${escapeJsonPointerToken(key)}`, "This field is not an operation or a recognized Path Item field in the selected OpenAPI minor version.", ); if (!diagnosed.ok) return diagnosed; @@ -391,7 +390,7 @@ function* collectPaths( // Reserve expanded pointer/path strings before escaping or building a row. const admitted = inventory.reserve(512 + path.length * 16); if (!admitted.ok) return admitted; - const pointer = `/paths/${pointerPart(path)}`; + const pointer = `/paths/${escapeJsonPointerToken(path)}`; const read = path.startsWith("/") ? yield* pathItem(sourcePaths[path], { inventory, path, pointer, modern }) : inventory.diagnostic( diff --git a/src/domain/apkSignature/apkSignatureSchemes.test.ts b/src/domain/apkSignature/apkSignatureSchemes.test.ts index b7419eab2f..3c297c3c3e 100644 --- a/src/domain/apkSignature/apkSignatureSchemes.test.ts +++ b/src/domain/apkSignature/apkSignatureSchemes.test.ts @@ -5,10 +5,12 @@ import { contentDigestAlgorithm, decodeRotationLineage, decodeSigners, - MAX_LINEAGE_NODES, - MAX_SIGNERS, } from "./apkSignatureSchemes.js"; +// Fixed input boundaries exercise the decoder without importing its internal limits. +const MAX_SIGNERS = 32; +const MAX_LINEAGE_NODES = 32; + const ATTR_ID = 0xbeeff00d; const u32 = (n: number): number[] => [ diff --git a/src/domain/apkSignature/apkSignatureSchemes.ts b/src/domain/apkSignature/apkSignatureSchemes.ts index 8e7714a101..e4032a0851 100644 --- a/src/domain/apkSignature/apkSignatureSchemes.ts +++ b/src/domain/apkSignature/apkSignatureSchemes.ts @@ -66,12 +66,12 @@ const MAX_ELEMENTS = 4096; * Resource admission for signer fan-out, including per-signer X.509 work. * This is not an APK format maximum; excess records return a capacity error. */ -export const MAX_SIGNERS = 32; +const MAX_SIGNERS = 32; /** * Resource admission for lineage fan-out before certificate materialisation. * This is not a format maximum; longer declared chains are refused explicitly. */ -export const MAX_LINEAGE_NODES = 32; +const MAX_LINEAGE_NODES = 32; /** The only proof-of-rotation lineage encoding version apksig accepts. */ const LINEAGE_VERSION = 1; diff --git a/src/domain/apkSignature/apkV1Signature.test.ts b/src/domain/apkSignature/apkV1Signature.test.ts index e8518230f9..46f7553466 100644 --- a/src/domain/apkSignature/apkV1Signature.test.ts +++ b/src/domain/apkSignature/apkV1Signature.test.ts @@ -5,11 +5,13 @@ import { centralDirectoryV1SignatureEntries, detectV1Signature, localEntryData, - MAX_DUPLICATE_NAME_SAMPLE, - MAX_V1_SIGNATURE_ENTRIES, type ApkV1Entry, } from "./apkV1Signature.js"; +// Fixed examples keep the entry and output-sample limits independently checked. +const MAX_V1_SIGNATURE_ENTRIES = 4096; +const MAX_DUPLICATE_NAME_SAMPLE = 5; + /** A central-directory entry with no stored data. */ const entry = (name: string): ApkV1Entry => ({ name, diff --git a/src/domain/apkSignature/apkV1Signature.ts b/src/domain/apkSignature/apkV1Signature.ts index 554ee06211..662dbc5d24 100644 --- a/src/domain/apkSignature/apkV1Signature.ts +++ b/src/domain/apkSignature/apkV1Signature.ts @@ -54,7 +54,7 @@ export type ApkCentralDirectoryError = * Signing-relevant `META-INF/` entries retained. A signed APK holds a manifest * plus one `.SF` and one block per v1 signer; thousands is already pathological. */ -export const MAX_V1_SIGNATURE_ENTRIES = 4096; +const MAX_V1_SIGNATURE_ENTRIES = 4096; /** * v1 signers (signature block + `.SF` pairs) decoded. apksig accepts at most @@ -63,7 +63,7 @@ export const MAX_V1_SIGNATURE_ENTRIES = 4096; export const MAX_V1_SIGNERS = 32; /** Duplicated central-directory names decoded for the report; the rest are counted. */ -export const MAX_DUPLICATE_NAME_SAMPLE = 5; +const MAX_DUPLICATE_NAME_SAMPLE = 5; /** A signing-relevant central-directory entry and where its data is stored. */ export interface ApkV1Entry { diff --git a/src/domain/apkSignature/apkV1SignatureBlock.test.ts b/src/domain/apkSignature/apkV1SignatureBlock.test.ts index 4f60aea58f..00e41a5c83 100644 --- a/src/domain/apkSignature/apkV1SignatureBlock.test.ts +++ b/src/domain/apkSignature/apkV1SignatureBlock.test.ts @@ -19,12 +19,12 @@ import { signerInfo, tlv, } from "../../../tests/fixtures/apkSignature/v1SignatureBlock.js"; -import { - decodeV1SignatureBlock, - MAX_ASN1_ELEMENTS, - MAX_V1_BLOCK_CERTIFICATES, - MAX_V1_SIGNER_INFOS, -} from "./apkV1SignatureBlock.js"; +import { decodeV1SignatureBlock } from "./apkV1SignatureBlock.js"; + +// Fixed input boundaries exercise the decoder without importing its internal limits. +const MAX_V1_BLOCK_CERTIFICATES = 32; +const MAX_V1_SIGNER_INFOS = 32; +const MAX_ASN1_ELEMENTS = 65_536; const sha256 = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); diff --git a/src/domain/apkSignature/apkV1SignatureBlock.ts b/src/domain/apkSignature/apkV1SignatureBlock.ts index 29e259815a..5946e8303f 100644 --- a/src/domain/apkSignature/apkV1SignatureBlock.ts +++ b/src/domain/apkSignature/apkV1SignatureBlock.ts @@ -35,13 +35,13 @@ export type ApkV1BlockError = }; /** Certificates in one block: a signer leaf plus a short chain is the norm. */ -export const MAX_V1_BLOCK_CERTIFICATES = 32; +const MAX_V1_BLOCK_CERTIFICATES = 32; /** SignerInfos in one block; apksig verifies one, more is pathological. */ -export const MAX_V1_SIGNER_INFOS = 32; +const MAX_V1_SIGNER_INFOS = 32; /** Nesting depth; SignedData → X.509 extensions is under 12 levels deep. */ const MAX_ASN1_DEPTH = 32; /** Elements read while decoding one block; bounds work for any byte length. */ -export const MAX_ASN1_ELEMENTS = 65_536; +const MAX_ASN1_ELEMENTS = 65_536; export interface ApkV1SignerInfo { readonly identifiedBy: "issuer-and-serial" | "subject-key-identifier"; diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index 61f1dbab49..82993bb040 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { - bundleResourceRootSchema, bundleResourceBoundResultSchema, type BundleResourceRoot, } from "./bundleResourceRoot.js"; diff --git a/src/domain/browserScenarioDiff.ts b/src/domain/browserScenarioDiff.ts index 86c1811be4..e74255567f 100644 --- a/src/domain/browserScenarioDiff.ts +++ b/src/domain/browserScenarioDiff.ts @@ -81,7 +81,7 @@ export const compareBrowserScenarios = ( input.before_scenario, input.after_scenario, ); - const incomparableReason = eventCoverageReason( + const incomparableReason = joinIncomparableReasons( familyIncomparableReason, networkContentCoverageReason(input.before_scenario, input.after_scenario), beforeEvents.coverageReason, @@ -499,20 +499,6 @@ const digestScenarioEvents = ( ); }; -const eventCoverageReason = ( - familyReason: string | undefined, - networkContentReason: string | undefined, - beforeNetworkReason: string | undefined, - afterNetworkReason: string | undefined, -): string | undefined => { - return joinIncomparableReasons( - familyReason, - networkContentReason, - beforeNetworkReason, - afterNetworkReason, - ); -}; - interface CompareCapturedValuesInput { readonly artifact: BrowserScenarioArtifactKind; readonly before: CaptureState; diff --git a/src/domain/desktop/appTaskDraft.ts b/src/domain/desktop/appTaskDraft.ts index 4e83eed0cf..7f105c55d3 100644 --- a/src/domain/desktop/appTaskDraft.ts +++ b/src/domain/desktop/appTaskDraft.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { isTimestamp, type DesktopTaskCheckInput } from "./appTask.js"; import { @@ -33,7 +34,7 @@ import { } from "./desktopState.js"; import { diffNamedMembers, diffStateViews } from "./stateChanges.js"; import { DRAFT_VIEW_CHARS } from "./valueCuts.js"; -import { escapeToken, resolvePointer } from "./viewPointer.js"; +import { resolvePointer } from "./viewPointer.js"; type Version = DesktopStatePath["versions"][number]; type Suggestion = DesktopTaskCheckInput; @@ -178,8 +179,8 @@ const keyPointers = ( isRecord(view) && isRecord(view.groups) ? view.groups : {}, ).flatMap(([group, keys]) => Object.keys(isRecord(keys) ? keys : {}).map((key): [string, string] => [ - `${group}/${escapeToken(key)}`, - `/groups/${escapeToken(group)}/${escapeToken(key)}`, + `${group}/${escapeJsonPointerToken(key)}`, + `/groups/${escapeJsonPointerToken(group)}/${escapeJsonPointerToken(key)}`, ]), ), ), diff --git a/src/domain/desktop/appTaskDraftAdded.ts b/src/domain/desktop/appTaskDraftAdded.ts index 1fd48ff69f..8539a89afe 100644 --- a/src/domain/desktop/appTaskDraftAdded.ts +++ b/src/domain/desktop/appTaskDraftAdded.ts @@ -1,3 +1,7 @@ +import { + escapeJsonPointerToken, + unescapeJsonPointerToken, +} from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { collapseWhitespace, type DesktopTaskCheckInput } from "./appTask.js"; import { @@ -16,12 +20,7 @@ import { rankAttributes, } from "./attributeRank.js"; import { pairMembers } from "./memberPairing.js"; -import { - elementText, - escapeToken, - isXmlElement, - resolvePointer, -} from "./viewPointer.js"; +import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; import { selectorOf } from "./viewSelector.js"; type Suggestion = DesktopTaskCheckInput; @@ -136,9 +135,7 @@ const idsIn = (roots: readonly JsonValue[]): ReadonlySet => { /** The attribute keys the last segment of a pointer selects by. */ const selectorKeys = (pointer: string): ReadonlySet => { - const segment = (pointer.split("/").at(-1) ?? "") - .replaceAll("~1", "/") - .replaceAll("~0", "~"); + const segment = unescapeJsonPointerToken(pointer.split("/").at(-1) ?? ""); return new Set( [...segment.matchAll(/\[@(?[^=\]]+)=/gu)].map( (match) => match.groups?.key ?? "", @@ -214,7 +211,11 @@ export const attributeChecks = ( ); return rankAttributes( eligible.filter(({ key, value }) => - checkFits(context, `${pointer}/attributes/${escapeToken(key)}`, value), + checkFits( + context, + `${pointer}/attributes/${escapeJsonPointerToken(key)}`, + value, + ), ), ) .toSorted( @@ -225,7 +226,7 @@ export const attributeChecks = ( .map(({ key, value }) => valueEqualsCheck( file, - `${pointer}/attributes/${escapeToken(key)}`, + `${pointer}/attributes/${escapeJsonPointerToken(key)}`, value, ), ); @@ -329,7 +330,7 @@ export const fieldChecks = ( element.children.forEach((field, index) => { if (!isXmlElement(field)) return; const key = keys[index] ?? String(index); - const fieldAt = `${pointer}/children/${key.startsWith("[") ? escapeToken(key) : key}`; + const fieldAt = `${pointer}/children/${key.startsWith("[") ? escapeJsonPointerToken(key) : key}`; if ( ID_FIELD.test(field.name) || inSplitDate(ends.final, fieldAt) || @@ -420,11 +421,8 @@ const sharedFields = ( const selectorFields = (pointer: string): ReadonlySet => new Set( ( - selectorOf( - (pointer.split("/").at(-1) ?? "") - .replaceAll("~1", "/") - .replaceAll("~0", "~"), - ) ?? [] + selectorOf(unescapeJsonPointerToken(pointer.split("/").at(-1) ?? "")) ?? + [] ) .filter(({ prefix }) => prefix === ">") .map(({ key, value }) => `${key}\u0000${value}`), @@ -495,7 +493,7 @@ const fieldsOf = ( ]); return element.children.flatMap((field, index): Suggestion[] => { const key = keys[index] ?? ""; - const at = `${pointer}/children/${escapeToken(key)}`; + const at = `${pointer}/children/${escapeJsonPointerToken(key)}`; if ( !isXmlElement(field) || !key.startsWith("[") || @@ -515,7 +513,7 @@ const fieldsOf = ( isXmlElement(record) ? fieldsOf( context, - `${at}/children/${escapeToken(records[place] ?? "")}`, + `${at}/children/${escapeJsonPointerToken(records[place] ?? "")}`, record, { nested: false }, ) diff --git a/src/domain/desktop/appTaskDraftAlternatives.ts b/src/domain/desktop/appTaskDraftAlternatives.ts index 5f194fb6c2..c0eee64aec 100644 --- a/src/domain/desktop/appTaskDraftAlternatives.ts +++ b/src/domain/desktop/appTaskDraftAlternatives.ts @@ -1,3 +1,7 @@ +import { + escapeJsonPointerToken, + unescapeJsonPointerToken, +} from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { isStamp } from "./appTaskDraftNoise.js"; import { @@ -6,15 +10,12 @@ import { isPlaceholder, isSaveNumberedId, } from "./attributeRank.js"; -import { escapeToken, isXmlElement, resolvePointer } from "./viewPointer.js"; +import { isXmlElement, resolvePointer } from "./viewPointer.js"; import { selectorOf, spellCondition } from "./viewSelector.js"; /** Other pointers a value's entry lists. */ const MAX_ALTERNATIVES = 3; -const unescape = (token: string) => - token.replaceAll("~1", "/").replaceAll("~0", "~"); - /** * The value's pointer naming its element another way, for an author to pick * a key without another export: the innermost member a pointer selects by a @@ -32,7 +33,7 @@ export const alternativePointers = ( for (let index = segments.length - 1; index >= 2; index -= 1) { const conditions = segments[index - 1] === "children" - ? selectorOf(unescape(segments[index] ?? "")) + ? selectorOf(unescapeJsonPointerToken(segments[index] ?? "")) : undefined; if (conditions === undefined) continue; // A step its tag alone names, as an XHTML `h1` holding `id="chapter-two"`, gives way outward when nothing else names it. @@ -56,7 +57,7 @@ export const alternativePointers = ( ); const reads = segments[index + 1] === "attributes" && index + 3 === segments.length - ? unescape(segments[index + 2] ?? "") + ? unescapeJsonPointerToken(segments[index + 2] ?? "") : undefined; const alone = (key: string, value: string) => list.filter( @@ -81,7 +82,7 @@ export const alternativePointers = ( ? [ [ ...segments.slice(0, index), - escapeToken( + escapeJsonPointerToken( `[name=${element.name}]${spellCondition({ prefix: "@", key, value })}`, ), ...segments.slice(index + 1), diff --git a/src/domain/desktop/appTaskDraftAttributeText.ts b/src/domain/desktop/appTaskDraftAttributeText.ts index bc98b46f8f..35fe1a39af 100644 --- a/src/domain/desktop/appTaskDraftAttributeText.ts +++ b/src/domain/desktop/appTaskDraftAttributeText.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import type { DesktopTaskCheckInput } from "./appTask.js"; import { @@ -7,12 +8,7 @@ import { } from "./appTaskDraftNoise.js"; import { isIdentifyingAttribute, isNameLikeKey } from "./attributeRank.js"; import type { DesktopStateDifference } from "./stateChanges.js"; -import { - elementText, - escapeToken, - isXmlElement, - resolvePointer, -} from "./viewPointer.js"; +import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; /** How far a text check climbs from a run to the element an attribute names. */ const MAX_CLIMB = 3; @@ -185,7 +181,7 @@ export const textAttributes = ( ...change.removed.flatMap((pointer) => own(ends.baseline, pointer)), ].filter( ({ owner, key }) => - noise(`${owner}/attributes/${escapeToken(key)}`) === undefined, + noise(`${owner}/attributes/${escapeJsonPointerToken(key)}`) === undefined, ); }; diff --git a/src/domain/desktop/appTaskDraftGuards.ts b/src/domain/desktop/appTaskDraftGuards.ts index 6df054c302..8f8d65791b 100644 --- a/src/domain/desktop/appTaskDraftGuards.ts +++ b/src/domain/desktop/appTaskDraftGuards.ts @@ -1,3 +1,7 @@ +import { + escapeJsonPointerToken, + unescapeJsonPointerToken, +} from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { collapseWhitespace, type DesktopTaskCheckInput } from "./appTask.js"; import { sameJson } from "./appTaskChecks.js"; @@ -18,12 +22,7 @@ import { type ListStep, } from "./appTaskDraftSteps.js"; import { numberedInOrder } from "./positionalKeys.js"; -import { - elementText, - escapeToken, - isXmlElement, - resolvePointer, -} from "./viewPointer.js"; +import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; import { selectorOf, spellCondition, type Condition } from "./viewSelector.js"; import { childConditions } from "./selectorCandidates.js"; @@ -142,7 +141,7 @@ const sharing = ( ) => { const own = attributesOf(member); const selected = new Set( - (selectorOf(key.replaceAll("~1", "/").replaceAll("~0", "~")) ?? []).map( + (selectorOf(unescapeJsonPointerToken(key)) ?? []).map( ({ key: name }) => name, ), ); @@ -209,7 +208,7 @@ const siblingsOf = ( pairing.older.keys[partner] !== key || !isXmlElement(element) ? [] - : [{ member: escapeToken(key), element }]; + : [{ member: escapeJsonPointerToken(key), element }]; }), all, }; @@ -224,12 +223,12 @@ const attributesOf = ({ attributes }: Record) => /** The conditions a sibling's segment selects it by, `@` attributes and `>` children's text. */ const conditionsOf = (member: string) => - selectorOf(member.replaceAll("~1", "/").replaceAll("~0", "~")) ?? []; + selectorOf(unescapeJsonPointerToken(member)) ?? []; /** The attribute a path into a member reads, as `name` for `attributes/name`. */ const attributeRead = (rest: readonly string[]) => rest.length === 2 && rest[0] === "attributes" - ? (rest[1] ?? "").replaceAll("~1", "/").replaceAll("~0", "~") + ? unescapeJsonPointerToken(rest[1] ?? "") : undefined; /** What a guard at a path into a sibling reads at the seed: an attribute's value, or an element's whole text, trimmed. */ @@ -311,7 +310,9 @@ const rekeyed = ( resolvePointer(view, `${step.list}/${member}`, "xml"); const end = at(sibling.member, context.ends.final); for (const condition of candidates) { - const member = escapeToken(`[name=${tag}]${spellCondition(condition)}`); + const member = escapeJsonPointerToken( + `[name=${tag}]${spellCondition(condition)}`, + ); if ( at(member, context.ends.baseline) === element && at(member, context.ends.final) === end @@ -373,12 +374,13 @@ const guardField = ( !isIdentifyingAttribute(key) && !isStamp(value) && !numberedInOrder(all, String(sibling.element.name), key) && - varies(["attributes", escapeToken(key)]) + varies(["attributes", escapeJsonPointerToken(key)]) ? [{ key, value }] : [], ), ); - if (best !== undefined) return ["attributes", escapeToken(best.key)]; + if (best !== undefined) + return ["attributes", escapeJsonPointerToken(best.key)]; return own ? rest : undefined; }; diff --git a/src/domain/desktop/appTaskDraftJson.ts b/src/domain/desktop/appTaskDraftJson.ts index 1a45d92410..d032d6bc89 100644 --- a/src/domain/desktop/appTaskDraftJson.ts +++ b/src/domain/desktop/appTaskDraftJson.ts @@ -1,3 +1,8 @@ +import { + escapeJsonPointerToken, + jsonPointerTokens, + unescapeJsonPointerToken, +} from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import type { DesktopTaskCheckInput } from "./appTask.js"; import type { FileNote } from "./appTaskDraftFileNotes.js"; @@ -17,7 +22,6 @@ import { import { pairMembers } from "./memberPairing.js"; import type { DesktopStateDifference } from "./stateChanges.js"; import { - escapeToken, locatePointer as locateView, resolvePointer as resolveView, } from "./viewPointer.js"; @@ -46,9 +50,9 @@ export const generatedRecordId = ( return (pointer: string): boolean => { const segments = pointer.split("/"); if (segments.length < 3) return false; - const field = segments.at(-1)?.replaceAll("~1", "/").replaceAll("~0", "~"); - if (field === undefined || !isRecordIdField(field)) return false; - const member = segments.at(-2)?.replaceAll("~1", "/").replaceAll("~0", "~"); + const field = unescapeJsonPointerToken(segments.at(-1)!); + if (!isRecordIdField(field)) return false; + const member = unescapeJsonPointerToken(segments.at(-2)!); const list = segments.slice(0, -2).join("/"); if (!lists.has(list)) { const after = resolveView(ends.final, list, format); @@ -104,14 +108,14 @@ export const generatedIdsInAdditions = ( const { keys } = pairMembers(value, [], undefined, "json").newer; value.forEach((child, index) => pending.push({ - pointer: `${pointer}/${escapeToken(keys[index] ?? String(index))}`, + pointer: `${pointer}/${escapeJsonPointerToken(keys[index] ?? String(index))}`, value: child, }), ); } else if (value !== null && typeof value === "object") { for (const [key, child] of Object.entries(value)) pending.push({ - pointer: `${pointer}/${escapeToken(key)}`, + pointer: `${pointer}/${escapeJsonPointerToken(key)}`, value: child, }); } @@ -133,9 +137,7 @@ const MAX_LISTED = 5; */ const ownNameRecord = (pointer: string): string | undefined => { const spelled = pointer.split("/"); - const segments = spelled.map((segment) => - segment.replaceAll("~1", "/").replaceAll("~0", "~"), - ); + const segments = spelled.map(unescapeJsonPointerToken); const at = segments.findIndex((segment, index) => { const conditions = selectorOf(segment) ?? []; const rest = segments.slice(index + 1).join("/"); @@ -396,11 +398,7 @@ export const isLikeDefault = ( /** Whether a member's selector reads a field, as `[format=BOOK][>data~1Title=SPQR]` reads `/data/Title`: a guard of it proves only that such a member exists. */ const selectsBy = (member: string, field: string) => { - const path = field - .slice(1) - .split("/") - .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) - .join("/"); + const path = jsonPointerTokens(field).join("/"); return (selectorOf(member) ?? []).some( ({ prefix, key }) => prefix !== "@" && key === path, ); @@ -493,7 +491,7 @@ export const recordGuards = ( pairing.older.keys[partner] !== key || selectsBy(key, record.field) ? [] - : [`${record.list}/${escapeToken(key)}`]; + : [`${record.list}/${escapeJsonPointerToken(key)}`]; }) .slice(0, MAX_SIBLINGS_READ); let found = 0; diff --git a/src/domain/desktop/appTaskDraftMirrors.ts b/src/domain/desktop/appTaskDraftMirrors.ts index 035f48c58f..e95481009d 100644 --- a/src/domain/desktop/appTaskDraftMirrors.ts +++ b/src/domain/desktop/appTaskDraftMirrors.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { isNameLikeKey } from "./attributeRank.js"; import type { DesktopStateDifference } from "./stateChanges.js"; @@ -19,11 +20,9 @@ const documentAt = (view: JsonValue | null, pointer: string) => { /** The value of the name-like attribute a pointer's last step names its element by, as `label` for `[name=elementInformation][@name=label]`. */ const fieldKeyOf = (element: string) => - selectorOf( - (element.split("/").at(-1) ?? "") - .replaceAll("~1", "/") - .replaceAll("~0", "~"), - )?.find(({ prefix, key }) => prefix === "@" && isNameLikeKey(key))?.value; + selectorOf(unescapeJsonPointerToken(element.split("/").at(-1) ?? ""))?.find( + ({ prefix, key }) => prefix === "@" && isNameLikeKey(key), + )?.value; /** * Text an app shows of another field it keeps, which changed alike: a text diff --git a/src/domain/desktop/appTaskDraftNamed.ts b/src/domain/desktop/appTaskDraftNamed.ts index a479866b95..fac9b92826 100644 --- a/src/domain/desktop/appTaskDraftNamed.ts +++ b/src/domain/desktop/appTaskDraftNamed.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { collapseWhitespace, type DesktopTaskCheckInput } from "./appTask.js"; import { @@ -41,12 +42,7 @@ import { type DesktopStateDifference, } from "./stateChanges.js"; import { pairMembers, pairedAtSeed } from "./memberPairing.js"; -import { - escapeToken, - isXmlElement, - locatePointer, - resolvePointer, -} from "./viewPointer.js"; +import { isXmlElement, locatePointer, resolvePointer } from "./viewPointer.js"; import { ARCHIVE, referenceRoots, @@ -388,13 +384,13 @@ const addedChecks = ( value.forEach((item, index) => addedChecks( collected, - `${pointer}/${escapeToken(keys[index] ?? String(index))}`, + `${pointer}/${escapeJsonPointerToken(keys[index] ?? String(index))}`, item, ), ); } else if (isRecord(value) && Object.keys(value).length > 0) for (const [key, item] of Object.entries(value)) - addedChecks(collected, `${pointer}/${escapeToken(key)}`, item); + addedChecks(collected, `${pointer}/${escapeJsonPointerToken(key)}`, item); // A scalar, or an empty array or object, is checked as it is. else equals(collected, pointer, value); }; @@ -452,7 +448,7 @@ const renamedFrom = (collected: Collected, pointer: string) => { old.includes("][") ) return; - const at = `${list}/${escapeToken(`[name=${before.name}][@${key}=${old}]`)}`; + const at = `${list}/${escapeJsonPointerToken(`[name=${before.name}][@${key}=${old}]`)}`; const seed = locatePointer(ends.baseline, at, "xml"); if ( seed.kind === "value" && diff --git a/src/domain/desktop/appTaskDraftNested.ts b/src/domain/desktop/appTaskDraftNested.ts index a1fe5a542b..308e1cca6e 100644 --- a/src/domain/desktop/appTaskDraftNested.ts +++ b/src/domain/desktop/appTaskDraftNested.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import type { DesktopTaskCheckInput } from "./appTask.js"; import { @@ -13,7 +14,7 @@ import { isViewStateName, rankAttributes, } from "./attributeRank.js"; -import { escapeToken, isXmlElement, resolvePointer } from "./viewPointer.js"; +import { isXmlElement, resolvePointer } from "./viewPointer.js"; import { spellCondition } from "./viewSelector.js"; import { checkFits, type AddedContext } from "./appTaskDraftAdded.js"; @@ -71,7 +72,12 @@ export const recordListsOf = (element: JsonValue): RecordList[] => { return records.length > 0 && tags.get(child.name) === 1 && !/[=[\]]/u.test(child.name) - ? [{ steps: ["children", escapeToken(`[name=${child.name}]`)], records }] + ? [ + { + steps: ["children", escapeJsonPointerToken(`[name=${child.name}]`)], + records, + }, + ] : []; }); }; @@ -138,7 +144,7 @@ const recordKey = ( ? undefined : { key: best.key, - segment: escapeToken( + segment: escapeJsonPointerToken( `[name=${record.name}]${spellCondition({ prefix: "@", ...best })}`, ), }; @@ -225,12 +231,16 @@ export const nestedRecordChecks = ( const value = record.attributes[name]; return ( typeof value === "string" && - checkFits(context, `${at}/attributes/${escapeToken(name)}`, value) + checkFits( + context, + `${at}/attributes/${escapeJsonPointerToken(name)}`, + value, + ) ); }) .slice(0, MAX_PER_RECORD) .flatMap((name) => { - const field = `${at}/attributes/${escapeToken(name)}`; + const field = `${at}/attributes/${escapeJsonPointerToken(name)}`; const value = record.attributes[name]; return context.named(field) && typeof value === "string" ? [valueEqualsCheck(context.file, field, value)] diff --git a/src/domain/desktop/appTaskDraftNoise.ts b/src/domain/desktop/appTaskDraftNoise.ts index 4a8f134b65..c5e83a8a9c 100644 --- a/src/domain/desktop/appTaskDraftNoise.ts +++ b/src/domain/desktop/appTaskDraftNoise.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { isTimestamp } from "./appTask.js"; import { @@ -124,7 +125,7 @@ const COUNTER = /count|cycles?|revision/iu; export const attributeKeyOf = (pointer: string): string | undefined => { const segments = pointer.split("/"); return segments.at(-2) === "attributes" - ? segments.at(-1)?.replaceAll("~1", "/").replaceAll("~0", "~") + ? unescapeJsonPointerToken(segments.at(-1)!) : undefined; }; @@ -506,7 +507,7 @@ const COORDINATES = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?(?:[,;] ?-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)*$/u; /** Whether a value is a coordinate or a list of them, which an app may derive rather than its user enter. */ -export const isCoordinates = (value: JsonValue | undefined): boolean => +export const isCoordinates = (value: JsonValue | undefined): value is string => typeof value === "string" && COORDINATES.test(value.trim()); /** How far above an attribute's element the element whose text changed may sit for the attribute to be its layout. */ @@ -568,7 +569,7 @@ const derivedLayout = ( // A lone number is layout only where a name on its way says so; a list of them always is. return ( at >= 0 && - (/[,;]/u.test(String(value)) || + (/[,;]/u.test(value) || namesLayout(ends.final, pointer, [owner, ...above.slice(0, at)])) ); }), @@ -622,7 +623,7 @@ const drawingLayout = ( attributeKeyOf(pointer) !== undefined && isCoordinates(value) && drawn(pointer) && - (/[,;]/u.test(String(value)) || + (/[,;]/u.test(value) || namesLayout(viewOf(pointer), pointer, [ownerOfAttribute(pointer)])) ); }; diff --git a/src/domain/desktop/appTaskDraftSteps.ts b/src/domain/desktop/appTaskDraftSteps.ts index 80cd69a3b9..ef175418c2 100644 --- a/src/domain/desktop/appTaskDraftSteps.ts +++ b/src/domain/desktop/appTaskDraftSteps.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { isNameLikeKey } from "./attributeRank.js"; import { isXmlElement, resolvePointer } from "./viewPointer.js"; @@ -32,7 +33,7 @@ export const listSteps = (pointer: string, positional = false): ListStep[] => { /** Whether a step's member is named as a field is, by a name-like attribute alone, as QElectroTech's `elementInformation[@name=label]`. */ const namedAsField = ({ member }: ListStep) => { const [only, ...more] = ( - selectorOf(member.replaceAll("~1", "/").replaceAll("~0", "~")) ?? [] + selectorOf(unescapeJsonPointerToken(member)) ?? [] ).filter(({ prefix }) => prefix !== ""); return ( only !== undefined && diff --git a/src/domain/desktop/appTaskDraftText.ts b/src/domain/desktop/appTaskDraftText.ts index bc800df724..19d250d5bf 100644 --- a/src/domain/desktop/appTaskDraftText.ts +++ b/src/domain/desktop/appTaskDraftText.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { collapseWhitespace, type DesktopTaskCheckInput } from "./appTask.js"; import { @@ -161,7 +162,7 @@ const byOwnAttribute = (element: string, ends: Ends): Candidate | undefined => { ) continue; const selector = `[name=${after.name}][@${key}=${value}]`; - const pointer = `${parent}/children/${selector.replaceAll("~", "~0").replaceAll("/", "~1")}`; + const pointer = `${parent}/children/${escapeJsonPointerToken(selector)}`; if ( resolvePointer(ends.baseline, pointer, "xml") === before && resolvePointer(ends.final, pointer, "xml") === after diff --git a/src/domain/desktop/appTaskDraftValues.ts b/src/domain/desktop/appTaskDraftValues.ts index f7019440e3..e761b0f873 100644 --- a/src/domain/desktop/appTaskDraftValues.ts +++ b/src/domain/desktop/appTaskDraftValues.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import { z } from "zod"; import { jsonValueSchema, type JsonValue } from "../jsonValue.js"; @@ -14,12 +15,7 @@ import { recordListsOf } from "./appTaskDraftNested.js"; import { holdsFields, isRecordAt } from "./appTaskDraftText.js"; import { attributesOf, compactValue, previewWriter } from "./compactValue.js"; import { pairMembers, pairedAtSeed } from "./memberPairing.js"; -import { - escapeToken, - isXmlElement, - locatePointer, - resolvePointer, -} from "./viewPointer.js"; +import { isXmlElement, locatePointer, resolvePointer } from "./viewPointer.js"; /** Why a value's pointer cannot go into a check as it is, or why the value is noise. */ export const VALUE_NOTES = ["by_position", ...NOISE_REASONS] as const; @@ -476,7 +472,12 @@ const fieldsOf = (view: JsonValue | null, pointer: string): string[] => { return (isXmlElement(element) ? element.children : []).flatMap( (child, index) => isXmlElement(child) - ? [{ child, key: escapeToken(keys[index] ?? String(index)) }] + ? [ + { + child, + key: escapeJsonPointerToken(keys[index] ?? String(index)), + }, + ] : [], ); }; diff --git a/src/domain/desktop/memberPairing.ts b/src/domain/desktop/memberPairing.ts index 718e5f226d..3535975e5f 100644 --- a/src/domain/desktop/memberPairing.ts +++ b/src/domain/desktop/memberPairing.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { isTimestamp } from "./appTask.js"; import { @@ -362,7 +363,7 @@ export interface ListPlace { /** A list's place, read from its pointer, as `/root/children/[name=gnm:Sheets]/children` sits in `gnm:Sheets`. */ export const holdsTagOnly = (list: string): ListPlace => { const owner = selectorOf( - (list.split("/").at(-2) ?? "").replaceAll("~1", "/").replaceAll("~0", "~"), + unescapeJsonPointerToken(list.split("/").at(-2) ?? ""), )?.find(({ prefix, key }) => prefix === "" && key === "name")?.value; return { root: /(?:^|\/)root\/children$/u.test(list), diff --git a/src/domain/desktop/stateChanges.ts b/src/domain/desktop/stateChanges.ts index ff863ef91a..446aee3670 100644 --- a/src/domain/desktop/stateChanges.ts +++ b/src/domain/desktop/stateChanges.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import type { DesktopStateFormat } from "./stateFormats.js"; import { holdsTagOnly, pairMembers } from "./memberPairing.js"; @@ -24,9 +25,6 @@ const isRecord = ( ): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const escapePointer = (segment: string) => - segment.replaceAll("~", "~0").replaceAll("/", "~1"); - /** Longest JSON pointer listed; a longer one is only counted, so deep paths never fill the lists. */ const MAX_LISTED_POINTER = 4096; @@ -137,7 +135,7 @@ const walk = ( } else if (into.changed !== undefined && ownEntry(from) !== ownEntry(other)) note(into.changed, path); const visit = (key: string, item: JsonValue, counterpart?: JsonValue) => { - const segment = escapePointer(key); + const segment = escapeJsonPointerToken(key); path.segments.push(segment); path.length += segment.length + 1; walk(item, counterpart, path, into); @@ -283,7 +281,10 @@ const keyEntries = (view: JsonValue): Map => { for (const [group, keys] of Object.entries(groups)) if (isRecord(keys)) for (const [key, value] of Object.entries(keys)) - entries.set(`${group}/${escapePointer(key)}`, JSON.stringify(value)); + entries.set( + `${group}/${escapeJsonPointerToken(key)}`, + JSON.stringify(value), + ); return entries; }; diff --git a/src/domain/desktop/viewPointer.test.ts b/src/domain/desktop/viewPointer.test.ts index f5d5e0092d..d48a2194d3 100644 --- a/src/domain/desktop/viewPointer.test.ts +++ b/src/domain/desktop/viewPointer.test.ts @@ -26,6 +26,12 @@ const VIEW: JsonValue = { }; describe("locatePointer", () => { + it("decodes an escaped tilde once rather than treating the result as another escape", () => { + expect( + locatePointer({ "~1": "literal", "/": "slash" }, "/~01", "json"), + ).toMatchObject({ kind: "value", value: "literal" }); + }); + it("selects the one member a selector names, by field, by attribute, or both", () => { expect( locatePointer( diff --git a/src/domain/desktop/viewPointer.ts b/src/domain/desktop/viewPointer.ts index ca090d26e2..e3b38e9890 100644 --- a/src/domain/desktop/viewPointer.ts +++ b/src/domain/desktop/viewPointer.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; import { collapseWhitespace, isTimestamp } from "./appTask.js"; import { @@ -32,10 +33,6 @@ type Stepped = const INDEX = /^(?:0|[1-9][0-9]*)$/u; -/** A pointer segment for a key: `~` and `/` escaped as RFC 6901 writes them. */ -export const escapeToken = (token: string) => - token.replaceAll("~", "~0").replaceAll("/", "~1"); - const isRecord = ( value: JsonValue | null | undefined, ): value is Record => @@ -226,7 +223,7 @@ export const locatePointer = ( const segments = pointer.slice(1).split("/"); const steps = { searched: [] as string[], byPosition: [] as string[] }; for (const [position, segment] of segments.entries()) { - const token = segment.replaceAll("~1", "/").replaceAll("~0", "~"); + const token = unescapeJsonPointerToken(segment); if (Array.isArray(current)) { const array = segments .slice(0, position) diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index 82dc095870..eb923f5102 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -292,6 +292,25 @@ it("preserves a retryable capability failure through explicit-value redaction", expect(JSON.stringify(projectAnalysisError(result))).not.toContain("private"); }); +it.each([ + ["file-size", false], + ["memory", undefined], +] as const)( + "preserves %s resource refusal retryability through redaction", + (resource, retryable) => { + const source = new AnalysisResourceConstraintError( + "inspect_pe_resources", + resource, + "Selected artifact exceeds 64 bytes", + { max_file_bytes: 64 }, + retryable === undefined ? {} : { retryable }, + ); + expect(redactExplicitFailure(source, ["secret"])).toMatchObject({ + retryable, + }); + }, +); + it("coarsens format issue pointers without creating replacement coordinates", () => { const error = new AnalysisInputError( "inspect_web_network_capture", diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 878ff431e6..1a1c05017b 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -1,3 +1,4 @@ +import { unescapeJsonPointerToken } from "./jsonPointer.js"; import { BrowserObservationError } from "./browserObservationError.js"; import { EvidenceFileError } from "./evidenceErrors.js"; import type { AnalysisError } from "./analysisErrorBase.js"; @@ -54,7 +55,7 @@ const redactSensitivePointer = ( ): string => { let parent = ""; for (const segment of pointer.split("/").slice(1)) { - const decoded = segment.replaceAll("~1", "/").replaceAll("~0", "~"); + const decoded = unescapeJsonPointerToken(segment); const child = `${parent}/${segment}`; if (text(decoded) !== decoded || text(child) !== child) return parent; parent = child; diff --git a/src/domain/firmware/hexRecordSyntax.ts b/src/domain/firmware/hexRecordSyntax.ts index a3f3d7e369..c84e957f31 100644 --- a/src/domain/firmware/hexRecordSyntax.ts +++ b/src/domain/firmware/hexRecordSyntax.ts @@ -1,9 +1,9 @@ import { err, ok, type Result } from "../result.js"; /** ":" + count + address + type + 255 data bytes + checksum, excluding EOL. */ -export const INTEL_HEX_MAX_LINE_CHARS = 521; +const INTEL_HEX_MAX_LINE_CHARS = 521; /** "S" + type + count + 255 counted bytes, excluding EOL. */ -export const SREC_MAX_LINE_CHARS = 514; +const SREC_MAX_LINE_CHARS = 514; export type HexRecordEncoding = "intel-hex" | "srec"; diff --git a/src/domain/firmware/hexRecords.test.ts b/src/domain/firmware/hexRecords.test.ts index ec327092e9..9855dcdfda 100644 --- a/src/domain/firmware/hexRecords.test.ts +++ b/src/domain/firmware/hexRecords.test.ts @@ -6,11 +6,7 @@ import { createHexRecordDecoder, detectHexRecordEncoding, } from "./hexRecords.js"; -import { - INTEL_HEX_MAX_LINE_CHARS, - SREC_MAX_LINE_CHARS, - type HexRecordEncoding, -} from "./hexRecordSyntax.js"; +import type { HexRecordEncoding } from "./hexRecordSyntax.js"; import { INTEL_HEX_I16, INTEL_HEX_I32_GAP, @@ -233,7 +229,7 @@ describe("Intel HEX lines, termination and limits", () => { 0, Array.from({ length: 255 }, () => 1), ); - expect(longest).toHaveLength(INTEL_HEX_MAX_LINE_CHARS); + expect(longest).toHaveLength(521); expect(image(`${longest}\r\n:00000001FF\n`).metadata.data_bytes).toBe(255); const decoded = decode(`${longest}0\n`); expect(decoded).toMatchObject({ @@ -376,7 +372,7 @@ describe("S-record decoding", () => { 0, Array.from({ length: 252 }, () => 1), ); - expect(longest).toHaveLength(SREC_MAX_LINE_CHARS); + expect(longest).toHaveLength(514); expect(image(`${longest}\n`, "srec").metadata.data_bytes).toBe(252); expect(decode(`${longest} 0\n`, "srec").ok).toBe(false); }); diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index 6c2f1d2b62..64c1c96d2c 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -16,7 +16,6 @@ import { nameAndSwiftProjection, normalizeCfg, referencedStringProjection, - sorted, swiftLiteralCoverage, swiftLiteralSitesConflict, } from "./functionComparisonNormalization.js"; diff --git a/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts b/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts index 6988b4f3d5..951d073434 100644 --- a/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts @@ -7,7 +7,6 @@ import { import { createJavaScriptApplicationGraph, createJavaScriptApplicationEdge, - type JavaScriptApplicationGraph, } from "./javascriptApplicationGraph.js"; import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index e8c5e87b56..162ab650c8 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -34,8 +34,6 @@ import { GLOBAL_OBJECT_NAMES, globalObjectName, globalReferenceNamespace, - isUnmodifiedGlobalReference, - isUnmodifiedImportMetaUrl, } from "./javascriptSemanticGlobalReference.js"; import { readSemanticRequireLoader } from "./javascriptSemanticRequireOriginSlot.js"; import { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 3234457612..c29f75c0a3 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -119,7 +119,6 @@ import { } from "./javascriptSemanticCoverage.js"; import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; import { - javaScriptRuntimeContext, javaScriptProgramStrictness, parseJavaScriptSource, type ParsedJavaScriptSource, diff --git a/src/domain/javascript/javascriptSemanticCycles.test.ts b/src/domain/javascript/javascriptSemanticCycles.test.ts index 0019aae4ee..35f5c7db3b 100644 --- a/src/domain/javascript/javascriptSemanticCycles.test.ts +++ b/src/domain/javascript/javascriptSemanticCycles.test.ts @@ -1,17 +1,11 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; -import { readImportSpecifiers } from "../../../scripts/lib/import-specifiers.mjs"; +import { expectImportCycles } from "../../../tests/fixtures/importCycles.js"; const root = dirname(fileURLToPath(import.meta.url)); -const relativeSpecifiers = (source: string): readonly string[] => - readImportSpecifiers(source) - .map(({ specifier }) => specifier) - .filter((specifier) => specifier.startsWith(".")); - /** * Cycles that already existed outside the semantic analysis hub. Membership is * exact so a new edge cannot hide inside the allow-list. @@ -42,97 +36,8 @@ const allowedCycleEdges = [ "javascriptStaticAnalysisState.ts -> javascriptStaticAnalysisFindings.ts", ].sort(); -const productionFiles = (directory: string): string[] => { - const files: string[] = []; - for (const name of readdirSync(directory)) { - const path = join(directory, name); - if (statSync(path).isDirectory()) files.push(...productionFiles(path)); - else if (name.endsWith(".ts") && !name.includes(".test.")) files.push(path); - } - return files; -}; - -const importGraph = (): Map> => { - const files = new Set( - productionFiles(root).map((path) => relative(root, path)), - ); - const graph = new Map>(); - for (const file of files) { - const source = readFileSync(join(root, file), "utf8"); - const edges = new Set(); - for (const spec of relativeSpecifiers(source)) { - const resolved = resolve(dirname(join(root, file)), spec).replace( - /\.js$/, - ".ts", - ); - const target = relative(root, resolved); - if (files.has(target)) edges.add(target); - } - graph.set(file, edges); - } - return graph; -}; - -const stronglyConnected = ( - graph: Map>, -): readonly (readonly string[])[] => { - let index = 0; - const stack: string[] = []; - const onStack = new Set(); - const indices = new Map(); - const low = new Map(); - const components: string[][] = []; - const visit = (node: string): void => { - indices.set(node, index); - low.set(node, index); - index += 1; - stack.push(node); - onStack.add(node); - for (const next of graph.get(node) ?? []) { - if (!indices.has(next)) { - visit(next); - low.set(node, Math.min(low.get(node) ?? index, low.get(next) ?? index)); - } else if (onStack.has(next)) { - low.set( - node, - Math.min(low.get(node) ?? index, indices.get(next) ?? index), - ); - } - } - if (low.get(node) === indices.get(node)) { - const component: string[] = []; - let member = ""; - while (member !== node) { - member = stack.pop() ?? node; - onStack.delete(member); - component.push(member); - } - if (component.length > 1) components.push(component.sort()); - } - }; - for (const node of [...graph.keys()].sort()) { - if (!indices.has(node)) visit(node); - } - return components.sort( - (left, right) => - right.length - left.length || left.join().localeCompare(right.join()), - ); -}; - describe("javascript semantic import cycles", () => { it("keeps the semantic modules out of one import cycle", () => { - const graph = importGraph(); - const cycles = stronglyConnected(graph).map((cycle) => cycle.join("\n")); - expect(cycles).toEqual( - allowedCycles.map((cycle) => [...cycle].sort().join("\n")), - ); - const internalEdges = allowedCycles.flatMap((cycle) => - cycle.flatMap((source) => - [...(graph.get(source) ?? [])] - .filter((target) => cycle.includes(target)) - .map((target) => `${source} -> ${target}`), - ), - ); - expect(internalEdges.sort()).toEqual(allowedCycleEdges); + expectImportCycles(root, allowedCycles, allowedCycleEdges); }); }); diff --git a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts index 3144b0d1ab..f9049b12f2 100644 --- a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts +++ b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts @@ -9,7 +9,6 @@ import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceS import { resolveSemanticBindingState, type JavaScriptSemanticBindingState, - type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReference.js"; import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; diff --git a/src/domain/javascript/javascriptSemanticSlots.ts b/src/domain/javascript/javascriptSemanticSlots.ts index 4150231e40..352dc3dff1 100644 --- a/src/domain/javascript/javascriptSemanticSlots.ts +++ b/src/domain/javascript/javascriptSemanticSlots.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../jsonPointer.js"; import type { JavaScriptSemanticProperty, JavaScriptSemanticValue, @@ -100,6 +101,4 @@ export const semanticSlotAtPath = ( /** Encode path segments as an unambiguous JSON Pointer, preserving empty keys. */ export const semanticPropertyPointer = (path: readonly string[]): string => - path - .map((key) => `/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`) - .join(""); + path.map((key) => `/${escapeJsonPointerToken(key)}`).join(""); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 25862ab5a8..49bd4961f0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -507,20 +507,6 @@ const isRequestUrlOrPathLiteral = (value: string): boolean => value.startsWith("./") || value.startsWith("../"); -/** Explicit request references, classified after receiver-specific exclusions. */ -const isRequestUrlReference = (value: string): boolean => { - if ( - isRequestUrlOrPathLiteral(value) || - value.startsWith("?") || - value.startsWith("#") - ) - return true; - // WHATWG accepts HTTP/WS scheme forms without authority slashes. Parse - // without a base so bare keys stay ambiguous, and keep the source spelling. - if (!/^(?:https?|wss?):/iu.test(value)) return false; - return URL.parse(value) !== null; -}; - /** * Browsing-context and document `open` take a URL or type first and a target * name second. The name is only a spelling, since `parent` or `self` may be a diff --git a/src/domain/jsonPointer.ts b/src/domain/jsonPointer.ts new file mode 100644 index 0000000000..ed8949c43b --- /dev/null +++ b/src/domain/jsonPointer.ts @@ -0,0 +1,13 @@ +/** Encode one RFC 6901 reference token. */ +export const escapeJsonPointerToken = (token: string): string => + token.replaceAll("~", "~0").replaceAll("/", "~1"); + +/** Decode a reference token without validating the enclosing pointer. */ +export const unescapeJsonPointerToken = (token: string): string => + token.replaceAll("~1", "/").replaceAll("~0", "~"); + +/** Split an already-admitted JSON pointer into decoded reference tokens. */ +export const jsonPointerTokens = (pointer: string): string[] => + pointer === "" + ? [] + : pointer.slice(1).split("/").map(unescapeJsonPointerToken); diff --git a/src/domain/managed/managedMemberSignatureIdentity.test.ts b/src/domain/managed/managedMemberSignatureIdentity.test.ts index a07c2c1c1c..f14391e6e6 100644 --- a/src/domain/managed/managedMemberSignatureIdentity.test.ts +++ b/src/domain/managed/managedMemberSignatureIdentity.test.ts @@ -16,12 +16,15 @@ describe("managed signature identity", () => { ); }); - it("matches resolved digests and raw digests among themselves", () => { + it("keeps different raw digests distinct when resolution is unavailable", () => { + expect(signatureIdentity(signature(digest, null))).not.toBe( + signatureIdentity(signature("cd".repeat(32), null)), + ); + }); + + it("matches resolved digests despite different raw digests", () => { expect(signatureIdentity(signature("01".repeat(32), digest))).toBe( signatureIdentity(signature("02".repeat(32), digest)), ); - expect(signatureIdentity(signature(digest, null))).toBe( - signatureIdentity(signature(digest, null)), - ); }); }); diff --git a/src/domain/native/binaryLayout.test.ts b/src/domain/native/binaryLayout.test.ts index dbd086c7ad..c5a030c66e 100644 --- a/src/domain/native/binaryLayout.test.ts +++ b/src/domain/native/binaryLayout.test.ts @@ -91,7 +91,7 @@ it.each(["mutable", "shallow-frozen"])( }, ); -it("never treats sealing as format validity or caches a refused layout", () => { +it("does not treat an invalid sealed layout as valid on repeated reads", () => { const value = report(); value.sections[0]!.index = 10; freezeJsonSnapshot(value); diff --git a/src/domain/native/nativeInitializers.test.ts b/src/domain/native/nativeInitializers.test.ts index 873f3189a8..c3645760b8 100644 --- a/src/domain/native/nativeInitializers.test.ts +++ b/src/domain/native/nativeInitializers.test.ts @@ -1,6 +1,5 @@ import { expect, it } from "vitest"; import { - DEFAULT_NATIVE_INITIALIZER_PAGE, inspectNativeInitializersInputSchema, nativeInitializersSchema, NATIVE_INITIALIZER_LIMITATIONS, @@ -11,7 +10,7 @@ it("applies paging defaults and rejects relative paths and extra arguments", () inspectNativeInitializersInputSchema.parse({ path: "/artifacts/app" }), ).toMatchObject({ offset: 0, - limit: DEFAULT_NATIVE_INITIALIZER_PAGE, + limit: 64, max_file_bytes: 67108864, }); for (const input of [ diff --git a/src/domain/native/nativeInitializers.ts b/src/domain/native/nativeInitializers.ts index d6c5be306c..9813b10459 100644 --- a/src/domain/native/nativeInitializers.ts +++ b/src/domain/native/nativeInitializers.ts @@ -13,7 +13,7 @@ export const MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES = 536870912; /** Slots collected before the inventory is reported incomplete. */ export const MAX_NATIVE_INITIALIZER_ENTRIES = 65536; /** Default page size for returned entries. */ -export const DEFAULT_NATIVE_INITIALIZER_PAGE = 64; +const DEFAULT_NATIVE_INITIALIZER_PAGE = 64; /** Upper bound on one returned page. */ export const MAX_NATIVE_INITIALIZER_PAGE = 4096; /** Bytes admitted for one symbol or section name. */ @@ -24,7 +24,7 @@ export const MAX_NATIVE_INITIALIZER_NOTES = 24; * Upper bound on reported limitations: the standing ones, the note budget, a * line per tallied reason, an omission count and a withheld-name count. */ -export const MAX_NATIVE_INITIALIZER_LIMITATIONS = 40; +const MAX_NATIVE_INITIALIZER_LIMITATIONS = 40; const uint = z.number().int().min(0).max(0xffffffff); const safeNonNegative = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index 1c815899cd..659a4878d5 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -1248,7 +1248,7 @@ it("rejects old executable identity representations without migration", () => { ).toThrow("legacy_executable_sha256"); }); -it("requires compatible contracts and enforces capture age through a clock seam", () => { +it("requires compatible contracts and enforces capture age", () => { const capture = emptyCapture(); expect(() => compareProcessCaptures(capture, { @@ -1274,11 +1274,17 @@ it("requires compatible contracts and enforces capture age through a clock seam" ], }), ); + const completedAt = new Date(Date.now() - 86_400_000).toISOString(); + const stale = { + ...capture, + manifest: { + ...capture.manifest, + started_at: completedAt, + completed_at: completedAt, + }, + }; expect(() => - compareProcessCaptures(capture, capture, { - maxCaptureAgeMs: 1, - now: () => Date.parse("2026-01-01T00:00:01.000Z"), - }), + compareProcessCaptures(stale, stale, { maxCaptureAgeMs: 1_000 }), ).toThrow( expect.objectContaining({ issues: [ diff --git a/src/domain/process/processComparison.ts b/src/domain/process/processComparison.ts index 3547a33190..3a4cf8295c 100644 --- a/src/domain/process/processComparison.ts +++ b/src/domain/process/processComparison.ts @@ -46,10 +46,9 @@ export const PROCESS_COMPARISON_DIMENSIONS = [ export type ProcessComparisonDimension = (typeof PROCESS_COMPARISON_DIMENSIONS)[number]; -/** Caller-selected comparison policy; the clock is not persisted as policy. */ +/** Caller-selected comparison policy. */ export interface ProcessCaptureComparisonOptions { readonly maxCaptureAgeMs?: number; - readonly now?: () => number; readonly traceSpecification?: ProcessTraceSpecification; } @@ -479,7 +478,7 @@ const explicitlyTimedSources = ( const assertComparable = ( left: ProcessCapture, right: ProcessCapture, - options: { readonly maxCaptureAgeMs?: number; readonly now?: () => number }, + options: { readonly maxCaptureAgeMs?: number }, ): void => { if ( left.manifest.comparison_contract_sha256 !== @@ -500,7 +499,7 @@ const assertComparable = ( } if (options.maxCaptureAgeMs === undefined) return; const maxCaptureAgeMs = options.maxCaptureAgeMs; - const now = (options.now ?? Date.now)(); + const now = Date.now(); const stale = ( [ ["left", left], diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts index ce460dac2c..bf80520679 100644 --- a/src/domain/releaseExposure.ts +++ b/src/domain/releaseExposure.ts @@ -733,10 +733,7 @@ const metadataStatus = ( images.some((image) => image.kind === "failed" || image.kind === "absent") ) return "unknown"; - if ( - images.length === 0 || - images.every((image) => image.kind === "unsupported") - ) + if (images.every((image) => image.kind === "unsupported")) return "not_applicable"; return "not_observed"; }; diff --git a/src/domain/staticRuntimeCorrelation.test.ts b/src/domain/staticRuntimeCorrelation.test.ts index 75c35ab3bb..ba307e120d 100644 --- a/src/domain/staticRuntimeCorrelation.test.ts +++ b/src/domain/staticRuntimeCorrelation.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js"; -import { - INVESTIGATION_EXAMPLES, - FUNCTION_COMPARISON_EVIDENCE, -} from "../contracts/investigationExamples.js"; +import { FUNCTION_COMPARISON_EVIDENCE } from "../contracts/investigationExamples.js"; import { artifactComparisonResultSchema, compareArtifacts, diff --git a/src/domain/unicodeCodePointOrder.ts b/src/domain/unicodeCodePointOrder.ts index 4f635048d1..d0d2280ae7 100644 --- a/src/domain/unicodeCodePointOrder.ts +++ b/src/domain/unicodeCodePointOrder.ts @@ -18,9 +18,9 @@ export const compareUnicodeCodePoints = ( }; /** - * Collision-free composite key for deduplication/sorting. `\0`-joins - * collide when any component contains NUL (valid in JS property names); - * a JSON tuple escapes every component. Single owner for composite keys. + * JSON tuple key for deduplication/sorting. Unlike `\0`-joins, it preserves + * boundaries when string components contain NUL. Callers must account for JSON + * serialization semantics, such as undefined array elements becoming null. */ export const compositeKey = (parts: readonly unknown[]): string => JSON.stringify(parts); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index af373a5cbc..30565524a9 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -24,7 +24,6 @@ import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemantic import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperations.js"; import { semanticStaticPropertyKey, - semanticStaticPropertyName, isNullishJavaScriptSyntax, staticObjectProperty, staticObjectPropertyNames, diff --git a/src/domain/windows/ntfsMft.test.ts b/src/domain/windows/ntfsMft.test.ts index e5966a34a8..3e9695b970 100644 --- a/src/domain/windows/ntfsMft.test.ts +++ b/src/domain/windows/ntfsMft.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import { - MFT_DEFAULT_RECORD_BYTES, - MFT_MAX_FILE_BYTES, createMftDecoder, parseMftIndex, selectMftMetadata, } from "./ntfsMft.js"; +// The fixtures use the documented 1024-byte default, independently of the reader. +const MFT_DEFAULT_RECORD_BYTES = 1024; + interface RecordOptions { readonly sequenceNumber?: number; readonly hardLinkCount?: number; @@ -526,7 +527,7 @@ describe("parseMftIndex refusals and limits", () => { }); it("refuses a file above the fixed ceiling", () => { - const decoder = createMftDecoder(MFT_MAX_FILE_BYTES + 1); + const decoder = createMftDecoder(512 * 1024 * 1024 + 1); expect(decoder.ok).toBe(false); if (decoder.ok) return; expect(decoder.error).toMatchObject({ diff --git a/src/domain/windows/ntfsMft.ts b/src/domain/windows/ntfsMft.ts index 4e7306ff76..2cec504f92 100644 --- a/src/domain/windows/ntfsMft.ts +++ b/src/domain/windows/ntfsMft.ts @@ -27,7 +27,7 @@ const hasMftFileRecordSignature = (prefix: Uint8Array): boolean => MFT_FILE_RECORD_MAGIC; /** A record's declared size is a power of two from 512 upward; 1024 is NTFS's default. */ -export const MFT_DEFAULT_RECORD_BYTES = 1024; +const MFT_DEFAULT_RECORD_BYTES = 1024; const MFT_MIN_RECORD_BYTES = 512; const MFT_MAX_RECORD_BYTES = 65536; /** An update sequence array holds at least the sequence number and one fixup word. */ @@ -39,7 +39,7 @@ export const FIXUP_SECTOR_BYTES = 512; * is fetched, so this is deliberately * smaller than a volume's MFT can be. */ -export const MFT_MAX_FILE_BYTES = 512 * 1024 * 1024; +const MFT_MAX_FILE_BYTES = 512 * 1024 * 1024; /** Limit the inline header sample independently of full stream coverage. */ export const MFT_INLINE_HEADER_LIMIT = 32; diff --git a/src/electron/ElectronFuseCleanup.test.ts b/src/electron/ElectronFuseCleanup.test.ts index 5ef6b2811e..a9deb69f8c 100644 --- a/src/electron/ElectronFuseCleanup.test.ts +++ b/src/electron/ElectronFuseCleanup.test.ts @@ -514,12 +514,13 @@ it("joins shutdown during preflight and refuses any later launch or capture", as application_root: root, }; const running = provider.capture(input); - await entered.promise; let closed = false; - const closing = provider.close().then(() => { - closed = true; - }); + let closing: Promise | undefined; try { + await awaitAcknowledgement(entered.promise, running, "preflight read"); + closing = provider.close().then(() => { + closed = true; + }); await new Promise((resolve) => setImmediate(resolve)); expect(closed).toBe(false); release.resolve(); @@ -531,7 +532,8 @@ it("joins shutdown during preflight and refuses any later launch or capture", as } finally { release.resolve(); await running; - await closing; + if (closing !== undefined) await closing; + else await provider.close(); } }); diff --git a/src/electron/ElectronFuseProvider.test.ts b/src/electron/ElectronFuseProvider.test.ts index af7a2bfbda..acc2e625b3 100644 --- a/src/electron/ElectronFuseProvider.test.ts +++ b/src/electron/ElectronFuseProvider.test.ts @@ -28,7 +28,10 @@ import { import { ELECTRON_FUSE_STATE_BYTES } from "../domain/electron/electronFuses.js"; import { ElectronFuseProvider } from "./ElectronFuseProvider.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { readElectronFuseImage } from "./ElectronFuseReader.js"; +import { + ELECTRON_FUSE_LIMITS, + readElectronFuseImage, +} from "./ElectronFuseReader.js"; const provider = new ElectronFuseProvider(); @@ -292,18 +295,6 @@ describe("ELF embedded ASAR integrity applicability", () => { }); describe("Electron fuse reader resource boundaries", () => { - it("rejects invalid scan window sizes before allocating or scanning", async () => { - const root = await createTestTempDirectory( - "rea-electron-fuses-window-limit-", - ); - const path = join(root, "window"); - await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); - for (const chunkBytes of [0, -1, 1.5, 256 * 1024 + 1]) - await expect(readElectronFuseImage(path, { chunkBytes })).rejects.toThrow( - /chunkBytes must be an integer between/, - ); - }); - it("rejects overlapping universal Mach-O slices before repeated searching", async () => { const root = await createTestTempDirectory("rea-electron-fuses-overlap-"); const image = universalFuseImage().bytes; @@ -336,11 +327,15 @@ describe("Electron fuse path and container admission", () => { const root = await createTestTempDirectory("rea-electron-fuses-window-"); const image = thinFuseImage(OBSIDIAN_FUSE_BYTES); const path = join(root, "window"); - await writeFile(path, image.bytes); - const read = await readElectronFuseImage(path, { chunkBytes: 20 }); - expect(read.slices[0]?.sentinels[0]?.sentinel_offset).toBe( - image.sentinelOffset, + const sentinelOffset = ELECTRON_FUSE_LIMITS.chunkBytes - 8; + const bytes = Buffer.alloc( + sentinelOffset + image.bytes.length - image.sentinelOffset, ); + image.bytes.copy(bytes, 0, 0, image.sentinelOffset); + image.bytes.copy(bytes, sentinelOffset, image.sentinelOffset); + await writeFile(path, bytes); + const read = await readElectronFuseImage(path); + expect(read.slices[0]?.sentinels[0]?.sentinel_offset).toBe(sentinelOffset); expect(read.slices[0]?.sentinels[0]?.fuses).toHaveLength(8); }); diff --git a/src/electron/ElectronFuseReader.ts b/src/electron/ElectronFuseReader.ts index 8b719a39b6..a5eadc6062 100644 --- a/src/electron/ElectronFuseReader.ts +++ b/src/electron/ElectronFuseReader.ts @@ -89,7 +89,6 @@ export const readElectronFuseImage = async ( path: string, options?: { readonly signal?: AbortSignal; - readonly chunkBytes?: number; readonly selection?: ResolvedElectronFuseFile; readonly resources?: ArtifactResourceScope; }, @@ -141,15 +140,6 @@ export const readElectronFuseImage = async ( `Selected artifact exceeds ${ELECTRON_FUSE_LIMITS.maximumFileBytes} bytes: ${path}`, ); const size = Number(before.size); - const chunkBytes = options?.chunkBytes ?? ELECTRON_FUSE_LIMITS.chunkBytes; - if ( - !Number.isSafeInteger(chunkBytes) || - chunkBytes < 1 || - chunkBytes > ELECTRON_FUSE_LIMITS.chunkBytes - ) - throw new TypeError( - `chunkBytes must be an integer between 1 and ${ELECTRON_FUSE_LIMITS.chunkBytes}.`, - ); return withArtifactRegularFile( path, options?.resources ?? new ArtifactResourceScope(), @@ -166,7 +156,6 @@ export const readElectronFuseImage = async ( path, size, container.slices, - chunkBytes, signal, ); const current = await recheckPath(path); @@ -287,7 +276,6 @@ const scanSlices = async ( path: string, size: number, ranges: readonly SliceRange[], - chunkBytes: number, signal: AbortSignal | undefined, ): Promise<{ readonly sha256: string; @@ -299,11 +287,11 @@ const scanSlices = async ( hits: [] as number[], })); const hash = createHash("sha256"); - const chunk = Buffer.allocUnsafe(chunkBytes); + const chunk = Buffer.allocUnsafe(ELECTRON_FUSE_LIMITS.chunkBytes); let position = 0; while (position < size) { assertArtifactReadActive(signal); - const length = Math.min(chunkBytes, size - position); + const length = Math.min(chunk.length, size - position); const read = await handle.read(chunk, 0, length, position); if (read.bytesRead === 0) throw new RegularFileChangedError(path); assertArtifactReadActive(signal); diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 560d588286..adb7911d28 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -468,7 +468,6 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { let root: Pick | undefined; let rootLease: AnalysisCleanupLease | undefined; let processOwner: ProviderProcessSupervisor | undefined; - let processLease: AnalysisCleanupLease | undefined; let processCleanup: | { readonly reason: string; @@ -643,7 +642,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { ...(this.launcher === undefined ? {} : { launcher: this.launcher }), retainCleanup: (owner, cleanup) => { processOwner = owner; - processLease = processLeaseFor(owner); + processLeaseFor(owner); processCleanup = { reason: cleanup.reason, resources: @@ -830,7 +829,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { cause.cleanupOwner !== undefined ) { processOwner = cause.cleanupOwner; - processLease = processLeaseFor(cause.cleanupOwner); + processLeaseFor(cause.cleanupOwner); } let fileFailure: EvmFileSizeFailureEvidence | undefined; let markerCleanupFailure: ArtifactReaderFailure | undefined; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index fc3cb4c6b8..07e742f752 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -31,7 +31,6 @@ import { mapArtifactFileFailure, artifactSelectedPathErrno, } from "../artifacts/ArtifactFileOperation.js"; -import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js"; import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js"; import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; diff --git a/src/firmware/devicetree/DevicetreeProvider.ts b/src/firmware/devicetree/DevicetreeProvider.ts index 1756ff4cb7..385fe4820c 100644 --- a/src/firmware/devicetree/DevicetreeProvider.ts +++ b/src/firmware/devicetree/DevicetreeProvider.ts @@ -1,9 +1,9 @@ import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { type StableArtifactSnapshot, @@ -19,7 +19,6 @@ import type { DevicetreePort } from "../../application/binaryDiagnostics/Devicet import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -170,20 +169,13 @@ const devicetreeFailurePrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected devicetree blob could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "devicetree blob", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( OPERATION, origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/frida/FridaInstrumentationManager.test.ts b/src/frida/FridaInstrumentationManager.test.ts index 523fc12ba8..34a16cb850 100644 --- a/src/frida/FridaInstrumentationManager.test.ts +++ b/src/frida/FridaInstrumentationManager.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, onTestFinished, vi } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import type { Device, @@ -1869,7 +1870,11 @@ it.each([false, true])( { sourceKind: "inline", source: "send('marker')" }, controller.signal, ); - await entered.promise; + await awaitAcknowledgement( + entered.promise, + loading, + "provider lifecycle boundary", + ); const script = session.scripts[0]; if (script === undefined) throw new Error("Missing admitted script"); const unload = script.unload.bind(script); @@ -1933,7 +1938,11 @@ it("preserves a native rejection observed before cancellation during script clea { sourceKind: "inline", source: "void 0" }, controller.signal, ); - await entered.promise; + await awaitAcknowledgement( + entered.promise, + loading, + "provider lifecycle boundary", + ); controller.abort(new Error("late abort during cleanup")); held.resolve(); const result = await loading; diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 7c10b72d6d..3044130959 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -22,7 +22,6 @@ import type { GdbCommandResult, GdbOutputLimit, } from "../domain/commandObservation.js"; -import { DEBUGGER_CONTROL_REPLY_BYTES } from "../domain/analysisResourceLimits.js"; import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; import { err, ok, type Result } from "../domain/result.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; @@ -537,8 +536,6 @@ interface GdbSession { readonly verifyInferiorsEndedAfterExit: () => Promise; readonly knownInferiorIdentities: Map; readonly inferiorTty: string | undefined; - /** Startup and detach MI. Separate from the caller command-output cap. */ - readonly controlReplyBytes: number; readonly stdoutSettled: Promise; readonly settleStdout: () => void; inferiorTtyVerified: boolean; @@ -609,7 +606,6 @@ export class GdbSessionManager { readonly #maxSessions: DebuggerAdmissionLimit; readonly #maxPendingCommands: DebuggerAdmissionLimit; readonly #launchCommandOutput: DebuggerAdmissionLimit; - readonly #controlReplyBytes: number; get #commandDeadline(): DebuggerAdmissionLimit { const override = currentAnalysisOperationSnapshot()?.overrides[ @@ -662,11 +658,6 @@ export class GdbSessionManager { ) => Promise; /** @internal Read-only process identity observer for lifecycle tests. */ readonly readInferiorProcessStartTime?: ProcessStartTimeReader; - /** - * @internal Test seam for startup and detach MI truncation. - * Production uses the fixed control-reply budget. - */ - readonly controlReplyBytes?: number; } = {}, ) { this.#environment = { ...(options.environment ?? process.env) }; @@ -683,8 +674,6 @@ export class GdbSessionManager { this.#maxSessions = limits.maxSessions; this.#maxPendingCommands = limits.maxPendingCommands; this.#launchCommandOutput = limits.maxCommandOutputBytes; - this.#controlReplyBytes = - options.controlReplyBytes ?? DEBUGGER_CONTROL_REPLY_BYTES; } async start( @@ -907,7 +896,6 @@ export class GdbSessionManager { }, knownInferiorIdentities: new Map(), inferiorTty, - controlReplyBytes: this.#controlReplyBytes, stdoutSettled, settleStdout, inferiorTtyVerified: false, @@ -2167,7 +2155,7 @@ export class GdbSessionManager { const acceptedDeadline = this.#commandDeadline; const acceptedOutput = command.startsWith("-interpreter-exec console ") ? this.#commandOutput - : { ...debuggerControlReplyOutput, value: session.controlReplyBytes }; + : debuggerControlReplyOutput; return new Promise((resolve, reject) => { let abortListener!: () => void; const cleanup = (): void => { diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 2f801f448f..64dbcf8268 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -517,15 +517,11 @@ describe("Windows Ghidra temporary directory check", () => { ); }); - it("does not probe when no Windows installation was selected", async () => { - const probe = vi.fn(() => Promise.reject(new Error("unexpected probe"))); + it("omits the temporary directory check when no Windows installation was selected", async () => { const projected = await inspectSystemGhidraProvider( {}, inspectionResources.owner, - undefined, - probe, ); - expect(probe).not.toHaveBeenCalled(); expect(projected.checks.map(({ name }) => name)).not.toContain( "temp_directory", ); diff --git a/src/ghidra/GhidraDoctor.ts b/src/ghidra/GhidraDoctor.ts index ee6b172f39..335d0a1be8 100644 --- a/src/ghidra/GhidraDoctor.ts +++ b/src/ghidra/GhidraDoctor.ts @@ -120,9 +120,6 @@ export const inspectSystemGhidraProvider = async ( environment: Readonly, resourceOwner: RetainedResourceScope, signal?: AbortSignal, - probeTemporaryDirectory: ( - resourceOwner: RetainedResourceScope, - ) => Promise = probeGhidraTemporaryDirectory, ): Promise => { try { const installation = await inspectGhidraInstallation({ @@ -139,7 +136,7 @@ export const inspectSystemGhidraProvider = async ( // The restricted-host denial reported for #7075 is Windows-specific. const temporaryDirectory = installation.status === "available" && installation.platform === "win32" - ? await probeTemporaryDirectory(resourceOwner) + ? await probeGhidraTemporaryDirectory(resourceOwner) : undefined; return projectGhidraDoctorInspection(installation, temporaryDirectory); } catch (cause: unknown) { diff --git a/src/ghidra/GhidraExecutableHeader.ts b/src/ghidra/GhidraExecutableHeader.ts index aee59a2259..bd27c3e62b 100644 --- a/src/ghidra/GhidraExecutableHeader.ts +++ b/src/ghidra/GhidraExecutableHeader.ts @@ -9,7 +9,7 @@ import { readStableFilePrefix } from "../filesystem/readStableFileBytes.js"; import { ghidraFileFailure } from "./GhidraFileArtifacts.js"; /** Only this prefix is examined; the remainder is never retained or read. */ -export const GHIDRA_EXECUTABLE_HEADER_BYTES = 4096; +const GHIDRA_EXECUTABLE_HEADER_BYTES = 4096; /** Retain complete prefix observations independently of final descriptor cleanup. */ export const readGhidraExecutableHeader = async ( diff --git a/src/ghidra/GhidraInstallationProperties.ts b/src/ghidra/GhidraInstallationProperties.ts index c459802969..3f1220a183 100644 --- a/src/ghidra/GhidraInstallationProperties.ts +++ b/src/ghidra/GhidraInstallationProperties.ts @@ -19,7 +19,7 @@ import { ghidraApplicationProperties } from "./GhidraInstallationPolicy.js"; import { ghidraFileFailure } from "./GhidraFileArtifacts.js"; /** Metadata admission budget, not a Java Properties format limit. */ -export const MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES = 1024 * 1024; +const MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES = 1024 * 1024; export interface GhidraInstallationFileOptions { readonly resourceOwner: RetainedResourceScope; readonly signal?: AbortSignal; diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index e41f55dac6..cbc2456018 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -16,7 +16,7 @@ import { import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { GhidraRiscvCallingConvention } from "./GhidraRiscvProfile.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { copyFile, mkdir, readFile, realpath } from "node:fs/promises"; +import { copyFile, mkdir, realpath } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js"; diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index e3e5ad1c07..04d8bc37b9 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -113,13 +113,13 @@ const damage: ReadonlyArray< [ "modified source", (x) => { - x.source_files[0] && (x.source_files[0].modified_sha256 = "f".repeat(64)); + x.source_files[0]!.modified_sha256 = "f".repeat(64); }, ], [ "original source", (x) => { - x.source_files[0] && (x.source_files[0].original_sha256 = "f".repeat(64)); + x.source_files[0]!.original_sha256 = "f".repeat(64); }, ], [ diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 15d25ef2b4..63c6e8783e 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -671,16 +671,11 @@ export class GhidraProvider implements AnalysisProviderCandidate { ); const owned = attempt; this.#inspectionAttempt = owned; - void owned.promise.then( - () => { - if (this.#inspectionAttempt === owned) - this.#inspectionAttempt = undefined; - }, - () => { - if (this.#inspectionAttempt === owned) - this.#inspectionAttempt = undefined; - }, - ); + const clearInspection = (): void => { + if (this.#inspectionAttempt === owned) + this.#inspectionAttempt = undefined; + }; + void owned.promise.then(clearInspection, clearInspection); } const waited = await attempt.wait(callerSignal); if (waited.status === "cancelled") diff --git a/src/ghidra/GhidraTemporaryDirectory.test.ts b/src/ghidra/GhidraTemporaryDirectory.test.ts index f30f078ea4..31442dc58d 100644 --- a/src/ghidra/GhidraTemporaryDirectory.test.ts +++ b/src/ghidra/GhidraTemporaryDirectory.test.ts @@ -1,66 +1,60 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; import * as fs from "node:fs/promises"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; - import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js"; import { ghidraJavaHomeSaveDenial, probeGhidraTemporaryDirectory, - type GhidraTemporaryDirectoryHost, } from "./GhidraTemporaryDirectory.js"; import { ghidraSessionRoot } from "./GhidraSessionRoot.js"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, open: vi.fn(actual.open) }; + return { ...actual, open: vi.fn(actual.open), unlink: vi.fn(actual.unlink) }; }); const denied = (code: string): NodeJS.ErrnoException => Object.assign(new Error(`${code}: operation not permitted`), { code }); let resources = createInspectionResources(); +let directory: string; +beforeEach(async () => { + directory = await createTestTempDirectory("rea-ghidra-probe-"); + for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, directory); +}); afterEach(async () => { await resources.close().catch(() => undefined); resources = createInspectionResources(); -}); - -const host = ( - overrides: Partial = {}, -): GhidraTemporaryDirectoryHost => ({ - sessionRoot: () => "C:\\sandbox\\tmp", - writeProbe: (_path: string, _resourceOwner: RetainedResourceScope) => - Promise.resolve(), - removeProbe: () => Promise.resolve(), - ...overrides, + vi.mocked(fs.open).mockReset(); + vi.mocked(fs.unlink).mockReset(); + vi.unstubAllEnvs(); }); describe("Ghidra temporary directory probe", () => { - it("reports success only after the probe file is written and removed", async () => { - const removeProbe = vi.fn(() => Promise.resolve()); + it("writes and removes a probe in the session root", async () => { await expect( - probeGhidraTemporaryDirectory(resources.owner, host({ removeProbe })), - ).resolves.toEqual({ ok: true, directory: "C:\\sandbox\\tmp" }); - expect(removeProbe).toHaveBeenCalledTimes(1); + probeGhidraTemporaryDirectory(resources.owner), + ).resolves.toEqual({ + ok: true, + directory, + }); + const path = vi.mocked(fs.unlink).mock.calls[0]?.[0]; + expect(path).toBeDefined(); + await expect(fs.access(path!)).rejects.toMatchObject({ code: "ENOENT" }); + await resources.close(); + expect(fs.unlink).toHaveBeenCalledTimes(1); }); it("fails closed with the system code when the write is denied", async () => { - const removeProbe = vi.fn(() => Promise.reject(denied("ENOENT"))); - const result = await probeGhidraTemporaryDirectory( - resources.owner, - host({ - writeProbe: (_path: string, _resourceOwner: RetainedResourceScope) => - Promise.reject(denied("EPERM")), - removeProbe, - }), - ); + vi.mocked(fs.open).mockRejectedValueOnce(denied("EPERM")); + const result = await probeGhidraTemporaryDirectory(resources.owner); expect(result).toMatchObject({ ok: false, - directory: "C:\\sandbox\\tmp", + directory, stage: "write", code: "EPERM", }); @@ -68,83 +62,49 @@ describe("Ghidra temporary directory probe", () => { }); it("probes the Ghidra session root rather than an unusable inherited temp", async () => { - const writeProbe = vi.fn( - (_path: string, _resourceOwner: RetainedResourceScope) => - Promise.resolve(), + const dotted = resolve(directory, ".cache", "tmp"); + for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, dotted); + const expected = ghidraSessionRoot(); + expect(resolve(tmpdir())).toBe(dotted); + expect(expected).not.toBe(dotted); + await expect( + probeGhidraTemporaryDirectory(resources.owner), + ).resolves.toEqual({ + ok: true, + directory: expected, + }); + expect(dirname(String(vi.mocked(fs.open).mock.calls[0]?.[0]))).toBe( + expected, ); - const names = ["TMPDIR", "TMP", "TEMP"] as const; - const previous = names.map((name) => process.env[name]); - const dotted = resolve("work", ".cache", "tmp"); - for (const name of names) process.env[name] = dotted; - let expected: string; - try { - expected = ghidraSessionRoot(); - expect(resolve(tmpdir())).toBe(dotted); - expect(expected).not.toBe(dotted); - await expect( - probeGhidraTemporaryDirectory(resources.owner, { - writeProbe, - removeProbe: () => Promise.resolve(), - }), - ).resolves.toEqual({ ok: true, directory: expected }); - } finally { - names.forEach((name, index) => { - const value = previous[index]; - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - }); - } - expect(dirname(writeProbe.mock.calls[0]?.[0] ?? "")).toBe(expected); - expect(writeProbe.mock.calls[0]?.[1]).toBe(resources.owner); }); it.each([ - [ - "a failed write", - "write", - (_path: string, _resourceOwner: RetainedResourceScope) => - Promise.reject(denied("EIO")), - ], - [ - "a written probe", - "cleanup", - (_path: string, _resourceOwner: RetainedResourceScope) => - Promise.resolve(), - ], + ["a failed write", "write", true], + ["a written probe", "cleanup", false], ] as const)( "retains cleanup ownership of %s whose removal fails and retries it on close", - async (_label, stage, writeProbe) => { - const removeProbe = vi - .fn<(path: string) => Promise>() + async (_label, stage, writeFails) => { + if (writeFails) vi.mocked(fs.open).mockRejectedValueOnce(denied("EIO")); + vi.mocked(fs.unlink) .mockRejectedValueOnce(denied("EPERM")) - .mockRejectedValueOnce(denied("EPERM")) - .mockResolvedValue(undefined); - const result = await probeGhidraTemporaryDirectory( - resources.owner, - host({ writeProbe, removeProbe }), - ); + .mockRejectedValueOnce(denied("EPERM")); + const result = await probeGhidraTemporaryDirectory(resources.owner); expect(result).toMatchObject({ ok: false, stage, retainedPath: expect.stringContaining("rea-doctor-probe-"), }); const retainedPath = result.ok ? undefined : result.retainedPath; - const callsBeforeClose = removeProbe.mock.calls.length; await expect(resources.close()).rejects.toThrow(); await expect(resources.close()).resolves.toBeUndefined(); - expect(removeProbe.mock.calls.slice(callsBeforeClose)).toEqual([ - [retainedPath], - [retainedPath], - ]); + expect(fs.unlink).toHaveBeenCalledTimes(3); + expect(fs.unlink).toHaveBeenLastCalledWith(retainedPath); + if (retainedPath !== undefined) + await expect(fs.access(retainedPath)).rejects.toMatchObject({ + code: "ENOENT", + }); }, ); - - it("does not retain cleanup when the probe was removed", async () => { - const removeProbe = vi.fn(() => Promise.resolve()); - await probeGhidraTemporaryDirectory(resources.owner, host({ removeProbe })); - await resources.close(); - expect(removeProbe).toHaveBeenCalledTimes(1); - }); }); describe("Ghidra java_home.save denial", () => { @@ -202,7 +162,6 @@ it.each([ ])( "retains the default probe descriptor through $label", async ({ writeFails, closeFails, removeFails }) => { - const directory = await createTestTempDirectory("rea-ghidra-probe-owner-"); const { open } = await vi.importActual( "node:fs/promises", @@ -226,16 +185,10 @@ it.each([ vi.spyOn(file, "writeFile").mockRejectedValueOnce(writeFailure); return file; }); - let removalAttempts = 0; + if (removeFails) + vi.mocked(fs.unlink).mockRejectedValueOnce(denied("EPERM")); try { - const result = await probeGhidraTemporaryDirectory(resources.owner, { - sessionRoot: () => directory, - removeProbe: async (path) => { - removalAttempts += 1; - if (removeFails && removalAttempts === 1) throw denied("EPERM"); - await fs.unlink(path); - }, - }); + const result = await probeGhidraTemporaryDirectory(resources.owner); if (writeFails || closeFails) { expect(result).toMatchObject({ ok: false, @@ -248,7 +201,7 @@ it.each([ await resources.close(); expect(descriptor?.fd).toBe(-1); expect(closeAttempts).toBe(closeFails ? 2 : 1); - expect(removalAttempts).toBe(removeFails ? 2 : 1); + expect(fs.unlink).toHaveBeenCalledTimes(removeFails ? 2 : 1); } finally { opening.mockRestore(); if (descriptor !== undefined && descriptor.fd >= 0) diff --git a/src/ghidra/GhidraTemporaryDirectory.ts b/src/ghidra/GhidraTemporaryDirectory.ts index 68b6f33c1f..310e66362d 100644 --- a/src/ghidra/GhidraTemporaryDirectory.ts +++ b/src/ghidra/GhidraTemporaryDirectory.ts @@ -34,37 +34,26 @@ export type GhidraTemporaryDirectoryProbe = readonly retainedPath?: string; }; -/** Filesystem seam so classification is testable without a restricted host. */ -export interface GhidraTemporaryDirectoryHost { - readonly sessionRoot: () => string; - readonly writeProbe: ( - path: string, - resourceOwner: RetainedResourceScope, - ) => Promise; - readonly removeProbe: (path: string) => Promise; -} - -const systemHost: GhidraTemporaryDirectoryHost = { - sessionRoot: () => ghidraSessionRoot(), - writeProbe: async (path, resourceOwner) => { - const resources = new ArtifactResourceScope(); - try { - await withArtifactFileHandle( +const writeProbe = async ( + path: string, + resourceOwner: RetainedResourceScope, +) => { + const resources = new ArtifactResourceScope(); + try { + await withArtifactFileHandle( + path, + await open(path, "wx", 0o600), + resources, + undefined, + (handle) => handle.writeFile("rea doctor probe\n"), + ); + } finally { + if (resources.hasPending) + resourceOwner.retain( + { close: () => resources.close(), cleanupResources: [path] }, path, - await open(path, "wx", 0o600), - resources, - undefined, - (handle) => handle.writeFile("rea doctor probe\n"), ); - } finally { - if (resources.hasPending) - resourceOwner.retain( - { close: () => resources.close(), cleanupResources: [path] }, - path, - ); - } - }, - removeProbe: unlink, + } }; const errorCode = (cause: unknown): string | undefined => @@ -84,19 +73,17 @@ const errorMessage = (cause: unknown): string => */ export const probeGhidraTemporaryDirectory = async ( resourceOwner: RetainedResourceScope, - overrides: Partial = {}, ): Promise => { - const host = { ...systemHost, ...overrides }; - const directory = host.sessionRoot(); + const directory = ghidraSessionRoot(); const path = join(directory, `rea-doctor-probe-${randomUUID()}`); const remove = () => - host.removeProbe(path).catch((cause: unknown) => { + unlink(path).catch((cause: unknown) => { if (errorCode(cause) !== "ENOENT") throw cause; }); const retain = () => resourceOwner.retain({ close: remove, cleanupResources: [path] }, path); try { - await host.writeProbe(path, resourceOwner); + await writeProbe(path, resourceOwner); } catch (cause: unknown) { const cleanupOnly = cause instanceof ArtifactFileCleanupFailure && @@ -121,7 +108,7 @@ export const probeGhidraTemporaryDirectory = async ( }; } try { - await host.removeProbe(path); + await unlink(path); } catch (cause: unknown) { retain(); return { diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 9f241cb446..b658d3635b 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -1,11 +1,11 @@ import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import { GO_BINARY_PROVIDER_IDENTITY } from "./GoBinaryProviderIdentity.js"; import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, projectArtifactCloseFailure, stableArtifactInputRecovery, } from "../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { type StableArtifactSnapshot, readStableArtifact, @@ -22,7 +22,6 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { GoBinaryPort } from "../application/go/GoBinaryPort.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -153,20 +152,13 @@ const failureForPrimary = ( [{ path: ["path"], reason: "invalid_format", message: cause.message }], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + path, + "artifact", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(GO_BINARY_PROVIDER_IDENTITY.id, OPERATION, { cause, diagnostics: { diff --git a/src/gpu/GpuInventory.test.ts b/src/gpu/GpuInventory.test.ts index 269b14eb22..97efe073c2 100644 --- a/src/gpu/GpuInventory.test.ts +++ b/src/gpu/GpuInventory.test.ts @@ -150,3 +150,15 @@ it.each(["zlib", "zstd"] as const)( expect(observedReadBytes).toBeLessThan(2 * bytes.length); }, ); + +it("classifies an over-limit kernel symbol name as capacity", async () => { + const bytes = cubinFixture({ name: "x".repeat(65_536) }); + const inventory = new GpuInventory({ path: "/fixture", analysis: "kernels" }); + await inventory.inspect(new GpuBufferSource(bytes, "fixture")); + expect(inventory.objects[0]).toMatchObject({ + inspection: "capacity", + limitations: expect.arrayContaining([ + expect.stringContaining("65536-byte read allowance"), + ]), + }); +}); diff --git a/src/gpu/GpuInventory.ts b/src/gpu/GpuInventory.ts index b28d7cb833..2c48691fe3 100644 --- a/src/gpu/GpuInventory.ts +++ b/src/gpu/GpuInventory.ts @@ -289,7 +289,8 @@ export class GpuInventory { if (cause instanceof GpuReportCapacityFailure) throw cause; if ( !(cause instanceof BinaryFormatFailure) && - !(cause instanceof BinaryResourceFailure) + !(cause instanceof BinaryResourceFailure) && + !(cause instanceof BinaryStringReadLimitFailure) ) throw cause; row.inspection = diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 7b8e61dbdc..fcc2af03ea 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -95,7 +95,7 @@ import { } from "./protocol.js"; /** Aggregate in-memory retention budget for this provider's launcher output. */ -export const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024; +const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024; const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir(); diff --git a/src/hopper/HopperProvider.test.ts b/src/hopper/HopperProvider.test.ts index a0ce805223..be44411491 100644 --- a/src/hopper/HopperProvider.test.ts +++ b/src/hopper/HopperProvider.test.ts @@ -313,8 +313,11 @@ describe("Hopper relationship coverage", () => { ), }); } finally { - await client.close(); - callTool.mockRestore(); + try { + await client.close(); + } finally { + callTool.mockRestore(); + } } }, ); @@ -406,8 +409,11 @@ describe("Hopper caller reconciliation failures", () => { }, }); } finally { - await client.close(); - callTool.mockRestore(); + try { + await client.close(); + } finally { + callTool.mockRestore(); + } } }); }); diff --git a/src/hopper/HopperRegexSearch.ts b/src/hopper/HopperRegexSearch.ts index f4828eb6e3..47fa60b5f4 100644 --- a/src/hopper/HopperRegexSearch.ts +++ b/src/hopper/HopperRegexSearch.ts @@ -55,10 +55,10 @@ export class HopperRegexSearch { signal, }); this.#active.set(controller, pending); - void pending.then( - () => this.#active.delete(controller), - () => this.#active.delete(controller), - ); + const remove = (): void => { + this.#active.delete(controller); + }; + void pending.then(remove, remove); return pending; } diff --git a/src/ida/IdaConfiguration.test.ts b/src/ida/IdaConfiguration.test.ts index 9e05556f44..0b537f08c2 100644 --- a/src/ida/IdaConfiguration.test.ts +++ b/src/ida/IdaConfiguration.test.ts @@ -212,7 +212,6 @@ describe("IDA registration descriptor ownership", () => { retain: (resource) => retained.push(resource), run: (operation) => operation(), }; - const close = OwnedFileHandle.prototype.close; const reason = new Error("cancel while closing IDA registration"); const controller = new AbortController(); let entered!: () => void; diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index 7c2291f56c..d82a86970b 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -242,8 +242,8 @@ const mockedHttpBoundary = ({ it("preserves the SDK request timeout as a typed IDA timeout", async () => { const timeoutMs = 1000; - const requestTimeoutMs = 40; const pendingCall = vi.fn(); + const requestTimeoutMs = 40; mockedHttpBoundary({ serverName: "ida-timeout-fixture", tools: [], @@ -288,7 +288,6 @@ it("preserves the SDK request timeout as a typed IDA timeout", async () => { it("bounds a stalled health poll by the remaining readiness deadline", async () => { const timeoutMs = 800; - const pendingCall = vi.fn(); let clockNow = 0; const clock = { now: () => clockNow, diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 5c90566c59..158c9699b4 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -11,6 +11,7 @@ import { import { basename, dirname, join, sep } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { createIdaTarget, RecordingIdaMcp, @@ -915,16 +916,26 @@ describe("attached IDA function failure handling", () => { { procedure: "main" }, { signal: controller.signal }, ); - await started; - controller.abort(); - const close = client.close(); - expect(producer.closes).toBe(0); - release?.(); - const result = await call; - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); - expect((await close).ok).toBe(true); - expect(producer.closes).toBe(1); + try { + await awaitAcknowledgement(started, call, "IDA read-only call"); + controller.abort(); + const close = client.close(); + expect(producer.closes).toBe(0); + release?.(); + const result = await call; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); + expect((await close).ok).toBe(true); + expect(producer.closes).toBe(1); + } finally { + controller.abort(); + release?.(); + try { + await call; + } finally { + await client.close(); + } + } }); }); @@ -2322,7 +2333,7 @@ it("keeps the session cleanup owner until an ignored-abort HTTP acquisition and ); const executing = client.execute("health", {}); try { - await started; + await awaitAcknowledgement(started, executing, "IDA HTTP acquisition"); const result = await executing; expect(result).toMatchObject({ ok: false, diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 1f89e6354d..8ee9b18a73 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -1,5 +1,4 @@ import { IL2CPP_METADATA_PROVIDER_IDENTITY } from "./Il2CppMetadataProviderIdentity.js"; -import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { Il2cppBinaryFormatFailure, IL2CPP_BINARY_BYTES, @@ -15,7 +14,10 @@ import { } from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; +import { + projectSelectedArtifactFileFailure, + stableArtifactInputRecovery, +} from "../artifacts/StableArtifactFailure.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -33,7 +35,6 @@ import { createIl2cppMetadataAdmission } from "./Il2cppMetadataAdmission.js"; import { hexAddress as hex } from "./Il2cppBinaryImage.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -154,20 +155,14 @@ const failureForPrimary = ( [{ path: [field], reason: "invalid_format", message: cause.message }], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: [field], - reason: "invalid_value", - message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + path, + "artifact", + field, + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError( IL2CPP_METADATA_PROVIDER_IDENTITY.id, OPERATION, diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index 6a63745667..d9e19c3bed 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -4,7 +4,6 @@ import type { JavaScriptRuntimeTargetList, JavaScriptRuntimeTargetLocation, } from "../domain/javascript/javascriptRuntimeObservation.js"; -import { BrowserObservationError } from "../domain/browserObservationError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; import { parseCdpEndpointValue, readCdpJson } from "../browser/CdpEndpoint.js"; diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index cf3a079a6f..50630fb3d8 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -246,14 +246,10 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( } }, owner.signal.signal); owner.acquisition = acquisition; - void acquisition.promise.then( - () => { - if (owner.acquisition === acquisition) owner.acquisition = undefined; - }, - () => { - if (owner.acquisition === acquisition) owner.acquisition = undefined; - }, - ); + const clearAcquisition = (): void => { + if (owner.acquisition === acquisition) owner.acquisition = undefined; + }; + void acquisition.promise.then(clearAcquisition, clearAcquisition); return waitForAcquisition(owner, acquisition, signal); }; diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index 96885f50b6..d5e6e9498a 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { createJebAnalysisProvider } from "../composition/jeb.js"; import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; @@ -551,6 +552,8 @@ it("forwards in-flight cancellation to the MCP call", async () => { async close() {}, async call(_name, _args, signal) { entered(); + if (signal!.aborted) + throw new AnalysisCancelledError(_name, { cause: signal!.reason }); await new Promise((_resolve, reject) => signal!.addEventListener( "abort", @@ -568,13 +571,22 @@ it("forwards in-flight cancellation to the MCP call", async () => { { operation: "inspect_jeb_client", input: {} }, { signal: controller.signal }, ); - await started; - controller.abort(); - const result = await pending; - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toBeInstanceOf(AnalysisCancelledError); - expect(result.error.cause).toBe(controller.signal.reason); + try { + await awaitAcknowledgement(started, pending, "JEB MCP call"); + controller.abort(); + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(controller.signal.reason); + } + } finally { + controller.abort(); + try { + await pending; + } finally { + await provider.close(); + } } }); diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 5978870dec..01ac21e993 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -44,7 +44,6 @@ import { kunaCapabilities } from "./KunaProviderCapabilities.js"; import { KUNA_PROVIDER_IDENTITY } from "./KunaProviderMetadata.js"; import { KUNA_COMMAND_REMEDIATION, - KUNA_PROVIDER_ID, KUNA_RELEASE_VERIFICATION_EXPLANATION, KUNA_UNVERIFIED_VERSION_REMEDIATION, KUNA_VERIFIED_VERSION, @@ -70,7 +69,6 @@ export class KunaProvider implements AnalysisProviderCandidate { readonly #heapRequest: RegexWorkerHeapRequest; readonly #platform: NodeJS.Platform; readonly #launcher: KunaProcessLauncher | undefined; - readonly #probeTimeoutMs: number | undefined; readonly #singleFunctionRuns: number | undefined; #cache: KunaProjectCache | undefined; #runSlots: KunaRunSlots | undefined; @@ -80,8 +78,6 @@ export class KunaProvider implements AnalysisProviderCandidate { options: { readonly platform?: NodeJS.Platform; readonly launcher?: KunaProcessLauncher; - /** Test seam for bounded installation-probe lifecycle cases. */ - readonly probeTimeoutMs?: number; /** Test seam for foreground native runs; production derives it from the host. */ readonly singleFunctionRuns?: number; /** @@ -105,7 +101,6 @@ export class KunaProvider implements AnalysisProviderCandidate { options.regexWorkerHeapRequest ?? resolveRegexWorkerHeapRequest(this.#environment); this.#launcher = options.launcher; - this.#probeTimeoutMs = options.probeTimeoutMs; this.#singleFunctionRuns = options.singleFunctionRuns; } @@ -225,9 +220,6 @@ export class KunaProvider implements AnalysisProviderCandidate { ...(this.#launcher === undefined ? {} : { processLauncher: this.#launcher }), - ...(this.#probeTimeoutMs === undefined - ? {} - : { probeTimeoutMs: this.#probeTimeoutMs }), }); } catch (cause: unknown) { const retainedCleanup = kunaRetainedResourceCleanupError( diff --git a/src/model/SafetensorsHeader.ts b/src/model/SafetensorsHeader.ts index 7593b16003..3a463423c2 100644 --- a/src/model/SafetensorsHeader.ts +++ b/src/model/SafetensorsHeader.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../domain/jsonPointer.js"; import { setImmediate } from "node:timers/promises"; import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; import { TextDecoder } from "node:util"; @@ -196,8 +197,6 @@ const exactOffset = (value: bigint): number => { ); return Number(value); }; -const pointer = (value: string): string => - value.replaceAll("~", "~0").replaceAll("/", "~1"); export const readSafetensorsHeader = async ( reader: ModelHeaderReader, @@ -271,7 +270,7 @@ export const readSafetensorsHeader = async ( element_count: null, preview_complete: true, ...source, - json_pointer: `/__metadata__/${pointer(key)}`, + json_pointer: `/__metadata__/${escapeJsonPointerToken(key)}`, }); } } @@ -336,7 +335,7 @@ export const readSafetensorsHeader = async ( byte_length_state: "declared", storage_validation: bits === undefined ? "unknown" : "consistent", ...source, - json_pointer: `/${pointer(name)}`, + json_pointer: `/${escapeJsonPointerToken(name)}`, }); } const ordered = [...tensors].sort( diff --git a/src/native/NativeCommandPreparation.test.ts b/src/native/NativeCommandPreparation.test.ts index 242881a87e..b59a5a01b0 100644 --- a/src/native/NativeCommandPreparation.test.ts +++ b/src/native/NativeCommandPreparation.test.ts @@ -3,7 +3,10 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { failedInspectionDescriptor } from "../../tests/fixtures/processInspectionCleanup.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import { nativeMachoTargetForFile } from "../../tests/fixtures/nativeCommands.js"; +import { + nativeMachoTargetForFile, + NativeFixtureRunner, +} from "../../tests/fixtures/nativeCommands.js"; import { fat } from "../domain/binaryTarget.fixture.js"; import { primaryAnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -18,7 +21,6 @@ import { import { NativeMacOSProvider } from "./NativeMacOSProvider.js"; import { XcrunCommandRunner, NativeCommandFailure } from "./CommandRunner.js"; import { LldbCallTracer } from "./LldbCallTracer.js"; -import { NativeFixtureRunner } from "../../tests/fixtures/nativeCommands.js"; import { err } from "../domain/result.js"; vi.mock("../process/OwnedCommand.js", async (original) => ({ diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index 9e26899ec0..d917fc52b5 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -14,11 +14,11 @@ import { } from "../../domain/native/appleIps.js"; import type { StableArtifactSnapshot } from "../../artifacts/readStableArtifact.js"; import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { @@ -32,7 +32,6 @@ import type { AppleIpsPort } from "../../application/binaryDiagnostics/AppleIpsP import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -332,20 +331,14 @@ const appleIpsFailurePrimary = ( [{ path: [field], reason: "invalid_format", message: cause.message }], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: [field], - reason: "invalid_value", - message: `Selected report could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "report", + field, + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(APPLE_IPS_PROVIDER.id, OPERATION, { cause, diagnostics: { diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index 1aeaeed590..dbc875b738 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -1,9 +1,9 @@ import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { type StableArtifactSnapshot, @@ -19,7 +19,6 @@ import type { ElfNotesPort } from "../../application/binaryDiagnostics/ElfNotesP import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -212,20 +211,13 @@ const elfNotesFailurePrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected ELF image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "ELF image", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( OPERATION, origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/native/elf/ElfUnwindRangesProvider.ts b/src/native/elf/ElfUnwindRangesProvider.ts index 5245830c92..86044d8dad 100644 --- a/src/native/elf/ElfUnwindRangesProvider.ts +++ b/src/native/elf/ElfUnwindRangesProvider.ts @@ -1,9 +1,9 @@ import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { @@ -16,7 +16,6 @@ import type { ElfUnwindRangesPort } from "../../application/binaryDiagnostics/El import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -215,20 +214,13 @@ const elfUnwindFailurePrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected ELF image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "ELF image", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( OPERATION, origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/native/elf/KernelBuildConfigProvider.ts b/src/native/elf/KernelBuildConfigProvider.ts index 0075332321..0181da8151 100644 --- a/src/native/elf/KernelBuildConfigProvider.ts +++ b/src/native/elf/KernelBuildConfigProvider.ts @@ -1,9 +1,9 @@ import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { @@ -16,7 +16,6 @@ import type { KernelBuildConfigPort } from "../../application/binaryDiagnostics/ import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -199,20 +198,13 @@ const kernelBuildConfigFailurePrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected kernel image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "kernel image", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( OPERATION, origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/native/elf/KernelModuleProvider.ts b/src/native/elf/KernelModuleProvider.ts index 89a1ce53f7..4902614fdf 100644 --- a/src/native/elf/KernelModuleProvider.ts +++ b/src/native/elf/KernelModuleProvider.ts @@ -1,9 +1,9 @@ import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { type StableArtifactSnapshot, @@ -19,7 +19,6 @@ import type { KernelModulePort } from "../../application/binaryDiagnostics/Kerne import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -204,20 +203,13 @@ const kernelModuleFailurePrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected kernel module could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "kernel module", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( OPERATION, origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 93e026b5be..2e3dedf364 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -10,7 +10,6 @@ import * as nativeExportContracts from "../../domain/native/nativeExports.js"; import { BinaryFormatFailure, BinaryResourceFailure, - BinaryStringReadLimitFailure, } from "../metadata/BinaryContainer.js"; import { parseNativeExports, @@ -873,6 +872,33 @@ describe("ELF export version and name coverage", () => { }); }); +it.each(["definition", "requirement"] as const)( + "retains an export with an over-limit version %s name as partial", + (kind) => { + const longName = "v".repeat(65_536); + const result = parse( + elfExportImage({ + symbols: [{ name: "kept", version: 2 }], + ...(kind === "definition" + ? { versionDefinitions: ["base", longName] } + : { versionRequirements: [{ index: 2, name: longName }] }), + }), + ); + expect(result.exports).toMatchObject([ + { name: "kept", version: { index: 2, name: null } }, + ]); + expect(result.coverage.status).toBe("partial"); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "version_unresolved", + message: expect.stringContaining("65536-byte read allowance"), + }), + ]), + ); + }, +); + describe("ELF version requirements", () => { it("resolves defined copy-relocated symbols through version requirements", () => { const result = parse( @@ -1146,14 +1172,15 @@ describe("ELF symbol-name text", () => { expect(result.exports.map((entry) => entry.name)).toEqual( length === 65535 ? [name] : [], ); - if (length === 65536) + if (length === 65536) { + expect(result.coverage.status).toBe("partial"); expect(result.issues).toContainEqual( expect.objectContaining({ - message: expect.stringContaining( - new BinaryStringReadLimitFailure(65_536).message, - ), + code: "name_unreadable", + message: expect.stringContaining("65536-byte read allowance"), }), ); + } }, ); diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 771716ffa0..e3de7aa47c 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -2,6 +2,8 @@ import { BinaryFormatFailure, BinaryReader, BinaryResourceFailure, + isBinaryFormatOrStringLimitFailure, + type BinaryStringReadLimitFailure, mappedFileOffset, imageFileMappings, readMappedCString, @@ -96,7 +98,7 @@ const coverage = ( }; const failureMessage = (cause: unknown): string => { - if (cause instanceof BinaryFormatFailure) return cause.message; + if (isBinaryFormatOrStringLimitFailure(cause)) return cause.message; throw cause; }; @@ -350,7 +352,7 @@ const createVersionNameReader = () => { const decodedNames = new Map< string, | { readonly value: DecodedElfString | null } - | { readonly failure: BinaryFormatFailure } + | { readonly failure: BinaryFormatFailure | BinaryStringReadLimitFailure } >(); let cacheBytes = 0; const chargeCache = (bytes: number): void => { @@ -373,7 +375,7 @@ const createVersionNameReader = () => { try { raw = readElfStringBytes(names, offset); } catch (cause) { - if (!(cause instanceof BinaryFormatFailure)) throw cause; + if (!isBinaryFormatOrStringLimitFailure(cause)) throw cause; // Invalid offsets/unterminated aliases must not repeatedly scan strings // either. Account for the cached error and its stack conservatively. chargeCache(4096); diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index 9e47ec9f21..7f6b0d2762 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -1,9 +1,10 @@ import { + imageFileSizeRemediation, + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { type StableArtifactSnapshot, @@ -20,7 +21,6 @@ import type { NativeExportsPort } from "../../application/binaryDiagnostics/Nati import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -86,11 +86,6 @@ export class NativeExportsProvider implements NativeExportsPort { } } -const fileSizeRemediation = (input: InspectNativeExportsInput): string => - input.max_file_bytes >= MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` - : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; - const nativeExportsFailurePrimary = ( facts: ArtifactFilePrimaryFacts, input: InspectNativeExportsInput, @@ -161,7 +156,10 @@ const nativeExportsFailurePrimary = ( { cause, retryable: false, - remediationAction: fileSizeRemediation(input), + remediationAction: imageFileSizeRemediation( + input.max_file_bytes, + MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES, + ), }, ); if (cause.reason === "path" || cause.reason === "format") @@ -171,20 +169,13 @@ const nativeExportsFailurePrimary = ( [{ path: ["path"], reason: "invalid_format", message: cause.message }], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "image", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(NATIVE_EXPORTS_PROVIDER.id, OPERATION, { cause, diagnostics: { diff --git a/src/native/hardening/BinaryHardeningProvider.ts b/src/native/hardening/BinaryHardeningProvider.ts index 63acc5a161..9e798c3932 100644 --- a/src/native/hardening/BinaryHardeningProvider.ts +++ b/src/native/hardening/BinaryHardeningProvider.ts @@ -1,8 +1,10 @@ import { + imageFileSizeRemediation, + projectSelectedArtifactFileFailure, + stableArtifactInputRecovery, projectStableArtifactFailure, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { type StableArtifactSnapshot, @@ -19,7 +21,6 @@ import type { BinaryHardeningPort } from "../../application/binaryDiagnostics/Bi import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -131,11 +132,6 @@ export const readBinaryHardening = async ( }; }; -const fileSizeRemediation = (input: InspectBinaryHardeningInput): string => - input.max_file_bytes >= MAX_INSPECT_BINARY_HARDENING_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_BINARY_HARDENING_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` - : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; - const binaryHardeningFailurePrimary = ( facts: ArtifactFilePrimaryFacts, input: InspectBinaryHardeningInput, @@ -204,28 +200,26 @@ const binaryHardeningFailurePrimary = ( { cause, retryable: false, - remediationAction: fileSizeRemediation(input), + remediationAction: imageFileSizeRemediation( + input.max_file_bytes, + MAX_INSPECT_BINARY_HARDENING_FILE_BYTES, + ), }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); - } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "image", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(BINARY_HARDENING_PROVIDER.id, OPERATION, { cause, diagnostics: { diff --git a/src/native/initializers/NativeInitializersParsers.test.ts b/src/native/initializers/NativeInitializersParsers.test.ts index 791396ce5f..c8814da972 100644 --- a/src/native/initializers/NativeInitializersParsers.test.ts +++ b/src/native/initializers/NativeInitializersParsers.test.ts @@ -41,7 +41,6 @@ import { } from "./NativeInitializers.fixture.js"; import { MAX_NATIVE_INITIALIZER_ENTRIES, - MAX_NATIVE_INITIALIZER_LIMITATIONS, MAX_NATIVE_INITIALIZER_NAME_BYTES, MAX_NATIVE_INITIALIZER_NOTES, NATIVE_INITIALIZER_LIMITATIONS, @@ -516,106 +515,6 @@ describe("ELF section content admission", () => { ); }); -describe("ELF section content admission", () => { - it("keeps symbol relocations unresolved for a conflicting symbol entry width", async () => { - const bytes = elfRelocatableCtors(); - bytes.writeBigUInt64LE(16n, 0x500 + 4 * 64 + 56); - const report = await read(bytes); - expect( - report.entries.find((entry) => entry.kind === "init_array"), - ).toMatchObject({ - resolution: "unresolved", - value_va: null, - symbol: null, - }); - expect(report.status).toBe("incomplete"); - expect(report.limitations.join("\n")).toContain("entry size"); - }); - - it.each([ - { section: 7, label: "initializer array" }, - { section: 4, label: "symbol table" }, - { section: 5, label: "symbol string table" }, - { section: 8, label: "relocation table" }, - ])("preserves uncertainty for a compressed $label", async ({ section }) => { - const base = elfRelocatableCtors(); - const at = 0x500 + section * 64; - const offset = Number(base.readBigUInt64LE(at + 24)); - const size = Number(base.readBigUInt64LE(at + 32)); - const header = Buffer.alloc(24); - header.writeUInt32LE(1, 0); - header.writeBigUInt64LE(BigInt(size), 8); - header.writeBigUInt64LE(1n, 16); - const compressed = Buffer.concat([ - header, - deflateSync(base.subarray(offset, offset + size)), - ]); - const bytes = Buffer.concat([base, compressed]); - bytes.writeBigUInt64LE(base.readBigUInt64LE(at + 8) | 0x800n, at + 8); - bytes.writeBigUInt64LE(BigInt(base.length), at + 24); - bytes.writeBigUInt64LE(BigInt(compressed.length), at + 32); - const report = await read(bytes); - expect(report.status).toBe("incomplete"); - expect(report.coverage.total_entries).toBeNull(); - expect(report.limitations.join("\n")).toContain("compressed"); - if (section === 7) - expect(report.entries.some((entry) => entry.kind === "init_array")).toBe( - false, - ); - else { - const init = report.entries.find((entry) => entry.kind === "init_array"); - expect(init).toBeDefined(); - if (section === 5) expect(init?.symbol).toBeNull(); - else - expect(init).toMatchObject({ - resolution: "unresolved", - value_va: null, - }); - } - // The uncompressed fixture remains the working comparison. - expect((await read(base)).status).toBe("parsed"); - }); - - it("does not substitute an unrelated dynamic symbol table for DT_SYMTAB", async () => { - const bytes = elf64PieInitializers(); - bytes.writeBigUInt64LE(0x777n, 0x300 + 14 * 16 + 8); - const report = await read(bytes); - expect( - report.entries.find((entry) => entry.slot_va === "0x210"), - ).toMatchObject({ - resolution: "unresolved", - symbol: null, - value_va: null, - }); - expect(report.status).toBe("incomplete"); - }); - - it.each(["compressed", "not a string table"])( - "retains later symbol tables when an earlier name source is %s", - async (source) => { - const bytes = elf64PieInitializers(); - // The first (dynamic) symbol table links section 8; its bytes still contain imp_init. - if (source === "compressed") - bytes.writeBigUInt64LE(0x802n, 0x800 + 8 * 64 + 8); - else bytes.writeUInt32LE(1, 0x800 + 8 * 64 + 4); - const report = await read(bytes); - expect(report.status).toBe("incomplete"); - expect( - report.entries.find((entry) => entry.kind === "init"), - ).toMatchObject({ - symbol: { name: "ctor_a" }, - value_va: "0x100", - }); - expect( - report.entries.find((entry) => entry.slot_va === "0x210"), - ).toMatchObject({ - resolution: "unresolved_import", - symbol: null, - }); - }, - ); -}); - describe("ELF incomplete inputs", () => { it("reports an unsupported relocation machine as incomplete, not as raw zeros", async () => { const bytes = elf64PieInitializers(); @@ -931,9 +830,7 @@ describe("ELF repeated observations", () => { ).toEqual([ `${String(rows)} symbol name(s) exceeded the 1024-byte name budget and were not used.`, ]); - expect(report.limitations.length).toBeLessThanOrEqual( - MAX_NATIVE_INITIALIZER_LIMITATIONS, - ); + expect(report.limitations.length).toBeLessThanOrEqual(40); }); it("tallies relocation sections with an unexpected entry size", async () => { @@ -950,9 +847,7 @@ describe("ELF repeated observations", () => { ).toEqual([ `${String(sections)} relocation section(s) had an unexpected entry size and were not applied.`, ]); - expect(report.limitations.length).toBeLessThanOrEqual( - MAX_NATIVE_INITIALIZER_LIMITATIONS, - ); + expect(report.limitations.length).toBeLessThanOrEqual(40); }); it("keeps distinct notes to the budget and counts the rest", async () => { @@ -979,7 +874,7 @@ describe("ELF repeated observations", () => { // Standing limitations, the full note list and the withheld-name line fit the schema. expect( NATIVE_INITIALIZER_LIMITATIONS.length + lines.length + 1, - ).toBeLessThanOrEqual(MAX_NATIVE_INITIALIZER_LIMITATIONS); + ).toBeLessThanOrEqual(40); expect(notes.recorded).toBe(400); }); }); diff --git a/src/native/initializers/NativeInitializersProvider.ts b/src/native/initializers/NativeInitializersProvider.ts index 9386f327b2..db1e4c06c5 100644 --- a/src/native/initializers/NativeInitializersProvider.ts +++ b/src/native/initializers/NativeInitializersProvider.ts @@ -1,8 +1,10 @@ import { + imageFileSizeRemediation, + projectSelectedArtifactFileFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, projectStableArtifactFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { @@ -16,7 +18,6 @@ import type { NativeInitializersPort } from "../../application/binaryDiagnostics import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -184,11 +185,6 @@ const architectureName = (cpuType: number): string => { } }; -const fileSizeRemediation = (input: InspectNativeInitializersInput): string => - input.max_file_bytes >= MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` - : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; - const failurePrimary = ( facts: ArtifactFilePrimaryFacts, input: InspectNativeInitializersInput, @@ -274,28 +270,26 @@ const failurePrimary = ( { cause, retryable: false, - remediationAction: fileSizeRemediation(input), + remediationAction: imageFileSizeRemediation( + input.max_file_bytes, + MAX_INSPECT_NATIVE_INITIALIZERS_FILE_BYTES, + ), }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); - } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "image", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(NATIVE_INITIALIZERS_PROVIDER.id, OPERATION, { cause, diagnostics: { diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index a1ac3b8331..a587637ad0 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -41,17 +41,22 @@ export class BinaryResourceFailure extends Error { } /** A bounded read could not establish termination; it does not prove invalid text. */ -export class BinaryStringReadLimitFailure extends BinaryFormatFailure { +export class BinaryStringReadLimitFailure extends Error { constructor(readonly maximumBytes: number) { - // Preserve the existing broad failure contract for legacy callers while - // allowing coverage-aware consumers to distinguish a limit from corruption. super( - "malformed", `String termination was not established within its ${String(maximumBytes)}-byte read allowance`, ); + this.name = "BinaryStringReadLimitFailure"; } } +/** Recognized format failures and bounded string-read failures. */ +export const isBinaryFormatOrStringLimitFailure = ( + cause: unknown, +): cause is BinaryFormatFailure | BinaryStringReadLimitFailure => + cause instanceof BinaryFormatFailure || + cause instanceof BinaryStringReadLimitFailure; + /** Native loader region facts; language-specific fallback selection belongs to consumers. */ export interface LoadRegion { readonly mapping: FileMapping; diff --git a/src/native/metadata/ElfStringTable.test.ts b/src/native/metadata/ElfStringTable.test.ts index 99fcd947ab..11a25d70ad 100644 --- a/src/native/metadata/ElfStringTable.test.ts +++ b/src/native/metadata/ElfStringTable.test.ts @@ -37,7 +37,7 @@ it.each([65_536, 70_000])( failure = cause; } expect(failure).toBeInstanceOf(BinaryStringReadLimitFailure); - expect(failure).toBeInstanceOf(BinaryFormatFailure); + expect(failure).not.toBeInstanceOf(BinaryFormatFailure); expect(failure).toMatchObject({ maximumBytes: 65_536 }); }, ); diff --git a/src/native/pe/PeBinaryFailure.ts b/src/native/pe/PeBinaryFailure.ts index 67315f0f96..a111f3cdcd 100644 --- a/src/native/pe/PeBinaryFailure.ts +++ b/src/native/pe/PeBinaryFailure.ts @@ -1,8 +1,8 @@ import { + projectSelectedArtifactFileFailure, stableArtifactInputRecovery, projectStableArtifactFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import type { StableArtifactSnapshot } from "../../artifacts/readStableArtifact.js"; import { @@ -12,7 +12,6 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -114,20 +113,13 @@ const peBinaryFailurePrimary = ( [{ path: ["path"], reason: "invalid_format", message: cause.message }], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(operation, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(operation, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + operation, + input.path, + "image", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(providerId, operation, { cause, diagnostics: { diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 90b26652f0..63cc20259a 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -52,12 +52,9 @@ const fail = (message: string): never => { throw new PeCertificateAsn1Failure(message); }; -const required = (value: T | undefined, message: string): T => - value === undefined ? fail(message) : value; - // Authenticode signatures with full chains and countersignatures decode to a // few thousand nodes; the budget bounds sibling fan-out, which depth does not. -export const ASN1_NODE_BUDGET = 16_384; +const ASN1_NODE_BUDGET = 16_384; /** Decode one bounded ASN.1 TLV, including indefinite constructed BER values. */ export const readAsn1 = (bytes: Buffer): Asn1BerNode => { @@ -91,12 +88,12 @@ export const oidName = (oid: string): string | null => OID_NAMES[oid] ?? null; // One arc grows a BigInt by a shift per byte. Certificate OIDs, including a // 2.25 UUID arc, fit under both caps; the caps stop that growth. -export const MAX_OID_BYTES = 256; -export const MAX_OID_ARC_BYTES = 32; +const MAX_OID_BYTES = 256; +const MAX_OID_ARC_BYTES = 32; // One PKCS#7 payload shares this ceiling. Real issuer and subject names are // far smaller; the cap stops one wide string from occupying the file budget. -export const MAX_DECODED_NAME_BYTES = 65_536; +const MAX_DECODED_NAME_BYTES = 65_536; interface DecodedNameBudget { remaining: number; diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts index 44531102b2..4b4c39895d 100644 --- a/src/native/pe/PeCertificateParser.test.ts +++ b/src/native/pe/PeCertificateParser.test.ts @@ -12,16 +12,19 @@ import { pkcs7SignedDataIndefiniteEnvelopeFixture, } from "./PeCertificates.fixture.js"; import { - ASN1_NODE_BUDGET, - MAX_DECODED_NAME_BYTES, - MAX_OID_ARC_BYTES, - MAX_OID_BYTES, parseCertificate, PeCertificateAsn1Failure, readAsn1, } from "./PeCertificateAsn1.js"; import { parsePeCertificates } from "./PeCertificateParser.js"; +// Independent boundary examples: changing an allowance requires reviewing +// both the admitted and rejected certificate shapes. +const ASN1_NODE_BUDGET = 16_384; +const MAX_OID_BYTES = 256; +const MAX_OID_ARC_BYTES = 32; +const MAX_DECODED_NAME_BYTES = 65_536; + const defaults = inspectPeCertificatesInputSchema.parse({ path: "/artifacts/app.exe", }); diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 543233e43a..67df806ce1 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -1,10 +1,10 @@ import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { type StableArtifactSnapshot, @@ -20,7 +20,6 @@ import type { PeCertificatesPort } from "../../application/binaryDiagnostics/PeC import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -219,20 +218,13 @@ const peCertificatesFailurePrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected PE image could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "PE image", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( OPERATION, origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/native/pe/PeImportParser.test.ts b/src/native/pe/PeImportParser.test.ts index 27eaf5ce0e..85eb6b5946 100644 --- a/src/native/pe/PeImportParser.test.ts +++ b/src/native/pe/PeImportParser.test.ts @@ -151,6 +151,55 @@ it.each([ }, ); +it.each([4096, 4097])( + "reports a %i-byte DLL name at the fixed read boundary", + async (length) => { + const name = "n".repeat(length); + const report = await parse( + peImportImage({ + sectionBytes: 8192, + dlls: [{ name, functions: [] }], + }), + ); + if (length === 4096) { + expect(report.imports[0]?.name).toBe(name); + expect(report.coverage.status).toBe("complete"); + } else { + expect(report.imports[0]?.name).toBeNull(); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toContainEqual( + expect.objectContaining({ + code: "name_unreadable", + message: expect.stringContaining("4096-byte read allowance"), + }), + ); + } + }, +); + +it("retains later imports when a function name exceeds the read allowance", async () => { + const report = await parse( + peImportImage({ + sectionBytes: 8192, + dlls: [ + { + name: "example.dll", + functions: [{ name: "n".repeat(4097) }, { name: "kept" }], + }, + ], + }), + ); + expect(report.imports[0]?.functions).toMatchObject([{ name: "kept" }]); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toContainEqual( + expect.objectContaining({ + code: "name_unreadable", + message: expect.stringContaining("4096-byte read allowance"), + }), + ); + expect(report.imphash.md5).toBeNull(); +}); + it("does not consume a DLL name past the uint32 RVA end", async () => { const report = await parse(peImportNameAtRva(0xfffffff8)); expect(report.imports[0]?.name).toBeNull(); diff --git a/src/native/pe/PeImportParser.ts b/src/native/pe/PeImportParser.ts index ece8710151..6208eb28ec 100644 --- a/src/native/pe/PeImportParser.ts +++ b/src/native/pe/PeImportParser.ts @@ -11,6 +11,7 @@ import { BinaryMappingFailure, BinaryReader, BinaryResourceFailure, + isBinaryFormatOrStringLimitFailure, createFileMappingIndex, imageFileMappings, } from "../metadata/BinaryContainer.js"; @@ -103,7 +104,7 @@ const createRetention = () => { }; const failureMessage = (cause: unknown): string => { - if (cause instanceof BinaryFormatFailure) return cause.message; + if (isBinaryFormatOrStringLimitFailure(cause)) return cause.message; throw cause; }; diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 7256e506bd..6402a7ccd0 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -1,5 +1,8 @@ import { projectPeBinaryFailure } from "./PeBinaryFailure.js"; -import { projectArtifactCloseFailure } from "../../artifacts/StableArtifactFailure.js"; +import { + projectArtifactCloseFailure, + imageFileSizeRemediation, +} from "../../artifacts/StableArtifactFailure.js"; import { type StableArtifactSnapshot, readStableArtifact, @@ -76,11 +79,6 @@ export class PeImportsProvider implements PeImportsPort { } } -const fileSizeRemediation = (input: InspectPeImportsInput): string => - input.max_file_bytes >= MAX_INSPECT_PE_IMPORT_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_PE_IMPORT_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` - : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; - const dllRemediation = (input: InspectPeImportsInput): string => input.max_dlls >= MAX_INSPECT_PE_IMPORT_DLLS ? `The image exceeds the ${String(MAX_INSPECT_PE_IMPORT_DLLS)}-entry import-descriptor ceiling this operation accepts; select a smaller artifact.` @@ -128,7 +126,11 @@ const peImportsFailure = ( { operation: OPERATION, providerId: PE_IMPORTS_PROVIDER.id, - fileSizeRemediation: () => fileSizeRemediation(input), + fileSizeRemediation: () => + imageFileSizeRemediation( + input.max_file_bytes, + MAX_INSPECT_PE_IMPORT_FILE_BYTES, + ), limit: (failure) => failure instanceof PeImportLimitFailure ? failure.limit : null, limitRecovery: (limit) => limitRecovery(limit, input), diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index f430776673..6477df6661 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -1,5 +1,8 @@ import { projectPeBinaryFailure } from "./PeBinaryFailure.js"; -import { projectArtifactCloseFailure } from "../../artifacts/StableArtifactFailure.js"; +import { + projectArtifactCloseFailure, + imageFileSizeRemediation, +} from "../../artifacts/StableArtifactFailure.js"; import { type StableArtifactSnapshot, readStableArtifact, @@ -74,11 +77,6 @@ export class PeLoadConfigProvider implements PeLoadConfigPort { } } -const fileSizeRemediation = (input: InspectPeLoadConfigInput): string => - input.max_file_bytes >= MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` - : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; - const limitRecovery = ( limit: PeLoadConfigLimit | null, input: InspectPeLoadConfigInput, @@ -104,7 +102,11 @@ const peLoadConfigFailure = ( { operation: OPERATION, providerId: PE_LOAD_CONFIG_PROVIDER.id, - fileSizeRemediation: () => fileSizeRemediation(input), + fileSizeRemediation: () => + imageFileSizeRemediation( + input.max_file_bytes, + MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES, + ), limit: (failure) => failure instanceof PeLoadConfigLimitFailure ? failure.limit : null, limitRecovery: (limit) => limitRecovery(limit, input), diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 0d96cd158f..e25df7ec6f 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -148,13 +148,13 @@ export const parsePeResources = async ( bytes.subarray(header, header + PE_RESOURCE_DIRECTORY_BYTES), ); const { namedEntries: named, idEntries: ids, entryCount: count } = facts; + relative(at, PE_RESOURCE_DIRECTORY_BYTES + PE_RESOURCE_ENTRY_BYTES * count); if (count > input.max_entries - examined) limit( "PE resource directory entry budget exceeded; no partial inventory was returned.", "capacity", ); examined += count; - relative(at, PE_RESOURCE_DIRECTORY_BYTES + PE_RESOURCE_ENTRY_BYTES * count); const directory = { path, entry_locations: entryLocations, diff --git a/src/native/pe/PeResourcesProvider.test.ts b/src/native/pe/PeResourcesProvider.test.ts index c9715d46eb..f71fa7d131 100644 --- a/src/native/pe/PeResourcesProvider.test.ts +++ b/src/native/pe/PeResourcesProvider.test.ts @@ -1,22 +1,17 @@ -import { writeFile } from "node:fs/promises"; +import { truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; -import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js"; -import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { MAX_INSPECT_PE_DIRECTORY_ENTRIES, MAX_INSPECT_PE_FILE_BYTES, inspectPeResourcesInputSchema, } from "../../domain/native/peResources.js"; -import { kunaQueryFailureForCaller } from "../../kuna/KunaQueryErrors.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; -import { - inputBudgetRemediation, - PeResourcesProvider, -} from "./PeResourcesProvider.js"; +import { PeResourcesProvider } from "./PeResourcesProvider.js"; import { peResourceFixture } from "./PeResources.fixture.js"; +import { imageFileSizeRemediation } from "../../artifacts/StableArtifactFailure.js"; const inspect = async ( path: string, @@ -44,15 +39,16 @@ describe("PE resource provider budget reporting", () => { if (result.ok) throw new Error("Expected a refusal"); expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); const error = result.error as AnalysisResourceConstraintError; - expect(error.message).toContain("Selected artifact exceeds 64 bytes"); expect(error.reportedLimits).toMatchObject({ max_file_bytes: 64 }); expect(error.resource).toBe("file-size"); - expect(error.remediationAction).toContain("max_file_bytes"); + expect(error.remediationAction).toBe( + imageFileSizeRemediation(64, MAX_INSPECT_PE_FILE_BYTES), + ); expect(error.retryable).toBe(false); // The projected advice must not send the caller to worker write limits. expect(projectAnalysisError(error)).toMatchObject({ retryable: false, - remediation: { action: expect.stringContaining("max_file_bytes") }, + remediation: { action: error.remediationAction }, }); }); @@ -64,82 +60,100 @@ describe("PE resource provider budget reporting", () => { if (result.ok) throw new Error("Expected a refusal"); const error = result.error as AnalysisResourceConstraintError; expect(error.resource).toBe("capacity"); - expect(error.message).toContain("entry budget exceeded"); + expect(error.reportedLimits).toMatchObject({ max_entries: 1 }); expect(error.remediationAction).toContain("max_entries"); expect(projectAnalysisError(error)).toMatchObject({ retryable: false, - remediation: { action: expect.stringContaining("max_entries") }, + remediation: { action: error.remediationAction }, }); }); - it("keeps the boundary when a cleanup failure wraps the limit", () => { - // A file that grows past its byte limit while its handle also fails to - // close reaches the provider through withCleanup. - const wrapped = ArtifactReaderFailure.withCleanup( - new ArtifactReaderFailure("limit", "Selected artifact exceeds 64 bytes", { - boundary: "file-size", - }), - { reason: "close failed", resources: ["handle"] }, - ); - expect(wrapped.boundary).toBe("file-size"); - }); - - it("names the ceiling instead of an impossible bound at the schema maxima", () => { - const base = { path: "/artifacts/application.exe" }; - expect( - inputBudgetRemediation("file-size", { - ...base, + // Avoid reserving a 512 MiB file on filesystems without sparse truncation. + it.skipIf(process.platform === "win32")( + "names the file ceiling instead of an impossible bound", + async () => { + const root = await createTestTempDirectory("rea-pe-max-budget-"); + const oversized = join(root, "oversized.exe"); + await writeFile(oversized, ""); + await truncate(oversized, MAX_INSPECT_PE_FILE_BYTES + 1); + const fileLimit = await inspect(oversized, { max_file_bytes: MAX_INSPECT_PE_FILE_BYTES, - max_entries: 16384, - }), - ).toContain("input ceiling"); + }); + if (fileLimit.ok) throw new Error("Expected file-size refusal"); + expect(fileLimit.error).toMatchObject({ + resource: "file-size", + reportedLimits: { max_file_bytes: MAX_INSPECT_PE_FILE_BYTES }, + remediationAction: imageFileSizeRemediation( + MAX_INSPECT_PE_FILE_BYTES, + MAX_INSPECT_PE_FILE_BYTES, + ), + }); + expect( + inspectPeResourcesInputSchema.safeParse({ + path: oversized, + max_file_bytes: MAX_INSPECT_PE_FILE_BYTES + 1, + }).success, + ).toBe(false); + }, + ); + + it("names the resource-directory ceiling instead of an impossible bound", async () => { + const root = await createTestTempDirectory("rea-pe-max-entries-"); + const resources = [ + { type: "first" }, + { type: "second" }, + ...Array.from({ length: 65_535 }, (_, type) => ({ type })), + ]; + const fixture = peResourceFixture(false, resources, 2 * 1024 * 1024); + const directory = fixture.directoryOffsets[0]; + if (directory === undefined) + throw new Error("Expected a resource directory"); + expect(fixture.bytes.readUInt16LE(directory + 12)).toBe(2); + expect(fixture.bytes.readUInt16LE(directory + 14)).toBe(65_535); + expect(fixture.entryOffsets).toHaveLength(65_537); + expect(directory + 16 + 65_537 * 8).toBeLessThanOrEqual( + fixture.bytes.length, + ); + const path = join(root, "entries.exe"); + await writeFile(path, fixture.bytes); + const entries = await inspect(path, { + max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES, + }); + if (entries.ok) throw new Error("Expected entry-budget refusal"); + expect(entries.error).toBeInstanceOf(AnalysisResourceConstraintError); + const entryError = entries.error as AnalysisResourceConstraintError; + expect(entries.error).toMatchObject({ + resource: "capacity", + reportedLimits: { max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES }, + retryable: false, + }); + expect(typeof entryError.remediationAction).toBe("string"); + expect(projectAnalysisError(entryError)).toMatchObject({ + remediation: { action: entryError.remediationAction }, + }); expect( - inputBudgetRemediation("capacity", { - ...base, - max_file_bytes: 67108864, - max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES, - }), - ).toContain("resource-directory ceiling"); - // Below the ceiling the advice still names the bound the caller raises. - expect( - inputBudgetRemediation("file-size", { - ...base, - max_file_bytes: 64, - max_entries: 16384, - }), - ).toContain("Raise max_file_bytes above 64"); - expect( - inputBudgetRemediation("capacity", { - ...base, - max_file_bytes: 67108864, - max_entries: 1, - }), - ).toContain("Raise max_entries above 1"); + inspectPeResourcesInputSchema.safeParse({ + path, + max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES + 1, + }).success, + ).toBe(false); }); - it("preserves retryability through failure redaction and caller copies", async () => { - const source = new AnalysisResourceConstraintError( - "inspect_pe_resources", - "file-size", - "Selected artifact exceeds 64 bytes", - { max_file_bytes: 64 }, - { retryable: false }, - ); - const redacted = redactExplicitFailure(source, ["secret"]); - expect(redacted).toMatchObject({ retryable: false }); - const copied = kunaQueryFailureForCaller(source, "other_operation"); - expect(copied).toMatchObject({ retryable: false }); - // An undeclared retryability stays undeclared, so the shared projection - // keeps its resource-kind default. - const undeclared = new AnalysisResourceConstraintError( - "inspect_pe_resources", - "memory", - "Result allocation exceeded the budget", - null, - ); - expect(redactExplicitFailure(undeclared, ["secret"])).toMatchObject({ - retryable: undefined, + it("rejects a truncated resource table as malformed before applying the entry budget", async () => { + const root = await createTestTempDirectory("rea-pe-truncated-entries-"); + const fixture = peResourceFixture(); + const directory = fixture.directoryOffsets[0]; + if (directory === undefined) + throw new Error("Expected a resource directory"); + fixture.bytes.writeUInt16LE(0xffff, directory + 12); + fixture.bytes.writeUInt16LE(2, directory + 14); + const path = join(root, "truncated.exe"); + await writeFile(path, fixture.bytes); + const result = await inspect(path, { + max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES, }); + if (result.ok) throw new Error("Expected malformed table refusal"); + expect(result.error).toMatchObject({ _tag: "AnalysisInputError" }); }); }); diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index b8504ccfae..4de6c1b626 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,10 +1,11 @@ import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { + imageFileSizeRemediation, + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { @@ -21,7 +22,6 @@ import type { PeResourcesPort } from "../../application/binaryDiagnostics/PeReso import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -92,14 +92,12 @@ export class PeResourcesProvider implements PeResourcesPort { * there is no value left to raise the bound to, so the advice names the * ceiling instead of an impossible raise. */ -export const inputBudgetRemediation = ( +const inputBudgetRemediation = ( boundary: "file-size" | "capacity", input: InspectPeResourcesInput, ): string => boundary === "file-size" - ? input.max_file_bytes >= MAX_INSPECT_PE_FILE_BYTES - ? `The image exceeds the ${String(MAX_INSPECT_PE_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` - : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.` + ? imageFileSizeRemediation(input.max_file_bytes, MAX_INSPECT_PE_FILE_BYTES) : input.max_entries >= MAX_INSPECT_PE_DIRECTORY_ENTRIES ? `The image exceeds the ${String(MAX_INSPECT_PE_DIRECTORY_ENTRIES)}-entry resource-directory ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` : `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected resource directory entry budget.`; @@ -193,20 +191,13 @@ const peResourcesFailurePrimary = ( ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(operation, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(operation, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected PE image could not be read (${cause.code}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + operation, + input.path, + "PE image", + ); + if (fileFailure !== undefined) return fileFailure; return new ArtifactOperationError( operation, diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 421c5b69aa..574c0bcd2d 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -651,7 +651,8 @@ export class PwntoolsDecoder< }), ); } - } catch (cause: unknown) { + } catch (caught: unknown) { + let cause = caught; if (replyFinalization !== undefined && cause !== replyFinalization) { const primary = failure(cause); cause = replyFinalization.cleanupIncomplete diff --git a/src/native/windows/WindowsShortcutProvider.ts b/src/native/windows/WindowsShortcutProvider.ts index 02470710c0..76a8cde9ff 100644 --- a/src/native/windows/WindowsShortcutProvider.ts +++ b/src/native/windows/WindowsShortcutProvider.ts @@ -1,9 +1,9 @@ import { lstat, open } from "node:fs/promises"; import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact, @@ -22,7 +22,6 @@ import type { WindowsShortcutPort } from "../../application/binaryDiagnostics/Wi import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -349,20 +348,13 @@ const windowsShortcutFailurePrimary = ( { path: ["path"], reason: "invalid_format", message: cause.message }, ]); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected shortcut could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "shortcut", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(WINDOWS_SHORTCUT_PROVIDER.id, OPERATION, { cause, diagnostics: { diff --git a/src/process/OwnedCommand.test.ts b/src/process/OwnedCommand.test.ts index 6e5b374ff6..413cdb77c3 100644 --- a/src/process/OwnedCommand.test.ts +++ b/src/process/OwnedCommand.test.ts @@ -3,6 +3,7 @@ import { PassThrough, Writable } from "node:stream"; import { expect, it, vi } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { OwnedCommandFailure, runOwnedCommand } from "./OwnedCommand.js"; import type { ProcessCleanupResult } from "./ProcessOwnership.js"; @@ -95,6 +96,7 @@ it("captures synchronous EOF output after supervision and interprets it before c }); it("waits for delayed input finalization after output closes", async () => { + const controller = new AbortController(); const finalizing = createDeferred(); let finishInput: () => void = () => {}; const input = new Writable({ @@ -115,6 +117,7 @@ it("waits for delayed input finalization after output closes", async () => { observeCompleted, }, { + signal: controller.signal, launcher: async () => ({ process: fixture.child, ownership: fixture.ownership, @@ -122,14 +125,26 @@ it("waits for delayed input finalization after output closes", async () => { }), }, ); - await finalizing.promise; - await new Promise((resolve) => setImmediate(resolve)); - expect(observeCompleted).not.toHaveBeenCalled(); - expect(fixture.cleanup).not.toHaveBeenCalled(); - finishInput(); - await expect(pending).resolves.toMatchObject({ exitCode: 0 }); - expect(observeCompleted).toHaveBeenCalledOnce(); - expect(fixture.cleanup).toHaveBeenCalledOnce(); + try { + await awaitAcknowledgement( + finalizing.promise, + pending, + "input finalization", + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(observeCompleted).not.toHaveBeenCalled(); + expect(fixture.cleanup).not.toHaveBeenCalled(); + const finish = finishInput; + finishInput = () => {}; + finish(); + await expect(pending).resolves.toMatchObject({ exitCode: 0 }); + expect(observeCompleted).toHaveBeenCalledOnce(); + expect(fixture.cleanup).toHaveBeenCalledOnce(); + } finally { + controller.abort(); + finishInput(); + await pending.catch(() => undefined); + } }); it.each([2_000, null])( @@ -168,13 +183,22 @@ it.each([2_000, null])( cancellation: { reason }, cleanupFailure: null, }); - await finalizing.promise; - controller.abort(reason); - await rejected; - expect(observeCompleted).not.toHaveBeenCalled(); - expect(fixture.cleanup).toHaveBeenCalledOnce(); - expect(input.destroyed).toBe(true); - expect(fixture.child.kill).not.toHaveBeenCalled(); + try { + await awaitAcknowledgement( + finalizing.promise, + pending, + "input finalization", + ); + controller.abort(reason); + await rejected; + expect(observeCompleted).not.toHaveBeenCalled(); + expect(fixture.cleanup).toHaveBeenCalledOnce(); + expect(input.destroyed).toBe(true); + expect(fixture.child.kill).not.toHaveBeenCalled(); + } finally { + controller.abort(reason); + await pending.catch(() => undefined); + } }, ); diff --git a/src/process/OwnedCommandPreparation.test.ts b/src/process/OwnedCommandPreparation.test.ts index 4aa9dbb06b..b2c0b04040 100644 --- a/src/process/OwnedCommandPreparation.test.ts +++ b/src/process/OwnedCommandPreparation.test.ts @@ -1,6 +1,7 @@ import { ProcessOwnershipInspectionCancelled } from "./ProcessInspectionCancellation.js"; import { afterEach, expect, it, vi } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { DarwinProcessOwnershipInspectionError } from "./DarwinProcessRunTokenReader.js"; import { @@ -262,6 +263,12 @@ it("cancels no-deadline preparation with the caller's exact opaque reason", asyn launcher: async (input) => { expect(input.signal).toBe(controller.signal); started.resolve(); + if (input.signal?.aborted) { + emitted = new ProcessOwnershipInspectionCancelled( + input.signal.reason, + ); + throw emitted; + } await new Promise((_resolve, reject) => input.signal?.addEventListener( "abort", @@ -287,12 +294,21 @@ it("cancels no-deadline preparation with the caller's exact opaque reason", asyn preparationFailure: expect.any(ProcessOwnershipInspectionCancelled), snapshot: null, }); - await started.promise; - controller.abort(reason); - await expected; - const failure = await observed; - if (!(failure instanceof OwnedCommandFailure)) throw failure; - expect(failure.preparationFailure).toBe(emitted); - expect(emitted?.reason).toBe(reason); - expect(emitted?.cause).toBe(reason); + try { + await awaitAcknowledgement( + started.promise, + observed, + "command preparation", + ); + controller.abort(reason); + await expected; + const failure = await observed; + if (!(failure instanceof OwnedCommandFailure)) throw failure; + expect(failure.preparationFailure).toBe(emitted); + expect(emitted?.reason).toBe(reason); + expect(emitted?.cause).toBe(reason); + } finally { + controller.abort(reason); + await observed; + } }); diff --git a/src/process/OwnedLease.test.ts b/src/process/OwnedLease.test.ts index 303e4e21ac..a90e4da8b1 100644 --- a/src/process/OwnedLease.test.ts +++ b/src/process/OwnedLease.test.ts @@ -10,6 +10,7 @@ import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { HopperStartError } from "../domain/hopperErrors.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; // Unix socket paths have a short platform limit; the canonical system temp path can exceed it. const createTestTempDirectory = async (prefix: string): Promise => { const directory = await mkdtemp(join("/tmp", prefix)); @@ -121,18 +122,21 @@ describe.skipIf(process.platform === "win32")( const first = acquired.lease.release().then(() => { firstFinished = true; }); - await started.promise; - const second = acquired.lease.release().then(() => { - secondFinished = true; - }); + let second: Promise | undefined; try { + await awaitAcknowledgement(started.promise, first, "lease close"); + second = acquired.lease.release().then(() => { + secondFinished = true; + }); await new Promise((resolve) => setImmediate(resolve)); expect(firstFinished).toBe(false); expect(secondFinished).toBe(false); } finally { finish.resolve(); - await Promise.all([first, second]); + await Promise.allSettled([first, ...(second ? [second] : [])]); } + await first; + await second; expect(firstFinished).toBe(true); expect(secondFinished).toBe(true); }); diff --git a/src/process/ProcessOwnership.descriptorCancellation.test.ts b/src/process/ProcessOwnership.descriptorCancellation.test.ts index 6b7ae1c4b2..88b3374765 100644 --- a/src/process/ProcessOwnership.descriptorCancellation.test.ts +++ b/src/process/ProcessOwnership.descriptorCancellation.test.ts @@ -9,6 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it, onTestFinished, vi } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; const fixture = vi.hoisted(() => ({ files: new Map(), @@ -146,8 +147,12 @@ it("joins a pending successful native read before reporting explicit cancellatio .finally(() => { settled = true; }); - await entered.promise; - await completed.promise; + await awaitAcknowledgement(entered.promise, pending, "identity read"); + await awaitAcknowledgement( + completed.promise, + pending, + "sibling identity close", + ); // Let the completed sibling publish its parsed identity after descriptor close. await new Promise((resolve) => setImmediate(resolve)); controller.abort(reason); @@ -194,7 +199,7 @@ it("drains mixed identity outcomes and transfers every exact failed-close owner .finally(() => { settled = true; }); - await entered.promise; + await awaitAcknowledgement(entered.promise, pending, "identity read"); await new Promise((resolve) => setImmediate(resolve)); expect(settled).toBe(false); release.resolve(); @@ -263,10 +268,22 @@ it("keeps an actual physical read primary alongside a sibling's explicit cancell host, controller.signal, ).catch((cause: unknown) => cause); - await completed.promise; - await siblingStarted.promise; + await awaitAcknowledgement( + completed.promise, + pending, + "sibling identity close", + ); + await awaitAcknowledgement( + siblingStarted.promise, + pending, + "sibling identity read", + ); physical.resolve(); - await observedFailure.promise; + await awaitAcknowledgement( + observedFailure.promise, + pending, + "physical read failure", + ); sibling.resolve(); const failure = await pending; expect(failure).toBeInstanceOf(ProcessOwnershipIdentityBatchFailure); diff --git a/src/process/RegexMatcher.test.ts b/src/process/RegexMatcher.test.ts index b2f25b2dbb..a38174dc87 100644 --- a/src/process/RegexMatcher.test.ts +++ b/src/process/RegexMatcher.test.ts @@ -1,7 +1,6 @@ import { AnalysisSettings } from "../application/AnalysisSettings.js"; import { parseConfig } from "../config/parseConfig.js"; import { runtimeAnalysisConfig } from "../config/runtimeAnalysisSettings.js"; -import { matchRegexValues } from "./RegexMatcher.js"; import { expect, it } from "vitest"; import { @@ -15,10 +14,10 @@ import { } from "../config/regexWorkerHeapRequest.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { + matchRegexValues, execArgvFromCommandLine, readLaunchExecArgv, readLaunchNodeOptions, - regexMatchingDeadlineError, regexMatchingLimitation, regexWorkerHeapError, resolveLaunchRegexWorkerHeap, diff --git a/src/process/capture/ProcessSamplingShutdown.test.ts b/src/process/capture/ProcessSamplingShutdown.test.ts index 78fa772144..34c4ac0a00 100644 --- a/src/process/capture/ProcessSamplingShutdown.test.ts +++ b/src/process/capture/ProcessSamplingShutdown.test.ts @@ -52,10 +52,6 @@ it.each(["plain", "all-cancel", "mixed-fd", "opaque-peer"] as const)( configurable: true, value: "linux", }); - let started!: () => void; - const ready = new Promise((resolve) => { - started = resolve; - }); let original: unknown; const physical = new Error("opaque physical sibling rejection"); const observations = new Map([ @@ -68,7 +64,6 @@ it.each(["plain", "all-cancel", "mixed-fd", "opaque-peer"] as const)( .spyOn(systemProcessOwnershipHost, "environment") .mockImplementation(async (_pid, signal) => { if (signal === undefined) throw new Error("Missing sampling signal"); - started(); await new Promise((resolve) => { if (signal.aborted) resolve(); else @@ -122,19 +117,25 @@ it.each(["plain", "all-cancel", "mixed-fd", "opaque-peer"] as const)( }); onTestFinished(async () => { file?.allow(); - await scope.close(); - await stop().catch((cause: unknown) => { - if ( - !(cause instanceof ProcessCaptureInspectionFailure) && - !(cause instanceof ProcessCaptureSamplingFailure) - ) - throw cause; - }); - vi.restoreAllMocks(); - if (platform !== undefined) - Object.defineProperty(process, "platform", platform); + try { + try { + await scope.close(); + } finally { + await stop().catch((cause: unknown) => { + if ( + !(cause instanceof ProcessCaptureInspectionFailure) && + !(cause instanceof ProcessCaptureSamplingFailure) + ) + throw cause; + }); + } + } finally { + vi.restoreAllMocks(); + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + } }); - await ready; + await vi.waitFor(() => expect(environment).toHaveBeenCalledOnce()); const outcome = stop(); if (kind === "opaque-peer") { const failure: unknown = await outcome.catch((cause: unknown) => cause); @@ -174,6 +175,5 @@ it.each(["plain", "all-cancel", "mixed-fd", "opaque-peer"] as const)( throw new Error("Expected batch"); expect(original.observations).toBe(observations); } - expect(environment).toHaveBeenCalledOnce(); }, ); diff --git a/src/process/capture/TerminalRenderer.test.ts b/src/process/capture/TerminalRenderer.test.ts index 7bd1ff7fec..e670054868 100644 --- a/src/process/capture/TerminalRenderer.test.ts +++ b/src/process/capture/TerminalRenderer.test.ts @@ -204,10 +204,13 @@ it.each(["write", "resize"] as const)( ); await expect(renderer.frames()).rejects.toBe(failure); } finally { - await scope.close(); - terminalDispose.mockRestore(); - addonDispose.mockRestore(); - await rm(temporaryRoot, { recursive: true, force: true }); + try { + await scope.close(); + } finally { + terminalDispose.mockRestore(); + addonDispose.mockRestore(); + await rm(temporaryRoot, { recursive: true, force: true }); + } } }, ); @@ -244,9 +247,12 @@ it("retains disposal failures for a later cleanup retry", async () => { ); expect(terminalDispose).toHaveBeenCalledTimes(2); } finally { - await scope.close(); - terminalDispose.mockRestore(); - await rm(temporaryRoot, { recursive: true, force: true }); + try { + await scope.close(); + } finally { + terminalDispose.mockRestore(); + await rm(temporaryRoot, { recursive: true, force: true }); + } } }); @@ -327,8 +333,11 @@ it("renders retained chunks once after late PID knowledge, preserving event time expect(frame.serialized_state).toContain("child=selected"); } } finally { - await renderer.dispose(); - write.mockRestore(); + try { + await renderer.dispose(); + } finally { + write.mockRestore(); + } } }); diff --git a/src/providers/AnalysisClientFailure.test.ts b/src/providers/AnalysisClientFailure.test.ts index 82dbeb6c99..da9e735388 100644 --- a/src/providers/AnalysisClientFailure.test.ts +++ b/src/providers/AnalysisClientFailure.test.ts @@ -163,6 +163,7 @@ it("keeps the structured limit when a resource refusal changes operation", () => if (!(attributed instanceof AnalysisResourceConstraintError)) throw new Error("expected a resource constraint"); expect(attributed.operation).toBe("binary_overview"); + expect(attributed.retryable).toBe(false); expect(attributed.limit).toEqual(source.limit); expect(projectAnalysisError(attributed)).toMatchObject({ code: "resource_constraint", diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index 706176c8e2..30b2da8fc6 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -1,10 +1,10 @@ import { PYTHON_PYC_PROVIDER_IDENTITY } from "./PythonPycProviderIdentity.js"; import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { artifactFileCancellationCause, artifactFilePrimaryFacts, @@ -20,7 +20,6 @@ import type { PythonPycPort } from "../application/python/PythonPycPort.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -142,20 +141,13 @@ const failureForPrimary = ( ], ); } - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected pyc could not be read (${String(cause.code)}): ${input.path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + input.path, + "pyc", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(PYTHON_PYC_PROVIDER_IDENTITY.id, OPERATION, { cause, diagnostics: { diff --git a/src/reference/ReferenceSourceReaderFile.ts b/src/reference/ReferenceSourceReaderFile.ts index 1f9f853281..3fb2c1214b 100644 --- a/src/reference/ReferenceSourceReaderFile.ts +++ b/src/reference/ReferenceSourceReaderFile.ts @@ -283,7 +283,8 @@ const readStableFileEntry = async ( total: contents.total, ...(signal === undefined ? {} : { signal }), }); - } catch (cause: unknown) { + } catch (caught: unknown) { + let cause = caught; if (cause instanceof RegularFileAdmissionFailure) { retain(cause.owner); cause = cause.cause; diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index 4c525ed61f..f0bc894c5a 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -332,9 +332,6 @@ export class RizinDebugSessionManager { if (!parsed.ok) throw parsed.error; return parsed.value; } - get #maxCommandOutputBytes(): number { - return this.#commandOutput.value; - } async start( input: { diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts index bc60927689..effecc2a48 100644 --- a/src/rust/RustAuditableReader.test.ts +++ b/src/rust/RustAuditableReader.test.ts @@ -1,13 +1,16 @@ import { deflateSync } from "node:zlib"; import { expect, it } from "vitest"; import { rustAuditableMetadataSchema } from "../domain/rust/rustBinary.js"; + import { - RUST_SECTION_BYTES, readAuditableSection, RustAuditableFormatFailure, RustAuditableResourceFailure, } from "./RustAuditableReader.js"; +// Explicit 1 MiB input boundary, independent of the reader implementation. +const RUST_SECTION_BYTES = 1024 * 1024; + const section = (document: unknown) => readAuditableSection( ".dep-v0", diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts index 6b57e6c830..a0ae2a99b4 100644 --- a/src/rust/RustAuditableReader.ts +++ b/src/rust/RustAuditableReader.ts @@ -11,7 +11,7 @@ import { import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; /** Compressed zlib stream limit. */ -export const RUST_SECTION_BYTES = 1024 * 1024; +const RUST_SECTION_BYTES = 1024 * 1024; /** Inflated JSON may grow by at most this multiple of the compressed stream. */ export const RUST_INFLATE_RATIO = 64; /** Absolute inflated JSON limit, applied together with the ratio. */ diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 2d9f1041f8..08a33cb150 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -1,10 +1,10 @@ import { RUST_BINARY_PROVIDER_IDENTITY } from "./RustBinaryProviderIdentity.js"; import { + projectSelectedArtifactFileFailure, projectStableArtifactFailure, stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { type StableArtifactSnapshot, readStableArtifact, @@ -21,7 +21,6 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { RustBinaryPort } from "../application/rust/RustBinaryPort.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisInputError, @@ -395,20 +394,13 @@ const artifactFailure = ( cause instanceof ArtifactReaderFailure ) return artifactReaderFailure(cause, path, limits); - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { - cause, - }); - if (isSelectedRegularFileError(cause)) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, - }, - ]); - } + const fileFailure = projectSelectedArtifactFileFailure( + cause, + OPERATION, + path, + "artifact", + ); + if (fileFailure !== undefined) return fileFailure; return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { cause, diagnostics: { diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index a6369dd467..cd28b97e89 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -44,10 +44,7 @@ import { AnalysisResourceConstraintError, type AnalysisInputIssue, } from "../domain/analysisErrorCore.js"; -import { - projectAnalysisError, - projectAnalysisErrorWithoutPartialObservation, -} from "../domain/analysisErrorProjection.js"; +import { projectAnalysisErrorWithoutPartialObservation } from "../domain/analysisErrorProjection.js"; import { projectInputIssueGroupsWithinBudget } from "../domain/inputIssueProjection.js"; import type { z } from "zod"; import { createEvidence } from "../domain/evidence.js"; diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index 25e2817312..68acb2b171 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -14,7 +14,7 @@ import { } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { NoBinaryOpenError } from "../domain/configurationErrors.js"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import { estimateJsonStorage } from "../application/JsonAuthenticationHeadroom.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; diff --git a/src/server/environment/environmentDataPlane.ts b/src/server/environment/environmentDataPlane.ts index 0129b8a6aa..07ea71eef3 100644 --- a/src/server/environment/environmentDataPlane.ts +++ b/src/server/environment/environmentDataPlane.ts @@ -1,3 +1,4 @@ +import { escapeJsonPointerToken } from "../../domain/jsonPointer.js"; import { ResponseBodyCleanup } from "../../providers/ResponseBodyCleanup.js"; import type { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; @@ -26,14 +27,11 @@ export type RpcMethods = ( const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -const pointerSegment = (segment: string | number): string => - String(segment).replaceAll("~", "~0").replaceAll("/", "~1"); - /** The first document problem as `-32602`, located by a JSON pointer into the document. */ const documentError = (error: AnalysisInputError): RpcError => { const issue = error.issues[0]; const pointer = (issue?.path ?? []) - .map((segment) => `/${pointerSegment(segment)}`) + .map((segment) => `/${escapeJsonPointerToken(String(segment))}`) .join(""); return invalidParams( `${pointer === "" ? "the document" : pointer} ${issue?.message ?? "is not a rea-state/v1 document"}`, diff --git a/src/server/environment/serveEnvironment.test.ts b/src/server/environment/serveEnvironment.test.ts index 9bd4f5e16c..5b2c68bd4b 100644 --- a/src/server/environment/serveEnvironment.test.ts +++ b/src/server/environment/serveEnvironment.test.ts @@ -18,7 +18,6 @@ import { ENVIRONMENT_CARD_PATH, ENVIRONMENT_RPC_PATH, environmentRoutes, - MAX_RPC_BATCH_MESSAGES, serveEnvironment as startEnvironment, type ServedEnvironment, } from "./serveEnvironment.js"; @@ -335,13 +334,11 @@ describe("environment data plane validation", () => { ).json(), ).resolves.toMatchObject({ id: null, error: { code: -32601 } }); // A batch is bounded before any reply is built for it. - const oversized = await post( - Array.from({ length: MAX_RPC_BATCH_MESSAGES + 1 }, () => 0), - ); + const oversized = await post(Array.from({ length: 101 }, () => 0)); expect(oversized.status).toBe(400); await expect(oversized.json()).resolves.toMatchObject({ id: null, - error: { code: -32600 }, + error: { code: -32600, message: "batch exceeds 100 messages" }, }); }); }); diff --git a/src/server/environment/serveEnvironment.ts b/src/server/environment/serveEnvironment.ts index 99b9a22329..f64a3ddc2b 100644 --- a/src/server/environment/serveEnvironment.ts +++ b/src/server/environment/serveEnvironment.ts @@ -85,7 +85,7 @@ const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); /** At most this many messages in one data-plane batch; a larger batch is refused whole. */ -export const MAX_RPC_BATCH_MESSAGES = 100; +const MAX_RPC_BATCH_MESSAGES = 100; /** A JSON-RPC 2.0 request or notification: an id, when present, is a string, number or null; params are structured. */ const isRpcMessage = ( diff --git a/src/server/environment/serveEnvironmentState.test.ts b/src/server/environment/serveEnvironmentState.test.ts index 6a48599612..5a2fd433bd 100644 --- a/src/server/environment/serveEnvironmentState.test.ts +++ b/src/server/environment/serveEnvironmentState.test.ts @@ -7,6 +7,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; const closeFailureControl = vi.hoisted(() => ({ failure: null as Error | null, @@ -1140,7 +1141,11 @@ describe("state response cleanup", () => { signal: AbortSignal.timeout(10_000), }).catch(() => undefined); try { - await held.fetching; + await awaitAcknowledgement( + held.fetching, + answered, + "state fetch admission", + ); await environment.close(); giveResponse( new Response( @@ -1152,7 +1157,7 @@ describe("state response cleanup", () => { }), ), ); - await entered; + await awaitAcknowledgement(entered, cleanup, "late response cleanup"); await expect(environment.close()).rejects.toMatchObject({ _tag: "AnalysisOutputError", cleanupIncomplete: true, @@ -1161,6 +1166,7 @@ describe("state response cleanup", () => { release(); await expect(environment.close()).resolves.toBeUndefined(); } finally { + giveResponse(new Response(null, { status: 500 })); release(); await answered; await environment.close(); diff --git a/src/server/environment/stateIntake.test.ts b/src/server/environment/stateIntake.test.ts index 60119728e7..73b2b3580a 100644 --- a/src/server/environment/stateIntake.test.ts +++ b/src/server/environment/stateIntake.test.ts @@ -81,9 +81,15 @@ it("downloads with redirects refused and the 30 s timeout by default", async () }); expect(DEFAULT_STATE_INTAKE.timeoutMs).toBe(30_000); } finally { - await responses.close(); - await files.close(); - fetched.mockRestore(); + try { + await responses.close(); + } finally { + try { + await files.close(); + } finally { + fetched.mockRestore(); + } + } } }); diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 40310f8136..df43665537 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -141,8 +141,11 @@ it("includes the macOS Hopper installer diagnostic in setup remediation", async if (result.status !== "failed") throw new Error("expected setup failure"); expect(result.remediation).not.toContain("\u001b"); } finally { - await host.close?.(); - install.mockRestore(); + try { + await host.close?.(); + } finally { + install.mockRestore(); + } } }); diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index 6544701a4f..c347c7084b 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -76,9 +76,17 @@ const expectPeInputFailuresAcrossAdapters = async (input: { const { cli, call, root } = input; const malformedPath = join(root, "malformed.exe"); await writeFile(malformedPath, Buffer.from("not a PE image")); + const truncatedPath = join(root, "truncated-resource-table.exe"); + const truncated = peResourceFixture(); + const directory = truncated.directoryOffsets[0]; + if (directory === undefined) throw new Error("Missing resource directory"); + truncated.bytes.writeUInt16LE(0xffff, directory + 12); + truncated.bytes.writeUInt16LE(2, directory + 14); + await writeFile(truncatedPath, truncated.bytes); for (const [path, reason] of [ [join(root, "missing.exe"), "invalid_value"], [malformedPath, "invalid_format"], + [truncatedPath, "invalid_format"], ] as const) await expectPeInputFailureAcrossAdapters({ cli, diff --git a/tests/acceptance/artifactComparisonAsarUnpacked.test.ts b/tests/acceptance/artifactComparisonAsarUnpacked.test.ts index 19992608a7..b440403e04 100644 --- a/tests/acceptance/artifactComparisonAsarUnpacked.test.ts +++ b/tests/acceptance/artifactComparisonAsarUnpacked.test.ts @@ -4,61 +4,12 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { - artifactInventoryResultSchema, - type ArtifactInventoryResult, -} from "../../src/domain/artifactGraph.js"; -import { - artifactGraphDigest, - artifactManifestId, -} from "../../src/domain/artifactIdentity.js"; +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; import { parseArtifactInventoryEvidence } from "../../src/domain/artifactInventoryEvidence.js"; -import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { historicalInventoryEvidence } from "../fixtures/artifactEvidence.js"; import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; -const historicalInventory = (source: Evidence): Evidence => { - const inventory = artifactInventoryResultSchema.parse( - source.normalized_result, - ); - const occurrences = inventory.occurrences.map( - ({ unpacked: _unpacked, ...occurrence }) => occurrence, - ); - const graphSha256 = artifactGraphDigest({ - nodes: inventory.nodes, - occurrences, - edges: inventory.edges, - contradictions: inventory.integrity_contradictions, - }); - const historical: ArtifactInventoryResult = { - ...inventory, - occurrences, - manifest: { - ...inventory.manifest, - graph_sha256: graphSha256, - manifest_id: artifactManifestId( - inventory.manifest.root_artifact_id, - graphSha256, - ), - }, - }; - return createEvidence( - { - path: source.subject!.local_path, - sha256: inventory.manifest.root_sha256, - format: inventory.manifest.root_format, - }, - source.provider, - { - operation: "inventory_artifact", - parameters: {}, - result: historical, - confidence: "derived", - authority: "shipped-artifact", - }, - ); -}; - it("compares ASAR companion declarations from public MCP inventories and preserves historical unknowns", async () => { const root = await createTestTempDirectory("rea-asar-unpacked-comparison-"); const input = join(root, "source"); @@ -117,7 +68,10 @@ it("compares ASAR companion declarations from public MCP inventories and preserv // Model an authenticated pre-field snapshot with its original graph commitment, // rather than editing a current Evidence payload without updating its identity. - const historical = historicalInventory(packed); + const historical = historicalInventoryEvidence( + packed, + ({ unpacked: _unpacked, ...occurrence }) => occurrence, + ); const historicalParsed = parseArtifactInventoryEvidence(historical); expect( historicalParsed.inventory.occurrences.map( diff --git a/tests/acceptance/artifactComparisonEspImage.test.ts b/tests/acceptance/artifactComparisonEspImage.test.ts index a6fcfed74e..ee566a1c75 100644 --- a/tests/acceptance/artifactComparisonEspImage.test.ts +++ b/tests/acceptance/artifactComparisonEspImage.test.ts @@ -7,15 +7,8 @@ import { artifactInventorySummaryViewResultSchema } from "../../src/domain/analy import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js"; import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; -import { - artifactGraphDigest, - artifactManifestId, -} from "../../src/domain/artifactIdentity.js"; -import { - createEvidence, - parseEvidence, - type Evidence, -} from "../../src/domain/evidence.js"; +import { parseEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { historicalInventoryEvidence } from "../fixtures/artifactEvidence.js"; import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -80,46 +73,14 @@ it("reports an ESP segment whose load address alone changed", async () => { /** The inventory as REA recorded it before ESP image roots had `flash_dump`. */ const withoutFlashDump = (source: Evidence): Evidence => { - const inventory = artifactInventoryResultSchema.parse( - source.normalized_result, - ); - const occurrences = inventory.occurrences.map((occurrence) => { - if (occurrence.format_metadata?.kind !== "esp-image") return occurrence; - const { flash_dump: _dump, ...historical } = occurrence.format_metadata; - return { ...occurrence, format_metadata: historical }; - }); - // Recommit the reduced graph, as that REA committed it. - const graphSha256 = artifactGraphDigest({ - nodes: inventory.nodes, - occurrences, - edges: inventory.edges, - contradictions: inventory.integrity_contradictions, - }); - return createEvidence( - { - path: source.subject!.local_path, - sha256: inventory.manifest.root_sha256, - format: inventory.manifest.root_format, - }, - source.provider, - { - operation: "inventory_artifact", - parameters: {}, - result: { - ...inventory, - occurrences, - manifest: { - ...inventory.manifest, - graph_sha256: graphSha256, - manifest_id: artifactManifestId( - inventory.manifest.root_artifact_id, - graphSha256, - ), - }, - }, - confidence: "observed", - authority: "shipped-artifact", + return historicalInventoryEvidence( + source, + (occurrence) => { + if (occurrence.format_metadata?.kind !== "esp-image") return occurrence; + const { flash_dump: _dump, ...historical } = occurrence.format_metadata; + return { ...occurrence, format_metadata: historical }; }, + "observed", ); }; diff --git a/tests/acceptance/artifactComparisonNodeSea.test.ts b/tests/acceptance/artifactComparisonNodeSea.test.ts index 509f3d33ed..37d98bd124 100644 --- a/tests/acceptance/artifactComparisonNodeSea.test.ts +++ b/tests/acceptance/artifactComparisonNodeSea.test.ts @@ -3,64 +3,13 @@ import { join } from "node:path"; import { expect, it } from "vitest"; import { nodeSeaPe } from "../../src/artifacts/nodeSea/NodeSea.fixture.js"; -import { - artifactInventoryResultSchema, - type ArtifactInventoryResult, -} from "../../src/domain/artifactGraph.js"; -import { - artifactGraphDigest, - artifactManifestId, -} from "../../src/domain/artifactIdentity.js"; +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; import { parseArtifactInventoryEvidence } from "../../src/domain/artifactInventoryEvidence.js"; -import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import { historicalInventoryEvidence } from "../fixtures/artifactEvidence.js"; import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; -const withoutEntryMetadata = (source: Evidence): Evidence => { - const inventory = artifactInventoryResultSchema.parse( - source.normalized_result, - ); - const occurrences = inventory.occurrences.map((occurrence) => { - if (occurrence.format_metadata?.kind !== "node-sea-entry") - return occurrence; - const { format_metadata: _metadata, ...historical } = occurrence; - return historical; - }); - const graphSha256 = artifactGraphDigest({ - nodes: inventory.nodes, - occurrences, - edges: inventory.edges, - contradictions: inventory.integrity_contradictions, - }); - const historical: ArtifactInventoryResult = { - ...inventory, - occurrences, - manifest: { - ...inventory.manifest, - graph_sha256: graphSha256, - manifest_id: artifactManifestId( - inventory.manifest.root_artifact_id, - graphSha256, - ), - }, - }; - return createEvidence( - { - path: source.subject!.local_path, - sha256: inventory.manifest.root_sha256, - format: inventory.manifest.root_format, - }, - source.provider, - { - operation: "inventory_artifact", - parameters: {}, - result: historical, - confidence: "derived", - authority: "shipped-artifact", - }, - ); -}; - it("compares raw SEA asset keys without interpreting serialization order as metadata", async () => { const root = await createTestTempDirectory("rea-sea-key-comparison-"); const nfc = "caf\u00e9.txt"; @@ -137,7 +86,12 @@ it("compares raw SEA asset keys without interpreting serialization order as meta ).toEqual([]); // Recommit the reduced graph before sealing modeled historical Evidence. - const historical = withoutEntryMetadata(left); + const historical = historicalInventoryEvidence(left, (occurrence) => { + if (occurrence.format_metadata?.kind !== "node-sea-entry") + return occurrence; + const { format_metadata: _metadata, ...historical } = occurrence; + return historical; + }); const parsed = parseArtifactInventoryEvidence(historical); expect( parsed.inventory.occurrences.find( diff --git a/tests/acceptance/tools/historicalCaptureResources.test.ts b/tests/acceptance/tools/historicalCaptureResources.test.ts index d15cc08f9b..b38bafca93 100644 --- a/tests/acceptance/tools/historicalCaptureResources.test.ts +++ b/tests/acceptance/tools/historicalCaptureResources.test.ts @@ -11,8 +11,9 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { expect, it } from "vitest"; -import { captureDecoderProcessMemoryLimitBytes } from "../../../src/browser/history/CaptureRelease.js"; +import { javaScriptWorkerMemoryLimitBytes } from "../../../src/process/JavaScriptWorkerMemoryLimit.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../../../src/domain/webNetworkCapture.js"; +import { CAPTURE_RUNTIME_SUPPORTED } from "../../fixtures/captureDecoderLimits.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -53,10 +54,11 @@ const capture = (count: number) => // the decoder's Job Object commit limit is the enforced bound instead, and // Bun elsewhere has no bound this capture can reach. const testHeap = { oldGenerationMiB: 64, source: "setting" } as const; -const jobLimit = captureDecoderProcessMemoryLimitBytes( - testHeap.oldGenerationMiB, -); -it.skipIf(process.versions.bun !== undefined && jobLimit === null)( +const jobLimit = javaScriptWorkerMemoryLimitBytes(testHeap.oldGenerationMiB); +it.skipIf( + !CAPTURE_RUNTIME_SUPPORTED || + (process.versions.bun !== undefined && jobLimit === null), +)( "reports real HAR memory exhaustion through CLI and MCP and preserves server usability", async () => { const root = await createTestTempDirectory("rea-har-capacity-"); diff --git a/tests/acceptance/tools/qlogCapture.test.ts b/tests/acceptance/tools/qlogCapture.test.ts index f492d987de..9f9d17286b 100644 --- a/tests/acceptance/tools/qlogCapture.test.ts +++ b/tests/acceptance/tools/qlogCapture.test.ts @@ -4,8 +4,10 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { expect, it as runtimeTest } from "vitest"; -import { NODE_CAPTURE_HEAP_ENFORCED } from "../../../src/browser/history/CaptureRelease.js"; -import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../fixtures/captureDecoderLimits.js"; +import { + CAPTURE_DECODER_HAS_MEMORY_BOUND as hasMemoryBound, + CAPTURE_RUNTIME_SUPPORTED as runtimeSupported, +} from "../../fixtures/captureDecoderLimits.js"; import { webNetworkCaptureSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { @@ -17,10 +19,7 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execute = promisify(execFile); -const hasMemoryBound = - NODE_CAPTURE_HEAP_ENFORCED || - NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null; -const it = runtimeTest.skipIf(!hasMemoryBound); +const it = runtimeTest.skipIf(!hasMemoryBound || !runtimeSupported); const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url)); const event = { name: "transport:packet_sent", @@ -309,7 +308,7 @@ it("pages original trace-scoped facts after source deletion without conflating s ).toMatchObject({ error: { code: "invalid_request" } }); }, 60_000); -runtimeTest.runIf(!hasMemoryBound)( +runtimeTest.runIf(!hasMemoryBound && runtimeSupported)( "refuses qlog when this runtime cannot enforce a decoder memory bound", async () => { const { call } = await connectLocalToolsMcp(); @@ -527,7 +526,9 @@ it("fails whole-capture validation before selection and survives malformed, unsu await expectRecoveredCapture(call, path); }, 60_000); -runtimeTest.skipIf(process.versions.bun !== undefined || !hasMemoryBound)( +runtimeTest.skipIf( + process.versions.bun !== undefined || !hasMemoryBound || !runtimeSupported, +)( "contains actual qlog decoder heap exhaustion and keeps MCP usable", async () => { const root = await createTestTempDirectory("rea-qlog-capacity-"); diff --git a/tests/acceptance/webRuntimeCli.test.ts b/tests/acceptance/webRuntimeCli.test.ts index f4252aecf9..7945a4bbba 100644 --- a/tests/acceptance/webRuntimeCli.test.ts +++ b/tests/acceptance/webRuntimeCli.test.ts @@ -291,7 +291,7 @@ const cancellationCases = cancellationCommands.flatMap((command) => it.each(cancellationCases)( "$command preserves $signal status and awaited cleanup during CDP discovery", - async ({ command, operation, buildArgs, signal, expectedExitCode }) => { + async ({ operation, buildArgs, signal, expectedExitCode }) => { const sockets = new Set(); const server = createServer(() => undefined); server.on("connection", (socket) => { diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index 59803eb57b..9a90cfd38a 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -26,13 +26,12 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { protobufFrame } from "../../fixtures/protobufCapture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { NODE_CAPTURE_HEAP_ENFORCED } from "../../../src/browser/history/CaptureRelease.js"; -import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../fixtures/captureDecoderLimits.js"; -const hasMemoryBound = - NODE_CAPTURE_HEAP_ENFORCED || - NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null; +import { + CAPTURE_DECODER_HAS_MEMORY_BOUND as hasMemoryBound, + CAPTURE_RUNTIME_SUPPORTED as runtimeSupported, +} from "../../fixtures/captureDecoderLimits.js"; -const it = runtimeTest.skipIf(!hasMemoryBound); +const it = runtimeTest.skipIf(!hasMemoryBound || !runtimeSupported); // Share schema compilation, not sessions or their retained Evidence. const schemaValidation = createMcpSchemaValidation(); @@ -947,7 +946,7 @@ it.each([ expect(JSON.stringify(result)).not.toContain("malformed-private-input"); }); -runtimeTest.runIf(!hasMemoryBound)( +runtimeTest.runIf(!hasMemoryBound && runtimeSupported)( "reports SAZ unavailable when its runtime cannot enforce decoder memory", async () => { const response = await cliResponse(await fixture(await archive())); diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index 9ad8521817..59352dc72f 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -274,6 +274,14 @@ printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-cli\\n' result, `Execution of the selected adb binary was denied (EACCES): ${binary}. Set REA_ADB_PATH to an absolute path to the adb executable file, not a directory`, ); + const { details } = JSON.parse(result.stdout) as { + details: { diagnostics: Record }; + }; + expect(details.diagnostics).toMatchObject({ + selected_binary: binary, + path_source: "environment", + filesystem_error: "EACCES", + }); } // A parent component that is a file, a symlink loop and a missing path // fail before any process starts, so no exit code is reported. diff --git a/tests/boundary/cli/ghidraInstallationProperties.test.ts b/tests/boundary/cli/ghidraInstallationProperties.test.ts index 968c52b67e..d490c6b562 100644 --- a/tests/boundary/cli/ghidraInstallationProperties.test.ts +++ b/tests/boundary/cli/ghidraInstallationProperties.test.ts @@ -3,10 +3,11 @@ import { promisify } from "node:util"; import { mkdir, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; -import { MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES } from "../../../src/ghidra/GhidraInstallationProperties.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +const MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES = 1024 * 1024; + cliTest.for(["pipe", "capacity"] as const)( "doctor reports selected properties %s without launching Java", async (kind, { cli, skip }) => { diff --git a/tests/boundary/cli/jsonInputSize.test.ts b/tests/boundary/cli/jsonInputSize.test.ts index 33996b42f2..6fe0417e46 100644 --- a/tests/boundary/cli/jsonInputSize.test.ts +++ b/tests/boundary/cli/jsonInputSize.test.ts @@ -57,7 +57,7 @@ cliTest( cliTest( "traces valid Evidence beyond the whole-document string limit", - async ({ cli, processes }) => { + async ({ cli }) => { const root = await createTestTempDirectory("rea-json-streamed-evidence-"); const source = join(root, "application"); await mkdir(source); diff --git a/tests/boundary/cli/overnightBatch1CliSchemas.test.ts b/tests/boundary/cli/overnightBatch1CliSchemas.test.ts index b9aac999f1..9576d153ce 100644 --- a/tests/boundary/cli/overnightBatch1CliSchemas.test.ts +++ b/tests/boundary/cli/overnightBatch1CliSchemas.test.ts @@ -1,9 +1,8 @@ import { join } from "node:path"; import { Ajv2020 } from "ajv/dist/2020.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { Cli } from "incur"; +import { Cli, z } from "incur"; import { expect, it, vi } from "vitest"; -import { z } from "incur"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDeviceAnalysisPort.js"; import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; diff --git a/tests/boundary/cli/pcapCli.test.ts b/tests/boundary/cli/pcapCli.test.ts index e042f4985b..34de45077e 100644 --- a/tests/boundary/cli/pcapCli.test.ts +++ b/tests/boundary/cli/pcapCli.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { it } from "vitest"; import { expectConfigurationRefusal, serveCli } from "./cliServeHarness.js"; diff --git a/tests/boundary/filesystem/asarSelectedCompanion.test.ts b/tests/boundary/filesystem/asarSelectedCompanion.test.ts index 4b500abdba..c03bc8328a 100644 --- a/tests/boundary/filesystem/asarSelectedCompanion.test.ts +++ b/tests/boundary/filesystem/asarSelectedCompanion.test.ts @@ -4,7 +4,6 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; import { withArtifactClient } from "../../support/artifactClientScope.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; -import { createHash } from "node:crypto"; import { mkdir, readFile, diff --git a/tests/boundary/filesystem/bunStandaloneIntegration.test.ts b/tests/boundary/filesystem/bunStandaloneIntegration.test.ts index 367bc9d918..de2d4ba2f4 100644 --- a/tests/boundary/filesystem/bunStandaloneIntegration.test.ts +++ b/tests/boundary/filesystem/bunStandaloneIntegration.test.ts @@ -6,7 +6,6 @@ import { setImmediate } from "node:timers/promises"; import { expect, it, vi } from "vitest"; import { BunStandaloneArtifactReader } from "../../../src/artifacts/BunStandaloneArtifactReader.js"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; -import * as layout from "../../../src/artifacts/bun/BunModuleGraphLayout.js"; import { decodeBunModuleGraph } from "../../../src/artifacts/bun/BunModuleGraphMembers.js"; import { locateBunGraphFixture, diff --git a/tests/boundary/filesystem/cliJsonInput.test.ts b/tests/boundary/filesystem/cliJsonInput.test.ts index a4eec98606..8f80d020ec 100644 --- a/tests/boundary/filesystem/cliJsonInput.test.ts +++ b/tests/boundary/filesystem/cliJsonInput.test.ts @@ -420,10 +420,9 @@ describe("CLI JSON standard input", () => { }); it("rejects oversized stdin with too-large", async () => { - const stream = Readable.from(['{"a":"' + "x".repeat(100) + '"}']); + const stream = Readable.from([Buffer.alloc(8 * 1024 * 1024 + 1, 0x78)]); const result = await parseCliJsonInput("-", "test-input", undefined, { stdin: stream, - maxStdinBytes: 50, }); expect(result).toMatchObject({ ok: false, @@ -522,13 +521,24 @@ describe("CLI JSON stdin cancellation and encoding", () => { ); it("reads UTF-8 split between chunks and accepts the exact byte limit", async () => { - const bytes = Buffer.from('{"label":"中文🦀"}'); - expect( - await parseCliJsonInput("-", "test-input", undefined, { - stdin: Readable.from([...bytes].map((byte) => Buffer.from([byte]))), - maxStdinBytes: bytes.length, - }), - ).toEqual({ ok: true, value: { label: "中文🦀" } }); + const prefix = Buffer.from('{"label":"'); + const suffix = Buffer.from('中文🦀"}'); + const padding = Buffer.alloc( + 8 * 1024 * 1024 - prefix.length - suffix.length, + 0x61, + ); + const result = await parseCliJsonInput("-", "test-input", undefined, { + stdin: Readable.from([ + prefix, + padding, + ...[...suffix].map((byte) => Buffer.from([byte])), + ]), + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("Expected valid JSON at the stdin limit"); + expect(result.value).toEqual({ + label: `${"a".repeat(padding.length)}中文🦀`, + }); }); it("projects cancellation during stdin snapshot sealing", async () => { diff --git a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts index 2fc5678cdd..437086ab55 100644 --- a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts +++ b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts @@ -3,6 +3,7 @@ import { executeComposedWorkflow } from "../../../src/application/WorkflowEviden import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js"; import { ok } from "../../../src/domain/result.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { expect, it } from "vitest"; import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; @@ -200,8 +201,8 @@ it("deduplicates repeated and nested consumed records without linking unrelated return nested.ok ? ok(nested.value.result) : nested; }, ); - await entered.promise; try { + await awaitAcknowledgement(entered.promise, overview, "summary execution"); const other = await executeComposedWorkflow( fixture.session, async (observed) => { diff --git a/tests/boundary/filesystem/denoStandaloneIntegration.test.ts b/tests/boundary/filesystem/denoStandaloneIntegration.test.ts index 35ed28746a..a15741b5e9 100644 --- a/tests/boundary/filesystem/denoStandaloneIntegration.test.ts +++ b/tests/boundary/filesystem/denoStandaloneIntegration.test.ts @@ -13,12 +13,12 @@ import { denoElf, denoIntelTrailer, } from "../../../src/artifacts/deno/DenoStandalone.fixture.js"; -import { MAX_DENO_PATH_COMPONENT_BYTES } from "../../../src/artifacts/deno/DenoVirtualFs.js"; import { extractArtifact } from "../../../src/artifacts/extraction/ArtifactExtraction.js"; import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js"; import { scanCanonicalArtifactInventory } from "../../fixtures/artifactInventory.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +const MAX_DENO_PATH_COMPONENT_BYTES = 255; const source = "console.log('deno')\n"; const asset = "hello-asset\n"; diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index d497b4ce2a..7287015569 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -12,6 +12,7 @@ import { execFileSync } from "node:child_process"; import { createServer as createNetServer } from "node:net"; import { expect, it, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { createDesktopApplicationService } from "../../../src/composition/desktop.js"; import { desktopApplicationSchema, @@ -778,7 +779,7 @@ it("bounds simultaneous directory scans without cancelling the admitted caller", ); const first = service.inspect({ path: root }); try { - await started; + await awaitAcknowledgement(started, first, "desktop directory scan"); const second = await service.inspect({ path: root }); expect(second.ok).toBe(false); if (!second.ok) @@ -787,15 +788,20 @@ it("bounds simultaneous directory scans without cancelling the admitted caller", resource: "capacity", retryable: true, }); + release(); + const result = await first; + if (!result.ok) throw result.error; + expect( + desktopApplicationSchema.parse(result.value.normalized_result).files, + ).toHaveLength(1); } finally { release(); + try { + await first; + } finally { + await service.close(); + } } - const result = await first; - if (!result.ok) throw result.error; - expect( - desktopApplicationSchema.parse(result.value.normalized_result).files, - ).toHaveLength(1); - await service.close(); }); it("keeps directory-only marker identities separate across installation roots", async () => { diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index afe2666d16..1130072523 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -1,5 +1,8 @@ import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; -import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + parseEvidenceBundle, + createEvidenceBundle, +} from "../../../src/domain/evidenceBundle.js"; import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; @@ -15,14 +18,14 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; -import type { - AnalysisProvider, - CapabilityDescriptor, - ProviderIdentity, +import { + createAnalysisExecution, + type AnalysisProvider, + type CapabilityDescriptor, + type ProviderIdentity, } from "../../../src/application/AnalysisProvider.js"; import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; -import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; import { err, ok } from "../../../src/domain/result.js"; import { readAnalysisSnapshot, @@ -33,7 +36,6 @@ import { parseEvidence, type Evidence, } from "../../../src/domain/evidence.js"; -import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { REA_WORKFLOW_PROVIDER } from "../../../src/domain/investigationProviders.js"; import { workflowAnalysisProfile } from "../../../src/application/WorkflowAnalysisProfile.js"; import { diff --git a/tests/boundary/filesystem/ghidraExecutableHeader.test.ts b/tests/boundary/filesystem/ghidraExecutableHeader.test.ts index e42d5950aa..871b1f839a 100644 --- a/tests/boundary/filesystem/ghidraExecutableHeader.test.ts +++ b/tests/boundary/filesystem/ghidraExecutableHeader.test.ts @@ -9,10 +9,7 @@ import { } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; -import { - readGhidraExecutableHeader, - GHIDRA_EXECUTABLE_HEADER_BYTES, -} from "../../../src/ghidra/GhidraExecutableHeader.js"; +import { readGhidraExecutableHeader } from "../../../src/ghidra/GhidraExecutableHeader.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { createInspectionResources } from "../../fixtures/inspectionResources.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -64,11 +61,11 @@ it.each([0, 23, 8192])( ).toEqual({ ok: true, value: { - bytes: bytes.subarray(0, GHIDRA_EXECUTABLE_HEADER_BYTES), + bytes: bytes.subarray(0, 4096), size: length, }, }); - expect(bytesRead).toBe(Math.min(length, GHIDRA_EXECUTABLE_HEADER_BYTES)); + expect(bytesRead).toBe(Math.min(length, 4096)); } finally { await resources.close(); } diff --git a/tests/boundary/filesystem/ghidraInstallationProperties.test.ts b/tests/boundary/filesystem/ghidraInstallationProperties.test.ts index 5f8b147de0..136b0317e2 100644 --- a/tests/boundary/filesystem/ghidraInstallationProperties.test.ts +++ b/tests/boundary/filesystem/ghidraInstallationProperties.test.ts @@ -14,13 +14,12 @@ import { afterEach, expect, it, vi } from "vitest"; import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; -import { - readGhidraInstallationProperties, - MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES, -} from "../../../src/ghidra/GhidraInstallationProperties.js"; +import { readGhidraInstallationProperties } from "../../../src/ghidra/GhidraInstallationProperties.js"; import { createInspectionResources } from "../../fixtures/inspectionResources.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +const MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES = 1024 * 1024; + const controls = vi.hoisted(() => ({ file: undefined as ((handle: FileHandle, path: string) => void) | undefined, })); diff --git a/tests/boundary/filesystem/ghidraLanguageCatalogRecovery.test.ts b/tests/boundary/filesystem/ghidraLanguageCatalogRecovery.test.ts index 31adbada80..457d5f9115 100644 --- a/tests/boundary/filesystem/ghidraLanguageCatalogRecovery.test.ts +++ b/tests/boundary/filesystem/ghidraLanguageCatalogRecovery.test.ts @@ -11,6 +11,7 @@ import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -199,39 +200,45 @@ it.each(["released", "still failing"] as const)( .finally(() => { settled = true; }); - await entered.promise; - controller.abort(reason); - await Promise.resolve(); - // Cancellation never abandons the in-flight close of the retained owner. - expect(settled).toBe(false); - if (outcome === "released") fault.deny = false; - fault.onClose = undefined; - release.resolve(undefined); - const cancelled = await admission; - if (cancelled.ok) throw new Error("Expected cancelled admission"); - expect(fault.closes).toBe(2); - expect(fault.opens).toBe(1); + try { + await awaitAcknowledgement(entered.promise, admission, "catalog cleanup"); + controller.abort(reason); + await Promise.resolve(); + // Cancellation never abandons the in-flight close of the retained owner. + expect(settled).toBe(false); + if (outcome === "released") fault.deny = false; + fault.onClose = undefined; + release.resolve(undefined); + const cancelled = await admission; + if (cancelled.ok) throw new Error("Expected cancelled admission"); + expect(fault.closes).toBe(2); + expect(fault.opens).toBe(1); - if (outcome === "released") { - expect(cancelled.error).toMatchObject({ - _tag: "AnalysisCancelledError", - cause: reason, - cleanupIncomplete: false, - }); - expect(cancelled.error.cause).toBe(reason); + if (outcome === "released") { + expect(cancelled.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + }); + expect(cancelled.error.cause).toBe(reason); + expect(first.error.cleanupIncomplete).toBe(false); + expect(registry.hasPendingOwnedResources()).toBe(false); + } else { + expect(cancelled.error.cleanupIncomplete).toBe(true); + expect(cancelled.error.cleanupResources).toEqual([definition]); + expect(first.error.cleanupIncomplete).toBe(true); + expect(registry.hasPendingOwnedResources()).toBe(true); + fault.deny = false; + } + const recovered = await registry.select(target, "ghidra"); + expect(recovered.ok).toBe(true); expect(first.error.cleanupIncomplete).toBe(false); expect(registry.hasPendingOwnedResources()).toBe(false); - } else { - expect(cancelled.error.cleanupIncomplete).toBe(true); - expect(cancelled.error.cleanupResources).toEqual([definition]); - expect(first.error.cleanupIncomplete).toBe(true); - expect(registry.hasPendingOwnedResources()).toBe(true); - fault.deny = false; + } finally { + fault.onClose = undefined; + release.resolve(undefined); + await admission.catch(() => undefined); } - const recovered = await registry.select(target, "ghidra"); - expect(recovered.ok).toBe(true); - expect(first.error.cleanupIncomplete).toBe(false); - expect(registry.hasPendingOwnedResources()).toBe(false); }, ); diff --git a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts index 5cc068c49f..8b0ec0e5ad 100644 --- a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts +++ b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts @@ -7,6 +7,7 @@ import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js"; import { silentLogger } from "../../../src/logger.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { STARTUP_OWNER_CLOSED } from "../../../src/process/SharedStartupAttempt.js"; import { readFile, @@ -391,15 +392,21 @@ it("joins whole provider resolution before shutdown and gates the next admission opens++; }; const resolving = provider.resolveAnalysisProfile(target); - await entered.promise; - const count = opens; - let closed = false; - const closing = provider.closeOwnedResources().then((result) => { - closed = true; - return result; - }); - const next = provider.resolveAnalysisProfile(target); + let closing: ReturnType | undefined; + let next: ReturnType | undefined; try { + await awaitAcknowledgement( + entered.promise, + resolving, + "profile resolution", + ); + const count = opens; + let closed = false; + closing = provider.closeOwnedResources().then((result) => { + closed = true; + return result; + }); + next = provider.resolveAnalysisProfile(target); await new Promise((resolve) => setImmediate(resolve)); expect(closed).toBe(false); expect(opens).toBe(count); @@ -421,7 +428,11 @@ it("joins whole provider resolution before shutdown and gates the next admission expect(provider.hasPendingOwnedResources()).toBe(false); } finally { release.resolve(); - await Promise.all([resolving, closing, next]); + await Promise.allSettled([ + resolving, + ...(closing ? [closing] : []), + ...(next ? [next] : []), + ]); await provider.closeOwnedResources(); } }); diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts index 1079012301..9a1ccdbbee 100644 --- a/tests/boundary/filesystem/nativeExports.test.ts +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -126,6 +126,38 @@ describe("native export stable filesystem boundary", () => { }); describe("native export text capacity", () => { + it("retains valid exports and partial Evidence when a symbol name exceeds the read allowance", async () => { + const root = await createTestTempDirectory("rea-native-export-name-limit-"); + const path = join(root, "libfixture.so"); + const bytes = elfExportImage({ + symbols: [{ name: "n".repeat(65_536) }, { name: "kept" }], + }); + await writeFile(path, bytes); + const service = createNativeExportsService(); + try { + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + const evidence = parseEvidence(result.value); + const report = nativeExportsSchema.parse(evidence.normalized_result); + expect(evidence.subject).toMatchObject({ + local_path: path, + digest: { sha256: createHash("sha256").update(bytes).digest("hex") }, + }); + expect(report).toMatchObject({ + exports: [expect.objectContaining({ name: "kept" })], + coverage: { status: "partial" }, + issues: expect.arrayContaining([ + expect.objectContaining({ + code: "name_unreadable", + message: expect.stringContaining("65536-byte read allowance"), + }), + ]), + }); + } finally { + await service.close(); + } + }); + it.each(["symbol", "version"] as const)( "bounds UTF-8 bytes of retained ELF %s text and its exact-byte spelling", async (kind) => { diff --git a/tests/boundary/filesystem/peImports.test.ts b/tests/boundary/filesystem/peImports.test.ts index 9c79798e35..0a76b580e2 100644 --- a/tests/boundary/filesystem/peImports.test.ts +++ b/tests/boundary/filesystem/peImports.test.ts @@ -1,6 +1,7 @@ import { symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { peImportsSchema } from "../../../src/domain/native/peImports.js"; import { createPeImportsService } from "../../../src/composition/binaryDiagnostics.js"; import { peImportImage } from "../../fixtures/binaryDiagnostics/peImports.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -38,6 +39,30 @@ describe("PE import stable filesystem boundary", () => { } }); + it("retains partial Evidence for an import name beyond the read allowance", async () => { + const root = await createTestTempDirectory("rea-pe-imports-name-limit-"); + const path = join(root, "app.exe"); + await writeFile( + path, + peImportImage({ + sectionBytes: 8192, + dlls: [{ name: "n".repeat(4097), functions: [] }], + }), + ); + const result = await createPeImportsService().inspect({ path }); + if (!result.ok) throw result.error; + const report = peImportsSchema.parse(result.value.normalized_result); + expect(report.coverage.status).toBe("partial"); + expect(report.imports[0]?.name).toBeNull(); + expect(report.issues).toContainEqual( + expect.objectContaining({ + code: "name_unreadable", + message: expect.stringContaining("4096-byte read allowance"), + }), + ); + expect(result.value.subject?.local_path).toBe(path); + }); + it("returns typed resource and cancellation failures", async () => { const root = await createTestTempDirectory("rea-pe-imports-budget-"); const path = join(root, "app.exe"); diff --git a/tests/boundary/filesystem/stableArtifact.test.ts b/tests/boundary/filesystem/stableArtifact.test.ts index 2b03a7a75a..fe62f4d10b 100644 --- a/tests/boundary/filesystem/stableArtifact.test.ts +++ b/tests/boundary/filesystem/stableArtifact.test.ts @@ -10,6 +10,7 @@ import { import { join } from "node:path"; import { createHash } from "node:crypto"; import { expect, it, onTestFinished } from "vitest"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; import { artifactFileCompletion, @@ -162,7 +163,7 @@ it.each(["complete", "fail"] as const)( settled = true; }); try { - await entered; + await awaitAcknowledgement(entered, result, "stable artifact read"); controller.abort(reason); await new Promise((resolve) => setImmediate(resolve)); expect(settled).toBe(false); @@ -176,6 +177,7 @@ it.each(["complete", "fail"] as const)( expect(artifactFileCompletion(failure)).toBeUndefined(); expect(descriptor?.fd).toBe(-1); } finally { + controller.abort(reason); release(); await result; } diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 8051bcadb1..0ee51abb39 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -43,6 +43,35 @@ const inspectMetadata = async (path: string, limit?: number) => { } }; +it("identifies which selected IL2CPP file is missing", async () => { + const root = await createTestTempDirectory("rea-il2cpp-missing-"); + const path = join(root, "global-metadata.dat"); + const binaryPath = join(root, "missing-binary"); + const provider = new Il2cppMetadataProvider(); + try { + const missingMetadata = await provider.inspect({ path }); + if (missingMetadata.ok) + throw new Error("Expected missing metadata failure"); + expect(missingMetadata.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [{ path: ["path"], reason: "invalid_value" }], + }); + + await writeFile(path, createIl2cppMetadataFixture({ version: 31 }).bytes); + const missingBinary = await provider.inspect({ + path, + binary_path: binaryPath, + }); + if (missingBinary.ok) throw new Error("Expected missing binary failure"); + expect(missingBinary.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [{ path: ["binary_path"], reason: "invalid_value" }], + }); + } finally { + await provider.close(); + } +}); + /** Synthetic v108 header: checks bounded observations, not record-layout compatibility. */ const version108Header = (stringBytes: Buffer) => { const header = Buffer.alloc(500); diff --git a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts index 3183738cd6..28ec16b85c 100644 --- a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts +++ b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts @@ -95,6 +95,62 @@ it.each(["worker-phase", "final-cleanup", "no-cancellation"] as const)( }, ); +it("retains worker cleanup when progress reporting fails during analysis", async () => { + const root = await createTestTempDirectory("rea-js-progress-cleanup-"); + await writeFile(join(root, "main.js"), "export const observed = 7;"); + await writeFile(join(root, "second.js"), "export const later = 8;"); + const primary = new Error("progress reporting failed"); + let sourceReports = 0; + let denyRemoval = true; + let runtimePath: string | undefined; + const originalClose = PrivateRuntimeRoot.prototype.close; + const close = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockImplementation(async function (this: PrivateRuntimeRoot) { + if (basename(this.path).startsWith("rea-javascript-analysis-")) { + runtimePath = this.path; + if (denyRemoval) throw new Error("worker root removal failed"); + } + await originalClose.call(this); + }); + const reuse = vi + .spyOn(JavaScriptAnalysisWorker.prototype, "canReuse") + .mockReturnValue(false); + const scope = new ArtifactResourceScope(); + onTestFinished(async () => { + denyRemoval = false; + try { + await scope.close(); + } finally { + close.mockRestore(); + reuse.mockRestore(); + } + }); + const result = await analyzeOwnedApplication( + { input_path: root, format: "directory" }, + scope, + { + progress: { + async report(event) { + if ( + event.phase === "parse_javascript_source" && + ++sourceReports === 2 + ) + throw primary; + }, + }, + }, + ); + if (result.ok) throw new Error("Expected progress failure"); + expect(runtimePath).toBeDefined(); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain(runtimePath); + expect(result.error.cause).toMatchObject({ cause: { cause: primary } }); + denyRemoval = false; + await scope.close(); + expect(result.error.cleanupIncomplete).toBe(false); +}); + it.each(["during cleanup", "on the next control turn"] as const)( "retains the final source when cancellation arrives %s after analysis", async (timing) => { diff --git a/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts b/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts index 1981eb2417..27422118c4 100644 --- a/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts +++ b/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts @@ -16,6 +16,7 @@ import { createTestBinarySession, } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; const failureLeaves = (reason: unknown): unknown[] => reason instanceof AggregateError @@ -80,7 +81,7 @@ it("reports a failed session close, joins concurrent attempts and retries the re harness = acquired.harness; const closing = harness.close(); const outcome = closing.catch((reason: unknown) => reason); - await entered.promise; + await awaitAcknowledgement(entered.promise, outcome, "provider close"); const concurrent = harness.close(); const concurrentOutcome = concurrent.catch((reason: unknown) => reason); expect(closeProvider).toHaveBeenCalledTimes(1); diff --git a/tests/boundary/mcp/cutterCancellationMcp.test.ts b/tests/boundary/mcp/cutterCancellationMcp.test.ts index f0f276331e..6162d4fa3c 100644 --- a/tests/boundary/mcp/cutterCancellationMcp.test.ts +++ b/tests/boundary/mcp/cutterCancellationMcp.test.ts @@ -1,6 +1,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { McpServer } from "@modelcontextprotocol/server"; import { expect, it } from "vitest"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { chmod, rm, writeFile } from "node:fs/promises"; import { createServer, type Socket } from "node:net"; import { join } from "node:path"; @@ -164,7 +165,11 @@ it("shares one cleanup proof across concurrent Cutter MCP commands without Binar const first = call("first queued command"); const second = call("second queued command"); pending = [first, second]; - await entered; + await awaitAcknowledgement( + entered, + Promise.race([first, second]), + "Cutter cleanup verification", + ); await new Promise((resolve) => setImmediate(resolve)); releaseProof(); const results = await Promise.all([first, second]); @@ -252,7 +257,7 @@ it("cancels active discovery sockets, stops queued probes and recovers on the sa { name: "list_cutter_sessions", arguments: {} }, { signal: controller.signal }, ); - await connected; + await awaitAcknowledgement(connected, request, "Cutter discovery", 10_000); controller.abort(new Error("selected discovery cancellation")); await expect(request).rejects.toThrow("selected discovery cancellation"); await expect.poll(() => settled).toBe(true); @@ -279,9 +284,12 @@ it("cancels active discovery sockets, stops queued probes and recovers on the sa } finally { controller.abort(); for (const socket of sockets) socket.destroy(); - await Promise.allSettled([client.close(), server.close()]); - await service.close(); - await new Promise((resolve) => peer.close(() => resolve())); + try { + await Promise.allSettled([client.close(), server.close()]); + await service.close(); + } finally { + await new Promise((resolve) => peer.close(() => resolve())); + } } }); diff --git a/tests/boundary/mcp/evidenceImportCancellation.test.ts b/tests/boundary/mcp/evidenceImportCancellation.test.ts index cd1fe90b33..0ba700cc6b 100644 --- a/tests/boundary/mcp/evidenceImportCancellation.test.ts +++ b/tests/boundary/mcp/evidenceImportCancellation.test.ts @@ -293,7 +293,6 @@ it("retries a transient import close failure before admitting the bundle", async ); await writeFile(path, JSON.stringify(createEvidenceBundle([imported]))); - const originalClose = OwnedFileHandle.prototype.close; const close = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementationOnce(async () => { diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index 26b2c6beef..808742e557 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -1,3 +1,4 @@ +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { advertisedMcpToolSchemas, assertRecordedMcpEvidence, @@ -314,7 +315,8 @@ it("settles the admitted EVM operation after SDK cancellation and accepts a subs () => "completed", (error: unknown) => error, ); - await entered.promise; + onTestFinished(() => controller.abort()); + await awaitAcknowledgement(entered.promise, cancellation, "EVM inspection"); controller.abort(new Error("SDK EVM cancellation verification")); expect(await cancellation).toMatchObject({ message: expect.stringContaining("SDK EVM cancellation verification"), diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index 572c32118b..1271ade8bb 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -13,6 +13,7 @@ import type { Session, } from "frida"; import { expect, it, onTestFinished, vi } from "vitest"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { FridaScriptLoadCancelledError } from "../../../src/application/frida/FridaInstrumentationPort.js"; import type { @@ -333,7 +334,11 @@ it.each(["attached", "during-resume", "after-resume"] as const)( name: "resume_frida_session", arguments: { session_id: id }, }); - await held.entered; + await awaitAcknowledgement( + held.entered, + resuming, + "Frida operation admission", + ); if (timing === "during-resume") fixture.markDetached(); expect(await state()).toBe( timing === "during-resume" ? "detached" : "paused", @@ -467,7 +472,11 @@ it.each([ (value) => ({ kind: "result" as const, value }), (cause: unknown) => ({ kind: "rejected" as const, cause }), ); - await held.entered; + await awaitAcknowledgement( + held.entered, + pending, + "Frida operation admission", + ); expect(fixture.unloaded).toEqual(["completed-before-detach"]); if (cancel) { controller.abort(new Error("cancel during final detach")); @@ -610,7 +619,11 @@ it("preserves the native resume failure after MCP cancellation reaches the serve { signal: controller.signal }, ) .catch((cause: unknown) => cause); - await held.entered; + await awaitAcknowledgement( + held.entered, + pending, + "Frida operation admission", + ); controller.abort(new Error("cancel pending resume")); await fixture.client.ping(); expect(serverSignal?.aborted).toBe(true); @@ -1034,7 +1047,11 @@ it.each(["load", "resume", "unload"] as const)( original.structuredContent, ).normalized_result.script_id; const first = fixture.load(id, "blocking"); - await held.entered; + await awaitAcknowledgement( + held.entered, + first, + "Frida operation admission", + ); const controller = new AbortController(); const cancelled = operation === "load" @@ -1078,7 +1095,11 @@ it.each(["create", "load"] as const)( const rejected = expect(cancelled).rejects.toThrow( "cancel native operation", ); - await held.entered; + await awaitAcknowledgement( + held.entered, + cancelled, + "Frida operation admission", + ); controller.abort(new Error("cancel native operation")); await fixture.client.ping(); await rejected; @@ -1120,7 +1141,11 @@ it("retains cancellation source facts without claiming native creation when no s const loading = fixture .load(id, "no-returned-handle", controller.signal) .catch((cause: unknown) => cause); - await held.entered; + await awaitAcknowledgement( + held.entered, + loading, + "Frida operation admission", + ); controller.abort(new Error("cancel while creating")); await fixture.client.ping(); held.release(); @@ -1166,7 +1191,11 @@ it("retains original malformed file identity and decoded representation through { signal: controller.signal }, ) .catch((cause: unknown) => cause); - await held.entered; + await awaitAcknowledgement( + held.entered, + loading, + "Frida operation admission", + ); controller.abort(new Error("cancel file load")); await fixture.client.ping(); held.release(); @@ -1235,7 +1264,11 @@ it.each([ (value) => ({ kind: "result" as const, value }), (cause: unknown) => ({ kind: "rejected" as const, cause }), ); - await held.entered; + await awaitAcknowledgement( + held.entered, + loading, + "Frida operation admission", + ); if (cancel) { controller.abort(new Error("cancel before native rejection")); await fixture.client.ping(); @@ -1308,7 +1341,11 @@ it("awaits an admitted attach on server shutdown and retries failed native clean }, }) .catch(() => undefined); - await held.entered; + await awaitAcknowledgement( + held.entered, + starting, + "Frida operation admission", + ); const { closing } = await pendingShutdown(fixture.server); expect(fixture.controls.detachCalls).toBe(0); held.release(); @@ -1343,7 +1380,11 @@ it("drains remote discovery before shutdown and retains failed connection cleanu }, }) .catch(() => undefined); - await held.entered; + await awaitAcknowledgement( + held.entered, + discovering, + "Frida operation admission", + ); const { closing } = await pendingShutdown(fixture.server); held.release(); expect(await closing).toBeInstanceOf(AggregateError); @@ -1363,7 +1404,11 @@ it.each(["create", "load"] as const)( const rejected = expect(cancelled).rejects.toThrow( "cancel retained script", ); - await held.entered; + await awaitAcknowledgement( + held.entered, + cancelled, + "Frida operation admission", + ); controller.abort(new Error("cancel retained script")); await fixture.client.ping(); await rejected; @@ -1416,7 +1461,11 @@ it.each([0, 1])( const rejected = expect(loading).rejects.toThrow( "cancel before queued resume", ); - await held.entered; + await awaitAcknowledgement( + held.entered, + loading, + "Frida operation admission", + ); const resume = () => fixture.client.callTool({ name: "resume_frida_session", @@ -1474,7 +1523,7 @@ it.each([0, 1, 2, 3, 4, 8])( }; const loading = fixture.load(id, "settling-native", controller.signal); const observed = loading.catch(() => undefined); - await held.entered; + await awaitAcknowledgement(held.entered, loading, "Frida script load"); const resuming = fixture.client.callTool({ name: "resume_frida_session", arguments: { session_id: id }, diff --git a/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts b/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts index cc33a01f10..bc92d1b7a5 100644 --- a/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts +++ b/tests/boundary/mcp/ghidraStartupCancellationMcp.test.ts @@ -13,6 +13,7 @@ import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProv import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js"; @@ -109,9 +110,17 @@ it.each([0, 1])( (cause: unknown) => ({ ok: false as const, cause }), ), ); - await admitted[index]?.promise; + await awaitAcknowledgement( + admitted[index]!.promise, + calls[index]!, + "MCP caller admission", + ); } - await entered.promise; + await awaitAcknowledgement( + entered.promise, + Promise.race(calls), + "Ghidra startup", + ); controllers[cancelledIndex]?.abort(); await vi.waitFor(() => expect(startResults[cancelledIndex]).toMatchObject({ diff --git a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts index cd43628d70..24fa2d7a05 100644 --- a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts @@ -4,6 +4,7 @@ import { createTestBinarySession, createDeferred, } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js"; import type { GoBinary } from "../../../src/domain/go/goBinary.js"; import { silentLogger } from "../../../src/logger.js"; @@ -60,7 +61,7 @@ it("retains late-cancelled Go artifact Evidence through MCP and keeps the server { name: "inspect_go_binary", arguments: { path: report.artifact.path } }, { signal: controller.signal }, ); - await started.promise; + await awaitAcknowledgement(started.promise, call, "MCP observation"); controller.abort(); gate.resolve(); await expect(call).rejects.toThrow(); @@ -127,7 +128,7 @@ it("retains late-cancelled Inspector target Evidence through MCP and keeps the s }, { signal: controller.signal }, ); - await started.promise; + await awaitAcknowledgement(started.promise, call, "MCP observation"); controller.abort(); gate.resolve(); await expect(call).rejects.toThrow(); @@ -214,7 +215,7 @@ it("records late-cancelled Browser target Evidence and keeps the server usable", }, { signal: controller.signal }, ); - await started.promise; + await awaitAcknowledgement(started.promise, call, "MCP observation"); controller.abort(); gate.resolve(); await expect(call).rejects.toThrow(); diff --git a/tests/boundary/mcp/mcpFixtureLifecycle.test.ts b/tests/boundary/mcp/mcpFixtureLifecycle.test.ts index eeaa2d8815..8b9a804262 100644 --- a/tests/boundary/mcp/mcpFixtureLifecycle.test.ts +++ b/tests/boundary/mcp/mcpFixtureLifecycle.test.ts @@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/server"; import { expect, it, vi } from "vitest"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { createTestMcp } from "../../support/mcp/mcpFixture.js"; /** Keep real SDK objects recoverable even when a red ownership assertion fails. */ @@ -110,7 +111,11 @@ it("registers acquisition before a producer can reenter shutdown and attach its (reason: unknown) => ({ ok: false as const, reason }), ); try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + opening, + "MCP lifecycle boundary", + ); await Promise.resolve(); await Promise.resolve(); expect(closeSettled).toBe(false); @@ -154,7 +159,11 @@ it("shares one concurrent close attempt and does not reclose released SDK object await close(); }); first = fixture.mcp.close(); - await entered.promise; + await awaitAcknowledgement( + entered.promise, + first, + "MCP lifecycle boundary", + ); second = fixture.mcp.close(); await Promise.resolve(); await Promise.resolve(); @@ -195,7 +204,11 @@ it("cancels a real initialize handshake before awaiting its held server handler" ); const serverClose = vi.spyOn(server, "close"); try { - await entered.promise; + await awaitAcknowledgement( + entered.promise, + opening, + "MCP lifecycle boundary", + ); await fixture.mcp.close(); expect(openingSettled).toBe(true); expect(await opening).toMatchObject({ ok: false }); diff --git a/tests/boundary/mcp/promptMcp.test.ts b/tests/boundary/mcp/promptMcp.test.ts index f8d699eaf2..ab64d1ed33 100644 --- a/tests/boundary/mcp/promptMcp.test.ts +++ b/tests/boundary/mcp/promptMcp.test.ts @@ -5,6 +5,7 @@ import { import { ok as resultOk, err, ok } from "../../../src/domain/result.js"; import { readFile, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { Client, @@ -616,7 +617,7 @@ it("guided prompt completion avoids cold startup and cancels warm lookup on clos const directory = await createTestTempDirectory("rea-prompt-warm-only-"); const target = join(directory, "target.hop"); await writeFile(target, "fixture target"); - const fixture = await createWarmCompletionFixture(target); + const fixture = await createWarmCompletionFixture(); const { client } = fixture; const opened = await client.callTool({ @@ -742,9 +743,11 @@ it.each(["replacement", "shutdown"])( const first = await connectHttp(); let next: Client | undefined; let finishing: Promise | undefined; + let initial: ReturnType | undefined; try { - void complete(first, "document", "").catch(() => undefined); - await started; + initial = complete(first, "document", ""); + void initial.catch(() => undefined); + await awaitAcknowledgement(started, initial, "prompt completion start"); let finished = false; finishing = ( ending === "shutdown" @@ -755,7 +758,11 @@ it.each(["replacement", "shutdown"])( ).then(() => { finished = true; }); - await aborted; + await awaitAcknowledgement( + aborted, + finishing, + "prompt completion cancellation", + ); await new Promise((resolve) => setImmediate(resolve)); expect(connections).toHaveBeenCalledTimes(1); expect(finished).toBe(false); @@ -820,7 +827,7 @@ describe("guided prompt completion lifecycle", () => { { signal: controller.signal }, ); await withVerifierCleanup(async () => { - await started; + await awaitAcknowledgement(started, pending, "prompt completion start"); controller.abort(); await expect(pending).rejects.toThrow(); release(); @@ -898,7 +905,7 @@ describe("guided prompt completion lifecycle", () => { }, 10_000); }); -const createWarmCompletionFixture = async (target: string) => { +const createWarmCompletionFixture = async () => { const identity = { id: "deep-fixture", name: "Deep fixture provider", diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index 03d2e603e3..a8851c1a3b 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -1907,7 +1907,7 @@ it.each(["ready", "cancelled", "physical"] as const)( }); let closing: ReturnType | undefined; try { - await entered; + await awaitAcknowledgement(entered, observing, "provider readiness"); if (mode !== "ready") { closing = session.close(); const during = structured( @@ -1979,9 +1979,11 @@ it.each(["ready", "cancelled", "physical"] as const)( expect(starts).toEqual([]); } finally { finish(); - await observing; - await closing; - await session.close(); + try { + await Promise.all([observing, closing]); + } finally { + await session.close(); + } } }, ); diff --git a/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts b/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts index 6f44974b04..e32801c019 100644 --- a/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts +++ b/tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts @@ -1,8 +1,7 @@ import pino from "pino"; import type { CallToolResult } from "@modelcontextprotocol/server"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { rm, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expect, it, vi } from "vitest"; diff --git a/tests/boundary/mcp/serverLifecycleMcp.test.ts b/tests/boundary/mcp/serverLifecycleMcp.test.ts index 051cd1ea50..2083a89048 100644 --- a/tests/boundary/mcp/serverLifecycleMcp.test.ts +++ b/tests/boundary/mcp/serverLifecycleMcp.test.ts @@ -3,6 +3,7 @@ import { parseMcpEvidenceBundle } from "../../fixtures/mcpEvidenceBundle.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, expect, it } from "vitest"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; import { HopperRemoteError } from "../../../src/domain/hopperErrors.js"; @@ -615,6 +616,7 @@ it("preserves distinct results across concurrent tool calls", async () => { ["list_names", [{ address: "0x3000", value: "fixture_global" }]], ]); const pending = new Map void>(); + let releaseRemaining = false; let allStarted: () => void = () => { throw new Error("start gate missing"); }; @@ -629,6 +631,7 @@ it("preserves distinct results across concurrent tool calls", async () => { await new Promise((resolve) => { pending.set(name, resolve); if (pending.size === payloads.size) allStarted(); + if (releaseRemaining) resolve(); }); return ok(payload); }, @@ -637,18 +640,28 @@ it("preserves distinct results across concurrent tool calls", async () => { const requests = names.map((name) => client.callTool({ name, arguments: {} }), ); - await started; - for (const name of [...names].reverse()) { - const release = pending.get(name); - if (release === undefined) - throw new Error("request did not reach provider"); - release(); - } - const results = await Promise.all(requests); - results.forEach((result, index) => { - expect(result.isError).not.toBe(true); - expect(structured(result)).toMatchObject({ - normalized_result: payloads.get(names[index] ?? ""), + try { + await awaitAcknowledgement( + started, + Promise.all(requests), + "MCP tool calls", + ); + for (const name of [...names].reverse()) { + const release = pending.get(name); + if (release === undefined) + throw new Error("request did not reach provider"); + release(); + } + const results = await Promise.all(requests); + results.forEach((result, index) => { + expect(result.isError).not.toBe(true); + expect(structured(result)).toMatchObject({ + normalized_result: payloads.get(names[index] ?? ""), + }); }); - }); + } finally { + releaseRemaining = true; + for (const release of pending.values()) release(); + await Promise.allSettled(requests); + } }); diff --git a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts index 0122663abf..49f1b5b95c 100644 --- a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts +++ b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts @@ -118,7 +118,7 @@ it("restores MCP tool availability after cancelling a target switch", async () = { signal: controller.signal }, ); const cancelled = expect(switching).rejects.toThrow(); - await closing.promise; + await awaitAcknowledgement(closing.promise, switching, "provider close"); controller.abort(); await cancellationObserved.promise; release.resolve(); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 2f2aeb01ad..f8c342abcc 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -6,10 +6,11 @@ import { readFile, realpath, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { createDeferred, @@ -98,7 +99,12 @@ describe("MCP snapshot lifecycle ordering", () => { name: "address_name", arguments: { address: "0x1000", document: "fixture" }, }); - await started.promise; + onTestFinished(() => release.resolve(ok(first))); + await awaitAcknowledgement( + started.promise, + observation, + "session observation", + ); const snapshotPath = join(directory, "analysis.json"); const closing = mcp.callTool({ name: "close_binary", @@ -360,8 +366,16 @@ describe("target-free MCP lifecycle", () => { await server.connect(serverTransport); await mcp.connect(clientTransport); + onTestFinished(() => + availability.resolve({ + status: "available", + code: null, + reason: null, + diagnostics: { configured: true }, + }), + ); const status = mcp.callTool({ name: "binary_session", arguments: {} }); - await entered.promise; + await awaitAcknowledgement(entered.promise, status, "provider readiness"); expect((await session.open(targetPath)).ok).toBe(true); availability.resolve({ status: "available", diff --git a/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts b/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts index c1611a1021..edb1892e00 100644 --- a/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts +++ b/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts @@ -26,6 +26,10 @@ mcpTest.skipIf(process.platform === "win32")( for (const name of [ "inspect_rust_binary", "inspect_elf_notes", + "inspect_native_initializers", + "inspect_binary_hardening", + "inspect_native_exports", + "inspect_apk_signature", "inspect_devicetree", "inspect_sqlite_database", ]) { diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index 0716f3b637..85314d2890 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -4,6 +4,7 @@ import { } from "../../fixtures/advertisedMcpTool.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterAll, expect, it, onTestFinished, vi } from "vitest"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; import { readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -308,45 +309,52 @@ it.each([undefined, "summary"] as const)( { signal: controller.signal }, ); const rejected = expect(cancellation).rejects.toThrow(); - await started; - controller.abort(new Error("cancel after provider work starts")); - let serverReason: unknown; try { - await harness.client.ping(); - serverReason = serverSignal?.reason; - } finally { - releaseProvider?.(); - } - await rejected; - expect(inspect).toHaveBeenCalledOnce(); - const result = await inspect.mock.results[0]?.value; - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCancelledError", - partialObservation: { - operation: "inspect_wasm_artifact", - normalized_result: value, - confidence: "observed", + await awaitAcknowledgement(started, cancellation, "WASM provider start"); + controller.abort(new Error("cancel after provider work starts")); + let serverReason: unknown; + try { + await harness.client.ping(); + serverReason = serverSignal?.reason; + } finally { + releaseProvider?.(); + } + await rejected; + expect(inspect).toHaveBeenCalledOnce(); + const result = await inspect.mock.results[0]?.value; + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + partialObservation: { + operation: "inspect_wasm_artifact", + normalized_result: value, + confidence: "observed", + }, }, - }, - }); - if (result === undefined || result.ok) - throw new Error("Expected cancelled WASM Evidence"); - expect(serverSignal?.aborted).toBe(true); - expect(result.error.cause).toBe(serverReason); - const partial = parseEvidence(result.error.partialObservation); - expect(partial.parameters).toEqual({ - path: value.artifact.path, - glue_paths: [], - candidate_paths: [], - }); - await harness.client.ping(); - await vi.waitFor(() => { - // Failed summary delivery retains the complete producer, without a derived view. - expect(session.exportEvidenceBundle().records).toEqual([partial]); - }); - expect(session.evidenceById(partial.evidence_id)).toEqual(partial); + }); + if (result === undefined || result.ok) + throw new Error("Expected cancelled WASM Evidence"); + expect(serverSignal?.aborted).toBe(true); + expect(result.error.cause).toBe(serverReason); + const partial = parseEvidence(result.error.partialObservation); + expect(partial.parameters).toEqual({ + path: value.artifact.path, + glue_paths: [], + candidate_paths: [], + }); + await harness.client.ping(); + await vi.waitFor(() => { + // Failed summary delivery retains the complete producer, without a derived view. + expect(session.exportEvidenceBundle().records).toEqual([partial]); + }); + expect(session.evidenceById(partial.evidence_id)).toEqual(partial); + } finally { + controller.abort(); + releaseProvider?.(); + await cancellation.catch(() => undefined); + await rejected.catch(() => undefined); + } }, ); diff --git a/tests/boundary/mcp/webBundleViewsMcp.test.ts b/tests/boundary/mcp/webBundleViewsMcp.test.ts index b48f1163b6..867258559e 100644 --- a/tests/boundary/mcp/webBundleViewsMcp.test.ts +++ b/tests/boundary/mcp/webBundleViewsMcp.test.ts @@ -7,7 +7,7 @@ import { webBundlePageViewResultSchema, webBundleSummaryViewResultSchema, } from "../../../src/domain/analysisView/analysisView.js"; -import type { WebBundleAnalysis } from "../../../src/domain/webBundleAnalysis.js"; +import { sampleWebBundleAnalysis } from "../../fixtures/webBundleAnalysis.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; @@ -16,104 +16,6 @@ import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; const schemaValidation = createMcpSchemaValidation(); afterAll(() => schemaValidation.clear()); -const sampleWebBundleAnalysis = (): WebBundleAnalysis => ({ - capture: { - target_url: "https://example.test/app", - target_origin: "https://example.test", - scripts_observed: 3, - scripts_analyzed: 2, - source_artifacts: [], - }, - observations: { - chunks: { - nodes: [ - { - script_key: "app.js", - url: "https://example.test/app.js", - artifact_sha256: "a".repeat(64), - bytes: 1200, - }, - { - script_key: "vendor.js", - url: "https://example.test/vendor.js", - artifact_sha256: "b".repeat(64), - bytes: 4500, - }, - ], - edges: [], - }, - routes: [], - endpoints: [ - { - value: "/api/users", - mechanism: "call:fetch", - location: { script_key: "app.js", line: 10, column: 2 }, - }, - { - value: "/api/users", - mechanism: "call:fetch", - location: { script_key: "vendor.js", line: 42, column: 8 }, - }, - { - value: "/api/orders", - mechanism: "call:sendBeacon", - location: { script_key: "app.js", line: 85, column: 4 }, - }, - ], - webmcp_declarations: [], - source_maps: { - status: "not_requested", - requested: 0, - processed: 0, - items: [], - }, - }, - inferences: [ - { - kind: "endpoint_candidate", - value: "/api/dynamic-user", - confidence: "low", - basis: [ - { - script_key: "app.js", - artifact_sha256: "a".repeat(64), - line: 25, - column: 4, - detector: "call:client.get:unverified-network-receiver", - }, - ], - }, - { - kind: "vendor_fingerprint", - value: "React", - confidence: "high", - basis: [ - { - script_key: "vendor.js", - artifact_sha256: "b".repeat(64), - line: 1, - column: 0, - detector: "vendor:react", - }, - ], - }, - ], - unknowns: [ - { - dimension: "network", - reason: "Unverified client receiver", - affected_script_keys: ["app.js"], - }, - ], - completeness: { - status: "partial", - parsed_scripts: 2, - parse_failures: 1, - visited_ast_nodes: 150, - }, - limitations: ["Script evaluation was omitted"], -}); - const makeParent = () => createEvidence( undefined, diff --git a/tests/boundary/native/pwndbgReplyFinalization.test.ts b/tests/boundary/native/pwndbgReplyFinalization.test.ts index 833cd161a5..cee654ca00 100644 --- a/tests/boundary/native/pwndbgReplyFinalization.test.ts +++ b/tests/boundary/native/pwndbgReplyFinalization.test.ts @@ -2,7 +2,6 @@ import { createHash } from "node:crypto"; import { resolvePwntoolsLimits } from "../../../src/domain/native/pwntoolsResourceLimits.js"; import { mkdir, writeFile } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; -import { join } from "node:path"; import { expect, it, onTestFinished, vi } from "vitest"; const replyClose = vi.hoisted(() => ({ diff --git a/tests/boundary/process/artifactProducerCancellation.test.ts b/tests/boundary/process/artifactProducerCancellation.test.ts index 55c808550c..00baf2b7a5 100644 --- a/tests/boundary/process/artifactProducerCancellation.test.ts +++ b/tests/boundary/process/artifactProducerCancellation.test.ts @@ -13,7 +13,6 @@ import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; -import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import type { ExecutionOptions } from "../../../src/application/AnalysisProvider.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/process/processCleanupRetryOwnership.test.ts b/tests/boundary/process/processCleanupRetryOwnership.test.ts index d87df69072..2a4644eb56 100644 --- a/tests/boundary/process/processCleanupRetryOwnership.test.ts +++ b/tests/boundary/process/processCleanupRetryOwnership.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { setImmediate } from "node:timers/promises"; import { expect, it } from "vitest"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js"; @@ -203,20 +204,24 @@ it("joins an already-started release when shutdown begins", async () => { await rm(path, { recursive: true }); }), ); - await entered.promise; let closed = false; - const closing = scope.close().then(() => { - closed = true; - }); + let closing: Promise | undefined; try { + await awaitAcknowledgement(entered.promise, releasing, "resource release"); + closing = scope.close().then(() => { + closed = true; + }); await setImmediate(); expect(closed).toBe(false); await expect(stat(root)).resolves.toBeDefined(); } finally { unblock.resolve(); - await Promise.all([releasing, closing]); + await Promise.allSettled([releasing, ...(closing ? [closing] : [])]); + await scope.close(); await rm(root, { recursive: true, force: true }); } + await releasing; + await closing; expect(attempts).toBe(1); expect(closed).toBe(true); await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 5df9028c8d..1bdb4dae48 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -1522,23 +1522,28 @@ it("rejects an inferior TTY when GDB does not confirm the setting command", asyn }); it("rejects a truncated inferior TTY readback before publishing the session", async () => { - const tty = `/dev/pts/${"x".repeat(200)}`; + const tty = "/dev/pts/42"; const fake = createFakeSpawner((child, token, command) => { - child.stdout.write( - command.includes("inferior-tty-show") - ? `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n` - : `${token}^done\n`, - ); - }); - const manager = managerWithFakeSpawner(fake.spawnProcess, { - controlReplyBytes: 80, + if (command.includes("inferior-tty-show")) { + const record = `~"${"x".repeat(1024 * 1024)}"\n`; + for (let index = 0; index < 17; index += 1) child.stdout.write(record); + child.stdout.write( + `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`, + ); + } else child.stdout.write(`${token}^done\n`); }); + const manager = managerWithFakeSpawner(fake.spawnProcess); const started = await manager.start(undefined, { inferiorTty: tty }); expect(started).toMatchObject({ ok: false, - error: { _tag: "AnalysisCapabilityUnavailableError" }, + error: { + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining( + "did not report the exact caller-selected inferior TTY", + ), + }, }); expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); }); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts index d66cbbfd49..5e8d84b101 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts @@ -3,14 +3,13 @@ import { access, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { evidenceSchema } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; import { createRizinCoverageHarness, createInjectedProcess, incompleteStop, } from "./RizinDebugSessionCoverage.fixtures.js"; -const { cleanup, makeProvider, start, startInjected, createManager } = +const { cleanup, makeProvider, start, startInjected } = createRizinCoverageHarness(); afterEach(cleanup); diff --git a/tests/boundary/providers/RizinDebugSessionManagers.test.ts b/tests/boundary/providers/RizinDebugSessionManagers.test.ts index 61e31ee8c7..49768049c6 100644 --- a/tests/boundary/providers/RizinDebugSessionManagers.test.ts +++ b/tests/boundary/providers/RizinDebugSessionManagers.test.ts @@ -5,7 +5,6 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { evidenceSchema } from "../../../src/domain/evidence.js"; -import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; import { createRizinTestManager, rizinTestInferiorTty, diff --git a/tests/boundary/providers/adb/adbScreenPublicationCancellation.test.ts b/tests/boundary/providers/adb/adbScreenPublicationCancellation.test.ts index a1cd6b1abc..a1dfaa07ce 100644 --- a/tests/boundary/providers/adb/adbScreenPublicationCancellation.test.ts +++ b/tests/boundary/providers/adb/adbScreenPublicationCancellation.test.ts @@ -316,7 +316,7 @@ test("does not replace a destination created after the initial existence check", ); const output = join(f.root, "captures"); const destination = join(output, "screen.png"); - vi.mocked(link).mockImplementationOnce(async (...args) => { + vi.mocked(link).mockImplementationOnce(async () => { await actualFs.writeFile(destination, "caller-owned"); throw Object.assign(new Error("hard links are unsupported"), { code: "EPERM", diff --git a/tests/boundary/providers/desktopTaskPython.test.ts b/tests/boundary/providers/desktopTaskPython.test.ts index 2241097d88..ed6b620ae3 100644 --- a/tests/boundary/providers/desktopTaskPython.test.ts +++ b/tests/boundary/providers/desktopTaskPython.test.ts @@ -298,6 +298,38 @@ it("evaluates every kind of file check without computer-exec as REA does", async ); }); +it("decodes JSON pointer escapes once in the exported verifier", async () => { + const checks: DesktopTaskCheck[] = [ + { + id: "tilde", + kind: "value_equals", + file: "settings.json", + pointer: "/~01", + value: "literal", + }, + { + id: "slash", + kind: "value_equals", + file: "settings.json", + pointer: "/~1", + value: "slash", + }, + ]; + const views = { + "settings.json": { + format: "json" as const, + view: { "~1": "literal", "/": "slash" }, + truncated: false, + }, + }; + const output = await runVerifier(checks, [[0, views]], [], { exec: false }); + const expected = evaluateTaskChecks(checks, new Map(Object.entries(views))); + expect(output.results.map(({ id, result }) => [id, result])).toEqual( + expected.map(({ id, passed }) => [id, passed]), + ); + expect(output.results.map(({ result }) => result)).toEqual([true, true]); +}); + it("grades an app that saves seconds after the agent stops on what it saved", async () => { // Gnote saved 2.2 s after the verifier's first read in the run this missed. const saved = 2.2; diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 6b80daa975..cd0d013804 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -39,10 +39,7 @@ import { GhidraLaunchError, } from "../../../../src/ghidra/GhidraLauncher.js"; import type { GhidraDiagnostic } from "../../../../src/ghidra/GhidraClientTypes.js"; -import type { - GhidraLaunchSession, - GhidraLauncher, -} from "../../../../src/ghidra/GhidraLauncher.js"; +import type { GhidraLauncher } from "../../../../src/ghidra/GhidraLauncher.js"; import type { ProviderProcessHandle } from "../../../../src/process/ProviderProcess.js"; import type { GhidraTransportKind } from "../../../../src/ghidra/GhidraTransport.js"; import { diff --git a/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts b/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts index 833da518d2..5b89ef4d21 100644 --- a/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.diagnostics.test.ts @@ -3,12 +3,11 @@ import { access } from "node:fs/promises"; import { expect, it, onTestFinished, vi } from "vitest"; import { HopperTimeoutError } from "../../../../src/domain/hopperErrors.js"; -import { - HopperClient, - HOPPER_PROCESS_DIAGNOSTIC_BYTES, -} from "../../../../src/hopper/HopperClient.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; +const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024; + const expectStoppedLauncher = (launcher: HopperFixtureLauncher): void => { const child = launcher.processes[0]; if (child === undefined) throw new Error("Diagnostic fixture did not launch"); diff --git a/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts b/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts index d65dc07a63..3e4ff434cc 100644 --- a/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.externalExit.test.ts @@ -1,14 +1,12 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { once } from "node:events"; import { rm } from "node:fs/promises"; -import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { createAnalysisExecution, type AnalysisClient, - type AnalysisOperation, type AnalysisProviderCandidate, type CapabilityDescriptor, type ProviderIdentity, @@ -89,8 +87,7 @@ const makeCandidate = ( }; const fixtureAnalysisClient = (identity: ProviderIdentity): AnalysisClient => ({ - execute: (operation: AnalysisOperation) => - Promise.resolve(ok(createAnalysisExecution(null, identity))), + execute: () => Promise.resolve(ok(createAnalysisExecution(null, identity))), close: () => Promise.resolve(ok(null)), }); diff --git a/tests/boundary/providers/hopper/hopperClient.health.test.ts b/tests/boundary/providers/hopper/hopperClient.health.test.ts index f8267c7e86..0a0ad3144e 100644 --- a/tests/boundary/providers/hopper/hopperClient.health.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.health.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { ok } from "../../../../src/domain/result.js"; @@ -89,7 +90,7 @@ it("reports launch work until the first authenticated handshake completes", asyn await client.close(); }); const starting = client.callTool("current_document"); - await entered; + await awaitAcknowledgement(entered, starting, "Hopper launch", 10_000); expect(client.requestActivity()).toBeNull(); expect(client.operationHealth()).toMatchObject({ state: "busy", diff --git a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts index 03b64fbd32..6b5840d1a1 100644 --- a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { access } from "node:fs/promises"; -import { createServer, type Server } from "node:net"; +import { createServer } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; @@ -129,9 +129,6 @@ class LateSilentLauncher implements BridgeLauncher { * auth/preamble request added before it would hit the -32601 branch. */ class BadHealthLauncher implements BridgeLauncher { - #server: Server | undefined; - #process: ChildProcess | undefined; - async launch(session: BridgeSession) { const server = createServer((socket) => { let buffer = ""; @@ -173,7 +170,6 @@ class BadHealthLauncher implements BridgeLauncher { resolve(); }); }); - this.#server = server; const child = spawn( process.execPath, ["-e", "setInterval(() => {}, 1000)"], @@ -181,7 +177,6 @@ class BadHealthLauncher implements BridgeLauncher { stdio: "ignore", }, ); - this.#process = child; return ok({ process: child, ownsProcessLifetime: true as const, diff --git a/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts b/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts index 10f8b050c0..80c8a68908 100644 --- a/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaDirectoryIndependentRetryMcp.test.ts @@ -7,7 +7,7 @@ import { parseEvidence } from "../../../../src/domain/evidence.js"; import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js"; -import { advertisedMcpToolSchemas } from "../../../fixtures/advertisedMcpTool.js"; +import { callAdvertisedMcpTool } from "../../../fixtures/advertisedMcpTool.js"; import { createMcpSchemaValidation } from "../../../fixtures/mcpSchemaValidation.js"; import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -71,23 +71,8 @@ it.each(["compact", "full"] as const)( }, }); try { - const call = async (name: string, args: Record) => { - const schema = await advertisedMcpToolSchemas( - harness.client, - name, - schemas.assertions, - ); - const result = await harness.client.callTool( - { name, arguments: args }, - { toolDefinition: schema.toolDefinition }, - ); - expect(result.isError, JSON.stringify(result)).not.toBe(true); - expect( - schema.ajv.validate(schema.outputSchema, result.structuredContent), - JSON.stringify(schema.ajv.errors), - ).toBe(true); - return result.structuredContent; - }; + const call = (name: string, args: Record) => + callAdvertisedMcpTool(harness.client, name, args, schemas.assertions); await call("open_binary", { path: target, provider_id: "kuna" }); await call("procedure_assembly", { procedure: "main" }); const initial = parseEvidence( diff --git a/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts b/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts index ecb5e614e1..f0e68bb690 100644 --- a/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts @@ -8,7 +8,7 @@ import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js"; import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js"; -import { advertisedMcpToolSchemas } from "../../../fixtures/advertisedMcpTool.js"; +import { callAdvertisedMcpTool } from "../../../fixtures/advertisedMcpTool.js"; import { createMcpSchemaValidation } from "../../../fixtures/mcpSchemaValidation.js"; import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -145,23 +145,8 @@ it.each(["compact", "full"] as const)( }, }); try { - const call = async (name: string, args: Record) => { - const schema = await advertisedMcpToolSchemas( - harness.client, - name, - schemas.assertions, - ); - const result = await harness.client.callTool( - { name, arguments: args }, - { toolDefinition: schema.toolDefinition }, - ); - expect(result.isError, JSON.stringify(result)).not.toBe(true); - expect( - schema.ajv.validate(schema.outputSchema, result.structuredContent), - JSON.stringify(schema.ajv.errors), - ).toBe(true); - return result.structuredContent; - }; + const call = (name: string, args: Record) => + callAdvertisedMcpTool(harness.client, name, args, schemas.assertions); await call("open_binary", { path: target, provider_id: "kuna" }); await call("procedure_assembly", { procedure: "broken" }); const original = parseEvidence( diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 44f206bca2..cf8713f736 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -11,6 +11,7 @@ import { import { join } from "node:path"; import { expect, it as test, vi } from "vitest"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; import type { JsonValue } from "../../../../src/domain/jsonValue.js"; @@ -749,7 +750,12 @@ it("keeps an overflow as the primary failure when cancellation arrives during fi { signal: caller.signal }, ); try { - await entered; + await awaitAcknowledgement( + entered, + pending, + "Kuna overflow cleanup", + 10_000, + ); caller.abort("cancel during overflow cleanup"); releaseCleanup(); const result = await pending; diff --git a/tests/boundary/providers/kuna/kunaRunOwnership.test.ts b/tests/boundary/providers/kuna/kunaRunOwnership.test.ts index cfd98e7f61..ad97d7e2a3 100644 --- a/tests/boundary/providers/kuna/kunaRunOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaRunOwnership.test.ts @@ -1,4 +1,5 @@ import { setImmediate } from "node:timers/promises"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { expect, it as test, vi } from "vitest"; @@ -48,7 +49,11 @@ it.each(["procedure_callees", "xrefs"] as const)( const text = run(client, "procedure_pseudo_code", { procedure: "main" }); let reference: ReturnType | undefined; try { - await started.promise; + await awaitAcknowledgement( + started.promise, + text, + "Kuna decompile admission", + ); const budget = grants[0]?.owner; expect(budget).toBeDefined(); reference = run( diff --git a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts index 0c394bbc8b..bc8ee2eed3 100644 --- a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts @@ -315,7 +315,7 @@ it.skipIf(process.platform === "win32")( }); const provider = new KunaProvider( { REA_KUNA_COMMAND: executable, REA_KUNA_CACHE_DIR: join(root, "cache") }, - { launcher: launcher.launcher, probeTimeoutMs: 2_500 }, + { launcher: launcher.launcher }, ); const input = target(join(root, "target")); await writeFile(input.path, "target"); diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 191d949347..f4bd516b89 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -79,8 +79,11 @@ it("preserves a selected comparison source digest contradiction through failed c expect(result.error.cleanupIncomplete).toBe(false); } finally { blocked = false; - await resources.close(); - close.mockRestore(); + try { + await resources.close(); + } finally { + close.mockRestore(); + } } }); @@ -841,7 +844,6 @@ describe("managed comparison post-admission read failures", () => { const result = await compareManagedMemberPaths( { leftPath, rightPath }, { - resolveTarget: parseBinaryTarget, readSnapshot: async (path, operation, options) => { if (path === (side === "left" ? leftPath : rightPath)) { const failure = Object.assign(new Error(`read failed: ${code}`), { diff --git a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts index 80826f614c..db9ec952a7 100644 --- a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts @@ -9,11 +9,6 @@ import { CPU_ARM64, universalMacho } from "../../../fixtures/universalMacho.js"; import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; -import { - CPU, - fatImage, - machoImage, -} from "../../../../src/artifacts/apple/MachoImage.fixture.js"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; diff --git a/tests/boundary/providers/native/keyedArchiveEncoding.test.ts b/tests/boundary/providers/native/keyedArchiveEncoding.test.ts index b62061cbb6..88b5935af3 100644 --- a/tests/boundary/providers/native/keyedArchiveEncoding.test.ts +++ b/tests/boundary/providers/native/keyedArchiveEncoding.test.ts @@ -14,9 +14,9 @@ it.each(["utf8", "utf8-bom", "utf16le", "utf16be", "binary"] as const)( async (encoding) => { const root = await createTestTempDirectory("rea-keyed-encoding-"); const xml = await nativeFixture("archive-xml-encoding/archive.xml"); - const utf16 = Buffer.from(xml.replace("UTF-8", "UTF-16"), "utf16le"); // The binary builder emits type-8 records for UID; CF$UID is XML's convention. const binarySource = xml.replaceAll("CF$UID", "UID"); + const utf16 = Buffer.from(xml.replace("UTF-8", "UTF-16"), "utf16le"); const bytes = encoding === "utf8" ? Buffer.from(xml) @@ -67,8 +67,6 @@ it.each(["utf8-as-utf16", "utf16-as-utf8"] as const)( async (kind) => { const root = await createTestTempDirectory("rea-keyed-encoding-"); const xml = await nativeFixture("archive-xml-encoding/archive.xml"); - // The binary builder emits type-8 records for UID; CF$UID is XML's convention. - const binarySource = xml.replaceAll("CF$UID", "UID"); const bytes = kind === "utf8-as-utf16" ? Buffer.from(xml.replace("UTF-8", "UTF-16")) diff --git a/tests/boundary/providers/native/nativeCallTargetCleanup.test.ts b/tests/boundary/providers/native/nativeCallTargetCleanup.test.ts index 524daf8833..8c9360f5df 100644 --- a/tests/boundary/providers/native/nativeCallTargetCleanup.test.ts +++ b/tests/boundary/providers/native/nativeCallTargetCleanup.test.ts @@ -7,6 +7,7 @@ import { nativeMachoTargetForFile, } from "../../../fixtures/nativeCommands.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { AnalysisOutputError } from "../../../../src/domain/analysisErrorCore.js"; import { resolvedAnalysisError } from "../../../../src/domain/analysisErrorBase.js"; import { err, ok } from "../../../../src/domain/result.js"; @@ -131,12 +132,15 @@ it("joins admitted preflight before closing the tracer", async () => { ).createClient(target); onTestFinished(async () => { resume(); - await observing; - read.mockRestore(); - await client.close(); + try { + await observing; + } finally { + read.mockRestore(); + await client.close(); + } }); const observing = client.execute("observe_native_calls", parameters); - await entered; + await awaitAcknowledgement(entered, observing, "native target read"); let closed = false; const closing = client.close().then((result) => { closed = true; diff --git a/tests/boundary/providers/native/nativeCommandResolver.test.ts b/tests/boundary/providers/native/nativeCommandResolver.test.ts index ba7d02b845..0d14b80a41 100644 --- a/tests/boundary/providers/native/nativeCommandResolver.test.ts +++ b/tests/boundary/providers/native/nativeCommandResolver.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { afterEach, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { resolveXcrunTool, @@ -60,96 +61,130 @@ it("keeps a realpath I/O failure primary when cancellation is observed before re const started = new Promise((resolve) => { realpathStarted = resolve; }); - let rejectRealpath!: (cause: unknown) => void; + let rejectRealpath: ((cause: unknown) => void) | undefined; realpathState.resolve = () => new Promise((_resolve, reject) => { + if (controller.signal.aborted) { + reject(controller.signal.reason); + return; + } rejectRealpath = reject; realpathStarted(); }); const controller = new AbortController(); const cancellationReason = { source: "caller", phase: "tool resolution" }; const pending = resolveXcrunTool("file", {}, controller.signal, launcher); - await started; - controller.abort(cancellationReason); - const ioFailure = Object.assign(new Error("fixture realpath failure"), { - code: "EACCES", - }); - rejectRealpath(ioFailure); + try { + await awaitAcknowledgement(started, pending, "native tool realpath"); + controller.abort(cancellationReason); + const ioFailure = Object.assign(new Error("fixture realpath failure"), { + code: "EACCES", + }); + rejectRealpath?.(ioFailure); - const result = await pending; - if (result.ok) throw new Error("Expected realpath I/O failure"); - expect(result.error).toMatchObject({ - reason: "io", - cancellation: { reason: cancellationReason }, - }); - expect(result.error.cancellation?.reason).toBe(cancellationReason); - expect(result.error.cause).toBe(ioFailure); + const result = await pending; + if (result.ok) throw new Error("Expected realpath I/O failure"); + expect(result.error).toMatchObject({ + reason: "io", + cancellation: { reason: cancellationReason }, + }); + expect(result.error.cancellation?.reason).toBe(cancellationReason); + expect(result.error.cause).toBe(ioFailure); + } finally { + controller.abort(cancellationReason); + rejectRealpath?.(new Error("fixture cleanup")); + await pending; + } }); it("retains exact cancellation observed after successful tool path resolution", async () => { - let finishRealpath!: () => void; + let finishRealpath: (() => void) | undefined; let realpathStarted!: () => void; const started = new Promise((resolve) => { realpathStarted = resolve; }); realpathState.resolve = () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + if (controller.signal.aborted) { + reject(controller.signal.reason); + return; + } finishRealpath = () => resolve("/controlled/physical-tool"); realpathStarted(); }); const controller = new AbortController(); const cancellationReason = { source: "caller", phase: "tool selection" }; const pending = resolveXcrunTool("file", {}, controller.signal, launcher); - await started; - controller.abort(cancellationReason); - finishRealpath(); + try { + await awaitAcknowledgement(started, pending, "native tool realpath"); + controller.abort(cancellationReason); + finishRealpath?.(); - const result = await pending; - if (result.ok) throw new Error("Expected selection cancellation"); - expect(result.error).toMatchObject({ - reason: "cancelled", - cancellation: { reason: cancellationReason }, - }); - expect(result.error.cancellation?.reason).toBe(cancellationReason); - expect(result.error.cause).toMatchObject({ - reason: "cancelled", - cause: cancellationReason, - }); + const result = await pending; + if (result.ok) throw new Error("Expected selection cancellation"); + expect(result.error).toMatchObject({ + reason: "cancelled", + cancellation: { reason: cancellationReason }, + }); + expect(result.error.cancellation?.reason).toBe(cancellationReason); + expect(result.error.cause).toMatchObject({ + reason: "cancelled", + cause: cancellationReason, + }); + } finally { + controller.abort(cancellationReason); + finishRealpath?.(); + await pending; + } }); it.skipIf(process.platform === "win32")( "uses the AbortError cause as the exact tool-hash cancellation fact", async () => { - let finishRealpath!: () => void; + let finishRealpath: (() => void) | undefined; let realpathStarted!: () => void; const started = new Promise((resolve) => { realpathStarted = resolve; }); realpathState.resolve = () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + if (controller.signal.aborted) { + reject(controller.signal.reason); + return; + } finishRealpath = () => resolve("/dev/zero"); realpathStarted(); }); const controller = new AbortController(); const cancellationReason = { source: "caller", phase: "tool hash" }; const pending = resolveXcrunTool("file", {}, controller.signal, launcher); - await started; - finishRealpath(); - setImmediate(() => controller.abort(cancellationReason)); + try { + await awaitAcknowledgement( + started, + pending, + "native tool hash selection", + ); + finishRealpath?.(); + setImmediate(() => controller.abort(cancellationReason)); - const result = await pending; - if (result.ok) throw new Error("Expected hash cancellation"); - expect(result.error).toMatchObject({ - reason: "cancelled", - cancellation: { reason: cancellationReason }, - }); - expect(result.error.cause).toMatchObject({ - name: "AbortError", - code: "ABORT_ERR", - cause: cancellationReason, - }); - expect(result.error.cancellation?.reason).toBe(cancellationReason); - expect(result.error.cause).toBeInstanceOf(Error); - expect((result.error.cause as Error).cause).toBe(cancellationReason); + const result = await pending; + if (result.ok) throw new Error("Expected hash cancellation"); + expect(result.error).toMatchObject({ + reason: "cancelled", + cancellation: { reason: cancellationReason }, + }); + expect(result.error.cause).toMatchObject({ + name: "AbortError", + code: "ABORT_ERR", + cause: cancellationReason, + }); + expect(result.error.cancellation?.reason).toBe(cancellationReason); + expect(result.error.cause).toBeInstanceOf(Error); + expect((result.error.cause as Error).cause).toBe(cancellationReason); + } finally { + controller.abort(cancellationReason); + finishRealpath?.(); + await pending; + } }, ); diff --git a/tests/boundary/providers/native/nativePlistFileCleanup.test.ts b/tests/boundary/providers/native/nativePlistFileCleanup.test.ts index c4f66566c1..0afd7da0ff 100644 --- a/tests/boundary/providers/native/nativePlistFileCleanup.test.ts +++ b/tests/boundary/providers/native/nativePlistFileCleanup.test.ts @@ -9,6 +9,7 @@ import { nativeMachoTargetForFile, } from "../../../fixtures/nativeCommands.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; import { resolvedAnalysisError } from "../../../../src/domain/analysisErrorBase.js"; @@ -388,17 +389,22 @@ it.for([ exitCode: args[1] === "json" ? 1 : 0, }); }); - onTestFinished(() => { + onTestFinished(async () => { resume(); resumeValidation(); - read.mockRestore(); - commands.mockRestore(); + try { + await inspecting; + } finally { + read.mockRestore(); + commands.mockRestore(); + await client.close(); + } }); const inspecting = client.execute("inspect_plist", { path, native_command_timeout_ms: 1500, }); - await entered; + await awaitAcknowledgement(entered, inspecting, "plist file read"); let finished = false; const closing = client.close().then((result) => { finished = true; @@ -411,7 +417,11 @@ it.for([ error: { _tag: "AnalysisCapabilityUnavailableError" }, }); resume(); - await Promise.race([validationEntered, inspecting]); + await awaitAcknowledgement( + validationEntered, + inspecting, + "plist validation", + ); expect(validationStarted).toBe(true); await new Promise((resolve) => setImmediate(resolve)); expect(finished).toBe(false); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index ba5886b9f8..e5c437d7ea 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -951,8 +951,12 @@ describe("native late cancellation with cleanup", () => { {}, { signal: controller.signal }, ); - await started; - await cleanupAttempt; + await awaitAcknowledgement(started, pending, "native command start"); + await awaitAcknowledgement( + cleanupAttempt, + pending, + "native cleanup start", + ); controller.abort(cancellationReason); releaseFirstCleanup(); @@ -1143,51 +1147,59 @@ describe("native command cleanup lifecycle", () => { ); const execution = client.execute("list_architectures", {}); - await started; - const closing = client.close(); - await Promise.resolve(); - expect(cleanupRetries).toBe(0); - finishCommand( - err( - new NativeCommandFailure("lipo", "timeout", null, undefined, { - stdout: "partial architecture output", - stderr: "partial diagnostic", - stdoutBytes: 27, - stderrBytes: 19, - exitCode: null, - signal: null, - truncated: false, - }), - ), + const commandResult = err( + new NativeCommandFailure("lipo", "timeout", null, undefined, { + stdout: "partial architecture output", + stderr: "partial diagnostic", + stdoutBytes: 27, + stderrBytes: 19, + exitCode: null, + signal: null, + truncated: false, + }), ); + try { + await awaitAcknowledgement(started, execution, "native command start"); + const closing = client.close(); + await Promise.resolve(); + expect(cleanupRetries).toBe(0); + finishCommand(commandResult); - const [observed, closed] = await Promise.all([execution, closing]); - expect(observed.ok).toBe(false); - expect(closed.ok).toBe(false); - expect(cleanupRetries).toBe(1); - if (!closed.ok) { - expect(closed.error.cleanupResources).toEqual([ - "run:first", - "process-group:1234", - "run:second", - "process-group:5678", - ]); - expect(closed.error).toHaveProperty("diagnostics", { - reason: "Native command process cleanup remains incomplete", - failures: [ - { - reason: "first process group remains live", - resources: ["run:first", "process-group:1234"], - }, - { - reason: "second process group remains live", - resources: ["run:second", "process-group:5678"], - }, - ], - }); + const [observed, closed] = await Promise.all([execution, closing]); + expect(observed.ok).toBe(false); + expect(closed.ok).toBe(false); + expect(cleanupRetries).toBe(1); + if (!closed.ok) { + expect(closed.error.cleanupResources).toEqual([ + "run:first", + "process-group:1234", + "run:second", + "process-group:5678", + ]); + expect(closed.error).toHaveProperty("diagnostics", { + reason: "Native command process cleanup remains incomplete", + failures: [ + { + reason: "first process group remains live", + resources: ["run:first", "process-group:1234"], + }, + { + reason: "second process group remains live", + resources: ["run:second", "process-group:5678"], + }, + ], + }); + } + await expect(client.close()).resolves.toMatchObject({ ok: true }); + expect(cleanupRetries).toBe(2); + } finally { + finishCommand(commandResult); + try { + await execution; + } finally { + await client.close(); + } } - await expect(client.close()).resolves.toMatchObject({ ok: true }); - expect(cleanupRetries).toBe(2); }); }); diff --git a/tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts b/tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts index 3548c66545..2e8f52547a 100644 --- a/tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts +++ b/tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts @@ -263,7 +263,7 @@ it.each([ const { path, target } = await fixture(); const replay = new NativeFixtureRunner(); const unsigned: NativeCommandRunner = { - async run(tool, args, options) { + async run(tool, args) { const diagnostic = `${args.at(-1)}: code object is not signed at all\n`; const capture = await new NativeFixtureRunner( { codesign: diagnostic }, diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 2260ddee0e..de05296e04 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -1,3 +1,4 @@ +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { createAnalysisExecution, type AnalysisClient, @@ -35,7 +36,7 @@ import { writeFile, } from "node:fs/promises"; import { dirname, extname, join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; const client = (fail = false): AnalysisClient => ({ execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)), @@ -235,7 +236,15 @@ it("drains session calls and retires analysis after an admitted external mutatio }, (_value, error) => error, ); - await started.promise; + onTestFinished(async () => { + release.resolve(); + try { + await mutation; + } finally { + await session.close(); + } + }); + await awaitAcknowledgement(started.promise, mutation, "external mutation"); const queuedAnalysis = session.execute("address_name", {}); expect(calls).toEqual([]); @@ -894,8 +903,8 @@ describe("snapshot invalidation at mutation dispatch", () => { ...parameters, name: "after", }); - await started.promise; try { + await awaitAcknowledgement(started.promise, writing, "session write"); const during = await session.execute("address_name", parameters); if (!during.ok) throw during.error; expect(during.value.result).toBe("after"); @@ -1713,14 +1722,19 @@ describe("cancellation during profile resolution", () => { openSettled = true; return result; }); - await admitted.promise; - controller.abort(reason); let closeSettled = false; - const closing = session.close().then((result) => { - closeSettled = true; - return result; - }); + let closing: ReturnType | undefined; try { + await awaitAcknowledgement( + admitted.promise, + opening, + "profile admission", + ); + controller.abort(reason); + closing = session.close().then((result) => { + closeSettled = true; + return result; + }); await new Promise((resolve) => setImmediate(resolve)); expect(observedSignal?.reason).toBe(reason); expect(openSettled).toBe(false); @@ -1735,9 +1749,13 @@ describe("cancellation during profile resolution", () => { expect(created).toBe(0); expect(await session.status()).toMatchObject({ cleanup_pending: false }); } finally { + controller.abort(reason); release.resolve(); - await Promise.all([opening, closing]); - await session.close(); + try { + await Promise.all([opening, closing]); + } finally { + await session.close(); + } } }); }); diff --git a/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts b/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts index 40ac09a36d..5c8e315414 100644 --- a/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts +++ b/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts @@ -23,6 +23,7 @@ import { createTestBinarySession, } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; const CLOSE_RETRY = { operation: "close_binary" } as const; @@ -512,7 +513,7 @@ it("does not start a replacement cancelled while pending cleanup completes", asy allowed = true; const controller = new AbortController(); const opening = session.open(second, { signal: controller.signal }); - await entered.promise; + await awaitAcknowledgement(entered.promise, opening, "session cleanup retry"); controller.abort(); release.resolve(); expect(await opening).toMatchObject({ diff --git a/tests/composition/analysis-sessions/mips-identity.test.ts b/tests/composition/analysis-sessions/mips-identity.test.ts deleted file mode 100644 index cf6c52fb75..0000000000 --- a/tests/composition/analysis-sessions/mips-identity.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; -import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; -import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; -import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; -import { - createAnalysisSnapshotEntry, - parseAnalysisSnapshot, - serializeAnalysisSnapshot, - snapshotBinding, - snapshotTarget, - snapshotMatchesProfile, -} from "../../../src/domain/analysisSnapshot.js"; -import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; - -const target: BinaryTarget = { - path: "/source-owned.elf", - sha256: "a".repeat(64), - kind: "executable", - format: "elf", - architecture: "mips", - availableArchitectures: ["mips"], - mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 }, -}; -const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; -const profile = createAnalysisProfile(provider, { - mips_elf: { elf_class: 32, byte_order: "little", type: 2, flags: 0x70001001 }, -}); - -describe("MIPS identity through lifecycle, Evidence and snapshot consumers", () => { - it("round-trips query bindings without relaxing integrity", () => { - const result = [{ address: "0x20150", value: "rea_mips_entry" }]; - const parameters = {}; - const evidence = createEvidence(target, provider, { - operation: "list_procedures", - parameters, - result, - analysisProfile: profile, - }); - const binding = snapshotBinding(profile); - const entry = createAnalysisSnapshotEntry({ - target: snapshotTarget(target), - binding, - operation: "list_procedures", - parameters, - execution: { - result, - rawResult: null, - provider, - analysisProfile: profile, - limitations: [], - locations: [], - subject: target, - }, - }); - const snapshot = { - target: snapshotTarget(target), - binding, - entries: [entry], - workflow_entries: [], - evidence_bundle: createEvidenceBundle([evidence]), - }; - const restored = parseAnalysisSnapshot( - JSON.parse(serializeAnalysisSnapshot(snapshot)), - ); - expect(restored.target.architecture).toBe("mips"); - expect(restored.evidence_bundle.records[0]?.subject?.architecture).toBe( - "mips", - ); - expect(restored.entries[0]?.execution.subject?.architecture).toBe("mips"); - expect( - sessionOutputSchemas.open_binary.parse({ result: target }).result - .architecture, - ).toBe("mips"); - expect(() => - parseEvidence({ - ...evidence, - subject: { ...evidence.subject, architecture: "x86" }, - }), - ).toThrow("semantic identifier"); - const changed = createAnalysisProfile(provider, { - mips_elf: { - elf_class: 32, - byte_order: "big", - type: 2, - flags: 0x70001001, - }, - }); - expect(snapshotMatchesProfile(binding, changed)).toBe(false); - }); -}); diff --git a/tests/composition/analysis-sessions/powerpc-identity.test.ts b/tests/composition/analysis-sessions/nativeArchitectureIdentity.test.ts similarity index 56% rename from tests/composition/analysis-sessions/powerpc-identity.test.ts rename to tests/composition/analysis-sessions/nativeArchitectureIdentity.test.ts index 3dd1d374a2..30ed31a8b9 100644 --- a/tests/composition/analysis-sessions/powerpc-identity.test.ts +++ b/tests/composition/analysis-sessions/nativeArchitectureIdentity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; @@ -13,23 +13,80 @@ import { } from "../../../src/domain/analysisSnapshot.js"; import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; -const target: BinaryTarget = { +const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const commonTarget = { path: "/source-owned.elf", sha256: "a".repeat(64), kind: "executable", format: "elf", - architecture: "powerpc", - availableArchitectures: ["powerpc"], - powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags: 0 }, -}; -const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; -const profile = createAnalysisProfile(provider, { - powerpc_elf: { elf_class: 32, byte_order: "big", type: 2, flags: 0 }, -}); +} as const; +const cases = [ + { + architecture: "mips", + target: { + ...commonTarget, + architecture: "mips", + availableArchitectures: ["mips"], + mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 }, + }, + profile: { + mips_elf: { + elf_class: 32, + byte_order: "little", + type: 2, + flags: 0x70001001, + }, + }, + changed: { + mips_elf: { + elf_class: 32, + byte_order: "big", + type: 2, + flags: 0x70001001, + }, + }, + observation: { address: "0x20150", value: "rea_mips_entry" }, + }, + { + architecture: "powerpc", + target: { + ...commonTarget, + architecture: "powerpc", + availableArchitectures: ["powerpc"], + powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags: 0 }, + }, + profile: { + powerpc_elf: { elf_class: 32, byte_order: "big", type: 2, flags: 0 }, + }, + changed: { + powerpc_elf: { + elf_class: 32, + byte_order: "big", + type: 2, + flags: 0x00010000, + }, + }, + observation: { address: "0x100101b0", value: "rea_ppc_entry" }, + }, +] as const satisfies readonly { + architecture: string; + target: BinaryTarget; + profile: object; + changed: object; + observation: { address: string; value: string }; +}[]; -describe("PowerPC identity through lifecycle, Evidence and snapshot consumers", () => { - it("round-trips query bindings without relaxing integrity", () => { - const result = [{ address: "0x100101b0", value: "rea_ppc_entry" }]; +it.each(cases)( + "round-trips $architecture identity through Evidence and snapshots", + ({ + architecture, + target, + observation, + profile: facts, + changed: changedFacts, + }) => { + const result = [observation]; + const profile = createAnalysisProfile(provider, facts); const parameters = {}; const evidence = createEvidence(target, provider, { operation: "list_procedures", @@ -63,31 +120,24 @@ describe("PowerPC identity through lifecycle, Evidence and snapshot consumers", const restored = parseAnalysisSnapshot( JSON.parse(serializeAnalysisSnapshot(snapshot)), ); - expect(restored.target.architecture).toBe("powerpc"); + expect(restored.target.architecture).toBe(architecture); expect(restored.evidence_bundle.records[0]?.subject?.architecture).toBe( - "powerpc", + architecture, ); expect(restored.entries[0]?.execution.subject?.architecture).toBe( - "powerpc", + architecture, ); expect( sessionOutputSchemas.open_binary.parse({ result: target }).result .architecture, - ).toBe("powerpc"); + ).toBe(architecture); expect(() => parseEvidence({ ...evidence, subject: { ...evidence.subject, architecture: "x86" }, }), ).toThrow("semantic identifier"); - const changed = createAnalysisProfile(provider, { - powerpc_elf: { - elf_class: 32, - byte_order: "big", - type: 2, - flags: 0x00010000, - }, - }); + const changed = createAnalysisProfile(provider, changedFacts); expect(snapshotMatchesProfile(binding, changed)).toBe(false); - }); -}); + }, +); diff --git a/tests/composition/reverseProviderLaunchErrors.test.ts b/tests/composition/reverseProviderLaunchErrors.test.ts index 1cd768efa7..b2a1841d14 100644 --- a/tests/composition/reverseProviderLaunchErrors.test.ts +++ b/tests/composition/reverseProviderLaunchErrors.test.ts @@ -1000,7 +1000,7 @@ it("retains successful architecture and artifact output for an admitted provider }); it("tracks observed launch failures and clears them after subsequent successful launch", async () => { - const { directory, path } = await createArtifact(); + const { directory } = await createArtifact(); const command = resolve(directory, "failing-objdump"); let failLaunch = true; const service = new TestReverseEngineeringService({ diff --git a/tests/conformance/ghidra/mips-fixture.mjs b/tests/conformance/ghidra/mips-fixture.mjs index 1d19aaa578..5d6202d46a 100644 --- a/tests/conformance/ghidra/mips-fixture.mjs +++ b/tests/conformance/ghidra/mips-fixture.mjs @@ -59,10 +59,6 @@ export async function buildMipsFixture( let attested = compiled; if (legacy) { const raw = await readFile(path); - const write16 = (offset, value) => - byteOrder === "little" - ? raw.writeUInt16LE(value, offset) - : raw.writeUInt16BE(value, offset); const write32 = (offset, value) => byteOrder === "little" ? raw.writeUInt32LE(value, offset) diff --git a/tests/evaluation/noSharedModuleMockRule.test.ts b/tests/evaluation/noSharedModuleMockRule.test.ts index e5288b2f86..42d66c10b9 100644 --- a/tests/evaluation/noSharedModuleMockRule.test.ts +++ b/tests/evaluation/noSharedModuleMockRule.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; import { fileURLToPath } from "node:url"; import { RuleTester } from "oxlint/plugins-dev"; diff --git a/tests/evaluation/numericLimitCheck.test.ts b/tests/evaluation/numericLimitCheck.test.ts index 96764d0dcf..9ff48209e7 100644 --- a/tests/evaluation/numericLimitCheck.test.ts +++ b/tests/evaluation/numericLimitCheck.test.ts @@ -265,6 +265,33 @@ it("keeps mutable and shadowed aliases from proving a setting read", () => { ); }); +it("reports each mutable alias once while keeping dynamic reads unresolved", () => { + const source = ` + let key = "REA_SAMPLE_MAX_BYTES"; + consume(key); + consume(key); + Number(process.env[key]); + let other = "REA_OTHER_MAX_BYTES"; + consume(other); + `; + const discovered = discoverNumericLimitUses([ + { path: "src/aliases.ts", source }, + ]); + expect( + discovered.unresolved.filter( + (issue) => issue.reason === "mutable setting-key or value alias", + ), + ).toEqual([ + expect.objectContaining({ offset: source.indexOf("key =") }), + expect.objectContaining({ offset: source.indexOf("other =") }), + ]); + expect(discovered.unresolved).toContainEqual( + expect.objectContaining({ + reason: "dynamic environment key is not statically known", + }), + ); +}); + it("does not attribute an unrelated same-named local to a numeric limit", () => { const discovered = discoverNumericLimitUses([ { diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts index cd3751bca1..dc6da77f83 100644 --- a/tests/fixtures/advertisedMcpTool.ts +++ b/tests/fixtures/advertisedMcpTool.ts @@ -46,6 +46,26 @@ export const advertisedMcpToolSchemas = async ( }; }; +/** Call an advertised tool and validate its structured result against its schema. */ +export const callAdvertisedMcpTool = async ( + client: Client, + name: string, + args: Record, + validator: AdvertisedSchemaAssertions, +) => { + const schema = await advertisedMcpToolSchemas(client, name, validator); + const result = await client.callTool( + { name, arguments: args }, + { toolDefinition: schema.toolDefinition }, + ); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + schema.ajv.validate(schema.outputSchema, result.structuredContent), + JSON.stringify(schema.ajv.errors), + ).toBe(true); + return result.structuredContent; +}; + /** Keep public output, provenance and session retention checks together. */ export const assertRecordedMcpEvidence = ( response: Awaited>, diff --git a/tests/fixtures/artifactEvidence.ts b/tests/fixtures/artifactEvidence.ts index 49c748ed13..9c4fe7ffb0 100644 --- a/tests/fixtures/artifactEvidence.ts +++ b/tests/fixtures/artifactEvidence.ts @@ -1,6 +1,61 @@ -import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, +} from "../../src/domain/artifactGraph.js"; +import { + artifactGraphDigest, + artifactManifestId, +} from "../../src/domain/artifactIdentity.js"; import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +/** Recommit altered inventory occurrences as a historically recorded observation. */ +export const historicalInventoryEvidence = ( + source: Evidence, + alter: ( + occurrence: ArtifactInventoryResult["occurrences"][number], + ) => ArtifactInventoryResult["occurrences"][number], + confidence: "observed" | "derived" = "derived", +): Evidence => { + const inventory = artifactInventoryResultSchema.parse( + source.normalized_result, + ); + if (source.subject === null) + throw new TypeError("Inventory has no artifact subject"); + const occurrences = inventory.occurrences.map(alter); + const graphSha256 = artifactGraphDigest({ + nodes: inventory.nodes, + occurrences, + edges: inventory.edges, + contradictions: inventory.integrity_contradictions, + }); + return createEvidence( + { + path: source.subject.local_path, + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + source.provider, + { + operation: "inventory_artifact", + parameters: {}, + result: { + ...inventory, + occurrences, + manifest: { + ...inventory.manifest, + graph_sha256: graphSha256, + manifest_id: artifactManifestId( + inventory.manifest.root_artifact_id, + graphSha256, + ), + }, + }, + confidence, + authority: "shipped-artifact", + }, + ); +}; + /** Partition a real scan into authenticated partial observations for merge tests. */ export const fragmentInventoryEvidence = ( evidence: Evidence, diff --git a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts index 90eddc6792..105964eed6 100644 --- a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts +++ b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts @@ -5,13 +5,13 @@ export const SHELL_LINK_CLSID = Buffer.from([ 0x00, 0x00, 0x46, ]); -export const MY_COMPUTER_CLSID = Buffer.from([ +const MY_COMPUTER_CLSID = Buffer.from([ 0xe0, 0x4f, 0xd0, 0x20, 0xea, 0x3a, 0x69, 0x10, 0xa2, 0xd8, 0x08, 0x00, 0x2b, 0x30, 0x30, 0x9d, ]); /** 2024-01-15T12:00:00.000Z as FILETIME 100-nanosecond intervals. */ -export const FIXTURE_FILETIME = 133497936000000000n; +const FIXTURE_FILETIME = 133497936000000000n; export const LINK_FLAG = { HasLinkTargetIDList: 0x00000001, @@ -26,7 +26,7 @@ export const LINK_FLAG = { HasExpIcon: 0x00004000, } as const; -export const EXTRA_SIGNATURE = { +const EXTRA_SIGNATURE = { EnvironmentVariable: 0xa0000001, Tracker: 0xa0000003, ConsoleFE: 0xa0000004, @@ -40,7 +40,7 @@ export const TRACKER_MAC = Buffer.from([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]); /** UUID timestamp 100ns since 1582-10-15 matching 2024-01-15T12:00:00.000Z. */ export const TRACKER_UUID_TIMESTAMP = 139289664000000000n; -export type FixtureIdListItem = +type FixtureIdListItem = | { readonly kind: "root"; readonly sortIndex?: number; diff --git a/tests/fixtures/binaryPlistObjectTable.ts b/tests/fixtures/binaryPlistObjectTable.ts index a258f529b3..4925aa2079 100644 --- a/tests/fixtures/binaryPlistObjectTable.ts +++ b/tests/fixtures/binaryPlistObjectTable.ts @@ -1,4 +1,4 @@ -export interface BinaryPlistObjectTableLayout { +interface BinaryPlistObjectTableLayout { readonly offsetWidth: number; readonly objectCount: number; readonly tableOffset: number; diff --git a/tests/fixtures/browserScenarioCapture.ts b/tests/fixtures/browserScenarioCapture.ts index 550f2c51bb..ea7398430a 100644 --- a/tests/fixtures/browserScenarioCapture.ts +++ b/tests/fixtures/browserScenarioCapture.ts @@ -37,7 +37,7 @@ export interface ScenarioExchange { readonly responseHeaders?: readonly ScenarioHeader[]; } -export interface ScenarioHeader { +interface ScenarioHeader { readonly name: string; readonly value: string | null; } diff --git a/tests/fixtures/buildCaptureFakes.ts b/tests/fixtures/buildCaptureFakes.ts index 1e42449238..ac97a54671 100644 --- a/tests/fixtures/buildCaptureFakes.ts +++ b/tests/fixtures/buildCaptureFakes.ts @@ -34,7 +34,7 @@ export const BUILD_REQUEST = { const ENDS = ["--seed-checkpoint", "seed", "--end-checkpoint", "end"]; /** The app's package as the capture records it, the URL the export downloads its file from, if it needs one, the download it was installed from, if any, and the packages it installs beside the app. */ -export interface CapturedInstall { +interface CapturedInstall { readonly package?: DesktopPackage; readonly packageUrl?: string; readonly downloadInstall?: Readonly>; @@ -42,7 +42,7 @@ export interface CapturedInstall { } /** A task the export takes with the checkpoint it is checked at. */ -export interface BuildTaskAtEnd { +interface BuildTaskAtEnd { readonly task: unknown; readonly end_checkpoint: string; } diff --git a/tests/fixtures/buildComputerMcp.ts b/tests/fixtures/buildComputerMcp.ts index 33c14484cf..84adc63e6a 100644 --- a/tests/fixtures/buildComputerMcp.ts +++ b/tests/fixtures/buildComputerMcp.ts @@ -35,7 +35,7 @@ const hostEnvironment = Object.fromEntries( ); /** A check grades its first read, for tests whose app changes nothing while it is checked. */ -export const FIRST_READ: SettleTiming = { +const FIRST_READ: SettleTiming = { intervalMs: 0, windowMs: 0, boundMs: 0, diff --git a/tests/fixtures/captureDecoderLimits.ts b/tests/fixtures/captureDecoderLimits.ts index fe47f96d0f..44969bb4a0 100644 --- a/tests/fixtures/captureDecoderLimits.ts +++ b/tests/fixtures/captureDecoderLimits.ts @@ -1,10 +1,19 @@ +import { javaScriptWorkerMemoryLimitBytes } from "../../src/process/JavaScriptWorkerMemoryLimit.js"; +import { DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB } from "../../src/domain/analysisResourceLimits.js"; +import { NODE_CAPTURE_HEAP_ENFORCED } from "../../src/browser/history/CaptureRelease.js"; import { - captureDecoderProcessMemoryLimitBytes, - NODE_CAPTURE_HEAP_LIMITS, -} from "../../src/browser/history/CaptureRelease.js"; + javaScriptRuntime, + supportsJavaScriptRuntime, +} from "../../src/domain/runtimeVersion.js"; /** Default Job Object limit used by capture decoder fixtures. */ export const NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES = - captureDecoderProcessMemoryLimitBytes( - NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB, - ); + javaScriptWorkerMemoryLimitBytes(DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB); + +export const CAPTURE_RUNTIME_SUPPORTED = supportsJavaScriptRuntime( + javaScriptRuntime(process.versions), +); + +export const CAPTURE_DECODER_HAS_MEMORY_BOUND = + NODE_CAPTURE_HEAP_ENFORCED || + NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null; diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 78c9cca555..8d68a49560 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -1,7 +1,7 @@ import { vi } from "vitest"; /** Environment variables that redirect where supported clients keep config. */ -export const CLIENT_LOCATION_ENVIRONMENT = [ +const CLIENT_LOCATION_ENVIRONMENT = [ "HOME", "USERPROFILE", "HOMEDRIVE", diff --git a/tests/fixtures/descriptorCloseFailure.ts b/tests/fixtures/descriptorCloseFailure.ts index 86ccfe3cca..d6b605cf92 100644 --- a/tests/fixtures/descriptorCloseFailure.ts +++ b/tests/fixtures/descriptorCloseFailure.ts @@ -3,7 +3,7 @@ import { expect } from "vitest"; import type { Result } from "../../src/domain/result.js"; import type { AnalysisError } from "../../src/domain/analysisErrorBase.js"; -export interface DescriptorCloseFailure { +interface DescriptorCloseFailure { path: string; denied: boolean; handles: FileHandle[]; diff --git a/tests/fixtures/fakeAgentEnvSolves.ts b/tests/fixtures/fakeAgentEnvSolves.ts index 80ca1cf9e2..48022c5ff7 100644 --- a/tests/fixtures/fakeAgentEnvSolves.ts +++ b/tests/fixtures/fakeAgentEnvSolves.ts @@ -76,7 +76,7 @@ def run_bundle(root, *, tasks=(), model=None, agent=None, sandbox=None, emulate= return types.SimpleNamespace(runs=(run,)) `; -export interface FakeAgentEnvSolves { +interface FakeAgentEnvSolves { readonly environment: Readonly>; notes(name: string): Promise; } diff --git a/tests/fixtures/fakeBuildComputerRea.ts b/tests/fixtures/fakeBuildComputerRea.ts index 7a19b422e7..6c022f0a4b 100644 --- a/tests/fixtures/fakeBuildComputerRea.ts +++ b/tests/fixtures/fakeBuildComputerRea.ts @@ -185,7 +185,7 @@ sys.exit(0) `; /** Prepared outputs of the real CLI for the fake REA to print. */ -export interface FakeReaOutputs { +interface FakeReaOutputs { readonly capture: string; readonly draft: string; readonly export: string; @@ -195,7 +195,7 @@ export interface FakeReaOutputs { readonly withheld?: string; } -export interface FakeBuildComputerRea { +interface FakeBuildComputerRea { readonly home: string; readonly bin: string; readonly env: Readonly>; diff --git a/tests/fixtures/firmware/producer.mjs b/tests/fixtures/firmware/producer.mjs index ef013ec8bf..4cd25d5217 100644 --- a/tests/fixtures/firmware/producer.mjs +++ b/tests/fixtures/firmware/producer.mjs @@ -102,43 +102,46 @@ if (args.includes("--version")) { }); const hash = (value) => ({ __typename__: "HashReport", sha256: value }); const childTask = task(child, depth, "gzip-id"); + const nestedUnknownRecords = (childSize, digest) => [ + { + task: childTask, + reports: [ + stat(child, childSize), + hash(digest), + { + __typename__: "UnknownChunkReport", + id: "unknown-id", + start_offset: 0, + end_offset: content.length, + size: content.length, + }, + ], + subtasks: [], + }, + { + task: task(input, 0, ""), + reports: [ + stat(input, bytes.length), + hash(sha256), + { + __typename__: "ChunkReport", + id: "gzip-id", + start_offset: 0, + end_offset: bytes.length, + size: bytes.length, + handler_name: "gzip", + is_encrypted: false, + extraction_reports: [], + }, + ], + subtasks: [childTask], + }, + ]; if (mode === "nested-unknown") { - const nestedUnknownRecords = [ - { - task: childTask, - reports: [ - stat(child, content.length), - hash(childHash), - { - __typename__: "UnknownChunkReport", - id: "unknown-id", - start_offset: 0, - end_offset: content.length, - size: content.length, - }, - ], - subtasks: [], - }, - { - task: task(input, 0, ""), - reports: [ - stat(input, bytes.length), - hash(sha256), - { - __typename__: "ChunkReport", - id: "gzip-id", - start_offset: 0, - end_offset: bytes.length, - size: bytes.length, - handler_name: "gzip", - is_encrypted: false, - extraction_reports: [], - }, - ], - subtasks: [childTask], - }, - ]; - await writeFile(reportPath, JSON.stringify(nestedUnknownRecords)); + await writeFile( + reportPath, + JSON.stringify(nestedUnknownRecords(content.length, childHash)), + ); } if (mode === "nested-unknown-oversized") { // The child exceeds the firmware input limit, so a fresh run cannot @@ -165,42 +168,10 @@ if (args.includes("--version")) { await source.close(); } const oversizedHash = oversizedDigest.digest("hex"); - const nestedUnknownRecords = [ - { - task: childTask, - reports: [ - stat(child, oversized), - hash(oversizedHash), - { - __typename__: "UnknownChunkReport", - id: "unknown-id", - start_offset: 0, - end_offset: content.length, - size: content.length, - }, - ], - subtasks: [], - }, - { - task: task(input, 0, ""), - reports: [ - stat(input, bytes.length), - hash(sha256), - { - __typename__: "ChunkReport", - id: "gzip-id", - start_offset: 0, - end_offset: bytes.length, - size: bytes.length, - handler_name: "gzip", - is_encrypted: false, - extraction_reports: [], - }, - ], - subtasks: [childTask], - }, - ]; - await writeFile(reportPath, JSON.stringify(nestedUnknownRecords)); + await writeFile( + reportPath, + JSON.stringify(nestedUnknownRecords(oversized, oversizedHash)), + ); } if (mode === "depth-orphan") { // A depth-limited directory with nothing published under it cannot diff --git a/tests/fixtures/go/rodataStringImage.ts b/tests/fixtures/go/rodataStringImage.ts index e3bffc5e2e..afcd3ae70b 100644 --- a/tests/fixtures/go/rodataStringImage.ts +++ b/tests/fixtures/go/rodataStringImage.ts @@ -1,11 +1,11 @@ -export interface GoStringHeaderFixtureSpec { +interface GoStringHeaderFixtureSpec { readonly section: "rodata" | "data" | "text"; readonly offsetInSection: number; readonly ptr: bigint; readonly len: bigint; } -export interface GoRodataStringFixtureOptions { +interface GoRodataStringFixtureOptions { readonly format: "elf" | "pe" | "macho"; readonly bits?: 32 | 64; readonly byteOrder?: "little" | "big"; diff --git a/tests/fixtures/gpu/codeObjects.ts b/tests/fixtures/gpu/codeObjects.ts index d4b711a296..e77207f6b0 100644 --- a/tests/fixtures/gpu/codeObjects.ts +++ b/tests/fixtures/gpu/codeObjects.ts @@ -12,7 +12,7 @@ export const ptxFixture = `// Producer-shaped static PTX; portable tests do not ret; } `; -export interface ElfFixtureSection { +interface ElfFixtureSection { name: string; type?: number; flags?: bigint; diff --git a/tests/fixtures/il2cpp/metadata.ts b/tests/fixtures/il2cpp/metadata.ts index ad6fcea63d..585dab20ae 100644 --- a/tests/fixtures/il2cpp/metadata.ts +++ b/tests/fixtures/il2cpp/metadata.ts @@ -485,16 +485,6 @@ export const fixtureRows = (options: Il2cppMetadataFixtureOptions = {}) => { return { types, methods }; }; -/** A zero-filled section of `count` records, or none when `count` is 0. */ -const zeroFilledSection = ( - name: SectionName, - count: number, - recordBytes: number, -): [SectionName, SectionContent][] => - count === 0 - ? [] - : [[name, { bytes: Buffer.alloc(count * recordBytes), count }]]; - /** Real rows followed by zero-filled rows that no owner claims. */ const paddedRows = ( rows: Buffer, diff --git a/tests/fixtures/importCycles.ts b/tests/fixtures/importCycles.ts new file mode 100644 index 0000000000..51ba28ed3a --- /dev/null +++ b/tests/fixtures/importCycles.ts @@ -0,0 +1,107 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { expect } from "vitest"; +import { readImportSpecifiers } from "../../scripts/lib/import-specifiers.mjs"; + +const relativeSpecifiers = (source: string): readonly string[] => + readImportSpecifiers(source) + .map(({ specifier }) => specifier) + .filter((specifier) => specifier.startsWith(".")); + +const productionFiles = (directory: string): string[] => { + const files: string[] = []; + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) files.push(...productionFiles(path)); + else if (name.endsWith(".ts") && !name.includes(".test.")) files.push(path); + } + return files; +}; + +const importGraph = (root: string): Map> => { + const files = new Set( + productionFiles(root).map((path) => relative(root, path)), + ); + const graph = new Map>(); + for (const file of files) { + const source = readFileSync(join(root, file), "utf8"); + const edges = new Set(); + for (const spec of relativeSpecifiers(source)) { + const resolved = resolve(dirname(join(root, file)), spec).replace( + /\.js$/, + ".ts", + ); + const target = relative(root, resolved); + if (files.has(target)) edges.add(target); + } + graph.set(file, edges); + } + return graph; +}; + +const stronglyConnected = ( + graph: Map>, +): readonly (readonly string[])[] => { + let index = 0; + const stack: string[] = []; + const onStack = new Set(); + const indices = new Map(); + const low = new Map(); + const components: string[][] = []; + const visit = (node: string): void => { + indices.set(node, index); + low.set(node, index); + index += 1; + stack.push(node); + onStack.add(node); + for (const next of graph.get(node) ?? []) { + if (!indices.has(next)) { + visit(next); + low.set(node, Math.min(low.get(node) ?? index, low.get(next) ?? index)); + } else if (onStack.has(next)) { + low.set( + node, + Math.min(low.get(node) ?? index, indices.get(next) ?? index), + ); + } + } + if (low.get(node) === indices.get(node)) { + const component: string[] = []; + let member = ""; + while (member !== node) { + member = stack.pop() ?? node; + onStack.delete(member); + component.push(member); + } + if (component.length > 1) components.push(component.sort()); + } + }; + for (const node of [...graph.keys()].sort()) { + if (!indices.has(node)) visit(node); + } + return components.sort( + (left, right) => + right.length - left.length || left.join().localeCompare(right.join()), + ); +}; + +/** Check cycle membership and internal edges against the subsystem's allowance. */ +export const expectImportCycles = ( + root: string, + allowedCycles: readonly (readonly string[])[], + allowedCycleEdges: readonly string[], +): void => { + const graph = importGraph(root); + const cycles = stronglyConnected(graph).map((cycle) => cycle.join("\n")); + expect(cycles).toEqual( + allowedCycles.map((cycle) => [...cycle].sort().join("\n")), + ); + const internalEdges = allowedCycles.flatMap((cycle) => + cycle.flatMap((source) => + [...(graph.get(source) ?? [])] + .filter((target) => cycle.includes(target)) + .map((target) => `${source} -> ${target}`), + ), + ); + expect(internalEdges.sort()).toEqual(allowedCycleEdges); +}; diff --git a/tests/fixtures/uimage.ts b/tests/fixtures/uimage.ts index 64cfd5e462..71dafb3360 100644 --- a/tests/fixtures/uimage.ts +++ b/tests/fixtures/uimage.ts @@ -5,7 +5,7 @@ const UIMAGE_HEADER_SIZE = 64; const UIMAGE_MAGIC = 0x27051956; const UIMAGE_NAME_LENGTH = 32; -export interface UImageFixtureOptions { +interface UImageFixtureOptions { readonly arch?: number; readonly os?: number; readonly type?: number; diff --git a/tests/fixtures/universalMacho.ts b/tests/fixtures/universalMacho.ts index 208c1257ac..ef4289e28a 100644 --- a/tests/fixtures/universalMacho.ts +++ b/tests/fixtures/universalMacho.ts @@ -1,5 +1,5 @@ /** One thin Mach-O slice and the CPU identity its header and FAT entry declare. */ -export interface UniversalSlice { +interface UniversalSlice { readonly bytes: Buffer; readonly cpuType: number; readonly cpuSubtype: number; @@ -8,7 +8,7 @@ export interface UniversalSlice { } /** The FAT32 container and where each slice landed. */ -export interface UniversalMachoFixture { +interface UniversalMachoFixture { readonly bytes: Buffer; readonly slices: readonly { readonly offset: number; diff --git a/tests/fixtures/webBundleAnalysis.ts b/tests/fixtures/webBundleAnalysis.ts new file mode 100644 index 0000000000..e48d334eaa --- /dev/null +++ b/tests/fixtures/webBundleAnalysis.ts @@ -0,0 +1,99 @@ +import type { WebBundleAnalysis } from "../../src/domain/webBundleAnalysis.js"; + +export const sampleWebBundleAnalysis = (): WebBundleAnalysis => ({ + capture: { + target_url: "https://example.test/app", + target_origin: "https://example.test", + scripts_observed: 3, + scripts_analyzed: 2, + source_artifacts: [], + }, + observations: { + chunks: { + nodes: [ + { + script_key: "app.js", + url: "https://example.test/app.js", + artifact_sha256: "a".repeat(64), + bytes: 1200, + }, + { + script_key: "vendor.js", + url: "https://example.test/vendor.js", + artifact_sha256: "b".repeat(64), + bytes: 4500, + }, + ], + edges: [], + }, + routes: [], + endpoints: [ + { + value: "/api/users", + mechanism: "call:fetch", + location: { script_key: "app.js", line: 10, column: 2 }, + }, + { + value: "/api/users", + mechanism: "call:fetch", + location: { script_key: "vendor.js", line: 42, column: 8 }, + }, + { + value: "/api/orders", + mechanism: "call:sendBeacon", + location: { script_key: "app.js", line: 85, column: 4 }, + }, + ], + webmcp_declarations: [], + source_maps: { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }, + }, + inferences: [ + { + kind: "endpoint_candidate", + value: "/api/dynamic-user", + confidence: "low", + basis: [ + { + script_key: "app.js", + artifact_sha256: "a".repeat(64), + line: 25, + column: 4, + detector: "call:client.get:unverified-network-receiver", + }, + ], + }, + { + kind: "vendor_fingerprint", + value: "React", + confidence: "high", + basis: [ + { + script_key: "vendor.js", + artifact_sha256: "b".repeat(64), + line: 1, + column: 0, + detector: "vendor:react", + }, + ], + }, + ], + unknowns: [ + { + dimension: "network", + reason: "Unverified client receiver", + affected_script_keys: ["app.js"], + }, + ], + completeness: { + status: "partial", + parsed_scripts: 2, + parse_failures: 1, + visited_ast_nodes: 150, + }, + limitations: ["Script evaluation was omitted"], +}); diff --git a/tests/fixtures/webSourceLocation.ts b/tests/fixtures/webSourceLocation.ts index 41744da508..15e988ca09 100644 --- a/tests/fixtures/webSourceLocation.ts +++ b/tests/fixtures/webSourceLocation.ts @@ -1,8 +1,10 @@ import { createHash } from "node:crypto"; -import type { WebSourceLocationArtifacts } from "../../src/application/WebSourceLocationPorts.js"; +import type { + WebSourceLocationArtifacts, + WebSourceMapPort, +} from "../../src/application/WebSourceLocationPorts.js"; import { webModuleArtifactsFixture } from "./webModuleTrace.js"; import { traceSourceMap } from "../../src/javascript/sourceMaps/TraceSourceMap.js"; -import type { WebSourceMapPort } from "../../src/application/WebSourceLocationPorts.js"; import { ok } from "../../src/domain/result.js"; /** Inert codec seam for workflow tests; real process verification is separate. */ diff --git a/tests/fixtures/wxapkg.ts b/tests/fixtures/wxapkg.ts index 58fa5211d0..9267ef2138 100644 --- a/tests/fixtures/wxapkg.ts +++ b/tests/fixtures/wxapkg.ts @@ -9,7 +9,7 @@ export interface WxapkgMember { readonly size?: number; } -export interface WxapkgOptions { +interface WxapkgOptions { readonly firstMark?: number; readonly lastMark?: number; readonly info?: number; diff --git a/tests/process-global/apkSignatureLifecycle.test.ts b/tests/process-global/apkSignatureLifecycle.test.ts index 407a61d82a..5972eb9f3a 100644 --- a/tests/process-global/apkSignatureLifecycle.test.ts +++ b/tests/process-global/apkSignatureLifecycle.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; const fault = vi.hoisted(() => ({ path: "", @@ -100,16 +101,19 @@ it("serializes reads across providers, preserves queued aborts and drains admitt resume: resume.promise, }; const reading = first.inspect({ path }); - await started.promise; - const queued = second.inspect({ path }); const controller = new AbortController(); const reason = new Error("cancel while queued"); - const cancelled = second.inspect({ path }, { signal: controller.signal }); + let queued: ReturnType | undefined; + let cancelled: ReturnType | undefined; + let closing: Promise | undefined; let closed = false; - const closing = second.close().then(() => { - closed = true; - }); try { + await awaitAcknowledgement(started.promise, reading, "APK signature read"); + queued = second.inspect({ path }); + cancelled = second.inspect({ path }, { signal: controller.signal }); + closing = second.close().then(() => { + closed = true; + }); controller.abort(reason); expect(await cancelled).toMatchObject({ ok: false, @@ -123,7 +127,12 @@ it("serializes reads across providers, preserves queued aborts and drains admitt expect(closed).toBe(true); } finally { resume.resolve(); - await Promise.all([reading, queued, cancelled, closing]); + await Promise.allSettled([ + reading, + ...(queued ? [queued] : []), + ...(cancelled ? [cancelled] : []), + ...(closing ? [closing] : []), + ]); await first.close(); await second.close(); } diff --git a/tests/process-global/environmentFinalizationCli.test.ts b/tests/process-global/environmentFinalizationCli.test.ts index feeb77af9d..0a7a5ce318 100644 --- a/tests/process-global/environmentFinalizationCli.test.ts +++ b/tests/process-global/environmentFinalizationCli.test.ts @@ -5,6 +5,7 @@ import type { ServedEnvironment } from "../../src/server/environment/serveEnviro import type { ServeStreamableHttpOptions } from "../../src/server/http/serveStreamableHttp.js"; import { StreamableHttpListenError } from "../../src/server/http/streamableHttpListenError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import { writeEnvironmentServeFiles } from "../fixtures/environmentServe.js"; const control = vi.hoisted(() => ({ @@ -130,7 +131,12 @@ it.each([ }, ); try { - await started; + await awaitAcknowledgement( + started, + pending, + "environment CLI startup", + 10_000, + ); const served = observedEnvironment(); if (served === undefined) throw new Error("Environment was not admitted"); if (ending === "signal") { diff --git a/tests/process-global/firmwareFileLifetime.test.ts b/tests/process-global/firmwareFileLifetime.test.ts index 4f54a555f6..6361594cb1 100644 --- a/tests/process-global/firmwareFileLifetime.test.ts +++ b/tests/process-global/firmwareFileLifetime.test.ts @@ -13,6 +13,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; const fault = vi.hoisted(() => ({ selected: "", @@ -903,7 +904,7 @@ it("waits through selected-range snapshot finalization when shutdown starts duri new AbortController().signal, ); try { - await entered; + await awaitAcknowledgement(entered, pending, "firmware source close"); let closed = false; const closing = files.close().then(() => { closed = true; @@ -1038,7 +1039,7 @@ it("shares one workspace cleanup attempt across concurrent shutdown callers", as blocked = false; const first = provider.close(); try { - await entered; + await awaitAcknowledgement(entered, first, "firmware workspace cleanup"); const second = provider.close(); let secondSettled = false; void second.then(() => { diff --git a/tests/process-global/firmwarePublicationDeadline.test.ts b/tests/process-global/firmwarePublicationDeadline.test.ts index e0dea71d36..0e247f21ed 100644 --- a/tests/process-global/firmwarePublicationDeadline.test.ts +++ b/tests/process-global/firmwarePublicationDeadline.test.ts @@ -2,6 +2,7 @@ import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult import { access, readFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import * as firmwareFiles from "../../src/firmware/FirmwareFiles.js"; import { SafeOutputTree } from "../../src/artifacts/SafeOutputTree.js"; import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; @@ -219,9 +220,10 @@ test("keeps each queued request's independent analysis and publication budgets", publication_timeout_ms: 123_456, analysis_timeout_ms: 234_567, }); + let queued: typeof pending | undefined; try { - await entered; - const queued = fixture.service.execute("extract_firmware", { + await awaitAcknowledgement(entered, pending, "firmware publication"); + queued = fixture.service.execute("extract_firmware", { path: fixture.path, output_directory: join(fixture.root, "second-output"), publication_timeout_ms: 654_321, @@ -246,8 +248,12 @@ test("keeps each queued request's independent analysis and publication budgets", expect(budgets).toEqual([234_567, 123_456, 765_432, 654_321]); } finally { release(); + try { + await Promise.allSettled([pending, queued]); + } finally { + await fixture.close(); + } } - await fixture.close(); await assertFirmwareCleanup(fixture.launches); }); diff --git a/tests/process-global/ghidraEndpointLifetime.test.ts b/tests/process-global/ghidraEndpointLifetime.test.ts index 8ea97201bd..cf13ffd30b 100644 --- a/tests/process-global/ghidraEndpointLifetime.test.ts +++ b/tests/process-global/ghidraEndpointLifetime.test.ts @@ -11,6 +11,7 @@ import { } from "node:fs/promises"; import { basename, join } from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; const fault = vi.hoisted(() => ({ path: "", @@ -243,7 +244,7 @@ it.skipIf(process.platform === "win32")( resourceScope: resources, }); operations.push(pending); - await observed; + await awaitAcknowledgement(observed, pending, "Ghidra endpoint read"); let closed = false; const closing = resources.close().then(() => { closed = true; diff --git a/tests/process-global/gpuFileLifetime.test.ts b/tests/process-global/gpuFileLifetime.test.ts index 3122e74e54..98ba4ede28 100644 --- a/tests/process-global/gpuFileLifetime.test.ts +++ b/tests/process-global/gpuFileLifetime.test.ts @@ -9,6 +9,7 @@ import { gpuCodeObjectsSchema, } from "../../src/domain/gpu/gpuCodeObjects.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import { amdFixture, cudaFixture } from "../fixtures/gpu/codeObjects.js"; type ReadFile = ( buffer: Buffer, @@ -231,7 +232,7 @@ it("joins an admitted file read during shutdown and rejects overlapping work", a let closing: Promise | undefined; try { active = provider.inspect({ path, analysis: "kernels" }); - await admitted; + await awaitAcknowledgement(admitted, active, "GPU file read admission"); let closed = false; closing = provider.close().then(() => { closed = true; @@ -249,10 +250,16 @@ it("joins an admitted file read during shutdown and rejects overlapping work", a expect(closed).toBe(true); } finally { release(); - await active; - await closing; - spy.mockRestore(); - await provider.close(); + try { + try { + await active; + } finally { + await closing; + } + } finally { + spy.mockRestore(); + await provider.close(); + } } }); diff --git a/tests/process-global/mainStartupCancellation.test.ts b/tests/process-global/mainStartupCancellation.test.ts index e1a3fec4b8..7c9c4ed83f 100644 --- a/tests/process-global/mainStartupCancellation.test.ts +++ b/tests/process-global/mainStartupCancellation.test.ts @@ -12,6 +12,7 @@ import { run } from "../../src/main.js"; import { registerProcessShutdown } from "../../src/main/processShutdown.js"; import { ownStdioStartupInput } from "../../src/main/StdioStartupInput.js"; import { MCP_SHUTDOWN_FAILED } from "../../src/main/messages.js"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import { createDeferred, createTestBinarySession, @@ -107,37 +108,49 @@ it.each([ registerShutdown: (handler) => registerProcessShutdown(handler, { signals, stdin: input }), }); - const signal = await entered.promise; - expect(signals.listenerCount("SIGINT")).toBe(1); - expect(signals.listenerCount("SIGTERM")).toBe(1); - if (trigger === "EOF") input.end(); - else if (trigger === "CAPACITY") - input.write(Buffer.alloc(STDIO_DEFAULT_MAX_BUFFER_SIZE + 1)); - else if (trigger === "READ_ERROR") - input.emit("error", new Error("controlled stdin read failure")); - else signals.emit(trigger); - expect(await pending).toBe(failedClose ? 1 : 0); - expect(signal.aborted).toBe(true); - expect(serve).not.toHaveBeenCalled(); - expect(close).toHaveBeenCalledWith({ retainProviderDocuments: true }); - if (failedClose) { - expect(output.join("")).toContain(MCP_SHUTDOWN_FAILED); - if (trigger === "CAPACITY" || trigger === "READ_ERROR") { - const primary = - trigger === "CAPACITY" - ? "MCP input exceeded" - : "MCP stdin could not be read"; - expect(output.join("")).toContain(primary); - expect(output.join("").indexOf(primary)).toBeLessThan( - output.join("").indexOf(MCP_SHUTDOWN_FAILED), - ); - } + try { + await awaitAcknowledgement( + entered.promise.then(() => undefined), + pending, + "initial target open", + 10_000, + ); + const signal = await entered.promise; + expect(signals.listenerCount("SIGINT")).toBe(1); + expect(signals.listenerCount("SIGTERM")).toBe(1); + if (trigger === "EOF") input.end(); + else if (trigger === "CAPACITY") + input.write(Buffer.alloc(STDIO_DEFAULT_MAX_BUFFER_SIZE + 1)); + else if (trigger === "READ_ERROR") + input.emit("error", new Error("controlled stdin read failure")); + else signals.emit(trigger); + expect(await pending).toBe(failedClose ? 1 : 0); + expect(signal.aborted).toBe(true); + expect(serve).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledWith({ retainProviderDocuments: true }); + if (failedClose) { + expect(output.join("")).toContain(MCP_SHUTDOWN_FAILED); + if (trigger === "CAPACITY" || trigger === "READ_ERROR") { + const primary = + trigger === "CAPACITY" + ? "MCP input exceeded" + : "MCP stdin could not be read"; + expect(output.join("")).toContain(primary); + expect(output.join("").indexOf(primary)).toBeLessThan( + output.join("").indexOf(MCP_SHUTDOWN_FAILED), + ); + } + closeFails = false; + signals.emit("SIGINT"); + await expect.poll(() => signals.listenerCount("SIGINT")).toBe(0); + } else expect(output).toEqual([]); + expect(signals.listenerCount("SIGTERM")).toBe(0); + } finally { closeFails = false; signals.emit("SIGINT"); - await expect.poll(() => signals.listenerCount("SIGINT")).toBe(0); - } else expect(output).toEqual([]); - expect(signals.listenerCount("SIGTERM")).toBe(0); - input.destroy(); + input.destroy(); + await pending.catch(() => undefined); + } }, ); @@ -173,25 +186,36 @@ it.each(["optional", "late-handle"] as const)( registerShutdown: (handler) => registerProcessShutdown(handler, { signals, stdin: input }), }); - await entered.promise; - if (stage === "optional") { - await new Promise((resolve) => { - input.once("end", resolve); - input.end(); + try { + await awaitAcknowledgement( + entered.promise, + pending, + "startup stage", + 10_000, + ); + if (stage === "optional") { + await new Promise((resolve) => { + input.once("end", resolve); + input.end(); + }); + } else signals.emit("SIGTERM"); + expect(closeHandle).not.toHaveBeenCalled(); + expect(closeSession).not.toHaveBeenCalled(); + release.resolve(); + expect(await pending).toBe(0); + expect(serve).toHaveBeenCalledTimes(stage === "optional" ? 0 : 1); + expect(closeHandle).toHaveBeenCalledTimes(stage === "optional" ? 0 : 1); + expect(closeSession).toHaveBeenCalledTimes(1); + expect(closeSession).toHaveBeenCalledWith({ + retainProviderDocuments: true, }); - } else signals.emit("SIGTERM"); - expect(closeHandle).not.toHaveBeenCalled(); - expect(closeSession).not.toHaveBeenCalled(); - release.resolve(); - expect(await pending).toBe(0); - expect(serve).toHaveBeenCalledTimes(stage === "optional" ? 0 : 1); - expect(closeHandle).toHaveBeenCalledTimes(stage === "optional" ? 0 : 1); - expect(closeSession).toHaveBeenCalledTimes(1); - expect(closeSession).toHaveBeenCalledWith({ - retainProviderDocuments: true, - }); - expect(signals.listenerCount("SIGINT")).toBe(0); - input.destroy(); + expect(signals.listenerCount("SIGINT")).toBe(0); + } finally { + signals.emit("SIGTERM"); + release.resolve(); + input.destroy(); + await pending.catch(() => undefined); + } }, ); diff --git a/tests/process-global/pwntoolsCleanup.test.ts b/tests/process-global/pwntoolsCleanup.test.ts index 7735c8ba9a..b7e35683b1 100644 --- a/tests/process-global/pwntoolsCleanup.test.ts +++ b/tests/process-global/pwntoolsCleanup.test.ts @@ -15,6 +15,7 @@ import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js" import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; import { stopProviderProcessFixture } from "../fixtures/providerProcess.js"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { createTestWorkspace, @@ -265,7 +266,7 @@ it.runIf(supportedHost)( await provider.close(); }); const first = provider.inspect({ path }); - await started; + await awaitAcknowledgement(started, first, "pwntools launch", 10_000); const controller = new AbortController(); const cancelled = provider.inspect({ path }, { signal: controller.signal }); const reason = { stage: "queued" }; diff --git a/tests/process-global/referenceSourceCancellation.test.ts b/tests/process-global/referenceSourceCancellation.test.ts index 87fc63e5c5..553cc54d34 100644 --- a/tests/process-global/referenceSourceCancellation.test.ts +++ b/tests/process-global/referenceSourceCancellation.test.ts @@ -113,11 +113,14 @@ it.each([ } finally { held?.release(); releaseClose?.(); - await running; - fileClose.mockRestore(); - scopeClose.mockRestore(); - vi.restoreAllMocks(); - process.exitCode = previousExitCode; + try { + await running; + } finally { + fileClose.mockRestore(); + scopeClose.mockRestore(); + vi.restoreAllMocks(); + process.exitCode = previousExitCode; + } } expect(await readFile(file, "utf8")).toBe(contents); }, diff --git a/tests/process-global/setupConfigurationConflicts.test.ts b/tests/process-global/setupConfigurationConflicts.test.ts index 6be1b320f4..1600b43e0a 100644 --- a/tests/process-global/setupConfigurationConflicts.test.ts +++ b/tests/process-global/setupConfigurationConflicts.test.ts @@ -11,6 +11,7 @@ import { } from "node:fs/promises"; import { join, relative } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; const writers = vi.hoisted(() => ({ before: undefined as undefined | ((path: string) => Promise), @@ -371,9 +372,14 @@ it.skipIf(process.platform === "win32")( }, { FIRST: "1" }, ); + let second: ReturnType | undefined; try { - await started; - const second = configureClientConfiguration( + await awaitAcknowledgement( + started, + first, + "setup configuration writer", + ); + second = configureClientConfiguration( { name: "cursor", registrationProfile: supportedClientRegistrationProfile("cursor"), @@ -413,6 +419,7 @@ it.skipIf(process.platform === "win32")( } finally { releaseFirst(); writers.before = undefined; + await Promise.allSettled([first, second]); } } }, @@ -466,26 +473,35 @@ it("releases a failed setup transaction so a queued request can configure the fi }; const first = configureClientConfiguration(client, { FIRST: "1" }); - await started; - const second = configureClientConfiguration(client, { SECOND: "2" }); - releaseWriter(); - const [failed, queued] = await Promise.all([first, second]); + let second: ReturnType | undefined; + try { + await awaitAcknowledgement(started, first, "setup configuration writer"); + second = configureClientConfiguration(client, { SECOND: "2" }); + releaseWriter(); + const [failed, queued] = await Promise.all([first, second]); - expect(failed).toMatchObject({ - status: "failed", - reason: "write", - failure: { - operation: "publish", - path, - detail: "The first writer could not stage its configuration", - }, - }); - expect(queued.status).toBe("configured"); - expect(JSON.parse(await readFile(path, "utf8")).mcpServers.rea.env).toEqual({ - SECOND: "2", - }); - expect(await readdir(root)).toEqual(["config.json"]); - expect(await configureClientConfiguration(client)).toEqual({ - status: "unchanged", - }); + expect(failed).toMatchObject({ + status: "failed", + reason: "write", + failure: { + operation: "publish", + path, + detail: "The first writer could not stage its configuration", + }, + }); + expect(queued.status).toBe("configured"); + expect(JSON.parse(await readFile(path, "utf8")).mcpServers.rea.env).toEqual( + { + SECOND: "2", + }, + ); + expect(await readdir(root)).toEqual(["config.json"]); + expect(await configureClientConfiguration(client)).toEqual({ + status: "unchanged", + }); + } finally { + releaseWriter(); + writers.before = undefined; + await Promise.allSettled([first, second]); + } }); diff --git a/tests/process-global/setupConfigurationRollback.test.ts b/tests/process-global/setupConfigurationRollback.test.ts index 48357c8a41..27e8b4e245 100644 --- a/tests/process-global/setupConfigurationRollback.test.ts +++ b/tests/process-global/setupConfigurationRollback.test.ts @@ -156,6 +156,7 @@ import { systemUninstallHost } from "../../src/application/Uninstall.js"; import { ConfigurationError } from "../../src/domain/configurationErrors.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { createDeferred } from "../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import { supportedClientRegistrationProfile } from "../../src/application/SupportedClients.js"; const ownReadHandle = async (path: string) => { @@ -854,18 +855,25 @@ it("uninstall close joins a publication already admitted by its host", async () configPath: path, format: "json", }); - await arrived.promise; let closed = false; - const closing = host.close?.().then((result) => { - closed = true; - return result; - }); + let closing: ReturnType> | undefined; try { + await awaitAcknowledgement( + arrived.promise, + removal, + "configuration publication", + ); + closing = host.close?.().then((result) => { + closed = true; + return result; + }); await new Promise((resolve) => setImmediate(resolve)); expect(closed).toBe(false); expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0); } finally { release.resolve(); + await Promise.allSettled([removal, ...(closing ? [closing] : [])]); + await host.close?.(); } expect((await removal).status).toBe("failed"); expect(await closing).toBeUndefined(); @@ -1035,7 +1043,11 @@ it("uninstall progresses when a peer leaves a denied-close descriptor pending", }); let timeout: ReturnType | undefined; try { - await arrived.promise; + await awaitAcknowledgement( + arrived.promise, + removal, + "configuration publication", + ); state.initialCleanupFailure = new RegularFileCleanupFailure({ path: other, owner, diff --git a/tests/process-global/shortFileReads.test.ts b/tests/process-global/shortFileReads.test.ts index 94f0d6d212..3d16a688c3 100644 --- a/tests/process-global/shortFileReads.test.ts +++ b/tests/process-global/shortFileReads.test.ts @@ -337,7 +337,7 @@ it("keeps fuse sentinel offsets, states and complete-file digest across short re fault.path = path; fault.maximum = 2; expect(await provider.inspect({ path })).toEqual(expected); - const scanned = await readElectronFuseImage(path, { chunkBytes: 7 }); + const scanned = await readElectronFuseImage(path); expect(scanned.sha256).toBe( createHash("sha256").update(bytes).digest("hex"), ); @@ -413,7 +413,8 @@ it.each(["cancelled", "eof", "io"] as const)( async (outcome) => { const root = await createTestTempDirectory("rea-fuse-scan-short-read-"); const path = join(root, "Electron"); - await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + const image = thinFuseImage(OBSIDIAN_FUSE_BYTES); + await writeFile(path, image.bytes); const controller = new AbortController(); const physicalFailure = Object.assign(new Error("scan read failed"), { code: "EIO", @@ -422,9 +423,8 @@ it.each(["cancelled", "eof", "io"] as const)( const scanPositions: number[] = []; fault.path = path; fault.beforeRead = (position, length) => { - // Header reads use their format windows. A seven-byte read at zero - // starts this invocation's explicitly selected scan window. - if (position === 0 && length === 7) { + // Header reads use their format windows; the scan starts with the full file. + if (position === 0 && length === image.bytes.length) { scanning = true; fault.maximum = 2; } @@ -440,7 +440,6 @@ it.each(["cancelled", "eof", "io"] as const)( }; const failure: unknown = await readElectronFuseImage(path, { signal: controller.signal, - chunkBytes: 7, }).catch((cause: unknown) => cause); expect(scanning).toBe(true); expect(scanPositions).toEqual([0, 2, 4]); diff --git a/tests/process-global/updateOutput.test.ts b/tests/process-global/updateOutput.test.ts index 2620264f05..d74be6b2d7 100644 --- a/tests/process-global/updateOutput.test.ts +++ b/tests/process-global/updateOutput.test.ts @@ -5,6 +5,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { setImmediate as nextTurn } from "node:timers/promises"; import { Writable } from "node:stream"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; const execution = vi.hoisted(() => ({ spawn: vi.fn() })); @@ -189,14 +190,19 @@ it("stops and joins metadata discovery when the update host closes", async () => const ready = nextChildReady(hangingProbe); const host = systemUpdateHost(process.cwd(), process.cwd(), process.env); const probing = host.installation(); - await ready; - expect(child?.exitCode).toBeNull(); - expect(host.close).toBeTypeOf("function"); - if (host.close === undefined) throw new Error("Missing update host close"); - expect(await host.close()).toBeUndefined(); - // Close must join the probe itself, not rely on the later probe deadline. - expect(child?.signalCode).not.toBeNull(); - await probing; + try { + await awaitAcknowledgement(ready, probing, "update probe readiness"); + expect(child?.exitCode).toBeNull(); + expect(host.close).toBeTypeOf("function"); + if (host.close === undefined) throw new Error("Missing update host close"); + expect(await host.close()).toBeUndefined(); + // Close must join the probe itself, not rely on the later probe deadline. + expect(child?.signalCode).not.toBeNull(); + await probing; + } finally { + await host.close?.(); + await probing; + } }); it("bounds a stalled metadata probe and keeps the runner usable", async () => { @@ -204,7 +210,7 @@ it("bounds a stalled metadata probe and keeps the runner usable", async () => { updateCommands = new UpdateCommandRunner(2000); const ready = nextChildReady(); const pending = updateCommands.runProbe(command(hangingProbe), process.env); - await ready; + await awaitAcknowledgement(ready, pending, "update probe readiness"); const result = await pending; expect(result).toMatchObject({ ok: false, @@ -233,7 +239,7 @@ it("does not launch probes after close, including a queued launch", async () => it("retains incomplete probe cleanup until a later close succeeds", async () => { const ready = nextChildReady(); const pending = updateCommands.runProbe(command(hangingProbe), process.env); - await ready; + await awaitAcknowledgement(ready, pending, "update probe readiness"); const stop = vi .spyOn(ProviderProcessSupervisor.prototype, "stop") .mockResolvedValue({ @@ -267,20 +273,25 @@ it("joins an install past the metadata deadline without cancelling it", async () process.env, "structured", ); - await ready; + await awaitAcknowledgement(ready, pending, "update install readiness"); await updateCommands.close(); expect(await pending).toEqual({ ok: true, value: "" }); expect(child?.exitCode).toBe(0); }); it("preserves cancellation during final progress acknowledgement", async () => { - let acknowledge!: () => void; + let acknowledge: (() => void) | undefined; + let releasing = false; let writing!: () => void; const started = new Promise((resolve) => { writing = resolve; }); const destination = new Writable({ write(_chunk, _encoding, callback) { + if (releasing) { + callback(); + return; + } acknowledge = callback; writing(); }, @@ -293,12 +304,23 @@ it("preserves cancellation during final progress acknowledgement", async () => { process.env, "human", ); - await started; - const closing = updateCommands.close(); - acknowledge(); - expect(await pending).toMatchObject({ - ok: false, - error: { detail: expect.stringContaining("cancelled") }, - }); - await closing; + try { + await awaitAcknowledgement(started, pending, "update progress write"); + const closing = updateCommands.close(); + acknowledge?.(); + acknowledge = undefined; + expect(await pending).toMatchObject({ + ok: false, + error: { detail: expect.stringContaining("cancelled") }, + }); + await closing; + } finally { + releasing = true; + acknowledge?.(); + try { + await updateCommands.close(); + } finally { + await pending; + } + } });