From c836f57b7d72e6843c160e32afd6f7e14be70fdf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:05:32 +0800 Subject: [PATCH] refactor: consolidate shared analysis boundaries (#10293) --- bridge/cutter/rea_bridge.py | 9 - .../ClientRegistrationEnvironment.ts | 56 ++---- src/application/ClientRegistrationStatus.ts | 2 +- src/application/DshCordisPatch.ts | 49 ++--- src/application/MipsGnuAttributes.ts | 22 +-- .../RegistrationEnvironmentValidation.ts | 49 +++++ src/application/SetupSkill.ts | 4 +- src/application/Uninstall.ts | 2 +- .../analysisView/AnalysisViewService.ts | 16 +- .../binary/BinarySessionExecution.ts | 2 +- .../desktop/DesktopBuildCapture.ts | 11 +- src/application/ownedProcessAvailability.ts | 8 +- src/application/process/ProcessCli.ts | 9 +- .../process/ProcessComparisonService.ts | 26 ++- src/artifacts/ArtifactCarrierRange.ts | 55 ++++++ src/artifacts/ArtifactPreparation.test.ts | 81 ++++++++ src/artifacts/ArtifactPreparation.ts | 40 ++++ src/artifacts/BunStandaloneArtifactReader.ts | 41 +--- src/artifacts/DecodedRegionArtifactReader.ts | 36 +--- src/artifacts/DenoStandaloneArtifactReader.ts | 51 ++--- src/artifacts/FitArtifactReader.ts | 36 +--- src/artifacts/NtfsMftArtifactReader.test.ts | 42 +++++ src/artifacts/NtfsMftArtifactReader.ts | 36 +--- src/artifacts/PositionalArtifactReader.ts | 39 +--- src/artifacts/UImageArtifactReader.ts | 36 +--- src/artifacts/UnityFsArtifactReader.ts | 36 +--- src/artifacts/WxapkgArtifactReader.ts | 36 +--- src/artifacts/deno/DenoStandaloneLocation.ts | 62 +------ src/artifacts/deno/DenoVirtualFs.ts | 10 +- src/artifacts/nodeSea/NodeSeaLocation.ts | 63 +------ src/captures/TsharkCaptureProvider.ts | 4 +- src/cli/analysisEvidenceDelivery.ts | 23 +-- src/cli/androidCommands.ts | 25 +-- src/cli/apkSignatureCommands.ts | 28 +-- src/cli/cliJsonOption.ts | 21 +++ src/cli/gpuCommands.ts | 26 +-- src/cli/modelCommands.ts | 29 +-- src/cli/rustCommands.ts | 29 +-- src/contracts/compactJsonSchema.ts | 21 +-- src/contracts/inputSchemaPresentation.ts | 24 +-- src/contracts/jsonSchemaKeywords.ts | 21 +++ src/domain/analysisView/analysisView.ts | 70 +------ src/domain/analysisView/analysisViewParent.ts | 14 ++ src/domain/analysisView/managedMemberView.ts | 18 +- src/domain/desktop/jsonRecordPairing.ts | 9 +- src/domain/elfAttributeUleb.ts | 17 ++ .../javascript/javascriptModuleDeclaration.ts | 4 + .../javascript/javascriptSemanticAnalysis.ts | 5 +- .../javascript/javascriptSemanticResources.ts | 33 ++-- .../javascript/javascriptStaticAnalysis.ts | 7 +- .../managed/managedInspectionEvidence.ts | 29 ++- src/domain/riscvIsaDeclaration.ts | 22 +-- src/domain/webModuleImports.ts | 5 +- src/flutter/FlutterBuildProvider.ts | 4 +- src/kuna/KunaSessionClient.ts | 9 +- src/native/pe/PeBinaryFailure.ts | 154 +++++++++++++++ src/native/pe/PeImportParser.ts | 26 +-- src/native/pe/PeImportsProvider.ts | 152 ++------------- src/native/pe/PeLoadConfigParser.test.ts | 4 + src/native/pe/PeLoadConfigParser.ts | 24 +-- src/native/pe/PeLoadConfigProvider.ts | 153 ++------------- src/native/pe/PeRvaSpans.ts | 22 +++ src/signals/SigrokRecordingProvider.ts | 4 +- .../apkSignatureViewsCliMcp.test.ts | 31 ++++ tests/acceptance/rustBinaryCli.test.ts | 31 ++++ .../cli/androidPackageViewCli.test.ts | 33 ++++ .../filesystem/dshClientConfiguration.test.ts | 32 ++++ .../embeddedCarrierCancellation.test.ts | 32 ++++ .../mcp/managedMemberSummaryMcp.test.ts | 175 ++++++++++++++++++ tests/fixtures/cutter_bridge_protocol_test.py | 36 ++-- 70 files changed, 1240 insertions(+), 1131 deletions(-) create mode 100644 src/application/RegistrationEnvironmentValidation.ts create mode 100644 src/artifacts/ArtifactCarrierRange.ts create mode 100644 src/artifacts/ArtifactPreparation.test.ts create mode 100644 src/artifacts/ArtifactPreparation.ts create mode 100644 src/cli/cliJsonOption.ts create mode 100644 src/domain/elfAttributeUleb.ts create mode 100644 src/native/pe/PeBinaryFailure.ts create mode 100644 src/native/pe/PeRvaSpans.ts create mode 100644 tests/boundary/filesystem/embeddedCarrierCancellation.test.ts diff --git a/bridge/cutter/rea_bridge.py b/bridge/cutter/rea_bridge.py index cc3dd507b5..d7ffd3cb03 100644 --- a/bridge/cutter/rea_bridge.py +++ b/bridge/cutter/rea_bridge.py @@ -958,15 +958,6 @@ class _BridgeServer(threading.Thread): proof.update(fragment.encode("utf-8")) return proof.hexdigest() - def _encode_authenticated_response( - self, value: dict[str, Any], client_nonce: str, server_nonce: str - ) -> bytes | None: - """Return one exact frame. The socket path writes the same pieces.""" - proof = self._authenticated_proof(value, client_nonce, server_nonce) - if proof is None: - return None - return b"".join(self._iter_authenticated_frame(value, proof)) - def _iter_authenticated_frame(self, value: dict[str, Any], proof: str): yield _AUTHENTICATED_PREFIX for fragment in _response_fragments(value): diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 70dc16fe19..d8f4617090 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -1,15 +1,9 @@ +import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js"; import { clientRegistrationSource } from "./ClientRegistrationSource.js"; import type { SetupClient } from "./SupportedClients.js"; -import { - inspectAnalysisSettingSeeds, - launchAnalysisSetting, -} from "../config/analysisSettings.js"; -import { - parseEnvironment, - parseEnvironmentValues, - type Environment, -} from "../config/environment.js"; +import { launchAnalysisSetting } from "../config/analysisSettings.js"; +import type { Environment } from "../config/environment.js"; import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS, DEFAULT_HOPPER_STARTUP_TIMEOUT_MS, @@ -314,34 +308,24 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async ( const validatedDetails = ( environment: Readonly>, validation: "fragment" | "effective", - platform: NodeJS.Platform = process.platform, - expressionSettings: ReadonlySet = new Set(), + platform: NodeJS.Platform, + expressionSettings: ReadonlySet, ): Result => { - const runtimeEnvironment = snapshotEnvironment(environment, platform); - const unresolved = new Set( - Object.keys( - snapshotEnvironment( - Object.fromEntries([...expressionSettings].map((name) => [name, ""])), - platform, - ), - ), + const validated = validateRegistrationEnvironment( + environment, + expressionSettings, + validation, + platform, ); - const validated = - validation === "fragment" - ? parseEnvironmentValues(runtimeEnvironment, unresolved) - : parseEnvironment(runtimeEnvironment, unresolved); - if (!validated.ok) return validated; - const seedIssues = inspectAnalysisSettingSeeds( - runtimeEnvironment, - unresolved, - ); - if (seedIssues.length > 0) - return err( - new ConfigurationError("Invalid REA environment configuration", { - settings: seedIssues, - }), - ); - return ok(environmentDetails(environment, validated.value, unresolved)); + return validated.ok + ? ok( + environmentDetails( + environment, + validated.value.configuration, + validated.value.unresolvedSettings, + ), + ) + : validated; }; const admittedTimeout = ( @@ -397,7 +381,7 @@ const registrationTimeoutInputs = ( const environmentDetails = ( environment: Readonly>, configuration: Environment, - unresolvedSettings: ReadonlySet = new Set(), + unresolvedSettings: ReadonlySet, ): ClientRegistrationEnvironmentDetails => ({ values: Object.fromEntries( Object.entries(environment).sort(([left], [right]) => diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 51a90e8ff3..a55ea4f4ae 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -649,7 +649,7 @@ const packageVersionRemediation = ( const unavailableStatus = ( client: SetupClient, state: "missing" | "invalid", - configPath: string = client.configPath, + configPath: string, ): ClientRegistrationStatus => ({ client: client.name, config_path: configPath, diff --git a/src/application/DshCordisPatch.ts b/src/application/DshCordisPatch.ts index 794f637a78..ab1f0cf065 100644 --- a/src/application/DshCordisPatch.ts +++ b/src/application/DshCordisPatch.ts @@ -1,18 +1,11 @@ -import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js"; +import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js"; import { isMap, isSeq, type Node, type YAMLMap } from "yaml"; -import { - parseEnvironment, - parseEnvironmentValues, - type Environment, -} from "../config/environment.js"; +import type { Environment } from "../config/environment.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js"; import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js"; -import { - mergeEnvironmentOverrides, - snapshotEnvironment, -} from "../process/snapshotEnvironment.js"; +import { mergeEnvironmentOverrides } from "../process/snapshotEnvironment.js"; export const DSH_ENTRY_ID = "rea-mcp"; export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client"; @@ -255,35 +248,15 @@ export class DshCordisDocument { ); literals[key] = value; } - const runtimeEnvironment = snapshotEnvironment(literals, platform); - const unresolved = new Set( - Object.keys( - snapshotEnvironment( - Object.fromEntries([...unresolvedSettings].map((key) => [key, ""])), - platform, - ), - ), + const validated = validateRegistrationEnvironment( + literals, + unresolvedSettings, + validation, + platform, ); - const validated = - validation === "fragment" - ? parseEnvironmentValues(runtimeEnvironment, unresolved) - : parseEnvironment(runtimeEnvironment, unresolved); - if (!validated.ok) return validated; - const seedIssues = inspectAnalysisSettingSeeds( - runtimeEnvironment, - unresolved, - ); - if (seedIssues.length > 0) - return err( - new ConfigurationError("Invalid REA environment configuration", { - settings: seedIssues, - }), - ); - return ok({ - values: literals, - configuration: validated.value, - unresolvedSettings: unresolved, - }); + return validated.ok + ? ok({ values: literals, ...validated.value }) + : validated; } desired( diff --git a/src/application/MipsGnuAttributes.ts b/src/application/MipsGnuAttributes.ts index 1d39f9bbbb..07be0b499b 100644 --- a/src/application/MipsGnuAttributes.ts +++ b/src/application/MipsGnuAttributes.ts @@ -1,3 +1,4 @@ +import { readElfAttributeUleb } from "../domain/elfAttributeUleb.js"; import type { MipsGnuAttributes } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -22,21 +23,6 @@ export const parseMipsGnuAttributes = ( const readU32 = (offset: number): number => littleEndian ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); - const readUleb = ( - offset: number, - end: number, - ): { readonly value: number; readonly next: number } | undefined => { - let value = 0; - for (let index = 0; index < 5 && offset + index < end; index += 1) { - const byte = bytes[offset + index]!; - value += (byte & 0x7f) * 2 ** (7 * index); - if ((byte & 0x80) === 0) - return value <= 0xffffffff - ? { value, next: offset + index + 1 } - : undefined; - } - return undefined; - }; let fpAbi: number | null = null; let msa: number | null = null; @@ -65,7 +51,7 @@ export const parseMipsGnuAttributes = ( let cursor = vendorEnd + 1; while (cursor < end) { const scopeStart = cursor; - const scope = readUleb(cursor, end); + const scope = readElfAttributeUleb(bytes, cursor, end); if (scope === undefined) return err("malformed MIPS GNU attribute scope tag"); if (scope.value !== TAG_FILE) @@ -85,7 +71,7 @@ export const parseMipsGnuAttributes = ( const scopeEnd = scopeStart + scopeLength; cursor = scope.next + 4; while (cursor < scopeEnd) { - const tag = readUleb(cursor, scopeEnd); + const tag = readElfAttributeUleb(bytes, cursor, scopeEnd); if (tag === undefined) return err("malformed MIPS GNU attribute tag"); if ( tag.value !== TAG_GNU_MIPS_ABI_FP && @@ -94,7 +80,7 @@ export const parseMipsGnuAttributes = ( return err( `MIPS ELF declares GNU attribute tag ${tag.value}, which REA does not inspect`, ); - const value = readUleb(tag.next, scopeEnd); + const value = readElfAttributeUleb(bytes, tag.next, scopeEnd); if (value === undefined) return err("malformed MIPS GNU attribute value"); if (tag.value === TAG_GNU_MIPS_ABI_FP) { diff --git a/src/application/RegistrationEnvironmentValidation.ts b/src/application/RegistrationEnvironmentValidation.ts new file mode 100644 index 0000000000..9520358d49 --- /dev/null +++ b/src/application/RegistrationEnvironmentValidation.ts @@ -0,0 +1,49 @@ +import { inspectAnalysisSettingSeeds } from "../config/analysisSettings.js"; +import { + parseEnvironment, + parseEnvironmentValues, + type Environment, +} from "../config/environment.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; + +/** Validate literal registration settings while retaining expression-owned unknowns. */ +export const validateRegistrationEnvironment = ( + values: Readonly>, + expressionSettings: ReadonlySet, + validation: "fragment" | "effective", + platform: NodeJS.Platform, +): Result< + { + readonly configuration: Environment; + readonly unresolvedSettings: ReadonlySet; + }, + ConfigurationError +> => { + const runtimeEnvironment = snapshotEnvironment(values, platform); + const unresolvedSettings = new Set( + Object.keys( + snapshotEnvironment( + Object.fromEntries([...expressionSettings].map((name) => [name, ""])), + platform, + ), + ), + ); + const validated = + validation === "fragment" + ? parseEnvironmentValues(runtimeEnvironment, unresolvedSettings) + : parseEnvironment(runtimeEnvironment, unresolvedSettings); + if (!validated.ok) return validated; + const seedIssues = inspectAnalysisSettingSeeds( + runtimeEnvironment, + unresolvedSettings, + ); + if (seedIssues.length > 0) + return err( + new ConfigurationError("Invalid REA environment configuration", { + settings: seedIssues, + }), + ); + return ok({ configuration: validated.value, unresolvedSettings }); +}; diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index f66e7248e7..c4aeb7a30c 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -420,8 +420,8 @@ export const validateCanonicalSkillDirectories = async ( const canonicalSkillFiles = async ( home: string, clientIds: readonly string[], - environment: Readonly = {}, - platform: NodeJS.Platform = process.platform, + environment: Readonly, + platform: NodeJS.Platform, resources?: ArtifactResourceScope, ): Promise => { const destinations = skillDestinations( diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index a2060ba3aa..5cc8a452db 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -872,7 +872,7 @@ const readClientConfiguration = async ( client: UninstallClient, fileSystem: UninstallFileSystem, resources: ArtifactResourceScope, - ownershipEstablished = false, + ownershipEstablished: boolean, ): Promise => { const applicabilityUnverified = client.applicability === "unverified" && !ownershipEstablished; diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 243bb323b1..c84b91a885 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -17,7 +17,7 @@ import { AnalysisCapabilityUnavailableError, } from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; -import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { validateAnalysisViewSubject } from "../../domain/analysisView/analysisViewParent.js"; import { safeParseEvidence } from "../../domain/evidenceValidation.js"; import { createEvidence, @@ -249,15 +249,11 @@ const projectResolvedAnalysisView = ( }, }); if (!projected.ok) return projected; - if ( - parent.subject !== null && - parent.subject.digest.sha256 !== projected.value.artifact?.sha256 - ) - return err( - new EvidenceIntegrityError( - `Parent subject SHA-256 ${parent.subject.digest.sha256} differs from analysis artifact SHA-256 ${projected.value.artifact?.sha256 ?? "unknown"}.`, - ), - ); + const identity = validateAnalysisViewSubject( + parent.subject, + projected.value.artifact?.sha256, + ); + if (!identity.ok) return identity; return ok(createAnalysisViewEvidence(input, presentation, projected.value)); }; diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index bb8c0726af..b4d346d2fd 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -60,7 +60,7 @@ interface PrepareSessionExecutionInput { /** Recovery for an operation absent from the selected binary route. */ const undeclaredOperationGuidance = ( operation: string, - entryCommentAvailable = false, + entryCommentAvailable: boolean, ): string => { const entryComment = entryCommentAvailable && diff --git a/src/application/desktop/DesktopBuildCapture.ts b/src/application/desktop/DesktopBuildCapture.ts index ee5515930c..c3652f4fed 100644 --- a/src/application/desktop/DesktopBuildCapture.ts +++ b/src/application/desktop/DesktopBuildCapture.ts @@ -235,10 +235,6 @@ type CheckpointTaken = Omit< readonly more_changed_files: number | null; }; -/** The capture's progress event in a line the controller waited for. */ -const eventOf = (line: string): DesktopCaptureEvent | undefined => - captureProgressEvent(line); - /** * One capture in the computer. `unreleased` is a capture that may be * running although its start failed, kept until a stop is confirmed; @@ -441,7 +437,8 @@ export class DesktopBuildCapture { ), answer, }); - const event = answer.line === undefined ? undefined : eventOf(answer.line); + const event = + answer.line === undefined ? undefined : captureProgressEvent(answer.line); if (event?.kind !== "window_open") return err({ failure: await endedCapture( @@ -501,7 +498,9 @@ export class DesktopBuildCapture { { timeoutS: 600, signal }, ); const event = - answer.line === undefined ? undefined : eventOf(answer.line); + answer.line === undefined + ? undefined + : captureProgressEvent(answer.line); if (event?.kind === "checkpoint_refused") return err(refused(MARK, event.reason, "Use another checkpoint name.")); if (event?.kind !== "checkpoint") diff --git a/src/application/ownedProcessAvailability.ts b/src/application/ownedProcessAvailability.ts index 6b81dc8e49..9a6004f5a8 100644 --- a/src/application/ownedProcessAvailability.ts +++ b/src/application/ownedProcessAvailability.ts @@ -4,14 +4,12 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { + DARWIN_PROCESS_OWNERSHIP_REMEDIATION, LINUX_PROCESS_OWNERSHIP_REMEDIATION, LinuxOwnedProcessPrerequisiteError, } from "../process/ProcessOwnershipObservation.js"; import type { ProviderAvailability } from "./AnalysisProvider.js"; -const DARWIN_REMEDIATION = - "Check the selected Xcode or Command Line Tools and Swift compiler configuration, then restart REA and retry."; - // Provider adapters re-attribute capability errors and keep the original as cause. const prerequisiteRefusal = ( cause: unknown, @@ -94,8 +92,8 @@ export const ownedProcessPrerequisiteAvailability = ( return { status: "unavailable", code: "runtime_unavailable", - reason: `${darwin.message}. ${DARWIN_REMEDIATION}`, - remediation: DARWIN_REMEDIATION, + reason: `${darwin.message}. ${DARWIN_PROCESS_OWNERSHIP_REMEDIATION}`, + remediation: DARWIN_PROCESS_OWNERSHIP_REMEDIATION, diagnostics: { stage: "ownership_preparation", ...(darwin.compilerOutcome === undefined diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index b5cbcff092..a50b470b1d 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -20,6 +20,7 @@ import { processScenarioSchema } from "../../domain/process/processScenario.js"; import { compareParsedProcessEvidence, readProcessComparisonSource, + unsupportedProcessCaptureProvider, } from "./ProcessComparisonService.js"; import { createProgressReporter } from "../ProgressReporter.js"; import { captureProcessScenario } from "../../process/capture/ProcessHarness.js"; @@ -287,13 +288,7 @@ const projectCaptureFileFailure = ( "invalid_value", ); case "unsupported-provider": - return new AnalysisInputError("compare_process_captures", undefined, [ - { - path: [side, "provider"], - reason: "invalid_value", - message: `The ${side} Evidence provider ${failure.provider.id} v${failure.provider.version} (${failure.provider.name}) is not the current or supported legacy ${PROCESS_PROVIDER.id} v${PROCESS_PROVIDER.version} process capture provider.`, - }, - ]); + return unsupportedProcessCaptureProvider(side, failure); case "invalid-capture": return invalidCaptureEvidence(failure.cause, side); } diff --git a/src/application/process/ProcessComparisonService.ts b/src/application/process/ProcessComparisonService.ts index 3f7a6f458a..247c216ef6 100644 --- a/src/application/process/ProcessComparisonService.ts +++ b/src/application/process/ProcessComparisonService.ts @@ -124,15 +124,7 @@ export const readProcessComparisonSource = ( ]), ); case "unsupported-provider": - return err( - new AnalysisInputError(operation, undefined, [ - { - path: [side, "provider"], - reason: "invalid_value", - message: `The ${side} Evidence provider ${failure.provider.id} v${failure.provider.version} (${failure.provider.name}) is not the current or supported legacy ${PROCESS_PROVIDER.id} v${PROCESS_PROVIDER.version} process capture provider.`, - }, - ]), - ); + return err(unsupportedProcessCaptureProvider(side, failure)); case "invalid-capture": return err( new EvidenceIntegrityError( @@ -146,6 +138,22 @@ export const readProcessComparisonSource = ( } }; +/** Report the same provider admission failure at both public adapters. */ +export const unsupportedProcessCaptureProvider = ( + side: "left" | "right", + failure: Extract< + ProcessCaptureEvidenceFailure, + { kind: "unsupported-provider" } + >, +): AnalysisInputError => + new AnalysisInputError("compare_process_captures", undefined, [ + { + path: [side, "provider"], + reason: "invalid_value", + message: `The ${side} Evidence provider ${failure.provider.id} v${failure.provider.version} (${failure.provider.name}) is not the current or supported legacy ${PROCESS_PROVIDER.id} v${PROCESS_PROVIDER.version} process capture provider.`, + }, + ]); + const comparisonUnknownInput = ( parsed: { readonly left_evidence_id: string; diff --git a/src/artifacts/ArtifactCarrierRange.ts b/src/artifacts/ArtifactCarrierRange.ts new file mode 100644 index 0000000000..dffb8bbaa5 --- /dev/null +++ b/src/artifacts/ArtifactCarrierRange.ts @@ -0,0 +1,55 @@ +import type { FileHandle } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFileHandleRange } from "../filesystem/readFileHandleRange.js"; +import { abortIfNeeded } from "./ArtifactHash.js"; + +interface ArtifactRangeCarrier { + readonly handle: FileHandle; + readonly fileSize: number; + readonly signal: AbortSignal | undefined; +} + +/** Read exactly one bounded carrier range, returning undefined for a short read. */ +export const readArtifactRange = async ( + carrier: ArtifactRangeCarrier, + offset: number, + length: number, +): Promise => { + abortIfNeeded(carrier.signal); + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(length) || + offset < 0 || + length < 0 || + offset > carrier.fileSize || + length > carrier.fileSize - offset + ) + return undefined; + const bytes = await readFileHandleRange(carrier.handle, offset, length, () => + abortIfNeeded(carrier.signal), + ); + // The shared reader reports EOF before a late cancellation. These carrier + // readers have always observed cancellation after every read, including EOF. + abortIfNeeded(carrier.signal); + return bytes.length === length ? bytes : undefined; +}; + +/** Hash one carrier range in bounded chunks; undefined means it was truncated. */ +export const hashArtifactRange = async ( + carrier: ArtifactRangeCarrier, + offset: number, + length: number, +): Promise => { + const hash = createHash("sha256"); + let remaining = length; + let position = offset; + while (remaining > 0) { + const chunkLength = Math.min(64 * 1024, remaining); + const chunk = await readArtifactRange(carrier, position, chunkLength); + if (chunk === undefined) return undefined; + hash.update(chunk); + position += chunkLength; + remaining -= chunkLength; + } + return hash.digest("hex"); +}; diff --git a/src/artifacts/ArtifactPreparation.test.ts b/src/artifacts/ArtifactPreparation.test.ts new file mode 100644 index 0000000000..7445815348 --- /dev/null +++ b/src/artifacts/ArtifactPreparation.test.ts @@ -0,0 +1,81 @@ +import { expect, it, vi } from "vitest"; +import { ArtifactPreparation } from "./ArtifactPreparation.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; + +it.each(["physical", "cleanup", "waiter-cancelled"] as const)( + "does not retry another caller's %s failure", + async (kind) => { + const owner = new ArtifactPreparation(); + const first = new AbortController(); + const waiter = new AbortController(); + const reason = new Error("first caller cancelled"); + let reject!: (cause: unknown) => void; + const pending = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }); + const prepare = vi.fn(() => pending); + const assertOpen = vi.fn(); + const initial = owner.run(first.signal, prepare, assertOpen); + const peer = owner.run(waiter.signal, prepare, assertOpen); + first.abort(reason); + if (kind === "waiter-cancelled") waiter.abort("waiter stopped"); + const failure = + kind === "physical" + ? new Error("read failed") + : new ArtifactReaderFailure("cancelled", "cancelled", { + cause: reason, + ...(kind === "cleanup" + ? { cleanup: { reason: "close failed", resources: ["file"] } } + : {}), + }); + reject(failure); + await expect(initial).rejects.toBe(failure); + await expect(peer).rejects.toBe(failure); + expect(prepare).toHaveBeenCalledTimes(1); + expect(assertOpen).not.toHaveBeenCalled(); + + // A later call can try again after the failed shared preparation settles. + await expect( + owner.run( + undefined, + async () => {}, + () => {}, + ), + ).resolves.toBeUndefined(); + }, +); + +it("shares one replacement when multiple callers survive a peer cancellation", async () => { + const owner = new ArtifactPreparation(); + const first = new AbortController(); + const reason = new Error("first caller cancelled"); + let reject!: (cause: unknown) => void; + const initialWork = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }); + let complete!: () => void; + const replacement = new Promise((resolve) => { + complete = resolve; + }); + const retry = vi.fn(() => replacement); + const initial = owner.run( + first.signal, + () => initialWork, + () => {}, + ); + const left = owner.run(undefined, retry, () => {}); + const right = owner.run(undefined, retry, () => {}); + first.abort(reason); + const failure = new ArtifactReaderFailure("cancelled", "cancelled", { + cause: reason, + }); + reject(failure); + await expect(initial).rejects.toBe(failure); + expect(retry).toHaveBeenCalledTimes(1); + complete(); + await expect(Promise.all([left, right])).resolves.toEqual([ + undefined, + undefined, + ]); + expect(retry).toHaveBeenCalledTimes(1); +}); diff --git a/src/artifacts/ArtifactPreparation.ts b/src/artifacts/ArtifactPreparation.ts new file mode 100644 index 0000000000..4401bb07e3 --- /dev/null +++ b/src/artifacts/ArtifactPreparation.ts @@ -0,0 +1,40 @@ +import { artifactReadCancelledBy } from "./ArtifactFileOperation.js"; + +/** Share one preparation while allowing another caller to retry its cancellation. */ +export class ArtifactPreparation { + #pending: + | { + readonly signal: AbortSignal | undefined; + readonly promise: Promise; + } + | undefined; + + async run( + signal: AbortSignal | undefined, + prepare: () => Promise, + assertOpen: () => void, + ): Promise { + for (;;) { + const pending = (this.#pending ??= { signal, promise: prepare() }); + try { + await pending.promise; + return; + } catch (cause: unknown) { + if (this.#pending === pending) this.#pending = undefined; + if ( + pending.signal !== signal && + signal?.aborted !== true && + artifactReadCancelledBy(cause, pending.signal) + ) { + assertOpen(); + continue; + } + throw cause; + } + } + } + + clear(): void { + this.#pending = undefined; + } +} diff --git a/src/artifacts/BunStandaloneArtifactReader.ts b/src/artifacts/BunStandaloneArtifactReader.ts index f229d9780a..9754854ee9 100644 --- a/src/artifacts/BunStandaloneArtifactReader.ts +++ b/src/artifacts/BunStandaloneArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { DEFAULT_BUN_READER_LIMITS, type BunReaderLimits, @@ -16,10 +17,7 @@ import type { import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { normalizeArtifactPath, ArtifactPathRegistry, @@ -62,12 +60,7 @@ export class BunStandaloneArtifactReader implements ArtifactReader { readonly #streams = new Set(); readonly #source: ArtifactFileSource; #admission: BunStandaloneAdmission | undefined; - #prepared: - | { - readonly signal: AbortSignal | undefined; - readonly promise: Promise; - } - | undefined; + readonly #preparation = new ArtifactPreparation(); #header: BunStandaloneMetadata | undefined; #closing = false; @@ -108,27 +101,11 @@ export class BunStandaloneArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.readSelectedPath(signal); }); let index = 0; @@ -209,7 +186,7 @@ export class BunStandaloneArtifactReader implements ArtifactReader { this.#entries.clear(); this.#header = undefined; this.#admission = undefined; - this.#prepared = undefined; + this.#preparation.clear(); } provenance(): readonly [] { diff --git a/src/artifacts/DecodedRegionArtifactReader.ts b/src/artifacts/DecodedRegionArtifactReader.ts index 695b6408c4..fd600cab19 100644 --- a/src/artifacts/DecodedRegionArtifactReader.ts +++ b/src/artifacts/DecodedRegionArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { Readable } from "node:stream"; import { setImmediate } from "node:timers/promises"; @@ -18,10 +19,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; @@ -72,9 +70,7 @@ export class DecodedRegionArtifactReader implements ArtifactReader { readonly #streams = new Set(); readonly #source: ArtifactFileSource; readonly #decoding: RegionDecoding; - #prepared: - | { signal: AbortSignal | undefined; promise: Promise } - | undefined; + readonly #preparation = new ArtifactPreparation(); /** Whole-file facts only; the region map is the sole owner of region bytes. */ #described: Omit | undefined; #closing = false; @@ -115,27 +111,11 @@ export class DecodedRegionArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.read(signal); }); const coverage = this.#described?.coverage; diff --git a/src/artifacts/DenoStandaloneArtifactReader.ts b/src/artifacts/DenoStandaloneArtifactReader.ts index 0616ea6d01..2e40a76abe 100644 --- a/src/artifacts/DenoStandaloneArtifactReader.ts +++ b/src/artifacts/DenoStandaloneArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { Readable, Transform, pipeline } from "node:stream"; import { setImmediate } from "node:timers/promises"; @@ -11,10 +12,7 @@ import type { } from "../domain/artifactFormatMetadata.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; -import { - assertArtifactReadActive, - artifactReadCancelledBy, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { ArtifactPathRegistry, @@ -42,6 +40,8 @@ import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js" import { DENO_CHILD_SUFFIXES, DENO_PATH_LENGTH_LIMITATION, + DENO_PATH_PARTS_LIMITATION, + DENO_UNSAFE_PATH_LIMITATION, denoLogicalPathFits, MAX_DENO_LOGICAL_PATH_PARTS, type DenoVfsMember, @@ -73,12 +73,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { readonly #streams = new Set(); readonly #source: ArtifactFileSource; #admission: DenoStandaloneAdmission | undefined; - #prepared: - | { - readonly signal: AbortSignal | undefined; - readonly promise: Promise; - } - | undefined; + readonly #preparation = new ArtifactPreparation(); #header: DenoStandaloneMetadata | undefined; #closing = false; @@ -117,27 +112,11 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.readSelectedPath(signal); }); if (this.#header?.vfs_listing === "unsupported") @@ -247,7 +226,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { this.#entries.clear(); this.#header = undefined; this.#admission = undefined; - this.#prepared = undefined; + this.#preparation.clear(); } provenance(): readonly [] { @@ -612,9 +591,7 @@ const remoteDestination = ( return { path: fallback, sourcePath: text, - limitations: [ - `The virtual name exceeds the fixed ${String(MAX_DENO_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`, - ], + limitations: [DENO_PATH_PARTS_LIMITATION], }; let normalized: string; try { @@ -625,9 +602,7 @@ const remoteDestination = ( return { path: fallback, sourcePath: text, - limitations: [ - "The virtual name is not a safe relative path; an indexed path is used and the original name is retained.", - ], + limitations: [DENO_UNSAFE_PATH_LIMITATION], }; } // A specifier is generated into a path, so its spelling can follow every diff --git a/src/artifacts/FitArtifactReader.ts b/src/artifacts/FitArtifactReader.ts index 1d2f604c94..2130ad7e61 100644 --- a/src/artifacts/FitArtifactReader.ts +++ b/src/artifacts/FitArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { Readable, Transform, pipeline } from "node:stream"; import { setImmediate } from "node:timers/promises"; @@ -13,10 +14,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { createFitDigest, @@ -37,9 +35,7 @@ export class FitArtifactReader implements ArtifactReader { readonly #streams = new Set(); readonly #path: string; readonly #source: ArtifactFileSource; - #prepared: - | { signal: AbortSignal | undefined; promise: Promise } - | undefined; + readonly #preparation = new ArtifactPreparation(); #metadata: FitMetadata | undefined; #coverage: ArtifactReaderTraversal | undefined; #closing = false; @@ -71,27 +67,11 @@ export class FitArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.read(signal); }); if (this.#coverage !== undefined) diff --git a/src/artifacts/NtfsMftArtifactReader.test.ts b/src/artifacts/NtfsMftArtifactReader.test.ts index 944af32087..3fec45e728 100644 --- a/src/artifacts/NtfsMftArtifactReader.test.ts +++ b/src/artifacts/NtfsMftArtifactReader.test.ts @@ -273,3 +273,45 @@ it("delivers acquired extension metadata through the canonical inventory schema" }, }); }); + +it("does not finish a traversal when close begins during final source verification", async () => { + const reader = new NtfsMftArtifactReader( + await writeMft("$MFT", recordBytes({})), + ); + const read = ArtifactFileSource.prototype.read; + let calls = 0; + let entered!: () => void; + let resume!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + resume = resolve; + }); + const spy = vi + .spyOn(ArtifactFileSource.prototype, "read") + .mockImplementation(async function (this: ArtifactFileSource, signal) { + const result = await read.call(this, signal); + if (++calls === 2) { + entered(); + await gate; + } + return result; + }); + try { + const traversal = reader.entries()[Symbol.asyncIterator]().next(); + const rejected = expect(traversal).rejects.toMatchObject({ + reason: "unavailable", + }); + await started; + const closing = reader.close(); + resume(); + await rejected; + await closing; + expect(calls).toBe(2); + } finally { + resume(); + spy.mockRestore(); + await reader.close(); + } +}); diff --git a/src/artifacts/NtfsMftArtifactReader.ts b/src/artifacts/NtfsMftArtifactReader.ts index 27651ea9d9..4f54fa3982 100644 --- a/src/artifacts/NtfsMftArtifactReader.ts +++ b/src/artifacts/NtfsMftArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import type { Readable } from "node:stream"; import { @@ -17,10 +18,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; @@ -34,9 +32,7 @@ export class NtfsMftArtifactReader implements ArtifactReader { readonly format = "ntfs-mft" as const; readonly #scope = new ArtifactResourceScope(); readonly #source: ArtifactFileSource; - #prepared: - | { signal: AbortSignal | undefined; promise: Promise } - | undefined; + readonly #preparation = new ArtifactPreparation(); #metadata: ArtifactFormatMetadata | undefined; #limitations: readonly string[] = []; #closing = false; @@ -67,27 +63,11 @@ export class NtfsMftArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); this.#assertOpen(signal); await this.#source.read(signal); }); diff --git a/src/artifacts/PositionalArtifactReader.ts b/src/artifacts/PositionalArtifactReader.ts index c77f176975..72b0931095 100644 --- a/src/artifacts/PositionalArtifactReader.ts +++ b/src/artifacts/PositionalArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { Readable, Transform, pipeline } from "node:stream"; import { setImmediate } from "node:timers/promises"; @@ -11,10 +12,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; @@ -45,12 +43,7 @@ export abstract class PositionalArtifactReader implements ArtifactReader { readonly #streams = new Set(); readonly #label: string; readonly #source: ArtifactFileSource; - #prepared: - | { - readonly signal: AbortSignal | undefined; - readonly promise: Promise; - } - | undefined; + readonly #preparation = new ArtifactPreparation(); #coverage: ArtifactReaderTraversal | undefined; #closing = false; @@ -84,27 +77,11 @@ export abstract class PositionalArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.assertOpen(signal), + ); await this.#source.read(signal); }); this.assertOpen(signal); diff --git a/src/artifacts/UImageArtifactReader.ts b/src/artifacts/UImageArtifactReader.ts index 3bea584a94..05c9af9347 100644 --- a/src/artifacts/UImageArtifactReader.ts +++ b/src/artifacts/UImageArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { Readable, Transform, pipeline } from "node:stream"; import { crc32 } from "node:zlib"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; @@ -8,10 +9,7 @@ import type { UImageMetadata, } from "../domain/artifactFormatMetadata.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactReaderFailure, copyArtifactEntry, @@ -50,9 +48,7 @@ export class UImageArtifactReader implements ArtifactReader { readonly #entries = new Map(); readonly #streams = new Set(); readonly #source: ArtifactFileSource; - #prepared: - | { signal: AbortSignal | undefined; promise: Promise } - | undefined; + readonly #preparation = new ArtifactPreparation(); #header: UImageMetadata | undefined; #closing = false; @@ -80,27 +76,11 @@ export class UImageArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.read(signal); }); this.#assertOpen(signal); diff --git a/src/artifacts/UnityFsArtifactReader.ts b/src/artifacts/UnityFsArtifactReader.ts index 1ac8ef1536..e25069ab45 100644 --- a/src/artifacts/UnityFsArtifactReader.ts +++ b/src/artifacts/UnityFsArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; import type { FileHandle } from "node:fs/promises"; import { setImmediate } from "node:timers/promises"; @@ -12,10 +13,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; import type { ArtifactFormatMetadata, @@ -58,9 +56,7 @@ export class UnityFsArtifactReader implements ArtifactReader { #entries = new Map(); readonly #streams = new Map(); readonly #source: ArtifactFileSource; - #prepared: - | { signal: AbortSignal | undefined; promise: Promise } - | undefined; + readonly #preparation = new ArtifactPreparation(); #header: UnityFsMetadata | undefined; #blocks: readonly UnityFsBlock[] = []; #dataStart = 0; @@ -93,27 +89,11 @@ export class UnityFsArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.read(signal); }); this.#assertOpen(signal); diff --git a/src/artifacts/WxapkgArtifactReader.ts b/src/artifacts/WxapkgArtifactReader.ts index 1d374ac3aa..4bc11a737f 100644 --- a/src/artifacts/WxapkgArtifactReader.ts +++ b/src/artifacts/WxapkgArtifactReader.ts @@ -1,3 +1,4 @@ +import { ArtifactPreparation } from "./ArtifactPreparation.js"; import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; import type { FileHandle } from "node:fs/promises"; import { setImmediate } from "node:timers/promises"; @@ -12,10 +13,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactReadCancelledBy, - assertArtifactReadActive, -} from "./ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; import type { ArtifactFormatMetadata, @@ -54,9 +52,7 @@ export class WxapkgArtifactReader implements ArtifactReader { #entries = new Map(); readonly #streams = new Map(); readonly #source: ArtifactFileSource; - #prepared: - | { signal: AbortSignal | undefined; promise: Promise } - | undefined; + readonly #preparation = new ArtifactPreparation(); #header: WxapkgMetadata | undefined; #remainingReadOperations = 0; #admittedReadOperations = 0; @@ -86,27 +82,11 @@ export class WxapkgArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - for (;;) { - const pending = (this.#prepared ??= { - signal, - promise: this.#prepare(signal), - }); - try { - await pending.promise; - break; - } catch (cause: unknown) { - if (this.#prepared === pending) this.#prepared = undefined; - if ( - pending.signal !== signal && - signal?.aborted !== true && - artifactReadCancelledBy(cause, pending.signal) - ) { - this.#assertOpen(signal); - continue; - } - throw cause; - } - } + await this.#preparation.run( + signal, + () => this.#prepare(signal), + () => this.#assertOpen(signal), + ); await this.#source.read(signal); }); this.#assertOpen(signal); diff --git a/src/artifacts/deno/DenoStandaloneLocation.ts b/src/artifacts/deno/DenoStandaloneLocation.ts index 119a445a41..f68f409e94 100644 --- a/src/artifacts/deno/DenoStandaloneLocation.ts +++ b/src/artifacts/deno/DenoStandaloneLocation.ts @@ -1,4 +1,7 @@ -import { createHash } from "node:crypto"; +import { + hashArtifactRange, + readArtifactRange as readExact, +} from "../ArtifactCarrierRange.js"; import type { FileHandle } from "node:fs/promises"; import { @@ -895,7 +898,9 @@ const collapseRanges = async ( "capacity", ); remainingBytes -= range.length; - const hash = await digestRange(carrier, range.offset, range.length); + const hash = await hashArtifactRange(carrier, range.offset, range.length); + if (hash === undefined) + throw denoCarrierFailure("Deno compile section is truncated"); if (chosenHash === undefined) chosenHash = hash; else if (hash !== chosenHash) throw conflict(); } @@ -908,59 +913,6 @@ const collapseRanges = async ( }; }; -const digestRange = async ( - carrier: Carrier, - offset: number, - length: number, -): Promise => { - const hash = createHash("sha256"); - let remaining = length; - let position = offset; - while (remaining > 0) { - const chunkLength = Math.min(64 * 1024, remaining); - const chunk = await readExact(carrier, position, chunkLength); - if (chunk === undefined) - throw denoCarrierFailure("Deno compile section is truncated"); - hash.update(chunk); - position += chunkLength; - remaining -= chunkLength; - } - return hash.digest("hex"); -}; - -const readExact = async ( - carrier: Carrier, - offset: number, - length: number, -): Promise => { - abortIfNeeded(carrier.signal); - if ( - !Number.isSafeInteger(offset) || - !Number.isSafeInteger(length) || - offset < 0 || - length < 0 || - offset > carrier.fileSize || - length > carrier.fileSize - offset - ) - return undefined; - if (length === 0) return Buffer.alloc(0); - const bytes = Buffer.alloc(length); - let filled = 0; - while (filled < length) { - abortIfNeeded(carrier.signal); - const read = await carrier.handle.read( - bytes, - filled, - length - filled, - offset + filled, - ); - abortIfNeeded(carrier.signal); - if (read.bytesRead === 0) return undefined; - filled += read.bytesRead; - } - return bytes; -}; - const fixedCString = (bytes: Uint8Array): string => { const end = bytes.indexOf(0); return Buffer.from(end < 0 ? bytes : bytes.subarray(0, end)).toString( diff --git a/src/artifacts/deno/DenoVirtualFs.ts b/src/artifacts/deno/DenoVirtualFs.ts index 582058f421..d0d26e23e8 100644 --- a/src/artifacts/deno/DenoVirtualFs.ts +++ b/src/artifacts/deno/DenoVirtualFs.ts @@ -37,10 +37,10 @@ export const DENO_CHILD_SUFFIXES = { sourceMap: ".deno-sourcemap", } as const; -const INDEXED_PATH_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`; +export const DENO_PATH_PARTS_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`; const NAME_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_VFS_NAME_UNITS)}-unit name allowance; an indexed path is used and the original name is not retained.`; export const DENO_PATH_LENGTH_LIMITATION = `The virtual name exceeds the fixed ${String(MAX_DENO_PATH_COMPONENT_BYTES)}-byte path component or ${String(MAX_DENO_LOGICAL_PATH_BYTES)}-byte logical path allowance; an indexed path is used and the original name is retained.`; -const UNSAFE_PATH_LIMITATION = +export const DENO_UNSAFE_PATH_LIMITATION = "The virtual name is not a safe relative path; an indexed path is used and the original name is retained."; const MTIME_LIMITATION = "The file mtime is not a safe integer; it is omitted."; const SYMLINK_PART_LIMITATION = `A symlink target part exceeds the fixed ${String(MAX_DENO_VFS_NAME_UNITS)}-unit name allowance; the target parts are not retained.`; @@ -384,10 +384,10 @@ const logicalDestination = ( if (parent.limitation !== null) return indexed(parent.limitation, source); if (name.length > MAX_DENO_VFS_NAME_UNITS) return indexed(NAME_LIMITATION, null); - if (unsafeName(name)) return indexed(UNSAFE_PATH_LIMITATION, source); + if (unsafeName(name)) return indexed(DENO_UNSAFE_PATH_LIMITATION, source); const parts = parent.parts + 1; if (parts > MAX_DENO_LOGICAL_PATH_PARTS) - return indexed(INDEXED_PATH_LIMITATION, source); + return indexed(DENO_PATH_PARTS_LIMITATION, source); const joined = parent.path === null ? name : `${parent.path}/${name}`; try { const path = normalizeArtifactPath(joined); @@ -402,7 +402,7 @@ const logicalDestination = ( }; } catch (cause) { if (cause instanceof ArtifactReaderFailure && cause.reason === "path") - return indexed(UNSAFE_PATH_LIMITATION, source); + return indexed(DENO_UNSAFE_PATH_LIMITATION, source); throw cause; } }; diff --git a/src/artifacts/nodeSea/NodeSeaLocation.ts b/src/artifacts/nodeSea/NodeSeaLocation.ts index e2796f60b4..a15c037aa2 100644 --- a/src/artifacts/nodeSea/NodeSeaLocation.ts +++ b/src/artifacts/nodeSea/NodeSeaLocation.ts @@ -1,3 +1,7 @@ +import { + hashArtifactRange, + readArtifactRange as readExact, +} from "../ArtifactCarrierRange.js"; import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js"; import { readMachoSectionHeader } from "../../domain/apple/machoSectionHeader.js"; import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; @@ -214,8 +218,11 @@ const collapseRanges = async ( if (chosen === undefined) return undefined; if (unique.length > 1) { const hashes = new Set(); - for (const range of unique) - hashes.add(await digestRange(carrier, range.offset, range.length)); + for (const range of unique) { + const hash = await hashArtifactRange(carrier, range.offset, range.length); + if (hash === undefined) throw formatFailure(NODE_SEA_TRUNCATED); + hashes.add(hash); + } if (hashes.size !== 1) throw formatFailure(NODE_SEA_DUPLICATE_BLOB); } return { @@ -694,58 +701,6 @@ const locatePeResources = async (carrier: Carrier): Promise => { }); }; -const digestRange = async ( - carrier: Carrier, - offset: number, - length: number, -): Promise => { - const hash = createHash("sha256"); - let remaining = length; - let position = offset; - while (remaining > 0) { - const chunkLength = Math.min(64 * 1024, remaining); - const chunk = await readExact(carrier, position, chunkLength); - if (chunk === undefined) throw formatFailure(NODE_SEA_TRUNCATED); - hash.update(chunk); - position += chunkLength; - remaining -= chunkLength; - } - return hash.digest("hex"); -}; - -const readExact = async ( - carrier: Carrier, - offset: number, - length: number, -): Promise => { - abortIfNeeded(carrier.signal); - if ( - !Number.isSafeInteger(offset) || - !Number.isSafeInteger(length) || - offset < 0 || - length < 0 || - offset > carrier.fileSize || - length > carrier.fileSize - offset - ) - return undefined; - if (length === 0) return Buffer.alloc(0); - const bytes = Buffer.alloc(length); - let filled = 0; - while (filled < length) { - abortIfNeeded(carrier.signal); - const read = await carrier.handle.read( - bytes, - filled, - length - filled, - offset + filled, - ); - abortIfNeeded(carrier.signal); - if (read.bytesRead === 0) return undefined; - filled += read.bytesRead; - } - return bytes; -}; - const cString = (bytes: Buffer, offset: number, length: number): string => { let end = offset; const limit = Math.min(bytes.length, offset + length); diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 3a82a9ee72..7fc4f98232 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -474,11 +474,11 @@ export class TsharkCaptureProvider implements PacketCapturePort { workspacePath: string | null, effectiveLimits: LinuxResourceLimits | undefined, observedOutput: AnalysisCapturedOutput | undefined, - operation = OPERATION, + operation: string, limits: { readonly output_bytes: number; readonly command_timeout_ms: number; - } = LIMITS, + }, ): AnalysisError { if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 939ef92390..5b637d8c80 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -18,6 +18,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { parseCliJsonOption } from "./cliJsonOption.js"; import { createEvidenceBundle, createImmutableEvidenceBundle, @@ -102,20 +103,14 @@ export const parseCliAnalysisEvidenceDelivery = < Delivery & { readonly evidenceOutput: string | undefined }, AnalysisError > => { - let view: unknown; - try { - view = options.view === undefined ? undefined : JSON.parse(options.view); - } catch (cause) { - return err( - new AnalysisInputError(operation, { cause }, [ - { - path: ["view"], - reason: "invalid_format", - message: "view must be valid JSON", - }, - ]), - ); - } + const decoded = parseCliJsonOption( + operation, + "view", + options.view, + "view must be valid JSON", + ); + if (!decoded.ok) return decoded; + const view = decoded.value; const raw = { detail: options.detail, ...(view === undefined ? {} : { view }), diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index b14264747e..f495804a70 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -17,7 +17,7 @@ import { } from "./analysisEvidenceDelivery.js"; import { androidPackageDeliverySchema } from "../domain/android/androidPackageQuery.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { parseCliJsonOption } from "./cliJsonOption.js"; import { parseAnalysisInput } from "../application/AnalysisInput.js"; import { androidJniSelectionSchema } from "../domain/android/androidJniBindings.js"; import { androidJniDeliverySchema } from "../domain/android/androidJniQuery.js"; @@ -105,21 +105,14 @@ export const registerAndroidCommands = ( androidJniDeliverySchema, ); if (!delivery.ok) return projectAnalysisError(delivery.error); - let query: unknown = {}; - try { - if (options.query !== undefined) - query = JSON.parse(options.query); - } catch (cause: unknown) { - return projectAnalysisError( - new AnalysisInputError(operation, { cause }, [ - { - path: ["query"], - reason: "invalid_format", - message: "query must be a JSON selection object.", - }, - ]), - ); - } + const decoded = parseCliJsonOption( + operation, + "query", + options.query, + "query must be a JSON selection object.", + ); + if (!decoded.ok) return projectAnalysisError(decoded.error); + const query = decoded.value === undefined ? {} : decoded.value; const selected = parseAnalysisInput( operation, query, diff --git a/src/cli/apkSignatureCommands.ts b/src/cli/apkSignatureCommands.ts index 395b3964b0..57d62c192b 100644 --- a/src/cli/apkSignatureCommands.ts +++ b/src/cli/apkSignatureCommands.ts @@ -1,12 +1,11 @@ +import { parseCliJsonOption } from "./cliJsonOption.js"; import { z } from "incur"; import type { Logger } from "pino"; import { APK_SIGNATURE_PROVIDER_IDENTITY } from "../apkSignature/ApkSignatureRelease.js"; import { apkSignatureModule } from "../composition/apkSignature.js"; import { inspectApkSignatureInputSchema } from "../domain/apkSignature/apkSignatureQuery.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { safeParseJson } from "../domain/safeJson.js"; import { logCliCommand } from "../cliLogging.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -41,24 +40,13 @@ export const registerApkSignatureCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, apkSignatureModule.cliCommand, async () => { - const decoded = - options.view === undefined - ? { ok: true as const, value: undefined } - : safeParseJson(options.view); - if (!decoded.ok) - return projectAnalysisError( - new AnalysisInputError( - "inspect_apk_signature", - { cause: decoded.cause }, - [ - { - path: ["view"], - reason: "invalid_format", - message: "view must be a JSON APK signature selection", - }, - ], - ), - ); + const decoded = parseCliJsonOption( + "inspect_apk_signature", + "view", + options.view, + "view must be a JSON APK signature selection", + ); + if (!decoded.ok) return projectAnalysisError(decoded.error); const input = inspectApkSignatureInputSchema.safeParse({ path: absoluteCallerPath(args.path), detail: options.detail, diff --git a/src/cli/cliJsonOption.ts b/src/cli/cliJsonOption.ts new file mode 100644 index 0000000000..a1d7e54699 --- /dev/null +++ b/src/cli/cliJsonOption.ts @@ -0,0 +1,21 @@ +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { safeParseJson } from "../domain/safeJson.js"; + +/** Decode one named CLI option before using the selected inputs. */ +export const parseCliJsonOption = ( + operation: string, + name: string, + text: string | undefined, + message: string, +): Result => { + if (text === undefined) return ok(undefined); + const parsed = safeParseJson(text); + return parsed.ok + ? parsed + : err( + new AnalysisInputError(operation, { cause: parsed.cause }, [ + { path: [name], reason: "invalid_format", message }, + ]), + ); +}; diff --git a/src/cli/gpuCommands.ts b/src/cli/gpuCommands.ts index c6270beec4..9d6b847c87 100644 --- a/src/cli/gpuCommands.ts +++ b/src/cli/gpuCommands.ts @@ -1,8 +1,7 @@ import { inspectGpuCodeObjectsInputSchema } from "../domain/gpu/gpuCodeObjects.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { safeParseJson } from "../domain/safeJson.js"; +import { parseCliJsonOption } from "./cliJsonOption.js"; import { z } from "incur"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; @@ -61,22 +60,13 @@ export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => { for (const name of ["object_ids", "targets", "view"] as const) { const value = options[name]; if (value === undefined) continue; - const decoded = safeParseJson(value); - if (!decoded.ok) { - return projectAnalysisError( - new AnalysisInputError( - "inspect_gpu_code_objects", - { cause: decoded.cause }, - [ - { - path: [name], - reason: "invalid_format", - message: `${name} must be valid JSON`, - }, - ], - ), - ); - } + const decoded = parseCliJsonOption( + "inspect_gpu_code_objects", + name, + value, + `${name} must be valid JSON`, + ); + if (!decoded.ok) return projectAnalysisError(decoded.error); selectors[name] = decoded.value; } const rawInput = { diff --git a/src/cli/modelCommands.ts b/src/cli/modelCommands.ts index 2016ca8a46..7546357b09 100644 --- a/src/cli/modelCommands.ts +++ b/src/cli/modelCommands.ts @@ -1,8 +1,7 @@ +import { parseCliJsonOption } from "./cliJsonOption.js"; import { inspectModelArtifactInputSchema } from "../domain/model/modelArtifactQuery.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { safeParseJson } from "../domain/safeJson.js"; import { z } from "incur"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; @@ -48,25 +47,13 @@ export const registerModelCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectModelArtifact, async () => { - const decoded = - options.view === undefined - ? { ok: true as const, value: undefined } - : safeParseJson(options.view); - if (!decoded.ok) { - return projectAnalysisError( - new AnalysisInputError( - "inspect_model_artifact", - { cause: decoded.cause }, - [ - { - path: ["view"], - reason: "invalid_format", - message: "view must be a JSON model page selection", - }, - ], - ), - ); - } + const decoded = parseCliJsonOption( + "inspect_model_artifact", + "view", + options.view, + "view must be a JSON model page selection", + ); + if (!decoded.ok) return projectAnalysisError(decoded.error); const view = decoded.value; const selected = inspectModelArtifactInputSchema.safeParse({ path: absoluteCallerPath(args.path), diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index 4d6c381393..c353a1b945 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -1,3 +1,4 @@ +import { parseCliJsonOption } from "./cliJsonOption.js"; import { z } from "incur"; import { producerLimitsOption } from "./producerLimitsOption.js"; import { rustProducerLimitsSchema } from "../domain/rust/rustResourceLimits.js"; @@ -12,9 +13,7 @@ import { } from "./analysisEvidenceDelivery.js"; import { inspectRustBinaryRequestSchema } from "../domain/rust/rustBinaryQuery.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { safeParseJson } from "../domain/safeJson.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -62,25 +61,13 @@ export const registerRustCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => { - const decoded = - options.view === undefined - ? { ok: true as const, value: undefined } - : safeParseJson(options.view); - if (!decoded.ok) { - return projectAnalysisError( - new AnalysisInputError( - "inspect_rust_binary", - { cause: decoded.cause }, - [ - { - path: ["view"], - reason: "invalid_format", - message: "view must be a JSON package or edge selection", - }, - ], - ), - ); - } + const decoded = parseCliJsonOption( + "inspect_rust_binary", + "view", + options.view, + "view must be a JSON package or edge selection", + ); + if (!decoded.ok) return projectAnalysisError(decoded.error); const view = decoded.value; const input = inspectRustBinaryRequestSchema.safeParse({ path: absoluteCallerPath(args.path), diff --git a/src/contracts/compactJsonSchema.ts b/src/contracts/compactJsonSchema.ts index 473607bd36..451400a99c 100644 --- a/src/contracts/compactJsonSchema.ts +++ b/src/contracts/compactJsonSchema.ts @@ -7,6 +7,7 @@ import { schemaChildren, schemaMaps, schemaLiteralValues, + schemaSubschemas, } from "./jsonSchemaKeywords.js"; // Shared compact JSON Schema projection primitives; canonical validation stays separate. @@ -486,28 +487,20 @@ export const hasGatewayIncompatibleShape = (schema: unknown): boolean => { ) return true; } - return schemaChildrenAndMaps(schema).some(hasGatewayIncompatibleShape); + return schemaSubschemas(schema, { includeDefinitions: true }).some( + hasGatewayIncompatibleShape, + ); }; const containsGatewayCombinator = (schema: unknown): boolean => { if (Array.isArray(schema)) return schema.some(containsGatewayCombinator); if (!isSchemaObject(schema)) return false; if (schema.oneOf !== undefined || schema.allOf !== undefined) return true; - return schemaChildrenAndMaps(schema).some(containsGatewayCombinator); + return schemaSubschemas(schema, { includeDefinitions: true }).some( + containsGatewayCombinator, + ); }; -/** Traverse only schema-valued positions; property names remain opaque data. */ -const schemaChildrenAndMaps = ( - schema: Readonly>, -): readonly unknown[] => - Object.entries(schema).flatMap(([key, value]) => { - if (schemaMaps.has(key) && isSchemaObject(value)) - return Object.values(value); - if (schemaArrays.has(key) && Array.isArray(value)) return value; - if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value]; - return []; - }); - /** * Present a reduced property's reference-resolved types. Reduced inputs omit * nested validation, but clients still require an item schema for an array diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index b25c534790..275eb800f7 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -8,6 +8,7 @@ import { schemaArrays, schemaChildren, schemaMaps, + schemaSubschemas, } from "./jsonSchemaKeywords.js"; const isObject = (value: unknown): value is Readonly> => @@ -269,21 +270,6 @@ const preserveSharedDefault = ( schema.default = defaults[0]; }; -/** Schema children, excluding definitions when following validation dependencies. */ -const normalizationChildren = ( - schema: Readonly>, - definitions: boolean, -): unknown[] => - Object.entries(schema).flatMap(([key, value]) => { - if (schemaMaps.has(key) && isObject(value)) - return !definitions && (key === "$defs" || key === "definitions") - ? [] - : Object.values(value); - if (schemaArrays.has(key) && Array.isArray(value)) return value; - if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value]; - return []; - }); - const hasNormalizedLiteral = (schema: Readonly>) => typeof schema.const === "boolean" || schema.const === "" || @@ -320,7 +306,9 @@ const normalizationContext = (root: Readonly>) => { if (seen.has(entry.schema)) continue; seen.add(entry.schema); entries.push({ schema: entry.schema, scope }); - for (const schema of normalizationChildren(entry.schema, true)) + for (const schema of schemaSubschemas(entry.schema, { + includeDefinitions: true, + })) pending.push({ schema, scope }); } const indexed = new Map>(); @@ -378,7 +366,9 @@ const normalizationContext = (root: Readonly>) => { } } if (!ignoresSiblings) - for (const child of normalizationChildren(schema, false)) + for (const child of schemaSubschemas(schema, { + includeDefinitions: false, + })) factAt(child, scope)?.dependents.add(fact); if (!ignoresSiblings) for (const key of [ diff --git a/src/contracts/jsonSchemaKeywords.ts b/src/contracts/jsonSchemaKeywords.ts index 6db6c95060..d3c8e0a0ab 100644 --- a/src/contracts/jsonSchemaKeywords.ts +++ b/src/contracts/jsonSchemaKeywords.ts @@ -21,6 +21,27 @@ export const schemaChildren = new Set([ "else", ]); +/** Visit schema-valued positions without interpreting instance-valued keywords. */ +export const schemaSubschemas = ( + schema: Readonly>, + options: { readonly includeDefinitions: boolean }, +): unknown[] => + Object.entries(schema).flatMap(([key, value]) => { + if ( + schemaMaps.has(key) && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) + return !options.includeDefinitions && + (key === "$defs" || key === "definitions") + ? [] + : Object.values(value); + if (schemaArrays.has(key) && Array.isArray(value)) return value; + if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value]; + return []; + }); + /** Instance values, unless explicitly reached by a schema reference. */ export const schemaLiteralValues = new Set([ "const", diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 0e8b37a8fe..ecc0634134 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -106,74 +106,6 @@ import { type InspectAnalysisViewInput, } from "./analysisViewRequest.js"; import { - webSourceLocationSummaryViewResultSchema, - webSourceLocationMatchesViewResultSchema, - webSourceLocationTextViewResultSchema, - procedurePseudocodeSummaryViewResultSchema, - nativeObservationSummaryViewResultSchemas, - byteEntropySummaryViewResultSchema, - nativeExportsSummaryViewResultSchema, - devicetreeSummaryViewResultSchema, - erlangBeamSummaryViewResultSchema, - erlangBeamTableViewResultSchema, - pythonPycSummaryViewResultSchema, - pythonPycCodeObjectsViewResultSchema, - gdbCommandSummaryViewResultSchema, - objdumpSummaryViewResultSchema, - fridaSessionStatusSummaryViewResultSchema, - goBinarySummaryViewResultSchema, - rustBinarySummaryViewResultSchema, - rustBinaryPageViewResultSchema, - binaryLayoutSummaryViewResultSchema, - javascriptApplicationSummaryViewResultSchema, - artifactInventorySummaryViewResultSchema, - desktopApplicationSummaryViewResultSchema, - wasmArtifactSummaryViewResultSchema, - machoSummaryViewResultSchema, - interfaceBuilderSummaryViewResultSchema, - evmInterfaceSummaryViewResultSchema, - managedArtifactSummaryViewResultSchema, - managedMemberSummaryViewResultSchema, - webNetworkCaptureSummaryViewResultSchema, - browserScenarioSummaryViewResultSchema, - androidPackageSummaryViewResultSchema, - androidJniSummaryViewResultSchema, - androidJniPageViewResultSchema, - androidClassSummaryViewResultSchema, - androidMethodSummaryViewResultSchema, - androidManifestViewResultSchema, - androidComponentsViewResultSchema, - androidComponentSummaryViewResultSchema, - pcapCaptureSummaryViewResultSchema, - dartAotSummaryViewResultSchema, - javaScriptRoutesViewResultSchema, - webBundleSummaryViewResultSchema, - webBundlePageViewResultSchema, - gpuCodeObjectsSummaryViewResultSchema, - gpuCodeObjectsPageViewResultSchema, - apkSignatureSummaryViewResultSchema, - apkSignaturePageViewResultSchema, - modelArtifactSummaryViewResultSchema, - modelArtifactPageViewResultSchema, - il2cppMetadataSummaryViewResultSchema, - nativeInventorySummaryViewResultSchema, - nativeFeatureTraceSummaryViewResultSchema, - nativeProcedureReferencesSummaryViewResultSchema, - nativeLoadImageSummaryViewResultSchema, - nativeFunctionMatchingSummaryViewResultSchema, - nativeFunctionSummaryViewResultSchema, - sourceToBundleSummaryViewResultSchema, - sourceToBundlePageViewResultSchema, - endpointComparisonSummaryViewResultSchema, - apiContractSummaryViewResultSchema, - playwrightTraceSummaryViewResultSchema, - playwrightTracePageViewResultSchema, - apiContractPageViewResultSchema, - graphqlContractPageViewResultSchema, - endpointComparisonPageViewResultSchema, - featureTraceSummaryViewResultSchema, - batchDecompileSummaryViewResultSchema, - featureTraceViewResultSchema, analysisViewResultSchema, type AnalysisViewResult, type UnsignedAnalysisView, @@ -256,7 +188,7 @@ export { analysisViewResultSchema, type AnalysisViewResult, type UnsignedAnalysisView, -}; +} from "./analysisViewResult.js"; /** Attach the content digest that must change when projected bytes change. */ export const sealAnalysisView = ( diff --git a/src/domain/analysisView/analysisViewParent.ts b/src/domain/analysisView/analysisViewParent.ts index 37d81426f7..d47030caa8 100644 --- a/src/domain/analysisView/analysisViewParent.ts +++ b/src/domain/analysisView/analysisViewParent.ts @@ -5,6 +5,20 @@ import type { Evidence } from "../evidence.js"; import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; import { ownedJsonValueSchema } from "../jsonValue.js"; +import { EvidenceIntegrityError } from "../evidenceErrors.js"; + +/** Bind a projected artifact to the parent subject when one is recorded. */ +export const validateAnalysisViewSubject = ( + subject: Evidence["subject"] | undefined, + artifactSha256: string | null | undefined, +): Result => + subject == null || subject.digest.sha256 === artifactSha256 + ? ok(undefined) + : err( + new EvidenceIntegrityError( + `Parent subject SHA-256 ${subject.digest.sha256} differs from analysis artifact SHA-256 ${artifactSha256 ?? "unknown"}.`, + ), + ); /** Parent record already resolved from inline Evidence or a session reference. */ export interface AnalysisViewParent { diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts index f11c4b04b0..b6f9acb821 100644 --- a/src/domain/analysisView/managedMemberView.ts +++ b/src/domain/analysisView/managedMemberView.ts @@ -18,7 +18,11 @@ import { import { z } from "zod"; import { managedMembersViewRequestSchema } from "./managedMemberViewSchemas.js"; import { managedViewIdentityUnknowns } from "./managedArtifactView.js"; -import { createAnalysisViewResultReader } from "./analysisViewParent.js"; +import { + createAnalysisViewResultReader, + validateAnalysisViewSubject, +} from "./analysisViewParent.js"; +import { validateManagedTokenScope } from "../managed/managedInspectionEvidence.js"; import type { ManagedMembersViewRequest, @@ -296,6 +300,18 @@ export const projectManagedMemberView = ( const parsed = parseMemberReport(parent); if (!parsed.ok) return parsed; const report = parsed.value; + // Match the public view boundary's subject check before scope validation. + const identity = validateAnalysisViewSubject( + parent.subject, + report.artifact.sha256, + ); + if (!identity.ok) return identity; + const scope = validateManagedTokenScope( + report, + parent.operation, + parent.evidenceId, + ); + if (!scope.ok) return scope; const shared = { parent_evidence_id: parent.evidenceId, parent_operation: "inspect_managed_members" as const, diff --git a/src/domain/desktop/jsonRecordPairing.ts b/src/domain/desktop/jsonRecordPairing.ts index a14fce3ddb..fee9ba999e 100644 --- a/src/domain/desktop/jsonRecordPairing.ts +++ b/src/domain/desktop/jsonRecordPairing.ts @@ -28,13 +28,10 @@ const isRecord = ( ): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const isJsonRecord = (value: JsonValue): value is Record => - isRecord(value); - /** Whether every member holds a string at a key a selector can carry, each its own. */ const namesEach = (members: readonly JsonValue[], key: string) => { const values = members.map((member) => - isJsonRecord(member) ? member[key] : undefined, + isRecord(member) ? member[key] : undefined, ); return ( values.every( @@ -222,7 +219,7 @@ export const pairRecordsById = ( } | undefined => { const [first] = [...newer, ...older]; - if (!isJsonRecord(first ?? null) || ![...newer, ...older].every(isJsonRecord)) + if (!isRecord(first) || ![...newer, ...older].every(isRecord)) return undefined; const key = Object.keys(first ?? {}) .filter( @@ -240,7 +237,7 @@ export const pairRecordsById = ( })[0]; if (key === undefined) return undefined; const idOf = (member: JsonValue) => - isJsonRecord(member) ? String(member[key]) : ""; + isRecord(member) ? String(member[key]) : ""; const counts = { newer: tally(newer), older: tally(older) }; const partnersOf = ( members: readonly JsonValue[], diff --git a/src/domain/elfAttributeUleb.ts b/src/domain/elfAttributeUleb.ts new file mode 100644 index 0000000000..48585b0134 --- /dev/null +++ b/src/domain/elfAttributeUleb.ts @@ -0,0 +1,17 @@ +/** Read a bounded 32-bit ULEB value within an ELF attribute scope. */ +export const readElfAttributeUleb = ( + bytes: Uint8Array, + offset: number, + end: number, +): { readonly value: number; readonly next: number } | undefined => { + let value = 0; + for (let index = 0; index < 5 && offset + index < end; index += 1) { + const byte = bytes[offset + index]!; + value += (byte & 0x7f) * 2 ** (7 * index); + if ((byte & 0x80) === 0) + return value <= 0xffffffff + ? { value, next: offset + index + 1 } + : undefined; + } + return undefined; +}; diff --git a/src/domain/javascript/javascriptModuleDeclaration.ts b/src/domain/javascript/javascriptModuleDeclaration.ts index 0bf60a2499..4a158fb41d 100644 --- a/src/domain/javascript/javascriptModuleDeclaration.ts +++ b/src/domain/javascript/javascriptModuleDeclaration.ts @@ -16,6 +16,10 @@ export const isErasedJavaScriptDeclaration = (node: t.Node): boolean => export const isUnmodeledJavaScriptRuntimeScope = (node: t.Node): boolean => t.isTSModuleDeclaration(node) || t.isTSEnumDeclaration(node); +/** Compiler options determine whether an inline type-only declaration retains a side effect. */ +export const TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION = + "Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency."; + /** Whether TypeScript explicitly erases this entire import declaration. */ export const isTypeOnlyImportDeclaration = ( node: t.ImportDeclaration, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 9c09d52412..3234457612 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -92,6 +92,7 @@ import { } from "./javascriptSemanticAmbientWrites.js"; import { hasUncertainTypeOnlyModuleSideEffect, + TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION, isTypeOnlyImportDeclaration, isTypeOnlyImportSpecifier, semanticImportEqualsSpecifier, @@ -507,9 +508,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps( ] : []), ...(hasUncertainTypeOnlyModuleSideEffect(file.program) - ? [ - "Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.", - ] + ? [TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION] : []), "Values and aliases were recovered from inert syntax only; no JavaScript was executed.", "Return sites include only direct callable returns; nested callable returns remain separate.", diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 635ade30db..0bff28ed2b 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -95,6 +95,19 @@ export const collectJavaScriptSemanticResources = ( ]; }; +/** Literal kinds Node rejects for both open flags and modes. */ +const rejectsFlagOrModeLiteral = (value: t.Node): boolean => { + return ( + t.isBigIntLiteral(value) || + t.isBooleanLiteral(value) || + t.isRegExpLiteral(value) || + t.isObjectExpression(value) || + t.isArrayExpression(value) || + (t.isUnaryExpression(value) && + t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)) + ); +}; + /** * `fs.open`/`fs.openSync` validate the flag before touching the filesystem, so * a flag Node rejects at the call site throws synchronously and no descriptor @@ -136,15 +149,7 @@ const rejectsOpenFlagLiteral = ( if (isNonFiniteNumber(value, context)) return true; // BigInt, boolean, object and array literals all fail Node's flag check. A // unary operator over a BigInt literal is a BigInt too. - return ( - t.isBigIntLiteral(value) || - t.isBooleanLiteral(value) || - t.isRegExpLiteral(value) || - t.isObjectExpression(value) || - t.isArrayExpression(value) || - (t.isUnaryExpression(value) && - t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)) - ); + return rejectsFlagOrModeLiteral(value); }; /** @@ -325,15 +330,7 @@ const rejectsOpenModeLiteral = ( // BigInt, boolean, object and array literals all fail Node's mode check. A // unary operator over a BigInt literal is a BigInt too, and the range helpers // above only price numeric operands. - return ( - t.isBigIntLiteral(value) || - t.isBooleanLiteral(value) || - t.isRegExpLiteral(value) || - t.isObjectExpression(value) || - t.isArrayExpression(value) || - (t.isUnaryExpression(value) && - t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)) - ); + return rejectsFlagOrModeLiteral(value); }; /** `fs.open`, `fs.openSync` and `fs/promises.open` take the flag in slot 1. */ diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 2f5e2b0d7e..d2cbcafbf9 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -72,6 +72,7 @@ import type { ParsedJavaScriptSource } from "./javascriptSourceParser.js"; import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import { hasUncertainTypeOnlyModuleSideEffect, + TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION, semanticStaticModuleSource, semanticImportEqualsSpecifier, } from "./javascriptModuleDeclaration.js"; @@ -279,11 +280,7 @@ const finalizeStaticAnalysis = ( : [ "One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.", ]), - ...(typeOnlySideEffect - ? [ - "Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.", - ] - : []), + ...(typeOnlySideEffect ? [TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION] : []), ...(electronObserved ? [ELECTRON_IDENTITY_LIMITATION] : []), ...(candidateElectron ? [ diff --git a/src/domain/managed/managedInspectionEvidence.ts b/src/domain/managed/managedInspectionEvidence.ts index 4eb7837e84..926e8aa421 100644 --- a/src/domain/managed/managedInspectionEvidence.ts +++ b/src/domain/managed/managedInspectionEvidence.ts @@ -64,11 +64,34 @@ export const readManagedInspectionEvidence = < `Managed Evidence ${operation} (${evidence.evidence_id}) subject SHA-256 ${subjectSha256} does not match normalized artifact SHA-256 ${artifactSha256}`, ), ); + const scope = validateManagedTokenScope( + result, + operation, + evidence.evidence_id, + ); + if (!scope.ok) return scope; + return ok({ evidence, result }); +}; + +/** Bind recorded token scope to the artifact and module that gave it meaning. */ +export const validateManagedTokenScope = ( + result: { + readonly artifact: { readonly sha256: string }; + readonly identity_scope?: { + readonly requires_artifact_sha256: string; + readonly requires_mvid: string | null; + }; + readonly module?: { readonly mvid: string | null } | null; + }, + operation: string, + evidenceId: string, +): Result => { + const artifactSha256 = result.artifact.sha256; const scope = result.identity_scope; if (scope !== undefined && scope.requires_artifact_sha256 !== artifactSha256) return err( integrityFailure( - `Managed Evidence ${operation} (${evidence.evidence_id}) token scope SHA-256 ${scope.requires_artifact_sha256} does not match normalized artifact SHA-256 ${artifactSha256}`, + `Managed Evidence ${operation} (${evidenceId}) token scope SHA-256 ${scope.requires_artifact_sha256} does not match normalized artifact SHA-256 ${artifactSha256}`, ), ); const moduleMvid = result.module?.mvid ?? null; @@ -79,10 +102,10 @@ export const readManagedInspectionEvidence = < ) return err( integrityFailure( - `Managed Evidence ${operation} (${evidence.evidence_id}) token scope MVID ${String(scopedMvid)} does not match module MVID ${String(moduleMvid)}`, + `Managed Evidence ${operation} (${evidenceId}) token scope MVID ${String(scopedMvid)} does not match module MVID ${String(moduleMvid)}`, ), ); - return ok({ evidence, result }); + return ok(undefined); }; const integrityFailure = (message: string): EvidenceIntegrityError => diff --git a/src/domain/riscvIsaDeclaration.ts b/src/domain/riscvIsaDeclaration.ts index a8d84fa428..4631dcb910 100644 --- a/src/domain/riscvIsaDeclaration.ts +++ b/src/domain/riscvIsaDeclaration.ts @@ -1,3 +1,4 @@ +import { readElfAttributeUleb } from "./elfAttributeUleb.js"; import type { RiscvIsaDeclaration } from "./binaryTargetTypes.js"; import { readElfTableDeclarations, @@ -52,21 +53,6 @@ export const parseRiscvAttributes = ( const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const readU32 = (offset: number): number => view.getUint32(offset, littleEndian); - const readUleb = ( - offset: number, - end: number, - ): { readonly value: number; readonly next: number } | undefined => { - let value = 0; - for (let index = 0; index < 5 && offset + index < end; index += 1) { - const byte = bytes[offset + index]!; - value += (byte & 0x7f) * 2 ** (7 * index); - if ((byte & 0x80) === 0) - return value <= 0xffffffff - ? { value, next: offset + index + 1 } - : undefined; - } - return undefined; - }; const readString = ( offset: number, end: number, @@ -103,7 +89,7 @@ export const parseRiscvAttributes = ( let cursor = vendor.next; while (cursor < end) { const scopeStart = cursor; - const scope = readUleb(cursor, end); + const scope = readElfAttributeUleb(bytes, cursor, end); if (scope === undefined) return err("malformed RISC-V attribute scope tag"); if (scope.value !== TAG_FILE) @@ -121,7 +107,7 @@ export const parseRiscvAttributes = ( const scopeEnd = scopeStart + scopeLength; cursor = scope.next + 4; while (cursor < scopeEnd) { - const tag = readUleb(cursor, scopeEnd); + const tag = readElfAttributeUleb(bytes, cursor, scopeEnd); if (tag === undefined) return err("malformed RISC-V attribute tag"); if (tag.value % 2 === 1) { const value = readString(tag.next, scopeEnd); @@ -136,7 +122,7 @@ export const parseRiscvAttributes = ( } cursor = value.next; } else { - const value = readUleb(tag.next, scopeEnd); + const value = readElfAttributeUleb(bytes, tag.next, scopeEnd); if (value === undefined) return err( `malformed integer value for RISC-V attribute tag ${tag.value}`, diff --git a/src/domain/webModuleImports.ts b/src/domain/webModuleImports.ts index 9a142f2fb4..e96355aa18 100644 --- a/src/domain/webModuleImports.ts +++ b/src/domain/webModuleImports.ts @@ -6,6 +6,7 @@ import type { WebModuleImport } from "./webModuleTrace.js"; import type { ExportedWebScript } from "./webScriptExport.js"; import { hasUncertainTypeOnlyModuleSideEffect, + TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION, semanticStaticModuleSource, } from "./javascript/javascriptModuleDeclaration.js"; @@ -74,9 +75,7 @@ export const collectWebModuleImports = ( diagnostics: [ ...(ast.errors?.map((error) => error.message) ?? []), ...(hasUncertainTypeOnlyModuleSideEffect(ast.program) - ? [ - "Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.", - ] + ? [TYPE_ONLY_MODULE_SIDE_EFFECT_LIMITATION] : []), ], }; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 6c48379b8a..fe5259201c 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -41,7 +41,7 @@ import { AnalysisProtocolError, } from "../domain/analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; -import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; +import { fixedAllowanceOptions as flutterBudget } from "../domain/fixedAllowanceRemediation.js"; import { AnalysisError, type AnalysisPartialObservation, @@ -84,8 +84,6 @@ const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024; // engine labels this keeps expanded result metadata below 512 KiB for ordinary // ABI names, independently of the archive's compressed-byte budget. const MAX_FLUTTER_ABIS = 32; -const flutterBudget = (allowance: string, kind: "parser" | "input") => - fixedAllowanceOptions(allowance, kind); /** A complete AOT image carries exactly one VM and one isolate section. */ const SYMBOL_PAIR_COUNT = 2; const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index e9c213bf6e..5e2465e6b8 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -2219,7 +2219,9 @@ export class KunaSessionClient implements AnalysisClient { }, admitSpan: (operation, signal, open) => this.#admitSpan(operation, signal, open), - finishSpan: (finish) => this.#finishSpan(finish), + // Finalization shares the admission queue so the next read observes + // either completed cleanup or its retained lease. + finishSpan: (finish) => this.#enqueueSpan(finish), }); } @@ -2265,11 +2267,6 @@ export class KunaSessionClient implements AnalysisClient { }); } - /** Release or retain this descriptor before the next span is admitted. */ - #finishSpan(finish: () => Promise): Promise { - return this.#enqueueSpan(finish); - } - /** * Retry span descriptors before another read. Installation cleanup stays on * the shared scope and is not re-adjudicated here. diff --git a/src/native/pe/PeBinaryFailure.ts b/src/native/pe/PeBinaryFailure.ts new file mode 100644 index 0000000000..67315f0f96 --- /dev/null +++ b/src/native/pe/PeBinaryFailure.ts @@ -0,0 +1,154 @@ +import { + 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 { + artifactFilePrimaryFacts, + artifactFileCancellationCause, + type ArtifactFilePrimaryFacts, +} from "../../artifacts/ArtifactFileOperation.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, + type ResourceConstraintRecovery, +} from "../../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; + +interface PeBinaryInput { + readonly path: string; + readonly max_file_bytes: number; +} +interface PeBinaryFailureOptions { + readonly operation: string; + readonly providerId: string; + readonly fileSizeRemediation: () => string; + readonly limit: (cause: BinaryResourceFailure) => Limit | null; + readonly limitRecovery: (limit: Limit | null) => ResourceConstraintRecovery; +} + +const peBinaryFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: PeBinaryInput, + options: PeBinaryFailureOptions, +): AnalysisError => { + const { operation, providerId } = options; + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + operation, + input.path, + cause.message, + { cause }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof BinaryResourceFailure) { + const limit = options.limit(cause); + return new AnalysisResourceConstraintError( + operation, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum_bytes, limit }, + { cause, ...options.limitRecovery(limit) }, + ); + } + if (cause instanceof BinaryFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + operation, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${input.path}: ${cause.message}`, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: options.fileSizeRemediation(), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError( + operation, + { cause, ...stableArtifactInputRecovery(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 ProviderAdapterError(providerId, operation, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +/** Keep one PE error interpretation while preserving the provider's cleanup and partial facts. */ +export const projectPeBinaryFailure = ( + cause: unknown, + input: PeBinaryInput, + options: PeBinaryFailureOptions, + snapshot?: StableArtifactSnapshot, +): AnalysisError => + projectStableArtifactFailure( + cause, + peBinaryFailurePrimary(artifactFilePrimaryFacts(cause), input, options), + input.path, + options.providerId, + options.operation, + snapshot, + ); diff --git a/src/native/pe/PeImportParser.ts b/src/native/pe/PeImportParser.ts index 842144fc85..ece8710151 100644 --- a/src/native/pe/PeImportParser.ts +++ b/src/native/pe/PeImportParser.ts @@ -1,3 +1,8 @@ +import { + fitsPeRvaSpan as fitsRva, + appendPeConsumedSpan as consume, + PE_RVA_END, +} from "./PeRvaSpans.js"; import { createPefileReadCompatibility } from "./PefileReadCompatibility.js"; import { setImmediate } from "node:timers/promises"; import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; @@ -35,15 +40,6 @@ const MAXIMUM_NAME_BYTES = 4096; const ENTRIES_PER_YIELD = 1024; const ORDINAL_FLAG32 = 0x80000000n; const ORDINAL_FLAG64 = 0x8000000000000000n; -const PE_RVA_END = 0x100000000; - -/** PE32+ keeps 32-bit RVAs even though preferred virtual addresses are 64-bit. */ -const fitsRva = (rva: number, size: number): boolean => - Number.isSafeInteger(rva) && - rva >= 0 && - Number.isSafeInteger(size) && - size > 0 && - rva <= PE_RVA_END - size; const requireRva = (rva: number, size: number): void => { if (!fitsRva(rva, size)) @@ -111,18 +107,6 @@ const failureMessage = (cause: unknown): string => { throw cause; }; -/** Append one consumed file span, merging it into the previous span when contiguous. */ -const consume = ( - spans: { offset: number; bytes: number }[], - offset: number, - bytes: number, -): void => { - const last = spans.at(-1); - if (last !== undefined && last.offset + last.bytes === offset) - last.bytes += bytes; - else spans.push({ offset, bytes }); -}; - const coverage = ( issues: readonly PeImportIssue[], examinedDlls: number, diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 4fb628a28a..7256e506bd 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -1,33 +1,15 @@ -import { - projectStableArtifactFailure, - stableArtifactInputRecovery, - projectArtifactCloseFailure, -} from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; -import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { projectPeBinaryFailure } from "./PeBinaryFailure.js"; +import { projectArtifactCloseFailure } from "../../artifacts/StableArtifactFailure.js"; import { type StableArtifactSnapshot, readStableArtifact, } from "../../artifacts/readStableArtifact.js"; -import { - artifactFilePrimaryFacts, - type ArtifactFilePrimaryFacts, - assertArtifactReadActive, - artifactFileCancellationCause, -} from "../../artifacts/ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import type { PeImportsPort } from "../../application/binaryDiagnostics/PeImportsPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; -import { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisAccessDeniedError, - AnalysisArtifactChangedError, - AnalysisCancelledError, - AnalysisInputError, - AnalysisResourceConstraintError, - AnalysisUnsupportedTargetError, - type ResourceConstraintRecovery, -} from "../../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { ResourceConstraintRecovery } from "../../domain/analysisErrorCore.js"; import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { MAX_INSPECT_PE_IMPORT_DLLS, @@ -36,13 +18,8 @@ import { MAX_INSPECT_PE_IMPORT_RETAINED_TEXT_BYTES, type InspectPeImportsInput, } from "../../domain/native/peImports.js"; -import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { err, ok } from "../../domain/result.js"; import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js"; -import { - BinaryFormatFailure, - BinaryResourceFailure, -} from "../metadata/BinaryContainer.js"; import { parsePeImports, PeImportLimitFailure, @@ -140,118 +117,21 @@ const limitRecovery = ( } }; -const peImportsFailurePrimary = ( - facts: ArtifactFilePrimaryFacts, - input: InspectPeImportsInput, -): AnalysisError => { - const { cause, origin } = facts; - if (origin === "owned-or-classified" && cause instanceof AnalysisError) - return cause; - if ( - origin === "owned-or-classified" && - cause instanceof ArtifactReaderFailure && - cause.reason === "integrity" - ) - return new AnalysisArtifactChangedError( - OPERATION, - input.path, - cause.message, - { cause }, - ); - if ( - origin === "owned-or-classified" && - cause instanceof ArtifactReaderFailure && - cause.reason === "cancelled" - ) - return new AnalysisCancelledError(OPERATION, { - cause: artifactFileCancellationCause(cause), - }); - if (cause instanceof BinaryResourceFailure) { - const limit = cause instanceof PeImportLimitFailure ? cause.limit : null; - return new AnalysisResourceConstraintError( - OPERATION, - "capacity", - `Selected artifact ${input.path}: ${cause.message}`, - { boundary: cause.boundary, maximum: cause.maximum_bytes, limit }, - { cause, ...limitRecovery(limit, input) }, - ); - } - if (cause instanceof BinaryFormatFailure) - return cause.kind === "unsupported" - ? new AnalysisUnsupportedTargetError( - OPERATION, - input.path, - cause.message, - { cause }, - ) - : new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: `Selected artifact ${input.path}: ${cause.message}`, - }, - ]); - if ( - origin === "owned-or-classified" && - cause instanceof ArtifactReaderFailure - ) { - if (cause.reason === "limit") - return new AnalysisResourceConstraintError( - OPERATION, - "file-size", - cause.message, - { max_file_bytes: input.max_file_bytes }, - { - cause, - retryable: false, - remediationAction: fileSizeRemediation(input), - }, - ); - if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError( - OPERATION, - { cause, ...stableArtifactInputRecovery(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 ProviderAdapterError(PE_IMPORTS_PROVIDER.id, OPERATION, { - cause, - diagnostics: { - path: input.path, - reason: cause instanceof Error ? cause.message : String(cause), - }, - }); -}; - const peImportsFailure = ( cause: unknown, input: InspectPeImportsInput, snapshot?: StableArtifactSnapshot, -): AnalysisError => { - const primary = peImportsFailurePrimary( - artifactFilePrimaryFacts(cause), - input, - ); - return projectStableArtifactFailure( +): AnalysisError => + projectPeBinaryFailure( cause, - primary, - input.path, - PE_IMPORTS_PROVIDER.id, - OPERATION, + input, + { + operation: OPERATION, + providerId: PE_IMPORTS_PROVIDER.id, + fileSizeRemediation: () => fileSizeRemediation(input), + limit: (failure) => + failure instanceof PeImportLimitFailure ? failure.limit : null, + limitRecovery: (limit) => limitRecovery(limit, input), + }, snapshot, ); -}; diff --git a/src/native/pe/PeLoadConfigParser.test.ts b/src/native/pe/PeLoadConfigParser.test.ts index beb6fd137a..692d0ac20a 100644 --- a/src/native/pe/PeLoadConfigParser.test.ts +++ b/src/native/pe/PeLoadConfigParser.test.ts @@ -34,6 +34,10 @@ describe.each([32, 64] as const)("PE%s load configuration", (bits) => { const report = await parse({ bits }); expect(peLoadConfigSchema.safeParse(report).success).toBe(true); expect(report.tls?.callbacks).toHaveLength(3); + // The callback array starts at file offset 0x800 and includes its null slot. + expect(report.tls?.callback_array_locations).toEqual([ + { offset: 0x800, bytes: bits === 32 ? 16 : 32 }, + ]); expect(report.tls?.callbacks[0]).toMatchObject({ rva: 0x2000, file_offset: 0x1600, diff --git a/src/native/pe/PeLoadConfigParser.ts b/src/native/pe/PeLoadConfigParser.ts index 18663530e1..9ea86dd00b 100644 --- a/src/native/pe/PeLoadConfigParser.ts +++ b/src/native/pe/PeLoadConfigParser.ts @@ -1,3 +1,7 @@ +import { + fitsPeRvaSpan as fitsRva, + appendPeConsumedSpan as consume, +} from "./PeRvaSpans.js"; import { setImmediate } from "node:timers/promises"; import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; import { @@ -29,7 +33,6 @@ import { const PE_TLS_DIRECTORY = 9; const PE_LOAD_CONFIG_DIRECTORY = 10; -const PE_RVA_END = 0x100000000; /** Callback slots decoded between event-loop yields and cancellation checks. */ const ENTRIES_PER_YIELD = 1024; @@ -50,25 +53,6 @@ export class PeLoadConfigLimitFailure extends BinaryResourceFailure { const hex = (value: bigint | number): string => `0x${value.toString(16)}`; -const fitsRva = (rva: number, size: number): boolean => - Number.isSafeInteger(rva) && - rva >= 0 && - Number.isSafeInteger(size) && - size > 0 && - rva <= PE_RVA_END - size; - -/** Append one consumed file span, merging it when the previous span abuts it. */ -const consume = ( - spans: { offset: number; bytes: number }[], - offset: number, - bytes: number, -): void => { - const last = spans.at(-1); - if (last !== undefined && last.offset + last.bytes === offset) - last.bytes += bytes; - else spans.push({ offset, bytes }); -}; - type DirectorySpan = PeLoadConfigTls["directory"]; /** One run of RVAs with a constant set of owning sections. */ diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index 00b5841ba9..f430776673 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -1,46 +1,23 @@ -import { - projectStableArtifactFailure, - stableArtifactInputRecovery, - projectArtifactCloseFailure, -} from "../../artifacts/StableArtifactFailure.js"; -import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; -import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { projectPeBinaryFailure } from "./PeBinaryFailure.js"; +import { projectArtifactCloseFailure } from "../../artifacts/StableArtifactFailure.js"; import { type StableArtifactSnapshot, readStableArtifact, } from "../../artifacts/readStableArtifact.js"; -import { - artifactFilePrimaryFacts, - type ArtifactFilePrimaryFacts, - assertArtifactReadActive, - artifactFileCancellationCause, -} from "../../artifacts/ArtifactFileOperation.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import type { PeLoadConfigPort } from "../../application/binaryDiagnostics/PeLoadConfigPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; -import { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisAccessDeniedError, - AnalysisArtifactChangedError, - AnalysisCancelledError, - AnalysisInputError, - AnalysisResourceConstraintError, - AnalysisUnsupportedTargetError, - type ResourceConstraintRecovery, -} from "../../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { ResourceConstraintRecovery } from "../../domain/analysisErrorCore.js"; import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { MAX_INSPECT_PE_LOAD_CONFIG_FILE_BYTES, MAX_INSPECT_PE_LOAD_CONFIG_CALLBACKS, type InspectPeLoadConfigInput, } from "../../domain/native/peLoadConfig.js"; -import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { err, ok } from "../../domain/result.js"; import { PE_LOAD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js"; -import { - BinaryFormatFailure, - BinaryResourceFailure, -} from "../metadata/BinaryContainer.js"; import { parsePeLoadConfig, PeLoadConfigLimitFailure, @@ -116,119 +93,21 @@ const limitRecovery = ( } : fixedAllowanceOptions("The PE structural decoding allowance", "parser"); -const peLoadConfigFailurePrimary = ( - facts: ArtifactFilePrimaryFacts, - input: InspectPeLoadConfigInput, -): AnalysisError => { - const { cause, origin } = facts; - if (origin === "owned-or-classified" && cause instanceof AnalysisError) - return cause; - if ( - origin === "owned-or-classified" && - cause instanceof ArtifactReaderFailure && - cause.reason === "integrity" - ) - return new AnalysisArtifactChangedError( - OPERATION, - input.path, - cause.message, - { cause }, - ); - if ( - origin === "owned-or-classified" && - cause instanceof ArtifactReaderFailure && - cause.reason === "cancelled" - ) - return new AnalysisCancelledError(OPERATION, { - cause: artifactFileCancellationCause(cause), - }); - if (cause instanceof BinaryResourceFailure) { - const limit = - cause instanceof PeLoadConfigLimitFailure ? cause.limit : null; - return new AnalysisResourceConstraintError( - OPERATION, - "capacity", - `Selected artifact ${input.path}: ${cause.message}`, - { boundary: cause.boundary, maximum: cause.maximum_bytes, limit }, - { cause, ...limitRecovery(limit, input) }, - ); - } - if (cause instanceof BinaryFormatFailure) - return cause.kind === "unsupported" - ? new AnalysisUnsupportedTargetError( - OPERATION, - input.path, - cause.message, - { cause }, - ) - : new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: `Selected artifact ${input.path}: ${cause.message}`, - }, - ]); - if ( - origin === "owned-or-classified" && - cause instanceof ArtifactReaderFailure - ) { - if (cause.reason === "limit") - return new AnalysisResourceConstraintError( - OPERATION, - "file-size", - cause.message, - { max_file_bytes: input.max_file_bytes }, - { - cause, - retryable: false, - remediationAction: fileSizeRemediation(input), - }, - ); - if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError( - OPERATION, - { cause, ...stableArtifactInputRecovery(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 ProviderAdapterError(PE_LOAD_CONFIG_PROVIDER.id, OPERATION, { - cause, - diagnostics: { - path: input.path, - reason: cause instanceof Error ? cause.message : String(cause), - }, - }); -}; - const peLoadConfigFailure = ( cause: unknown, input: InspectPeLoadConfigInput, snapshot?: StableArtifactSnapshot, -): AnalysisError => { - const primary = peLoadConfigFailurePrimary( - artifactFilePrimaryFacts(cause), - input, - ); - return projectStableArtifactFailure( +): AnalysisError => + projectPeBinaryFailure( cause, - primary, - input.path, - PE_LOAD_CONFIG_PROVIDER.id, - OPERATION, + input, + { + operation: OPERATION, + providerId: PE_LOAD_CONFIG_PROVIDER.id, + fileSizeRemediation: () => fileSizeRemediation(input), + limit: (failure) => + failure instanceof PeLoadConfigLimitFailure ? failure.limit : null, + limitRecovery: (limit) => limitRecovery(limit, input), + }, snapshot, ); -}; diff --git a/src/native/pe/PeRvaSpans.ts b/src/native/pe/PeRvaSpans.ts new file mode 100644 index 0000000000..be9be41611 --- /dev/null +++ b/src/native/pe/PeRvaSpans.ts @@ -0,0 +1,22 @@ +/** PE32+ retains 32-bit RVAs even where virtual addresses are 64-bit. */ +export const PE_RVA_END = 0x100000000; + +/** Whether a nonempty span fits within the 32-bit RVA space. */ +export const fitsPeRvaSpan = (rva: number, size: number): boolean => + Number.isSafeInteger(rva) && + rva >= 0 && + Number.isSafeInteger(size) && + size > 0 && + rva <= PE_RVA_END - size; + +/** Append a consumed file span, merging only with a contiguous predecessor. */ +export const appendPeConsumedSpan = ( + spans: { offset: number; bytes: number }[], + offset: number, + bytes: number, +): void => { + const last = spans.at(-1); + if (last !== undefined && last.offset + last.bytes === offset) + last.bytes += bytes; + else spans.push({ offset, bytes }); +}; diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index 225c5a11b8..8a4d04fa0e 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -473,11 +473,11 @@ export class SigrokRecordingProvider implements SignalRecordingPort { workspacePath: string | null, effectiveLimits: LinuxResourceLimits | undefined, observedOutput: AnalysisCapturedOutput | undefined, - operation = OPERATION, + operation: string, limits: { readonly output_bytes: number; readonly command_timeout_ms: number; - } = LIMITS, + }, ): AnalysisError { if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) diff --git a/tests/acceptance/apkSignatureViewsCliMcp.test.ts b/tests/acceptance/apkSignatureViewsCliMcp.test.ts index 743b67022a..ba60352800 100644 --- a/tests/acceptance/apkSignatureViewsCliMcp.test.ts +++ b/tests/acceptance/apkSignatureViewsCliMcp.test.ts @@ -388,3 +388,34 @@ cliTest( }); }, ); + +cliTest( + "rejects malformed APK signature view JSON before opening the selected path", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-view-json-"); + const result = await cli.run({ + arguments: [ + "inspect-apk-signature", + join(root, "missing.apk"), + "--view", + "{", + "--json", + ], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_apk_signature", + issues: [ + { + path: ["view"], + reason: "invalid_format", + message: "view must be a JSON APK signature selection", + }, + ], + }, + }); + expect(JSON.stringify(result.json)).not.toContain("ENOENT"); + }, +); diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index f2b503947c..d9f44a0ef6 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -718,3 +718,34 @@ cliTest( expect(report.auditable_metadata?.packages).toHaveLength(2); }, ); + +cliTest( + "rejects malformed Rust view JSON before opening the selected path", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-view-json-"); + const result = await cli.run({ + arguments: [ + "inspect-rust-binary", + join(root, "missing"), + "--view", + "{", + "--json", + ], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_rust_binary", + issues: [ + { + path: ["view"], + reason: "invalid_format", + message: "view must be a JSON package or edge selection", + }, + ], + }, + }); + expect(JSON.stringify(result.json)).not.toContain("ENOENT"); + }, +); diff --git a/tests/boundary/cli/androidPackageViewCli.test.ts b/tests/boundary/cli/androidPackageViewCli.test.ts index 6a6ac98aa8..bd52a6ff0a 100644 --- a/tests/boundary/cli/androidPackageViewCli.test.ts +++ b/tests/boundary/cli/androidPackageViewCli.test.ts @@ -104,3 +104,36 @@ cliTest.for([ }); }, ); + +cliTest( + "rejects malformed JNI query JSON before provider work", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "inspect-android-jni-bindings", + "/nonexistent/selected.apk", + "--query", + "{", + "--json", + ], + environment: { + REA_JADX_MCP_JAR: "/nonexistent/engine.jar", + REA_LOG_LEVEL: "silent", + }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_android_jni_bindings", + issues: [ + { + path: ["query"], + reason: "invalid_format", + message: "query must be a JSON selection object.", + }, + ], + }, + }); + }, +); diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index 38257e6cd7..04c5f9ddc0 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -12,6 +12,7 @@ import { } from "yaml"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { DshCordisDocument } from "../../../src/application/DshCordisPatch.js"; import { configureClientConfiguration, @@ -36,6 +37,37 @@ const command = currentClientRegistrationCommand({ beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); +it("normalizes DSH expression identity on Windows and accepts a literal alias override", () => { + const document = new DshCordisDocument(`- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + env: + rea_ghidra_startup_timeout_ms: !!js process.env.STARTUP + caller_value: keep +`); + const selected = document.environment({}, "effective", "win32"); + expect(selected.ok).toBe(true); + if (!selected.ok) throw selected.error; + expect([...selected.value.unresolvedSettings]).toEqual([ + "REA_GHIDRA_STARTUP_TIMEOUT_MS", + ]); + expect(selected.value.values).toEqual({ caller_value: "keep" }); + + const overridden = document.environment( + { REA_GHIDRA_STARTUP_TIMEOUT_MS: "45000" }, + "effective", + "win32", + ); + expect(overridden.ok).toBe(true); + if (!overridden.ok) throw overridden.error; + expect([...overridden.value.unresolvedSettings]).toEqual([]); + expect(overridden.value.values).toEqual({ + caller_value: "keep", + REA_GHIDRA_STARTUP_TIMEOUT_MS: "45000", + }); +}); + const dshClient = ( env: Parameters[2], platform: NodeJS.Platform = "linux", diff --git a/tests/boundary/filesystem/embeddedCarrierCancellation.test.ts b/tests/boundary/filesystem/embeddedCarrierCancellation.test.ts new file mode 100644 index 0000000000..79b7f9846a --- /dev/null +++ b/tests/boundary/filesystem/embeddedCarrierCancellation.test.ts @@ -0,0 +1,32 @@ +import { open, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { locateDenoStandalone } from "../../../src/artifacts/deno/DenoStandaloneLocation.js"; +import { locateNodeSea } from "../../../src/artifacts/nodeSea/NodeSeaLocation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + ["Deno", locateDenoStandalone], + ["Node SEA", locateNodeSea], +] as const)( + "retains cancellation arriving with carrier EOF for %s", + async (_name, locate) => { + const root = await createTestTempDirectory("rea-carrier-eof-"); + const path = join(root, "carrier"); + await writeFile(path, Buffer.alloc(64)); + const handle = await open(path, "r"); + onTestFinished(() => handle.close()); + const controller = new AbortController(); + const reason = new Error("cancelled while reading"); + vi.spyOn(handle, "read").mockImplementationOnce((async ( + buffer: unknown, + ) => { + controller.abort(reason); + return { bytesRead: 0, buffer }; + }) as typeof handle.read); + await expect(locate(handle, 64, controller.signal)).rejects.toMatchObject({ + reason: "cancelled", + cause: reason, + }); + }, +); diff --git a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts index d1887c62e6..c261085e4d 100644 --- a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts @@ -25,6 +25,8 @@ import { type ApplicationMcpHarness, } from "../../fixtures/applicationMcpHarness.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE } from "../../../src/contracts/managed/managedWorkflowExamples.js"; const COLLECTIONS = [ "types", @@ -360,3 +362,176 @@ it("keeps collections that historical member Evidence never examined unknown", a }), ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); }, 30000); + +it("binds managed member views to their recorded token scope", async () => { + const original = MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE.static_members; + const report = managedMemberInspectionSchema.parse( + original.normalized_result, + ); + if (report.module?.mvid == null) throw new Error("Fixture requires an MVID"); + const mismatchedDigest = "f".repeat(64); + const mismatchedMvid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const cases = [ + { + name: "digest", + result: { + ...report, + identity_scope: { + ...report.identity_scope, + requires_artifact_sha256: mismatchedDigest, + }, + }, + error: "token scope SHA-256", + }, + { + name: "MVID", + result: { + ...report, + identity_scope: { + ...report.identity_scope, + requires_mvid: mismatchedMvid, + }, + }, + error: "token scope MVID", + }, + { + name: "missing module", + result: { ...report, module: null }, + error: "token scope MVID", + }, + { + name: "case-insensitive MVID", + result: { + ...report, + module: { + ...report.module, + mvid: "abcdef12-3456-4abc-8def-123456789abc", + }, + identity_scope: { + ...report.identity_scope, + requires_mvid: "ABCDEF12-3456-4ABC-8DEF-123456789ABC", + }, + }, + error: undefined, + }, + { + name: "null MVID", + result: { + ...report, + module: null, + identity_scope: { ...report.identity_scope, requires_mvid: null }, + }, + error: undefined, + }, + ]; + for (const test of cases) { + for (const subject of [true, false]) { + const evidence = createEvidence( + subject + ? { + path: report.artifact.path, + format: "pe", + sha256: report.artifact.sha256, + } + : undefined, + original.provider, + { + operation: original.operation, + parameters: original.parameters, + result: test.result, + }, + ); + for (const view of [ + { kind: "summary" as const }, + { + kind: "managed-members" as const, + collection: "methods" as const, + offset: 0, + limit: 1, + }, + ]) { + const actual = await inspectAnalysisView({ + source: { kind: "inline", evidence }, + view, + }); + if (test.error === undefined) { + expect(actual.ok, test.name).toBe(true); + if (!actual.ok) continue; + if (!subject) + expect(JSON.stringify(actual.value.normalized_result)).toContain( + "no recorded subject", + ); + } else { + expect(actual, test.name).toMatchObject({ + ok: false, + error: { + _tag: "EvidenceIntegrityError", + message: expect.stringContaining(test.error), + }, + }); + } + } + } + } + + const incompatible = createEvidence( + { path: report.artifact.path, format: "pe", sha256: mismatchedDigest }, + original.provider, + { + operation: original.operation, + parameters: original.parameters, + result: { + ...report, + identity_scope: { + ...report.identity_scope, + requires_artifact_sha256: mismatchedDigest, + }, + }, + }, + ); + const actual = await inspectAnalysisView({ + source: { kind: "inline", evidence: incompatible }, + view: { kind: "summary" }, + }); + expect(actual).toMatchObject({ + ok: false, + error: { + _tag: "EvidenceIntegrityError", + message: expect.stringContaining("Parent subject SHA-256"), + }, + }); +}); + +it("rejects a contradictory managed token scope through MCP for inline and retained Evidence", async () => { + const original = MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE.static_members; + const report = managedMemberInspectionSchema.parse( + original.normalized_result, + ); + const evidence = createEvidence(undefined, original.provider, { + operation: original.operation, + parameters: original.parameters, + result: { + ...report, + identity_scope: { + ...report.identity_scope, + requires_artifact_sha256: "f".repeat(64), + }, + }, + }); + const { client, session } = await createHarness(); + expect(session.recordEvidence(evidence).ok).toBe(true); + for (const source of [ + { kind: "inline" as const, evidence }, + { kind: "retained-evidence" as const, evidence_id: evidence.evidence_id }, + ]) { + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { source, view: { kind: "summary" } }, + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + details: { reason: expect.stringContaining("token scope SHA-256") }, + }); + } +}); diff --git a/tests/fixtures/cutter_bridge_protocol_test.py b/tests/fixtures/cutter_bridge_protocol_test.py index 957731cf18..e9face4610 100644 --- a/tests/fixtures/cutter_bridge_protocol_test.py +++ b/tests/fixtures/cutter_bridge_protocol_test.py @@ -39,6 +39,19 @@ sys.modules[spec.name] = bridge spec.loader.exec_module(bridge) +def _encoded_response(server, value, client_nonce, server_nonce): + """Capture the production delivery path without its fallback response.""" + output = bytearray() + + class Capture: + def sendall(self, data): + output.extend(data) + + if not server._deliver_authenticated(Capture(), value, client_nonce, server_nonce): + return None + return bytes(output) + + def _frame(connection): data = bytearray() while b"\n" not in data: @@ -251,23 +264,22 @@ class ResponseEncodingTest(unittest.TestCase): with self.subTest(suffix=suffix): output = "a" * (5 * 1024 * 1024 - 1) + suffix value = {"ok": True, "output": output, "execution_state": "complete"} - encoded = self.server._encode_authenticated_response(value, "a" * 64, "b" * 64) + encoded = _encoded_response(self.server, value, "a" * 64, "b" * 64) self.assertIsNotNone(encoded) self.assertLessEqual(len(encoded), bridge.MAX_FRAME_BYTES) frame = json.loads(encoded) self.assertEqual(self.assert_authenticated_response(frame), value) - self.assertIsNone(self.server._encode_authenticated_response({ + self.assertIsNone(_encoded_response(self.server, { "ok": True, "output": "😀" * (5 * 1024 * 1024), "execution_state": "complete", }, "a" * 64, "b" * 64)) - def test_streamed_frame_matches_the_encoded_frame(self): + def test_streamed_frame_preserves_unicode_and_authentication(self): value = { "ok": True, "output": 'say "hi"\\\n雪\u2028', "execution_state": "complete", } - encoded = self.server._encode_authenticated_response(value, "a" * 64, "b" * 64) sender, receiver = socket.socketpair() try: receiver.settimeout(2) @@ -282,8 +294,8 @@ class ResponseEncodingTest(unittest.TestCase): finally: sender.close() receiver.close() - self.assertEqual(bytes(streamed), encoded) - self.assertEqual(json.loads(json.loads(encoded)["response"]), value) + frame = json.loads(bytes(streamed)) + self.assertEqual(self.assert_authenticated_response(frame), value) def test_json_pieces_stay_within_one_chunk(self): output = 'say "hi"\\\n雪\u0001' * 20000 @@ -891,7 +903,8 @@ class BridgeRequestCeilingTest(unittest.TestCase): server.session_id = "00000000-0000-4000-8000-000000000001" def encode(length): - return server._encode_authenticated_response( + return _encoded_response( + server, { "ok": True, "output": "A" * length, @@ -942,10 +955,11 @@ class BridgeRequestCeilingTest(unittest.TestCase): "completed_connection": "f" * 16, } self.assertIsNone( - server._encode_authenticated_response(status, "a" * 64, "b" * 64) + _encoded_response(server, status, "a" * 64, "b" * 64) ) required = server._bounded_status_response(status) - encoded = server._encode_authenticated_response( + encoded = _encoded_response( + server, required, "a" * 64, "b" * 64 ) self.assertIsNotNone(encoded) @@ -993,7 +1007,7 @@ class BridgeRequestCeilingTest(unittest.TestCase): "identity_status": "partial", } self.assertIsNone( - server._encode_authenticated_response(value, "a" * 64, "b" * 64) + _encoded_response(server, value, "a" * 64, "b" * 64) ) captured = bytearray() @@ -1028,7 +1042,7 @@ class BridgeRequestCeilingTest(unittest.TestCase): server = module._BridgeServer.__new__(module._BridgeServer) server.token = "token" server.session_id = "00000000-0000-4000-8000-000000000001" - encoded = server._encode_authenticated_response(value, "a" * 64, "b" * 64) + encoded = _encoded_response(server, value, "a" * 64, "b" * 64) self.assertIsNotNone(encoded) response = json.loads(json.loads(encoded)["response"]) self.assertEqual(response["output"], zeros)