Compare commits

...
Author SHA1 Message Date
morluto f2cffbdfdc fix(pcap): preserve executable read failures 2026-10-22 06:09:50 +08:00
morluto 5e046bb374 fix(pcap): read historical capture evidence 2026-10-22 06:05:55 +08:00
morluto 4924706e25 fix(pcap): report missing selected tools clearly 2026-10-22 05:52:17 +08:00
morluto 211d4b2219 perf(pcap): retain compact executable identity 2026-10-22 05:47:11 +08:00
morluto cb6dc4f3ef fix(pcap): share selected tool identity verification 2026-10-22 05:40:52 +08:00
Kaoru0822andClaude Opus 5.5 658c29b7fc chore: restore main's copies of files the merge hook reformatted
The pre-commit oxlint --fix touched two files this PR does not change; keep them identical to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:22:28 +00:00
Kaoru0822andClaude Opus 5.5 a22adc7809 Merge origin/main into refactor/tshark-single-owner
Resolve the node:fs/promises import conflict in pcapProviderCrossPlatform.test.ts by importing open, symlink and writeFile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:04:35 +00:00
morluto d0067ba34a fix(native)!: preserve Swift pointer nullability and import bindings
Resolve relative pointers using field-owned ABI encodings. Keep null, raw, rebased, imported and unknown targets distinct, and retain imported conformance rows with exact Mach-O binding provenance. Decode legacy rebase membership with source-derived memory and work bounds, preserving validated prefix facts when streams are incomplete.

Validation: check:ci, docs:check and build passed; 158 source tests and 20 schema/acceptance tests passed. Ten source-built Apple dispatch variants and four issue-specific CLI/MCP workflows passed without executing targets. Paired cold/warm CLI/MCP cost observations retained locally; model tokens and maximum-sized input runtime were not measured.

Closes #5683
Closes #5685

BREAKING CHANGE: Swift conformance type_name and protocol_name can be null when names cannot be resolved; consumers must use typed type_binding and protocol_binding evidence. MachoImportBinding replaces the former Objective-C-specific binding type and schema.
2026-10-21 08:17:12 +08:00
555aaditya e74e4d8f0b Merge remote-tracking branch 'origin/main' into refactor/tshark-single-owner 2026-10-14 16:28:29 +05:30
555aaditya f4eeba3654 fix(pcap): execute the TShark the shared locator selects
resolvePcapTools still returned bare tool names for PATH selections, so
the operating system could run a tool from a relative PATH entry while
the packet tools skipped that entry and selected a different install.

On POSIX hosts, resolve PATH selections through locateWiresharkTool and
run that absolute executable, failing with the existing tshark_missing or
capinfos_missing code when no absolute PATH entry holds the tool. Both
selections are validated before PATH is consulted, so a relative
environment value still reports its own configuration error. Windows
keeps PATH/PATHEXT resolution in the process launcher.

Refs #3403
2026-10-14 16:28:29 +05:30
555aaditya bbecf6fb05 refactor(pcap): give TShark selection and version admission one owner
The pcap and packet-capture families wrapped the same caller-supplied
TShark with diverging rules. inspect_pcap_* accepted an absolute
REA_TSHARK_COMMAND or tshark from PATH, while inspect_packet_* and the
server availability policy required an absolute REA_TSHARK_COMMAND and
parsed the version banner with a separate regex.

PcapConfiguration now owns the rule: selectWiresharkTool selects an
absolute environment path or the bare name on PATH and refuses relative
values; locateWiresharkTool resolves a PATH selection to the first
absolute PATH entry holding an executable file; parseWiresharkVersion
admits Wireshark 4.x banners for both providers. TsharkCaptureProvider
and the session availability policy consume these facts instead of
reinterpreting REA_TSHARK_COMMAND, so all four tools accept and refuse
the same configurations. inspect_packet_* still resolves, hashes and
rechecks the located executable, and records selection_source in its
tool Evidence.

Host support and resource bounds are unchanged; unifying them is the
next milestone in #3403.

Refs #3403
2026-10-14 15:05:58 +05:30
33 changed files with 2771 additions and 422 deletions

No files matched your search

+12 -1
View File
@@ -432,7 +432,18 @@ complete observation.
Pointers are decoded through the image's fixups:
- `LC_DYLD_CHAINED_FIXUPS` rebases and binds, including the authenticated
arm64e formats;
- legacy `LC_DYLD_INFO` bind opcodes.
- legacy `LC_DYLD_INFO` rebase and bind opcodes.
A Swift conformance whose type or protocol reference is an external bind
keeps its name as `null` and carries the raw dyld identity in
`type_binding` or `protocol_binding`; that record and its facet coverage are
partial. Consumers of this output must handle nullable conformance names
and use the typed binding fields instead of substituting a symbol as a
display name. Indirect relative-pointer records also retain their binding
identity. A decoded rebase to virtual address zero remains an address; only
an unfixed zero in a nullable ABI field is treated as null. Incomplete or
conflicting fixup streams keep affected pointers unresolved; coverage reports
parsing and resource limits while retaining validated prefix facts.
So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for
example `NSObject`). The `pointer_fixups` coverage facet names the mechanism.
+9 -2
View File
@@ -10,10 +10,17 @@ Supply an existing TShark 4.x installation **on Linux**, with util-linux
`prlimit` available at `/usr/bin/prlimit`. Override its absolute path with
`REA_TSHARK_PRLIMIT_COMMAND` if required. Unsupported hosts, including macOS
and Windows, are refused before inspection because this integration requires
an enforced address-space limit:
an enforced address-space limit.
TShark selection follows the same rule as [`inspect_pcap_capture`](pcap-analysis.md):
an absolute `REA_TSHARK_COMMAND`, otherwise `tshark` from the first absolute
`PATH` entry that holds an executable file. A relative `REA_TSHARK_COMMAND` is
refused rather than looked up. Either way the selected executable is resolved,
hashed and rechecked after each run, and Evidence records its
`selection_source` (`environment` or `path`):
```sh
export REA_TSHARK_COMMAND=/absolute/path/to/tshark
export REA_TSHARK_COMMAND=/absolute/path/to/tshark # optional when tshark is on PATH
rea inspect-packet-capture /artifacts/device.pcapng \
--fields ip.addr --fields udp.port --fields data.data \
--display_filter 'udp' --format json
+10 -1
View File
@@ -17,7 +17,16 @@ rea inspect-pcap-capture /captures/session.pcapng --json
```
Without explicit tool commands, this family resolves them through PATH and
records that selection source. Client inspection reads no capture file. Capture
records that selection source. On Linux and macOS it runs the tool found in the
first absolute PATH entry holding an executable file, the same selection the
[packet capture tools](packet-captures.md) make; relative PATH entries are
skipped. Windows keeps PATH/PATHEXT resolution in the process launcher. Client
inspection reads no capture file; on hosts where PATH/PATHEXT does not expose
an absolute candidate, its executable digest and resolved path are reported as
unresolved. Otherwise the canonical executable path and SHA-256 are reported.
The pcap provider and packet tools share the selected-tool identity check, and
changes to a resolved executable during a run fail with artifact-changed
Evidence. Capture
inspection requires Linux with util-linux prlimit or Windows with owned Job
Objects. macOS capture inspection returns a capability gap because the new
native memory boundary is unverified; its version probes remain available.
+3 -1
View File
@@ -30,7 +30,7 @@ const LINK_MODES = [
{
mode: "dyld-info",
flags: ["-Wl,-no_fixup_chains"],
fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
fixups: "LC_DYLD_INFO rebase opcodes",
swift: true,
},
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
@@ -70,6 +70,7 @@ const inspect = async (path) => {
};
const checkObjc = (metadata, mode) => {
assert.equal(fixupCoverage(metadata)?.status, "complete");
const fixture = metadata.objc_classes.find(
({ name, is_meta_class: meta }) => name === "ReaDispatchFixture" && !meta,
);
@@ -159,6 +160,7 @@ const checkObjc = (metadata, mode) => {
};
const checkSwift = (metadata, mode) => {
assert.equal(fixupCoverage(metadata)?.status, "complete");
if (
!metadata.swift_dispatch_slots.some(
(slot) =>
+1 -1
View File
@@ -478,7 +478,7 @@ const workflowAvailabilityFor = ({
: {
reason: "provider_missing",
remediation:
"On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
"On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
};
if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
return policy.signalRecordingEnabled === true
+27 -18
View File
@@ -57,6 +57,14 @@ import {
OwnedCommandFailure,
} from "../process/OwnedCommand.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
TSHARK_COMMAND_ENV,
assertWiresharkExecutableIdentity,
wiresharkExecutableIdentity,
locateWiresharkTool,
parseWiresharkVersion,
selectWiresharkTool,
} from "../pcap/PcapConfiguration.js";
const OPERATION = "inspect_packet_capture";
export const TSHARK_PROVIDER_IDENTITY = {
@@ -139,18 +147,22 @@ export class TsharkCaptureProvider implements PacketCapturePort {
await this.#resources.release();
if (this.platform !== "linux")
throw new Error(
"Packet inspection requires Linux with util-linux prlimit and REA_TSHARK_COMMAND so expanded captures are processed under an enforced address-space limit.",
"Packet inspection requires Linux with util-linux prlimit and TShark 4.x (on PATH or through absolute REA_TSHARK_COMMAND) so expanded captures are processed under an enforced address-space limit.",
);
const selection = selectWiresharkTool(this.environment, "tshark");
const configured = locateWiresharkTool(selection, this.environment);
if (configured === undefined)
throw new Error(
`TShark 4.x was not found on PATH; add it to PATH or set absolute ${TSHARK_COMMAND_ENV}.`,
);
const configured = this.environment.REA_TSHARK_COMMAND ?? "";
if (!isAbsolute(configured))
throw new Error("Supply absolute REA_TSHARK_COMMAND for TShark 4.x.");
const command = await realpath(configured);
await access(command, constants.X_OK);
const tool = await this.#resources.read(
const toolSnapshot = await this.#resources.read(
command,
64 * 1024 * 1024,
options?.signal,
);
const tool = wiresharkExecutableIdentity(toolSnapshot);
const configuredLimiter =
this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
if (!isAbsolute(configuredLimiter))
@@ -220,13 +232,13 @@ export class TsharkCaptureProvider implements PacketCapturePort {
"Configured resource limiter changed during analysis.",
{ capturedOutput },
);
if (current.sha256 !== tool.sha256)
throw new AnalysisArtifactChangedError(
operation,
command,
"Configured TShark executable changed during analysis.",
{ capturedOutput },
);
assertWiresharkExecutableIdentity(
operation,
command,
tool,
wiresharkExecutableIdentity(current),
{ capturedOutput },
);
phase = previousPhase;
resourcePath = "path" in input ? input.path : command;
if (output.exitCode !== 0)
@@ -245,11 +257,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
};
phase = "configuration";
const version = await execute(["--version"]);
if (
!/^TShark \(Wireshark\) 4\.[0-9]+\.[0-9]+(?:[ .(\r\n]|$)/u.test(
version.stdout.text,
)
)
if (parseWiresharkVersion(version.stdout.text, "tshark") === null)
throw new Error(
"Expected TShark 4.x; inspect the selected installation.",
);
@@ -257,6 +265,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
diagnostics,
tool: {
configured_path: configured,
selection_source: selection.source,
path: command,
sha256: tool.sha256,
version_banner: version.stdout.text,
@@ -553,7 +562,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
...(observedOutput === undefined
? {}
: { capturedOutput: observedOutput }),
userMessage: `${reason} On Linux, configure TShark 4.x through REA_TSHARK_COMMAND and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`,
userMessage: `${reason} On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`,
},
);
if (
@@ -15,7 +15,7 @@ export const CAPTURE_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_packet_fields"),
kind: "artifact-provider",
description:
"Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, REA_TSHARK_COMMAND and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.",
"Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, TShark 4.x (PATH or absolute REA_TSHARK_COMMAND) and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.",
inputSchema: inspectPacketFieldsInputSchema,
outputSchema: evidenceResultOf(packetFieldsCatalogSchema),
examples: [
@@ -31,7 +31,7 @@ export const CAPTURE_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_packet_capture"),
kind: "artifact-provider",
description:
"Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.",
"Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.",
inputSchema: inspectPacketCaptureInputSchema,
outputSchema: evidenceResultOf(packetCaptureSchema),
examples: [
@@ -74,6 +74,23 @@ const diagnostics = (offset: number, limit: number) =>
limit,
}) as const;
it("reads historical capture Evidence without inventing executable identity", () => {
const result = projectAnalysisView(capture(""), { kind: "summary" });
if (!result.ok) throw result.error;
if (result.value.kind !== "summary")
throw new Error("Expected a packet summary.");
if (!("client" in result.value.summary))
throw new Error("Expected packet client facts.");
expect(result.value.summary.client).toMatchObject({
tshark_resolved_path: null,
tshark_sha256: null,
tshark_identity_status: "unresolved",
capinfos_resolved_path: null,
capinfos_sha256: null,
capinfos_identity_status: "unresolved",
});
});
it("pages diagnostics without splitting a surrogate pair", () => {
const parent = capture("ab😀cd");
expect(projectAnalysisView(parent, diagnostics(3, 2))).toMatchObject({
+2 -2
View File
@@ -1,6 +1,6 @@
import { analysisViewInputError } from "./analysisViewErrors.js";
import { z } from "zod";
import { pcapResultSchemas } from "../pcap/pcapAnalysis.js";
import { pcapEvidenceSchemas } from "../pcap/pcapAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { err, ok, type Result } from "../result.js";
@@ -15,7 +15,7 @@ import {
} from "./analysisViewCoverage.js";
import { analysisTextPage } from "./analysisViewText.js";
const captureSchema = pcapResultSchemas.inspect_pcap_capture;
const captureSchema = pcapEvidenceSchemas.inspect_pcap_capture;
const fields = captureSchema.shape;
const pageShape = {
kind: z.literal("pcap"),
+5
View File
@@ -123,6 +123,11 @@ export const packetCaptureSchema = z
diagnostics: z.string(),
tool: z.strictObject({
configured_path: z.string(),
selection_source: z
.enum(["environment", "path"])
.describe(
"Whether REA_TSHARK_COMMAND selected the tool or PATH located it",
),
path: z.string(),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
version_banner: z.string(),
+21 -17
View File
@@ -199,10 +199,24 @@ export const objcCategorySchema = z.strictObject({
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
});
/** Exact Mach-O import binding observed at a pointer slot. */
export const machoImportBindingSchema = z.strictObject({
requested_address: z.string().min(1),
symbol: z.string().min(1),
binding_source: z.enum(["chained", "bind", "weak", "lazy"]),
library_ordinal: z.number().int().min(-3).max(Number.MAX_SAFE_INTEGER),
library: z.string().nullable(),
weak: z.boolean(),
addend: z.string().regex(/^-?\d+$/u),
});
export type MachoImportBinding = z.infer<typeof machoImportBindingSchema>;
/** Swift protocol conformance and its associated witness-table location. */
export const swiftConformanceSchema = z.strictObject({
type_name: z.string().min(1),
protocol_name: z.string().min(1),
type_name: z.string().min(1).nullable(),
type_binding: machoImportBindingSchema.nullable().optional(),
protocol_name: z.string().min(1).nullable(),
protocol_binding: machoImportBindingSchema.nullable().optional(),
module: z.string().nullable(),
witness_table: nativeMetadataLocationSchema,
location: nativeMetadataLocationSchema,
@@ -218,6 +232,7 @@ export const swiftDispatchSlotSchema = z.strictObject({
requirement: z.string().nullable(),
implementation: z.string().nullable(),
implementation_address: z.string().nullable(),
implementation_binding: machoImportBindingSchema.nullable().optional(),
thunk_address: z.string().nullable(),
location: nativeMetadataLocationSchema,
decode: nativeMetadataDecodeSchema,
@@ -231,6 +246,8 @@ export const relativePointerSchema = z.strictObject({
displacement: z.number().int(),
indirectable: z.boolean(),
indirect: z.boolean().nullable(),
encoding_tag: z.number().int().nonnegative().optional(),
binding: machoImportBindingSchema.optional(),
decode: nativeMetadataDecodeSchema,
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
});
@@ -253,17 +270,7 @@ export const nativeMetadataCoverageSchema = z.strictObject({
decoded: z.number().int().nonnegative(),
});
/** Exact Mach-O import binding observed at a caller-selected pointer slot. */
export const objcClassImportBindingSchema = z.strictObject({
requested_address: z.string().min(1),
symbol: z.string().min(1),
binding_source: z.enum(["chained", "bind", "weak", "lazy"]),
library_ordinal: z.number().int().min(-3).max(Number.MAX_SAFE_INTEGER),
library: z.string().nullable(),
weak: z.boolean(),
addend: z.string().regex(/^-?\d+$/u),
});
export const objcClassImportIdentitySchema = objcClassImportBindingSchema.omit({
export const objcClassImportIdentitySchema = machoImportBindingSchema.omit({
requested_address: true,
});
export const objcClassImportBindingCoverageSchema = z.strictObject({
@@ -273,9 +280,6 @@ export const objcClassImportBindingCoverageSchema = z.strictObject({
examined: z.number().int().nonnegative(),
decoded: z.number().int().nonnegative(),
});
export type ObjcClassImportBinding = z.infer<
typeof objcClassImportBindingSchema
>;
export type ObjcClassImportBindingCoverage = z.infer<
typeof objcClassImportBindingCoverageSchema
>;
@@ -348,7 +352,7 @@ export const objcSwiftMetadataSchema = z.strictObject({
swift_conformances: z.array(swiftConformanceSchema).default([]),
swift_dispatch_slots: z.array(swiftDispatchSlotSchema).default([]),
swift_symbols: z.array(swiftSymbolSchema).default([]),
objc_class_import_bindings: z.array(objcClassImportBindingSchema).optional(),
objc_class_import_bindings: z.array(machoImportBindingSchema).optional(),
objc_class_import_binding_coverage:
objcClassImportBindingCoverageSchema.optional(),
target_selection: machoTargetSelectionSchema.optional(),
+27
View File
@@ -41,9 +41,29 @@ const client = z.strictObject({
tshark_command: z.string().min(1),
tshark_source: z.enum(["environment", "path"]),
tshark_version: z.string().min(1).nullable(),
tshark_resolved_path: localPathStringSchema.nullable(),
tshark_sha256: digestSchema.nullable(),
tshark_identity_status: z.enum(["verified", "unresolved"]),
capinfos_command: z.string().min(1),
capinfos_source: z.enum(["environment", "path"]),
capinfos_version: z.string().min(1).nullable(),
capinfos_resolved_path: localPathStringSchema.nullable(),
capinfos_sha256: digestSchema.nullable(),
capinfos_identity_status: z.enum(["verified", "unresolved"]),
});
/** Defaults for historical report Evidence written before executable identity was recorded. */
const legacyClientIdentity = client.extend({
tshark_resolved_path: localPathStringSchema.nullable().default(null),
tshark_sha256: digestSchema.nullable().default(null),
tshark_identity_status: z
.enum(["verified", "unresolved"])
.default("unresolved"),
capinfos_resolved_path: localPathStringSchema.nullable().default(null),
capinfos_sha256: digestSchema.nullable().default(null),
capinfos_identity_status: z
.enum(["verified", "unresolved"])
.default("unresolved"),
});
const endpoint = z.strictObject({
@@ -108,3 +128,10 @@ export const pcapResultSchemas = {
.optional(),
}),
} as const;
/** Read compatibility for retained capture Evidence from earlier REA versions. */
export const pcapEvidenceSchemas = {
inspect_pcap_capture: pcapResultSchemas.inspect_pcap_capture.extend({
client: legacyClientIdentity,
}),
} as const;
+6 -9
View File
@@ -1,7 +1,7 @@
import type { JsonValue } from "./jsonValue.js";
import type { AddressedName } from "./hopperValues.js";
import type {
ObjcClassImportBinding,
MachoImportBinding,
ObjcClassImportBindingCoverage,
} from "./native/objcSwiftMetadata.js";
import {
@@ -31,7 +31,7 @@ export interface ObjcClassDiscoveryCoverage {
}
export interface ObjcClassImportBindingScan {
readonly bindings: readonly ObjcClassImportBinding[];
readonly bindings: readonly MachoImportBinding[];
readonly coverage: ObjcClassDiscoveryCoverage;
}
@@ -247,22 +247,19 @@ const uniqueByAddressAndName = (
};
interface ObjcClassOutputEntry extends AddressedName {
readonly binding_identity?: Omit<
ObjcClassImportBinding,
"requested_address"
> & {
readonly binding_identity?: Omit<MachoImportBinding, "requested_address"> & {
readonly slot_addresses: readonly string[];
};
}
const coalesceBoundClassAliases = (
candidates: readonly AddressedName[],
bindings: readonly ObjcClassImportBinding[],
bindings: readonly MachoImportBinding[],
): ObjcClassOutputEntry[] => {
const bindingsByAddress = new Map(
bindings.map((binding) => [binding.requested_address, binding]),
);
const bindingKey = (binding: ObjcClassImportBinding) =>
const bindingKey = (binding: MachoImportBinding) =>
JSON.stringify([
binding.symbol,
binding.binding_source,
@@ -271,7 +268,7 @@ const coalesceBoundClassAliases = (
binding.weak,
binding.addend,
]);
const groupedBindings = new Map<string, ObjcClassImportBinding[]>();
const groupedBindings = new Map<string, MachoImportBinding[]>();
for (const binding of bindings) {
const key = bindingKey(binding);
const existing = groupedBindings.get(key);
+16 -13
View File
@@ -4,19 +4,22 @@ import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js";
import type { FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js";
const describeFixups = (fixups: PointerFixups): string => {
const unread =
fixups.unreadRebaseBytes > 0
? `LC_DYLD_INFO rebase opcodes were not decoded (${fixups.unreadRebaseBytes} bytes)`
: undefined;
const kind =
fixups.kind === "chained"
? `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}`
: fixups.kind === "dyld-info"
? "LC_DYLD_INFO bind opcodes"
: unread === undefined
? "no fixup load commands"
: undefined;
return [unread, kind].filter((line) => line !== undefined).join("; ");
const descriptions: string[] = [];
if (fixups.unreadRebaseBytes > 0)
descriptions.push(
`LC_DYLD_INFO rebase opcodes were not decoded (${fixups.unreadRebaseBytes} bytes)`,
);
if (fixups.kind === "chained")
descriptions.push(
`chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}`,
);
if (fixups.legacyRebaseStreams > 0)
descriptions.push("LC_DYLD_INFO rebase opcodes");
if (fixups.legacyBindStreams > 0)
descriptions.push("LC_DYLD_INFO bind opcodes");
return descriptions.length === 0
? "no fixup load commands"
: descriptions.join("; ");
};
/** Append the per-facet coverage of one Apple dispatch metadata decode. */
+261
View File
@@ -120,6 +120,236 @@ const fixture = (
};
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
const swiftExternalSwiftBindingImage = (
boundField: "protocol" | "type" = "protocol",
) => {
const bytes = fixture();
const sectionCommand = 32;
bytes.writeUInt32LE(232, sectionCommand + 4);
bytes.writeUInt32LE(2, sectionCommand + 64);
bytes.write("__swift5_proto", 184);
bytes.write("__DATA", 200);
bytes.writeBigUInt64LE(base + 0xa00n, 216);
bytes.writeBigUInt64LE(4n, 224);
bytes.writeUInt32LE(0xa00, 232);
bytes.writeUInt32LE(2, 236);
const dyldInfo = sectionCommand + 232;
bytes.writeUInt32LE(0x80000022, dyldInfo);
bytes.writeUInt32LE(48, dyldInfo + 4);
bytes.writeUInt32LE(0xf00, dyldInfo + 16);
const bind = Buffer.from([
0x11, // SET_DYLIB_ORDINAL_IMM 1
0x40,
...Buffer.from(`${boundField === "protocol" ? "_$sSQMp" : "_$sSiMn"}\0`),
0x70,
0x80,
0x16, // SET_SEGMENT_AND_OFFSET_ULEB segment 0 + offset 0xb00
0x90,
0x00,
]);
bytes.writeUInt32LE(bind.length, dyldInfo + 20);
const dylibCommand = dyldInfo + 48;
bytes.writeUInt32LE(0xc, dylibCommand); // LC_LOAD_DYLIB
bytes.writeUInt32LE(56, dylibCommand + 4);
bytes.writeUInt32LE(24, dylibCommand + 8);
bytes.write("/usr/lib/libswiftCore.dylib\0", dylibCommand + 24);
bytes.writeUInt32LE(3, 16); // segment, dyld info, and dependent dylib
bytes.writeUInt32LE(336, 20);
bind.copy(bytes, 0xf00);
// Conformance at 0xa10: one reference is an indirect relative pointer to
// the import slot at 0xb00; the sibling reference points to local metadata.
bytes.writeInt32LE(0x10, 0xa00);
bytes.writeInt32LE(boundField === "protocol" ? 0x1ec : 0xec, 0xa14);
bytes.writeInt32LE(0, 0xa18); // Nullable witness-table pattern.
bytes.writeUInt32LE(boundField === "protocol" ? 0 : 8, 0xa1c);
bytes.writeInt32LE(boundField === "protocol" ? 0xf1 : 0x2f0, 0xa10); // Protocol reference: imported indirect slot or local descriptor.
bytes.writeBigUInt64LE(0n, 0xb00); // Bound slot has no in-image pointer.
bytes.writeUInt32LE(16, 0xc00);
bytes.writeInt32LE(0x1f8, 0xc08);
if (boundField === "type") {
bytes.writeUInt32LE(3, 0xd00);
bytes.writeInt32LE(0xf8, 0xd08);
bytes.write("Equatable\0", 0xe00);
} else {
bytes.write("Widget\0", 0xe00);
}
return bytes;
};
const swiftBoundWitnessSlotImage = () => {
const bytes = fixture();
const sectionCommand = 32;
bytes.writeUInt32LE(232, sectionCommand + 4);
bytes.writeUInt32LE(2, sectionCommand + 64);
bytes.write("__swift5_proto", 184);
bytes.write("__DATA", 200);
bytes.writeBigUInt64LE(base + 0xa00n, 216);
bytes.writeBigUInt64LE(4n, 224);
bytes.writeUInt32LE(0xa00, 232);
bytes.writeUInt32LE(2, 236);
const dyldInfo = sectionCommand + 232;
bytes.writeUInt32LE(0x80000022, dyldInfo);
bytes.writeUInt32LE(48, dyldInfo + 4);
bytes.writeUInt32LE(0xf20, dyldInfo + 8);
bytes.writeUInt32LE(6, dyldInfo + 12);
bytes.writeUInt32LE(0xf00, dyldInfo + 16);
const bind = Buffer.from([
0x11,
0x40,
...Buffer.from("_externalWitnessImpl\0"),
0x70,
0x88,
0x1c, // SET_SEGMENT_AND_OFFSET_ULEB segment 0 + witness slot 0xe08
0x90,
0x00,
]);
bytes.writeUInt32LE(bind.length, dyldInfo + 20);
const dylibCommand = dyldInfo + 48;
bytes.writeUInt32LE(0xc, dylibCommand);
bytes.writeUInt32LE(56, dylibCommand + 4);
bytes.writeUInt32LE(24, dylibCommand + 8);
bytes.write("/usr/lib/libswiftCore.dylib\0", dylibCommand + 24);
bytes.writeUInt32LE(3, 16);
bytes.writeUInt32LE(336, 20);
bind.copy(bytes, 0xf00);
// One unrelated, valid local rebase proves unmatched structural pointers
// stay known-raw after the complete legacy stream is walked.
Buffer.from([0x11, 0x20, 0xa0, 0x1c, 0x51, 0]).copy(bytes, 0xf20);
bytes.writeInt32LE(0x10, 0xa00);
bytes.writeInt32LE(0x1ec, 0xa14);
bytes.writeInt32LE(0x3e8, 0xa18);
bytes.writeUInt32LE(0, 0xa1c);
bytes.writeInt32LE(0x2f0, 0xa10);
bytes.writeUInt32LE(16, 0xc00);
bytes.writeInt32LE(0x238, 0xc08);
bytes.writeUInt32LE(3, 0xd00);
bytes.writeInt32LE(0x148, 0xd08);
bytes.writeUInt32LE(0, 0xd0c);
bytes.writeUInt32LE(1, 0xd10);
bytes.writeUInt32LE(1, 0xd18);
bytes.writeBigUInt64LE(base + 0xa10n, 0xe00);
bytes.write("Widget\0", 0xe40);
bytes.write("Equatable\0", 0xe50);
return bytes;
};
describe("Swift imported protocol references", () => {
it("preserves bind identity without reading VM address zero and marks coverage partial", () => {
const result = decodeAppleDispatchMetadata(
swiftExternalSwiftBindingImage(),
100,
provenance,
);
expect(result.swift_conformances).toMatchObject([
{
type_name: "Widget",
protocol_name: null,
protocol_binding: {
symbol: "_$sSQMp",
requested_address: "0x100000b00",
},
decode: { status: "partial" },
},
]);
expect(
result.relative_pointers.find(
({ field }) => field.address === "0x100000a10",
),
).toMatchObject({
target: { address: null, file_offset: null },
binding: { symbol: "_$sSQMp" },
decode: { status: "partial" },
});
expect(
result.coverage.find(
({ facet }) => facet === "swift_conformances_static_witness_slots",
),
).toMatchObject({ status: "partial" });
});
it("preserves an indirect imported type reference as a typed binding", () => {
const result = decodeAppleDispatchMetadata(
swiftExternalSwiftBindingImage("type"),
100,
provenance,
);
expect(result.swift_conformances).toMatchObject([
{
type_name: null,
type_binding: {
symbol: "_$sSiMn",
requested_address: "0x100000b00",
},
protocol_name: "Equatable",
protocol_binding: null,
decode: { status: "partial" },
},
]);
expect(
result.coverage.find(
({ facet }) => facet === "swift_conformances_static_witness_slots",
),
).toMatchObject({ status: "partial" });
});
it("reports only the legacy stream mechanisms actually examined", () => {
const bytes = swiftBoundWitnessSlotImage();
const dyldInfo = 32 + 232;
bytes.writeUInt32LE(0, dyldInfo + 16);
bytes.writeUInt32LE(0, dyldInfo + 20);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(
result.coverage.find(({ facet }) => facet === "pointer_fixups"),
).toEqual({
facet: "pointer_fixups",
status: "complete",
reason: "LC_DYLD_INFO rebase opcodes",
examined: 1,
decoded: 1,
});
});
it("retains an unmatched local witness header beside a legacy rebase and preserves a bound implementation", () => {
const result = decodeAppleDispatchMetadata(
swiftBoundWitnessSlotImage(),
100,
provenance,
);
expect(result.swift_conformances).toMatchObject([
{
type_name: "Widget",
protocol_name: "Equatable",
witness_table: { address: "0x100000e00" },
decode: { status: "decoded" },
},
]);
expect(result.swift_dispatch_slots).toMatchObject([
{
table_kind: "witness_table",
slot_index: 1,
implementation_address: null,
implementation_binding: {
symbol: "_externalWitnessImpl",
requested_address: "0x100000e08",
},
decode: { status: "partial" },
},
]);
expect(
result.coverage.find(
({ facet }) => facet === "swift_conformances_static_witness_slots",
),
).toMatchObject({ status: "partial" });
});
});
const withProtocolListSection = (bytes: Buffer): Buffer => {
const pointer = (at: number, offset: number) =>
bytes.writeBigUInt64LE(base + BigInt(offset), at);
@@ -254,6 +484,37 @@ describe("Apple dispatch pointer fixup metadata", () => {
});
});
describe("Apple dispatch binary metadata", () => {
it("retains structural Objective-C records when legacy rebase coverage is unknown", () => {
const bytes = fixture();
// Add LC_DYLD_INFO_ONLY with a nonempty rebase stream. Legacy rebase
// stream is malformed, so provenance is unknown while readable
// nonzero structural pointer words must remain useful.
bytes.writeUInt32LE(2, 16);
bytes.writeUInt32LE(200, 20);
bytes.writeUInt32LE(0x80000022, 184);
bytes.writeUInt32LE(48, 188);
bytes.writeUInt32LE(4000, 192);
bytes.writeUInt32LE(1, 196);
bytes.writeUInt8(0xf0, 4000);
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_classes[0]?.name).toBe("Fixture");
expect(result.objc_classes[0]?.methods).toMatchObject([
{ selector: "performAction:" },
]);
expect(result.objc_classes[0]?.decode).toMatchObject({
status: "partial",
});
expect(
result.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
),
).toMatchObject({ status: "partial" });
expect(
result.coverage.find(({ facet }) => facet === "pointer_fixups"),
).toMatchObject({ status: "partial" });
});
it.each([false, true])(
"decodes stripped absolute/relative method lists (relative=%s) with exact offsets",
(relative) => {
+11 -2
View File
@@ -76,10 +76,18 @@ export const decodeAppleDispatchMetadata = (
const u32 = (address: bigint) => bytes.readUInt32LE(offset(address, 4));
const decodePointer = (address: bigint) =>
fixups.decode(address, bytes.readBigUInt64LE(offset(address, 8)));
/** Rebased target of a pointer; a bind to another image reads as 0. */
/** In-image target of a raw or rebased pointer; a bind is not an address. */
const pointer = (address: bigint) => {
const decodedPointer = decodePointer(address);
return decodedPointer.kind === "rebase" ? decodedPointer.target : 0n;
if (decodedPointer.kind === "rebase" || decodedPointer.kind === "raw")
return decodedPointer.target;
if (decodedPointer.kind === "bind") return 0n;
// Preserve the on-disk observation for structural Objective-C metadata.
// `raw` is not promoted to an authoritative fixup result: the shared
// pointer-fixup coverage remains partial, and consumers that need to
// distinguish null from an unresolved bind use resolvePointer directly.
if (decodedPointer.kind === "unknown") return decodedPointer.raw;
throw new RangeError(decodedPointer.reason);
};
const bound = (address: bigint) => {
const decodedPointer = decodePointer(address);
@@ -152,6 +160,7 @@ export const decodeAppleDispatchMetadata = (
i32: (address: bigint) => bytes.readInt32LE(offset(address, 4)),
u64: (address: bigint) => bytes.readBigUInt64LE(offset(address, 8)),
pointer,
resolvePointer: decodePointer,
bound,
string,
location,
+356 -14
View File
@@ -155,6 +155,347 @@ const SEGMENTS: Segment[] = [
{ address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false },
];
const legacyRebaseInput = (stream: Buffer) => {
const data = Buffer.alloc(0x8000);
stream.copy(data, 0x7000);
return {
data,
fixups: parsePointerFixups(
data,
{
chained: null,
binds: [],
rebases: [{ offset: 0x7000, size: stream.length }],
dylibs: [],
},
[{ address: BASE, size: 0x2000n, offset: 0, executable: false }],
BASE,
),
};
};
it("marks only opcode-selected pointer slots rebased, including target zero", () => {
// SET_TYPE(pointer), SET_SEGMENT(offset 0x100), DO once,
// ADD_ADDR(0x10), DO once, DONE.
const { data, fixups } = legacyRebaseInput(
Buffer.from([0x11, 0x20, 0x80, 0x02, 0x51, 0x30, 0x10, 0x51, 0]),
);
data.writeBigUInt64LE(BASE + 0x200n, 0x100);
data.writeBigUInt64LE(0n, 0x118);
expect(fixups.decode(BASE + 0x100n, BASE + 0x200n)).toEqual({
kind: "rebase",
target: BASE + 0x200n,
});
expect(fixups.decode(BASE + 0x118n, 0n)).toEqual({
kind: "rebase",
target: 0n,
});
expect(fixups.decode(BASE + 0x110n, 0n)).toEqual({
kind: "raw",
target: 0n,
});
expect(fixups).toMatchObject({
kind: "dyld-info",
examined: 1,
decoded: 1,
unreadRebaseBytes: 0,
});
});
it("applies count-and-skip opcodes to their emitted pointer locations", () => {
// SET_TYPE(pointer), SET_SEGMENT(offset 0x100), DO 1 skipping 8,
// DO_REBASE_ADD_ADDR(8), DO once, DONE. The selected slots are 0x100, 0x110,
// and 0x120; the skipped words remain raw.
const { fixups } = legacyRebaseInput(
Buffer.from([
0x11, 0x20, 0x80, 0x02, 0x80, 0x01, 0x08, 0x70, 0x08, 0x51, 0,
]),
);
expect(fixups.decode(BASE + 0x100n, 0x200n)).toEqual({
kind: "rebase",
target: 0x200n,
});
expect(fixups.decode(BASE + 0x110n, 0x210n)).toEqual({
kind: "rebase",
target: 0x210n,
});
expect(fixups.decode(BASE + 0x120n, 0x220n)).toEqual({
kind: "rebase",
target: 0x220n,
});
expect(fixups.decode(BASE + 0x108n, 0n)).toEqual({
kind: "raw",
target: 0n,
});
expect(fixups.decode(BASE + 0x118n, 0n)).toEqual({
kind: "raw",
target: 0n,
});
});
it("retains known prefix rebases and leaves unmatched locations unknown on an invalid tail", () => {
const { fixups } = legacyRebaseInput(
Buffer.from([0x11, 0x20, 0x80, 0x02, 0x51, 0xf0]),
);
expect(fixups.decode(BASE + 0x100n, BASE + 0x200n)).toEqual({
kind: "rebase",
target: BASE + 0x200n,
});
expect(fixups.decode(BASE + 0x108n, BASE + 0x300n)).toMatchObject({
kind: "unknown",
raw: BASE + 0x300n,
reason: expect.stringContaining("Unsupported rebase opcode 0xf0"),
});
expect(fixups.issues).toMatchObject([
{ code: "rebase_stream_decode_failed" },
]);
});
it("does not treat 32-bit text rebases as 64-bit metadata pointers", () => {
const { fixups } = legacyRebaseInput(
Buffer.from([0x12, 0x20, 0x80, 0x02, 0x51, 0]),
);
expect(fixups.decode(BASE + 0x100n, BASE + 0x200n)).toMatchObject({
kind: "unsupported",
reason: "Legacy non-pointer rebase is not a 64-bit metadata pointer",
});
expect(fixups.decode(BASE + 0x108n, BASE + 0x300n)).toMatchObject({
kind: "raw",
target: BASE + 0x300n,
});
});
it("retains unaligned pointer locations accepted by dyld's rebase state checks", () => {
const { fixups } = legacyRebaseInput(
Buffer.from([0x11, 0x20, 0x81, 0x02, 0x51, 0]),
);
expect(fixups.decode(BASE + 0x101n, 0x1234n)).toEqual({
kind: "rebase",
target: 0x1234n,
});
expect(fixups.decode(BASE + 0x100n, 0n)).toEqual({
kind: "raw",
target: 0n,
});
});
it("rejects count expansion outside the selected file-backed segment without expanding it", () => {
const { fixups } = legacyRebaseInput(
Buffer.from([0x11, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x80, 0x10, 0x00]),
);
expect(fixups.unreadRebaseBytes).toBeGreaterThan(0);
expect(fixups.decode(BASE + 0x100n, 0n)).toMatchObject({
kind: "unknown",
reason: expect.stringContaining("leaves its file-backed segment"),
});
});
it("bounds cumulative work across repeated full-range runs and retains the prefix", () => {
const data = Buffer.alloc(0x10000);
const repeatedRun = Buffer.from([
0x11, // SET_TYPE(pointer)
0x20,
0, // SET_SEGMENT_AND_OFFSET(0, 0)
0x60,
0x80,
0x40, // DO_REBASE_ULEB_TIMES(8192)
]);
const stream = Buffer.concat([
...Array.from({ length: 100 }, () => repeatedRun),
Buffer.from([0]),
]);
stream.copy(data);
const fixups = parsePointerFixups(
data,
{
chained: null,
binds: [],
rebases: [{ offset: 0, size: stream.length }],
dylibs: [],
},
[
{
address: BASE,
size: BigInt(data.length),
offset: 0,
executable: false,
},
],
BASE,
);
expect(fixups.decoded).toBe(0);
expect(fixups.failures.join("\n")).toContain("source-derived budget");
expect(fixups.decode(BASE + 0x100n, 0x1234n)).toEqual({
kind: "rebase",
target: 0x1234n,
});
expect(fixups.decode(BASE + 0x101n, 0n)).toMatchObject({
kind: "unknown",
raw: 0n,
});
});
it("shares the expansion budget across separate rebase streams", () => {
const data = Buffer.alloc(0x10000);
const stream = Buffer.from([0x11, 0x20, 0, 0x60, 0x80, 0x40, 0]);
const controlData = Buffer.alloc(0x10000);
stream.copy(controlData);
const singleRun = parsePointerFixups(
controlData,
{
chained: null,
binds: [],
rebases: [{ offset: 0, size: stream.length }],
dylibs: [],
},
[
{
address: BASE,
size: BigInt(controlData.length),
offset: 0,
executable: false,
},
],
BASE,
);
expect(singleRun).toMatchObject({ examined: 1, decoded: 1, failures: [] });
const rebases = Array.from({ length: 17 }, (_, index) => {
const offset = index * 16;
stream.copy(data, offset);
return { offset, size: stream.length };
});
const fixups = parsePointerFixups(
data,
{
chained: null,
binds: [],
rebases,
dylibs: [],
},
[
{
address: BASE,
size: BigInt(data.length),
offset: 0,
executable: false,
},
],
BASE,
);
expect(fixups).toMatchObject({ examined: 17, decoded: 15 });
expect(fixups.failures.join("\n")).toContain("source-derived budget");
expect(fixups.decode(BASE + 0x100n, 0x1234n)).toEqual({
kind: "rebase",
target: 0x1234n,
});
});
describe("incomplete legacy bind applicability", () => {
it("keeps unmatched and known-rebase slots unknown when a bind tail is malformed", () => {
const data = Buffer.alloc(0x2000);
const bind = Buffer.from([0x10, 0x40, 0x78, 0, 0x70, 0, 0xf0, 0x90]);
const rebase = Buffer.from([0x11, 0x20, 0x80, 0x20, 0x51, 0]);
bind.copy(data, 0);
rebase.copy(data, 16);
const fixups = parsePointerFixups(
data,
{
chained: null,
binds: [{ offset: 0, size: bind.length, stream: "bind" }],
rebases: [{ offset: 16, size: rebase.length }],
dylibs: [],
},
[
{
address: BASE,
size: BigInt(data.length),
offset: 0,
executable: false,
},
],
BASE,
);
expect(fixups.decode(BASE + 0x1000n, 0n)).toMatchObject({
kind: "unknown",
raw: 0n,
reason: expect.stringContaining("could overlap this rebase location"),
});
expect(fixups.decode(BASE + 0x1008n, 0n)).toMatchObject({
kind: "unknown",
raw: 0n,
reason: expect.stringContaining("pointer-fixup applicability"),
});
expect(fixups.issues).toMatchObject([
{ code: "bind_stream_decode_failed" },
]);
});
it("retains a positive bind prefix while leaving unmatched slots unknown", () => {
const opcodes = Buffer.from([
0x11,
0x40,
...Buffer.from("_x\0"),
0x71,
0x08,
0x90,
0xf0,
]);
const fixups = parsePointerFixups(
opcodes,
{
chained: null,
binds: [{ offset: 0, size: opcodes.length, stream: "bind" }],
rebases: [],
dylibs: ["/library-1"],
},
SEGMENTS,
BASE,
);
expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject({
kind: "bind",
symbol: "_x",
});
expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({
kind: "unknown",
raw: 0n,
});
});
it("keeps unmatched chained-image slots unknown when a legacy bind tail fails", () => {
const chained = chainedData(1);
const bind = Buffer.from([0xf0, 0x90]);
const data = Buffer.concat([chained, bind]);
const fixups = parsePointerFixups(
data,
{
chained: { offset: 0, size: chained.length },
binds: [{ offset: chained.length, size: bind.length, stream: "bind" }],
rebases: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
);
expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({
kind: "unknown",
raw: 0n,
reason: expect.stringContaining("pointer-fixup applicability"),
});
});
});
describe("independent chained segment coverage", () => {
it("counts successfully decoded chained segments alongside an unsupported segment", () => {
const starts = 28;
@@ -232,13 +573,14 @@ describe("pointer fixup tables", () => {
});
it("keeps unread LC_DYLD_INFO rebase bytes when chained fixups are present", () => {
const data = chainedData(1);
const chained = chainedData(1);
const data = Buffer.concat([chained, Buffer.from([0xf0])]);
const fixups = parsePointerFixups(
data,
{
chained: { offset: 0, size: data.length },
chained: { offset: 0, size: chained.length },
binds: [],
rebases: [{ offset: 8, size: 12 }],
rebases: [{ offset: chained.length, size: 1 }],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
@@ -246,7 +588,7 @@ describe("pointer fixup tables", () => {
);
expect(fixups).toMatchObject({
kind: "chained",
unreadRebaseBytes: 12,
unreadRebaseBytes: 1,
});
});
@@ -260,7 +602,7 @@ describe("pointer fixup tables", () => {
{
chained: { offset: 0, size: 128 },
binds: [],
rebases: [{ offset: 8, size: 12 }],
rebases: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
@@ -269,7 +611,7 @@ describe("pointer fixup tables", () => {
expect(fixups).toMatchObject({
examined: 1,
decoded: 1,
unreadRebaseBytes: 12,
unreadRebaseBytes: 0,
});
expect(fixups.decode(BASE + 0x4008n, field(1n, 63))).toMatchObject({
kind: "bind",
@@ -313,7 +655,7 @@ describe("pointer fixup tables", () => {
});
// Pointers outside fixup segments are stored plainly.
expect(fixups.decode(BASE + 0x10n, 0x1234n)).toEqual({
kind: "rebase",
kind: "raw",
target: 0x1234n,
});
});
@@ -381,7 +723,7 @@ describe("parsed chained pointer membership", () => {
);
const plain = data.readBigUInt64LE(0x400b);
expect(fixups.decode(BASE + 0x400bn, plain)).toEqual({
kind: "rebase",
kind: "raw",
target: plain,
});
});
@@ -664,11 +1006,11 @@ describe("chained pointer membership", () => {
symbol: "_free",
});
expect(fixups.decode(BASE + 0x4010n, plain)).toEqual({
kind: "rebase",
kind: "raw",
target: plain,
});
expect(fixups.decode(BASE + 0x4030n, 0n)).toEqual({
kind: "rebase",
kind: "raw",
target: 0n,
});
},
@@ -693,11 +1035,11 @@ describe("chained pointer membership", () => {
data.writeUInt16LE(0x1000, 44);
const unknown = parsePointerFixups(data, commands, SEGMENTS, BASE);
expect(unknown.decode(BASE + 0x4008n, BASE + 8n)).toEqual({
kind: "rebase",
kind: "raw",
target: BASE + 8n,
});
expect(unknown.decode(BASE + 0x5008n, BASE + 16n)).toEqual({
kind: "rebase",
kind: "raw",
target: BASE + 16n,
});
});
@@ -940,7 +1282,7 @@ describe("legacy pointer fixup streams", () => {
library: "/usr/lib/libSystem.B.dylib",
});
expect(fixups.decode(BASE + 0x4018n, 0x99n)).toEqual({
kind: "rebase",
kind: "raw",
target: 0x99n,
});
});
@@ -981,7 +1323,7 @@ describe("legacy pointer fixup streams", () => {
library: "/usr/lib/libobjc.A.dylib",
});
expect(fixups.decode(BASE + 0x4018n, 0x99n)).toEqual({
kind: "rebase",
kind: "raw",
target: 0x99n,
});
});
+475 -76
View File
@@ -37,6 +37,18 @@ export type DecodedPointer =
}
| { readonly kind: "unsupported"; readonly reason: string };
/** Pointer-slot outcome, including whether a rebase was actually applied. */
export type PointerResolution =
| { readonly kind: "raw"; readonly target: bigint }
| { readonly kind: "rebase"; readonly target: bigint }
| Exclude<DecodedPointer, { readonly kind: "rebase" }>
| {
readonly kind: "unknown";
/** Preserved on-disk word; not an authoritative in-image target. */
readonly raw: bigint;
readonly reason: string;
};
/** Pointer decoding for one Mach-O slice. */
export interface PointerFixups {
readonly kind: "chained" | "dyld-info" | "none";
@@ -44,14 +56,17 @@ export interface PointerFixups {
readonly formats: readonly string[];
readonly failures: readonly string[];
readonly issues: readonly DecodeIssue[];
/** Fixup segments or bind streams this reader walked. */
/** Chained segments, legacy rebase streams, or bind streams this reader walked. */
readonly examined: number;
/** Fully decoded fixup segments or bind streams, in the same units as `examined`. */
readonly decoded: number;
/** Rebase opcode bytes present in the load command and not decoded. */
/** Declared legacy opcode streams examined by this owner. */
readonly legacyRebaseStreams: number;
readonly legacyBindStreams: number;
/** Bytes in rebase streams that could not be completely decoded. */
readonly unreadRebaseBytes: number;
/** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */
decode(address: bigint, raw: bigint): DecodedPointer;
decode(address: bigint, raw: bigint): PointerResolution;
}
const POINTER_FORMATS: Readonly<Record<number, string>> = {
@@ -86,6 +101,15 @@ interface BindRun {
| { readonly kind: "unsupported"; readonly reason: string };
}
interface BindFacts {
readonly runs: readonly BindRun[];
readonly failures: readonly string[];
readonly issues: readonly DecodeIssue[];
readonly examined: number;
readonly decoded: number;
readonly complete: boolean;
}
/** Resolve only valid dylib ordinals; preserve special scopes as raw ordinals. */
const libraryName = (
ordinal: number,
@@ -283,7 +307,8 @@ const readChainedPage = (
const chainedFixups = (
image: FixupImage,
range: { readonly offset: number; readonly size: number },
unreadRebaseBytes: number,
rebases: RebaseFacts,
binds: BindFacts,
): PointerFixups => {
const { bytes, segments, dylibs, baseAddress } = image;
const base = range.offset;
@@ -412,18 +437,22 @@ const chainedFixups = (
ranges.map(({ format }) => POINTER_FORMATS[format] ?? `format-${format}`),
),
];
const issues = ranges.flatMap(({ format, segment }) =>
POINTER_FORMATS[format] === undefined
? [
issue(
"pointer_fixups",
"unsupported_pointer_format",
`Unsupported chained pointer format ${format}`,
`0x${segment.address.toString(16)}`,
),
]
: [],
);
const issues = [
...rebases.issues,
...binds.issues,
...ranges.flatMap(({ format, segment }) =>
POINTER_FORMATS[format] === undefined
? [
issue(
"pointer_fixups",
"unsupported_pointer_format",
`Unsupported chained pointer format ${format}`,
`0x${segment.address.toString(16)}`,
),
]
: [],
),
];
const examinedViews = new Map<string, boolean>();
const decodedRanges = new Set<number>();
for (const [chainIndex, chain] of ranges.entries()) {
@@ -461,17 +490,58 @@ const chainedFixups = (
if (complete) decodedRanges.add(chainIndex);
}
const failures = issues.map(({ message }) => message);
const applicabilityFailures = [...rebases.failures, ...binds.failures];
const applicabilityComplete = rebases.complete && binds.complete;
return {
kind: "chained",
formats,
failures,
issues,
examined: Math.max(ranges.length, 1),
examined: Math.max(ranges.length, 1) + rebases.examined + binds.examined,
get decoded() {
return ranges.length === 0 ? 1 : decodedRanges.size;
return (
(ranges.length === 0 ? 1 : decodedRanges.size) +
rebases.decoded +
binds.decoded
);
},
unreadRebaseBytes,
legacyRebaseStreams: rebases.examined,
legacyBindStreams: binds.examined,
unreadRebaseBytes: rebases.unreadBytes,
decode(address, raw) {
const rawPointer = (): PointerResolution =>
applicabilityComplete
? { kind: "raw", target: raw }
: {
kind: "unknown",
raw,
reason: `LC_DYLD_INFO pointer-fixup applicability was not completely decoded: ${applicabilityFailures.join("; ")}`,
};
const legacyBind = resolveLegacyBind(binds.runs, address);
const legacyRebase = resolveLegacyRebase(
segments,
rebases.membership,
address,
raw,
);
if (legacyBind?.kind === "unsupported") return legacyBind;
if (legacyBind !== undefined && legacyRebase !== undefined)
return {
kind: "unsupported",
reason: `Both bind and rebase records target pointer location 0x${address.toString(16)}`,
};
if (legacyBind !== undefined && !rebases.complete)
return {
kind: "unknown",
raw,
reason: `Legacy rebase opcodes could overlap this bind location: ${rebases.failures.join("; ")}`,
};
if (legacyRebase !== undefined && !binds.complete)
return {
kind: "unknown",
raw,
reason: `Legacy bind opcodes could overlap this rebase location: ${binds.failures.join("; ")}`,
};
const chainIndex = ranges.findIndex(
({ segment }) =>
address >= segment.address &&
@@ -479,12 +549,14 @@ const chainedFixups = (
);
const chain = ranges[chainIndex];
// Pointers outside fixup segments, such as in __TEXT, are stored plainly.
if (chain === undefined) return { kind: "rebase", target: raw };
if (chain === undefined)
return legacyBind ?? legacyRebase ?? rawPointer();
const within = Number(address - chain.segment.address);
const page = Math.floor(within / chain.pageSize);
if (page >= chain.pageCount) return { kind: "rebase", target: raw };
if (page >= chain.pageCount)
return legacyBind ?? legacyRebase ?? rawPointer();
const start = bytes.readUInt16LE(chain.pageStarts + page * 2);
if (start === 0xffff) return { kind: "rebase", target: raw };
if (start === 0xffff) return legacyBind ?? legacyRebase ?? rawPointer();
if (POINTER_FORMATS[chain.format] === undefined)
return {
kind: "unsupported",
@@ -494,14 +566,37 @@ const chainedFixups = (
const facts = pageFacts(chain, page, start);
const at = within % chain.pageSize;
const locations = facts.locations;
if (((locations[at >>> 3] ?? 0) & (1 << (at & 7))) !== 0)
return decodeChainedPointer(raw, chain.format, baseAddress, imports);
if (((locations[at >>> 3] ?? 0) & (1 << (at & 7))) !== 0) {
if (
legacyBind !== undefined ||
legacyRebase !== undefined ||
!applicabilityComplete
) {
if (legacyBind !== undefined || legacyRebase !== undefined)
return {
kind: "unsupported",
reason: `Both chained and legacy fixup records target pointer location 0x${address.toString(16)}`,
};
return {
kind: "unknown",
raw,
reason: `Incomplete legacy fixup streams could overlap chained pointer location 0x${address.toString(16)}: ${applicabilityFailures.join("; ")}`,
};
}
const decoded = decodeChainedPointer(
raw,
chain.format,
baseAddress,
imports,
);
return decoded;
}
if (
facts.failure !== undefined &&
(facts.lastOffset === undefined || at > facts.lastOffset)
)
throw new RangeError(facts.failure);
return { kind: "rebase", target: raw };
return legacyBind ?? legacyRebase ?? rawPointer();
} catch (cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
const location = `0x${address.toString(16)}`;
@@ -529,15 +624,29 @@ const chainedFixups = (
};
};
const uleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
const uleb = (
bytes: Buffer,
cursor: { at: number },
end: number,
stream: "bind" | "rebase" = "bind",
rebaseWork?: { workRemaining: number },
): bigint => {
let value = 0n;
let shift = 0;
for (;;) {
if (cursor.at >= end) throw new RangeError("Truncated bind opcode operand");
if (cursor.at >= end)
throw new RangeError(`Truncated ${stream} opcode operand`);
if (rebaseWork !== undefined) {
if (rebaseWork.workRemaining === 0)
throw new RangeError(
"Legacy rebase parsing exceeded the source-derived work budget",
);
rebaseWork.workRemaining--;
}
const byte = bytes[cursor.at++] ?? 0;
const slice = byte & 0x7f;
if ((shift === 63 && slice > 1) || (shift === 64 && slice !== 0))
throw new RangeError("Oversized bind opcode operand");
throw new RangeError(`Oversized ${stream} opcode operand`);
if (shift < 64) value |= BigInt(slice) << BigInt(shift);
// Padding may extend the encoding, but never the represented integer.
shift = Math.min(64, shift + 7);
@@ -545,6 +654,235 @@ const uleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
}
};
interface RebaseFacts {
/** Two-bit state per source byte; allocated lazily only for emitted rebases. */
readonly membership: Uint8Array | null;
readonly failures: readonly string[];
readonly issues: readonly DecodeIssue[];
readonly examined: number;
readonly decoded: number;
readonly unreadBytes: number;
readonly complete: boolean;
}
const REBASE_POINTER_SIZE = 8n;
const REBASE_POINTER = 1;
const REBASE_NON_POINTER = 2;
const REBASE_CONFLICT = 3;
const markRebaseLocation = (
index: { bitmap: Uint8Array | null; workRemaining: number },
fileOffset: number,
type: number,
fileBytes: number,
): void => {
index.bitmap ??= new Uint8Array(Math.ceil(fileBytes / 4));
const at = Math.floor(fileOffset / 4);
const shift = (fileOffset % 4) * 2;
const previous = ((index.bitmap[at] ?? 0) >> shift) & 3;
const next = previous === 0 || previous === type ? type : REBASE_CONFLICT;
index.bitmap[at] =
((index.bitmap[at] ?? 0) & ~(3 << shift)) | (next << shift);
};
/** Interpret one bounded LC_DYLD_INFO rebase stream into file-backed membership. */
const readRebaseStream = (
image: FixupImage,
range: FixupCommands["rebases"][number],
index: { bitmap: Uint8Array | null; workRemaining: number },
): void => {
const { bytes, segments } = image;
const end = range.offset + range.size;
if (
!Number.isSafeInteger(range.offset) ||
!Number.isSafeInteger(range.size) ||
range.offset < 0 ||
range.size < 0 ||
!Number.isSafeInteger(end) ||
end > bytes.length
)
throw new RangeError("Rebase opcodes exceed the target");
const cursor = { at: range.offset };
let segmentIndex = -1;
let segmentOffset = 0n;
let type = 0;
const addRun = (count: bigint, skip: bigint): void => {
if (count === 0n) return;
if (type === 0 || type > 3)
throw new RangeError(`Unsupported legacy rebase type ${type}`);
const segment = segments[segmentIndex];
if (segment === undefined)
throw new RangeError("Rebase location has no selected segment");
const stride = REBASE_POINTER_SIZE + skip;
const lastOffset = segmentOffset + (count - 1n) * stride;
if (segmentOffset < 0n || lastOffset + REBASE_POINTER_SIZE > segment.size)
throw new RangeError("Rebase expansion leaves its file-backed segment");
if (!Number.isSafeInteger(segment.offset) || segment.offset < 0)
throw new RangeError("Rebase segment file offset is invalid");
const firstFileOffset = BigInt(segment.offset) + segmentOffset;
if (
firstFileOffset < 0n ||
lastOffset + BigInt(segment.offset) + REBASE_POINTER_SIZE >
BigInt(bytes.length)
)
throw new RangeError("Rebase location exceeds the source file");
const fact = type === 1 ? REBASE_POINTER : REBASE_NON_POINTER;
const admitted =
count < BigInt(index.workRemaining) ? count : BigInt(index.workRemaining);
for (let indexOffset = 0n; indexOffset < admitted; indexOffset++) {
const fileOffset = Number(firstFileOffset + indexOffset * stride);
markRebaseLocation(index, fileOffset, fact, bytes.length);
}
index.workRemaining -= Number(admitted);
if (admitted < count)
throw new RangeError(
"Legacy rebase opcode and expansion work exceeded the source-derived budget",
);
// dyld advances after the final record too; that cursor may legally be
// one past the segment because it is only bounds-checked on the next emit.
segmentOffset += count * stride;
};
while (cursor.at < end) {
if (index.workRemaining === 0)
throw new RangeError(
"Legacy rebase parsing exceeded the source-derived work budget",
);
index.workRemaining--;
const byte = bytes[cursor.at++] ?? 0;
const immediate = byte & 0x0f;
switch (byte & 0xf0) {
case 0x00: // REBASE_OPCODE_DONE; up to 15 trailing bytes are alignment.
if (end - cursor.at > 15)
throw new RangeError(
"Rebase opcodes terminated before the stream end",
);
cursor.at = end;
break;
case 0x10: // REBASE_OPCODE_SET_TYPE_IMM
type = immediate;
break;
case 0x20: {
// REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB
if (segments[immediate] === undefined)
throw new RangeError("Rebase segment index is out of range");
segmentIndex = immediate;
segmentOffset = uleb(bytes, cursor, end, "rebase", index);
break;
}
case 0x30: // REBASE_OPCODE_ADD_ADDR_ULEB
segmentOffset += uleb(bytes, cursor, end, "rebase", index);
break;
case 0x40: // REBASE_OPCODE_ADD_ADDR_IMM_SCALED
segmentOffset += BigInt(immediate) * REBASE_POINTER_SIZE;
break;
case 0x50: // REBASE_OPCODE_DO_REBASE_IMM_TIMES
addRun(BigInt(immediate), 0n);
break;
case 0x60: {
// REBASE_OPCODE_DO_REBASE_ULEB_TIMES
const count = uleb(bytes, cursor, end, "rebase", index);
addRun(count, 0n);
break;
}
case 0x70: {
// REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB
addRun(1n, 0n);
segmentOffset += uleb(bytes, cursor, end, "rebase", index);
break;
}
case 0x80: {
// REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB
const count = uleb(bytes, cursor, end, "rebase", index);
const skip = uleb(bytes, cursor, end, "rebase", index);
addRun(count, skip);
break;
}
default:
throw new RangeError(
`Unsupported rebase opcode 0x${byte.toString(16)}`,
);
}
}
};
const resolveLegacyRebase = (
segments: readonly Segment[],
membership: Uint8Array | null,
address: bigint,
raw: bigint,
): PointerResolution | undefined => {
if (membership === null) return undefined;
let fileOffset: number | undefined;
for (const segment of segments) {
const relative = address - segment.address;
if (relative < 0n || relative + REBASE_POINTER_SIZE > segment.size)
continue;
if (fileOffset !== undefined)
return {
kind: "unsupported",
reason: `Ambiguous segments map legacy rebase pointer location 0x${address.toString(16)}`,
};
const mapped = BigInt(segment.offset) + relative;
if (mapped < 0n || mapped > BigInt(Number.MAX_SAFE_INTEGER))
return {
kind: "unsupported",
reason: "Legacy rebase file offset exceeds the safe integer range",
};
fileOffset = Number(mapped);
}
if (fileOffset === undefined) return undefined;
const at = Math.floor(fileOffset / 4);
const shift = (fileOffset % 4) * 2;
const state = ((membership[at] ?? 0) >> shift) & 3;
if (state === 0) return undefined;
if (state === REBASE_POINTER) return { kind: "rebase", target: raw };
return {
kind: "unsupported",
reason:
state === REBASE_CONFLICT
? `Conflicting legacy rebase kinds target pointer location 0x${address.toString(16)}`
: "Legacy non-pointer rebase is not a 64-bit metadata pointer",
};
};
const parseLegacyRebases = (
image: FixupImage,
streams: FixupCommands["rebases"],
): RebaseFacts => {
// Dyld does not require pointer alignment here, so up to one starting byte
// per source byte is possible. Bound opcode scanning plus slot visits by 2N.
const workBudget = Math.min(Number.MAX_SAFE_INTEGER, image.bytes.length * 2);
const index: { bitmap: Uint8Array | null; workRemaining: number } = {
bitmap: null,
workRemaining: workBudget,
};
const failures: string[] = [];
let decoded = 0;
let unreadBytes = 0;
for (const stream of streams) {
try {
readRebaseStream(image, stream, index);
decoded++;
} catch (cause: unknown) {
failures.push(
`rebase opcodes: ${cause instanceof Error ? cause.message : String(cause)}`,
);
unreadBytes += stream.size;
}
}
return {
membership: index.bitmap,
failures,
issues: failures.map((message) =>
issue("pointer_fixups", "rebase_stream_decode_failed", message),
),
examined: streams.length,
decoded,
unreadBytes,
complete: failures.length === 0,
};
};
const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
let value = 0n;
let shift = 0;
@@ -759,6 +1097,62 @@ const readBindStream = (
}
};
const resolveLegacyBind = (
runs: readonly BindRun[],
address: bigint,
): PointerResolution | undefined => {
let match: BindRun | undefined;
for (const run of runs) {
const distance = address - run.start;
if (
distance >= 0n &&
distance % run.stride === 0n &&
distance / run.stride < run.count
) {
if (match !== undefined)
return {
kind: "unsupported",
reason: `Ambiguous bind records target pointer location 0x${address.toString(16)}`,
};
match = run;
}
}
return match === undefined
? undefined
: match.binding.kind === "unsupported"
? match.binding
: { kind: "bind", ...match.binding.value };
};
const parseLegacyBinds = (
image: FixupImage,
streams: FixupCommands["binds"],
): BindFacts => {
const runs: BindRun[] = [];
const failures: string[] = [];
let decoded = 0;
for (const stream of streams) {
try {
readBindStream(image, stream, runs);
decoded++;
} catch (cause: unknown) {
failures.push(
`${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
return {
runs,
failures,
issues: failures.map((message) =>
issue("pointer_fixups", "bind_stream_decode_failed", message),
),
examined: streams.length,
decoded,
complete: failures.length === 0,
};
};
/** Build pointer decoding for one slice from its fixup load commands. */
export const parsePointerFixups = (
bytes: Buffer,
@@ -767,14 +1161,22 @@ export const parsePointerFixups = (
baseAddress: bigint,
): PointerFixups => {
const image = { bytes, segments, dylibs: commands.dylibs, baseAddress };
const unreadRebaseBytes = commands.rebases.reduce(
(total, rebase) => total + rebase.size,
0,
);
const rebases = parseLegacyRebases(image, commands.rebases);
const binds = parseLegacyBinds(image, commands.binds);
if (commands.chained !== null) {
return chainedFixups(image, commands.chained, unreadRebaseBytes);
return chainedFixups(image, commands.chained, rebases, binds);
}
if (commands.binds.length === 0)
const applicabilityFailures = [...rebases.failures, ...binds.failures];
const applicabilityComplete = rebases.complete && binds.complete;
const rawPointer = (raw: bigint): PointerResolution =>
applicabilityComplete
? { kind: "raw", target: raw }
: {
kind: "unknown",
raw,
reason: `LC_DYLD_INFO pointer-fixup applicability was not completely decoded: ${applicabilityFailures.join("; ")}`,
};
if (commands.binds.length === 0 && commands.rebases.length === 0)
return {
kind: "none",
formats: [],
@@ -782,54 +1184,51 @@ export const parsePointerFixups = (
issues: [],
examined: 0,
decoded: 0,
unreadRebaseBytes,
decode: (_address, raw) => ({ kind: "rebase", target: raw }),
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
decode: (_address, raw) => rawPointer(raw),
};
const binds: BindRun[] = [];
const failures: string[] = [];
let decoded = 0;
for (const stream of commands.binds)
try {
readBindStream(image, stream, binds);
decoded++;
} catch (cause: unknown) {
failures.push(
`${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
const failures = [...rebases.failures, ...binds.failures];
const issues = [...rebases.issues, ...binds.issues];
return {
kind: "dyld-info",
formats: [],
failures,
issues: failures.map((message) =>
issue("pointer_fixups", "bind_stream_decode_failed", message),
),
examined: commands.binds.length,
decoded,
unreadRebaseBytes,
issues,
examined: binds.examined + rebases.examined,
decoded: binds.decoded + rebases.decoded,
legacyRebaseStreams: rebases.examined,
legacyBindStreams: binds.examined,
unreadRebaseBytes: rebases.unreadBytes,
decode(address, raw) {
let match: BindRun | undefined;
for (const run of binds) {
const { start, count, stride } = run;
const distance = address - start;
if (
distance >= 0n &&
distance % stride === 0n &&
distance / stride < count
) {
if (match !== undefined)
return {
kind: "unsupported",
reason: `Ambiguous bind records target pointer location 0x${address.toString(16)}`,
};
match = run;
}
}
return match === undefined
? { kind: "rebase", target: raw }
: match.binding.kind === "unsupported"
? match.binding
: { kind: "bind", ...match.binding.value };
const match = resolveLegacyBind(binds.runs, address);
const rebase = resolveLegacyRebase(
segments,
rebases.membership,
address,
raw,
);
if (match?.kind === "unsupported") return match;
if (match !== undefined && rebase !== undefined)
return {
kind: "unsupported",
reason: `Both bind and rebase records target pointer location 0x${address.toString(16)}`,
};
if (match !== undefined && !rebases.complete)
return {
kind: "unknown",
raw,
reason: `Legacy rebase opcodes could overlap this bind location: ${rebases.failures.join("; ")}`,
};
if (match !== undefined) return match;
if (rebase !== undefined && !binds.complete)
return {
kind: "unknown",
raw,
reason: `Legacy bind opcodes could overlap this rebase location: ${binds.failures.join("; ")}`,
};
return rebase ?? rawPointer(raw);
},
};
};
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PointerFixups } from "./AppleMachoFixups.js";
import { parsePointerFixups } from "./AppleMachoFixups.js";
import type { MachoLayout } from "./AppleMachoSelection.js";
import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js";
@@ -15,6 +16,8 @@ const createInput = (
issues: [],
examined: 1,
decoded: 1,
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
decode: (address) =>
address === 0x1000n
@@ -133,6 +136,39 @@ describe("Objective-C import binding address inspection", () => {
});
});
it("marks selected slots partial when legacy rebase ownership is unknown", () => {
const input = createInput();
const streams = Buffer.from([0x20, 0x80, 0]); // Truncated rebase operand; valid bind DONE.
const fixups = parsePointerFixups(
streams,
{
chained: null,
binds: [{ offset: 2, size: 1, stream: "bind" }],
rebases: [{ offset: 0, size: 2 }],
dylibs: [],
},
[{ address: 0x1000n, size: 0x1000n, offset: 0, executable: false }],
0n,
);
expect(
inspectObjcClassImportBindings({
...input,
addresses: ["0x1000"],
fixups,
}),
).toMatchObject({
bindings: [],
coverage: {
status: "partial",
reason: expect.stringContaining(
"LC_DYLD_INFO pointer-fixup applicability was not completely decoded",
),
examined: 1,
decoded: 0,
},
});
});
it("does not infer absent imports from unsupported fixup coverage", () => {
const input = createInput({
fixups: { ...createInput().fixups, kind: "none" },
+7 -3
View File
@@ -1,9 +1,9 @@
import type { PointerFixups } from "./AppleMachoFixups.js";
import type { MachoLayout } from "./AppleMachoSelection.js";
import type { ObjcClassImportBinding } from "../domain/native/objcSwiftMetadata.js";
import type { MachoImportBinding } from "../domain/native/objcSwiftMetadata.js";
export interface ObjcClassImportBindingScan {
readonly bindings: readonly ObjcClassImportBinding[];
readonly bindings: readonly MachoImportBinding[];
readonly coverage: {
readonly status: "complete" | "partial" | "unsupported" | "not_requested";
readonly reason: string | null;
@@ -51,7 +51,7 @@ export const inspectObjcClassImportBindings = (input: {
);
for (const failure of fixups.failures) reasons.add(failure);
const bindings: ObjcClassImportBinding[] = [];
const bindings: MachoImportBinding[] = [];
const examinedAddresses = new Set<string>();
const candidates = addresses.slice(0, maxRecords);
if (candidates.length < addresses.length)
@@ -76,6 +76,10 @@ export const inspectObjcClassImportBindings = (input: {
reasons.add(`${requestedAddress}: ${decoded.reason}`);
continue;
}
if (decoded.kind === "unknown") {
reasons.add(`${requestedAddress}: ${decoded.reason}`);
continue;
}
if (decoded.kind !== "bind" || !isObjcClassImport(decoded.symbol))
continue;
bindings.push({
+286 -34
View File
@@ -1,4 +1,5 @@
import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js";
import type { PointerResolution } from "./AppleMachoFixups.js";
import type { Segment, Section } from "./AppleMachoSelection.js";
import { issue, type FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js";
@@ -25,10 +26,69 @@ export interface DispatchRecordBudget {
admit(): boolean;
}
/** Field-owned Swift ABI pointer encodings used by the readers below. */
export type SwiftRelativeField =
| "conformanceRecord"
| "conformanceProtocol"
| "protocolName"
| "conformanceType"
| "typeName"
| "witnessTablePattern"
| "typeMetadataRecord"
| "classDescriptorName"
| "methodImplementation";
type SwiftRelativeFieldContract = {
encoding: "direct" | "indirectable" | "taggedDirect" | "compactFunction";
nullability: "nullable" | "nonnull";
};
/** Nullability and storage encoding follow the owning fields in Swift ABI. */
const relativeFieldContracts = {
conformanceRecord: { encoding: "direct", nullability: "nonnull" },
conformanceProtocol: {
encoding: "indirectable",
nullability: "nullable",
},
protocolName: { encoding: "direct", nullability: "nonnull" },
conformanceType: { encoding: "direct", nullability: "nullable" },
typeName: { encoding: "direct", nullability: "nonnull" },
witnessTablePattern: { encoding: "direct", nullability: "nullable" },
typeMetadataRecord: { encoding: "taggedDirect", nullability: "nonnull" },
classDescriptorName: { encoding: "direct", nullability: "nonnull" },
methodImplementation: {
encoding: "compactFunction",
nullability: "nullable",
},
} as const satisfies Record<SwiftRelativeField, SwiftRelativeFieldContract>;
/** Resolved address or a null encoding permitted by its Swift ABI field. */
export type SwiftRelativeTarget = bigint | null;
export type SwiftRelativeValue =
| { readonly kind: "target"; readonly target: bigint }
| { readonly kind: "null" }
| {
readonly kind: "binding";
readonly binding: Extract<PointerResolution, { readonly kind: "bind" }>;
readonly requestedAddress: bigint;
}
| { readonly kind: "unknown"; readonly reason: string };
/** Keep ABI null, unavailable values, and unresolved imports distinct. */
export const unresolvedSwiftRelativeReason = (
value: Exclude<SwiftRelativeValue, { readonly kind: "target" }>,
field: SwiftRelativeField,
): string => {
if (value.kind === "null") return `Null Swift relative pointer for ${field}`;
if (value.kind === "unknown") return value.reason;
return `Swift relative pointer for ${field} has an unresolved external binding at ${hex(value.requestedAddress)}`;
};
/** Byte readers for Swift relative-pointer and conformance decode facets. */
export interface SwiftDispatchReaders {
u32(address: bigint): number;
pointer(address: bigint): bigint;
resolvePointer(address: bigint): PointerResolution;
string(address: bigint): string;
location(address: bigint): ResolvedMetadataLocation;
evidence(address: bigint, description: string): BinaryMetadataEvidence;
@@ -37,6 +97,18 @@ export interface SwiftDispatchReaders {
const hex = (value: bigint) => `0x${value.toString(16)}`;
const decoded = { status: "decoded" as const, reason: null };
const importBinding = (
requestedAddress: bigint,
binding: Extract<PointerResolution, { readonly kind: "bind" }>,
) => ({
requested_address: hex(requestedAddress),
symbol: binding.symbol,
binding_source: binding.binding_source,
library_ordinal: binding.library_ordinal,
library: binding.library,
weak: binding.weak,
addend: binding.addend.toString(),
});
/** Create a Swift ABI signed-relative pointer decoder that records relative_pointers. */
export const createSwiftRelativeReader = (input: {
@@ -46,18 +118,93 @@ export const createSwiftRelativeReader = (input: {
result: ObjcSwiftMetadata;
}) => {
const { bytes, readers: read, budget, result } = input;
return (field: bigint, indirectable = false): bigint => {
return (field: bigint, kind: SwiftRelativeField): SwiftRelativeValue => {
const contract = relativeFieldContracts[kind];
const indirectable = contract.encoding === "indirectable";
const taggedDirect = contract.encoding === "taggedDirect";
const nullable = contract.nullability === "nullable";
const displacement = bytes.readInt32LE(read.offset(field, 4));
const encodingTag = taggedDirect ? displacement & 3 : undefined;
if (encodingTag !== undefined && encodingTag !== 0) {
const reason = `Unsupported Swift type metadata record tag ${encodingTag}`;
if (budget.admit())
result.relative_pointers.push({
field: read.location(field),
target: { address: null, file_offset: null },
displacement,
indirectable,
indirect: false,
encoding_tag: encodingTag,
decode: { status: "unsupported", reason },
evidence: read.evidence(
field,
"Swift ABI tagged type metadata record",
),
});
return { kind: "unknown", reason };
}
const indirect = indirectable && (displacement & 1) !== 0;
let target =
displacement === 0
? 0n
: field + BigInt(indirectable ? displacement & ~1 : displacement);
let target: SwiftRelativeTarget =
nullable && displacement === 0
? null
: field +
BigInt(
taggedDirect
? displacement & ~3
: indirectable
? displacement & ~1
: displacement,
);
let fileOffset: number | null = null;
let reason: string | null = null;
try {
if (indirect && target !== 0n) target = read.pointer(target);
if (target !== 0n) fileOffset = read.offset(target);
if (indirect && target !== null) {
const pointer = read.resolvePointer(target);
if (pointer.kind === "bind") {
if (budget.admit())
result.relative_pointers.push({
field: read.location(field),
target: { address: null, file_offset: null },
displacement,
indirectable,
indirect,
binding: importBinding(target, pointer),
decode: { status: "partial", reason: "External pointer binding" },
evidence: read.evidence(
field,
"Swift ABI indirect relative pointer and Mach-O import binding",
),
});
return {
kind: "binding",
binding: pointer,
requestedAddress: target,
};
}
if (pointer.kind === "unknown" || pointer.kind === "unsupported") {
const unresolved = pointer.reason;
if (budget.admit())
result.relative_pointers.push({
field: read.location(field),
target: { address: null, file_offset: null },
displacement,
indirectable,
indirect,
decode: { status: "partial", reason: unresolved },
evidence: read.evidence(
field,
"Swift ABI indirect relative pointer",
),
});
return { kind: "unknown", reason: unresolved };
}
const indirectTarget = pointer.target;
target =
pointer.kind === "raw" && indirectTarget === 0n
? null
: indirectTarget;
}
if (target !== null) fileOffset = read.offset(target);
} catch (cause) {
reason = cause instanceof Error ? cause.message : String(cause);
}
@@ -65,20 +212,20 @@ export const createSwiftRelativeReader = (input: {
result.relative_pointers.push({
field: read.location(field),
target: {
address: target === 0n ? null : hex(target),
address: target === null ? null : hex(target),
file_offset: fileOffset,
},
displacement,
indirectable,
indirect,
...(encodingTag === undefined ? {} : { encoding_tag: encodingTag }),
decode: reason === null ? decoded : { status: "partial", reason },
evidence: read.evidence(
field,
"Swift ABI signed-relative metadata pointer",
),
});
if (reason !== null) throw new RangeError(reason);
return target;
return target === null ? { kind: "null" } : { kind: "target", target };
};
};
@@ -87,13 +234,22 @@ export const decodeSwiftDispatchFacets = (input: {
sections: readonly Section[];
segments: readonly Segment[];
readers: SwiftDispatchReaders;
relative: (field: bigint, indirectable?: boolean) => bigint;
relative: (field: bigint, kind: SwiftRelativeField) => SwiftRelativeValue;
budget: DispatchRecordBudget;
result: ObjcSwiftMetadata;
}): FacetDecodeFacts => {
const { sections, segments, readers: read, relative, budget, result } = input;
const swiftFailures: string[] = [];
const issues = [] as ReturnType<typeof issue>[];
const requiredRelative = (
field: bigint,
kind: SwiftRelativeField,
): bigint => {
const value = relative(field, kind);
if (value.kind !== "target")
throw new RangeError(unresolvedSwiftRelativeReason(value, kind));
return value.target;
};
let swiftExamined = 0;
let decodedRecords = 0;
for (const section of sections.filter(
@@ -106,43 +262,113 @@ export const decodeSwiftDispatchFacets = (input: {
swiftExamined++;
const field = section.address + BigInt(index * 4);
try {
const descriptor = relative(field);
const descriptor = requiredRelative(field, "conformanceRecord");
const flags = read.u32(descriptor + 12n);
const protocol = relative(descriptor, true);
if ((read.u32(protocol) & 31) !== 3)
const protocolValue = relative(descriptor, "conformanceProtocol");
const protocol =
protocolValue.kind === "target" ? protocolValue.target : null;
const unresolvedProtocolReason =
protocolValue.kind === "unknown" || protocolValue.kind === "binding"
? unresolvedSwiftRelativeReason(
protocolValue,
"conformanceProtocol",
)
: null;
if (protocol !== null && (read.u32(protocol) & 31) !== 3)
throw new TypeError("Unsupported Swift protocol descriptor kind");
const protocolName = read.string(relative(protocol + 8n));
const protocolName =
protocol === null
? null
: read.string(requiredRelative(protocol + 8n, "protocolName"));
const protocolBinding =
protocolValue.kind === "binding"
? importBinding(
protocolValue.requestedAddress,
protocolValue.binding,
)
: null;
const typeKind = (flags >>> 3) & 7;
if (typeKind !== 0 && typeKind !== 1)
throw new TypeError(
"Unsupported Swift conformance type-reference kind",
);
let type = relative(descriptor + 4n);
if (typeKind === 1) type = read.pointer(type);
if (![16, 17, 18].includes(read.u32(type) & 31))
const typeValue = relative(descriptor + 4n, "conformanceType");
let type = typeValue.kind === "target" ? typeValue.target : null;
let typeBinding =
typeValue.kind === "binding"
? importBinding(typeValue.requestedAddress, typeValue.binding)
: null;
let unresolvedTypeReason =
typeValue.kind === "unknown" ? typeValue.reason : null;
if (typeKind === 1 && type !== null) {
const pointer = read.resolvePointer(type);
if (pointer.kind === "bind") {
typeBinding = importBinding(type, pointer);
type = null;
} else if (pointer.kind === "raw" && pointer.target === 0n) {
type = null;
unresolvedTypeReason =
"Null indirect Swift conformance type reference";
} else if (pointer.kind === "rebase" || pointer.kind === "raw") {
type = pointer.target;
} else {
type = null;
unresolvedTypeReason = pointer.reason;
}
}
if (type !== null && ![16, 17, 18].includes(read.u32(type) & 31))
throw new TypeError("Unsupported Swift type descriptor kind");
const typeName = read.string(relative(type + 8n));
const witness = relative(descriptor + 8n);
const typeName =
type === null
? null
: read.string(requiredRelative(type + 8n, "typeName"));
const witnessValue =
protocol !== null && type !== null
? relative(descriptor + 8n, "witnessTablePattern")
: { kind: "null" as const };
const witness =
witnessValue.kind === "target" ? witnessValue.target : null;
const unresolvedWitnessReason =
witnessValue.kind === "unknown" || witnessValue.kind === "binding"
? unresolvedSwiftRelativeReason(witnessValue, "witnessTablePattern")
: null;
const staticWitness =
witness !== 0n &&
witness !== null &&
protocol !== null &&
type !== null &&
(flags & ~0x38) === 0 &&
(read.u32(type) & 0x80) === 0;
let witnessReason: string | null = staticWitness
? null
: "Conditional, generic, resilient or missing static witness table is not decoded";
if (staticWitness && read.pointer(witness) !== descriptor)
witnessReason =
"Witness-table conformance header is unresolved or encoded";
: (unresolvedProtocolReason ??
unresolvedTypeReason ??
unresolvedWitnessReason ??
(protocolBinding !== null || typeBinding !== null
? "Conformance references an external Mach-O binding"
: "Conditional, generic, resilient or missing static witness table is not decoded"));
if (staticWitness) {
const header = read.resolvePointer(witness);
if (header.kind !== "raw" && header.kind !== "rebase")
witnessReason =
header.kind === "unknown" || header.kind === "unsupported"
? header.reason
: "Witness-table conformance header is an external binding";
else if (header.target !== descriptor)
witnessReason =
"Witness-table conformance header is unresolved or encoded";
}
const conformanceDecode =
witnessReason === null
? { status: "decoded" as const, reason: null }
: { status: "partial" as const, reason: witnessReason };
result.swift_conformances.push({
type_name: typeName,
type_binding: typeBinding,
protocol_name: protocolName,
protocol_binding: protocolBinding,
module: null,
witness_table:
witness === 0n
witness === null
? { address: null, file_offset: null }
: read.location(witness),
location: read.location(descriptor),
@@ -153,33 +379,46 @@ export const decodeSwiftDispatchFacets = (input: {
),
});
if (conformanceDecode.status === "decoded") decodedRecords++;
if (witnessReason !== null) {
swiftFailures.push(`${typeName}: ${witnessReason}`);
if (
witnessReason !== null ||
witness === null ||
typeName === null ||
protocolName === null
) {
const reason =
witnessReason ?? "Missing static witness table is not decoded";
swiftFailures.push(`${typeName ?? "<unknown type>"}: ${reason}`);
issues.push(
issue(
"swift_conformances_static_witness_slots",
"conformance_decode_partial",
witnessReason,
reason,
hex(descriptor),
),
);
continue;
}
const count = read.u32(protocol + 16n);
const signatureCount = read.u32(protocol + 12n);
const count = read.u32(protocol! + 16n);
const signatureCount = read.u32(protocol! + 12n);
if (count > 20_000 || signatureCount > 20_000)
throw new RangeError("Swift witness requirement count exceeds 20000");
for (let slot = 0; slot < count; slot++) {
if (!budget.admit()) break;
const entry = witness + BigInt((slot + 1) * 8);
const implementation = read.pointer(entry);
const implementationResolution = read.resolvePointer(entry);
const implementation =
implementationResolution.kind === "raw" ||
implementationResolution.kind === "rebase"
? implementationResolution.target
: null;
const requirementFlags = read.u32(
protocol + 24n + BigInt(signatureCount * 12 + slot * 8),
protocol! + 24n + BigInt(signatureCount * 12 + slot * 8),
);
const kind = requirementFlags & 15;
const synchronousFunction =
kind >= 1 && kind <= 4 && (requirementFlags & 0x20) === 0;
const executable =
implementation !== null &&
synchronousFunction &&
segments.some(
(segment) =>
@@ -192,7 +431,12 @@ export const decodeSwiftDispatchFacets = (input: {
: {
status: "partial" as const,
reason:
"Witness slot is non-function, async, nil, external, authenticated or encoded; implementation target is unresolved",
implementationResolution.kind === "bind"
? "Witness implementation is an external Mach-O binding"
: implementationResolution.kind === "unknown" ||
implementationResolution.kind === "unsupported"
? implementationResolution.reason
: "Witness slot is non-function, async, nil, external, authenticated or encoded; implementation target is unresolved",
};
result.swift_dispatch_slots.push({
owner: `${typeName}: ${protocolName}`,
@@ -201,6 +445,14 @@ export const decodeSwiftDispatchFacets = (input: {
requirement: null,
implementation: null,
implementation_address: executable ? hex(implementation) : null,
...(implementationResolution.kind === "bind"
? {
implementation_binding: importBinding(
entry,
implementationResolution,
),
}
: {}),
thunk_address: null,
location: read.location(entry),
decode: slotDecode,
@@ -0,0 +1,274 @@
import { expect, it } from "vitest";
import { objcSwiftMetadataSchema } from "../domain/native/objcSwiftMetadata.js";
import {
createSwiftRelativeReader,
type DispatchRecordBudget,
} from "./AppleSwiftDispatchFacets.js";
const readers = (bytes: Buffer) => ({
u32: (address: bigint) => bytes.readUInt32LE(Number(address)),
pointer: (address: bigint) => bytes.readBigUInt64LE(Number(address)),
resolvePointer: (address: bigint) => ({
kind: "raw" as const,
target: bytes.readBigUInt64LE(Number(address)),
}),
string: () => "fixture",
location: (address: bigint) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: (address: bigint, description: string) => [
{
kind: "binary_metadata" as const,
description,
location: {
address: `0x${address.toString(16)}`,
file_offset: Number(address),
},
artifact_path: "/fixture",
artifact_sha256: "a".repeat(64),
},
],
offset: (address: bigint, size = 1) => {
if (address < 0n || address + BigInt(size) > BigInt(bytes.length))
throw new RangeError("outside fixture bytes");
return Number(address);
},
});
const budget = (): DispatchRecordBudget => ({
truncated: false,
isFull: () => false,
markTruncated: () => undefined,
admit: () => true,
});
it("keeps an encoded null distinct from a nonzero relative offset to address zero", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(0, 0x20);
bytes.writeInt32LE(-0x40, 0x40);
bytes.writeInt32LE(0x25, 0x24);
bytes.writeBigUInt64LE(0n, 0x48);
bytes.writeInt32LE(0x25, 0x2c);
bytes.writeBigUInt64LE(0x60n, 0x50);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: readers(bytes),
budget: budget(),
result,
});
expect(relative(0x20n, "methodImplementation")).toEqual({ kind: "null" });
expect(relative(0x40n, "methodImplementation")).toEqual({
kind: "target",
target: 0n,
});
expect(relative(0x24n, "conformanceProtocol")).toEqual({ kind: "null" });
expect(relative(0x2cn, "conformanceProtocol")).toEqual({
kind: "target",
target: 0x60n,
});
expect(result.relative_pointers).toMatchObject([
{ displacement: 0, target: { address: null, file_offset: null } },
{ displacement: -0x40, target: { address: "0x0", file_offset: 0 } },
{ displacement: 0x25, indirect: true, target: { address: null } },
{ displacement: 0x25, indirect: true, target: { address: "0x60" } },
]);
});
it("resolves zero offsets for nonnullable fields to their own field address", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(0, 0x20);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: readers(bytes),
budget: budget(),
result,
});
for (const kind of [
"conformanceRecord",
"protocolName",
"typeName",
"typeMetadataRecord",
"classDescriptorName",
] as const) {
expect(relative(0x20n, kind)).toEqual({ kind: "target", target: 0x20n });
}
expect(result.relative_pointers).toHaveLength(5);
expect(result.relative_pointers).toMatchObject(
Array.from({ length: 5 }, () => ({
target: { address: "0x20", file_offset: 0x20 },
})),
);
});
it("accepts encoded zero only for nullable direct Swift fields", () => {
const bytes = Buffer.alloc(0x80);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: readers(bytes),
budget: budget(),
result,
});
for (const kind of [
"conformanceType",
"witnessTablePattern",
"methodImplementation",
] as const) {
expect(relative(0x20n, kind)).toEqual({ kind: "null" });
}
});
it("masks only the verified direct type metadata tag and keeps other tags unsupported", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(0x24, 0x20);
bytes.writeInt32LE(0x25, 0x28);
bytes.writeInt32LE(0x26, 0x2c);
bytes.writeInt32LE(0x27, 0x30);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: readers(bytes),
budget: budget(),
result,
});
expect(relative(0x20n, "typeMetadataRecord")).toEqual({
kind: "target",
target: 0x44n,
});
for (const [field, tag] of [
[0x28n, 1],
[0x2cn, 2],
[0x30n, 3],
] as const) {
expect(relative(field, "typeMetadataRecord")).toEqual({
kind: "unknown",
reason: `Unsupported Swift type metadata record tag ${tag}`,
});
}
expect(result.relative_pointers.slice(-4)).toMatchObject([
{ displacement: 0x24, encoding_tag: 0, target: { address: "0x44" } },
{
displacement: 0x25,
encoding_tag: 1,
decode: { status: "unsupported" },
},
{
displacement: 0x26,
encoding_tag: 2,
decode: { status: "unsupported" },
},
{
displacement: 0x27,
encoding_tag: 3,
decode: { status: "unsupported" },
},
]);
});
it("treats a zero pointer loaded through a nullable indirect field as null", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(-0x23, 0x24); // odd-tagged displacement to cell address 0
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: readers(bytes),
budget: budget(),
result,
});
expect(relative(0x24n, "conformanceProtocol")).toEqual({ kind: "null" });
expect(result.relative_pointers[0]).toMatchObject({
indirect: true,
target: { address: null, file_offset: null },
});
});
it("keeps a fixup rebase to VM address zero as an address, not null", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(-0x23, 0x24);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: {
...readers(bytes),
resolvePointer: () => ({ kind: "rebase", target: 0n }),
offset: (address, size = 1) => {
if (address === 0n) throw new RangeError("unmapped target address");
return readers(bytes).offset(address, size);
},
},
budget: budget(),
result,
});
expect(relative(0x24n, "conformanceProtocol")).toEqual({
kind: "target",
target: 0n,
});
expect(result.relative_pointers[0]).toMatchObject({
target: { address: "0x0", file_offset: null },
decode: { status: "partial", reason: "unmapped target address" },
});
});
it("preserves an indirect import binding instead of reading VM address zero", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(-0x23, 0x24);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: {
...readers(bytes),
resolvePointer: () => ({
kind: "bind",
symbol: "_$sSQMp",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/libswiftCore.dylib",
weak: false,
addend: 0n,
}),
},
budget: budget(),
result,
});
expect(relative(0x24n, "conformanceProtocol")).toMatchObject({
kind: "binding",
binding: { symbol: "_$sSQMp" },
});
expect(result.relative_pointers[0]).toMatchObject({
target: { address: null, file_offset: null },
binding: { symbol: "_$sSQMp", requested_address: "0x0" },
decode: { status: "partial" },
});
});
it("retains unsafe target addresses as partial decode facts", () => {
const bytes = Buffer.alloc(0x80);
bytes.writeInt32LE(0x100, 0x20);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const relative = createSwiftRelativeReader({
bytes,
readers: readers(bytes),
budget: budget(),
result,
});
expect(relative(0x20n, "protocolName")).toEqual({
kind: "target",
target: 0x120n,
});
expect(result.relative_pointers[0]).toMatchObject({
target: { address: "0x120", file_offset: null },
decode: { status: "partial", reason: "outside fixture bytes" },
});
});
+218 -137
View File
@@ -7,140 +7,219 @@ import { decodeSwiftDispatchFacets } from "./AppleSwiftDispatchFacets.js";
import type { DispatchRecordBudget } from "./AppleSwiftDispatchFacets.js";
import { issue } from "./AppleDispatchDecodeFacts.js";
describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => {
it("counts examined slots and reports an unresolved async slot", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const values = new Map<bigint, number>([
[0x200n, 0x80000010], // class descriptor with a vtable
[0x22cn, 0], // table offset
[0x230n, 2], // slot count
[0x21cn, 2], // descriptor positive words
[0x234n, 0x40], // async method flag
]);
const facts = decodeSwiftClassVtables({
entries: [0x100n],
result,
readers: {
u32: (address) => values.get(address) ?? 0,
relative: (address) =>
address === 0x100n ? 0x200n : address === 0x208n ? 0x300n : 0x400n,
string: () => "Widget",
location: (address) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: (address, description) => [
{
kind: "binary_metadata",
description,
location: {
address: `0x${address.toString(16)}`,
file_offset: Number(address),
},
artifact_path: "/fixture",
artifact_sha256: "a".repeat(64),
},
],
executable: (address) => address === 0x400n,
admit: () => true,
},
});
expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial");
expect(result.swift_dispatch_slots[1]?.decode.status).toBe("decoded");
expect(facts).toMatchObject({
facet: "swift_class_vtable_descriptors",
examined: 2,
decoded: 1,
exhaustive: false,
issues: [{ code: "vtable_implementation_unresolved", location: "0x234" }],
});
pushDispatchCoverage({
result,
failures: [],
categoryIssues: [],
examined: 0,
categoriesExamined: 0,
truncated: false,
objcListScanTruncated: false,
objcClassListScanTruncated: false,
fixups: {
kind: "none",
formats: [],
failures: [],
issues: [],
decode: (_address, raw) => ({ kind: "rebase", target: raw }),
examined: 0,
decoded: 0,
unreadRebaseBytes: 0,
},
swift: {
facet: "swift_conformances_static_witness_slots",
examined: 0,
decoded: 0,
exhaustive: true,
issues: [],
},
vtables: facts,
});
expect(
result.coverage.find(
({ facet }) => facet === "swift_class_vtable_descriptors",
),
).toMatchObject({
status: "partial",
examined: 2,
decoded: 1,
reason: expect.stringContaining("0x234"),
});
it("preserves unsupported type-metadata tag reasons instead of reporting null", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const facts = decodeSwiftClassVtables({
entries: [0x100n],
result,
readers: {
u32: () => 0,
relative: () => ({
kind: "unknown",
reason: "Unsupported Swift type metadata record tag 2",
}),
string: () => "unused",
location: (address) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: () => [],
executable: () => false,
admit: () => true,
},
});
it("returns a located issue for an emitted unresolved witness slot", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const values = new Map<bigint, number>([
[0x20cn, 0], // simple, unconditional conformance
[0x500n, 3], // protocol descriptor kind
[0x50cn, 0], // signature count
[0x510n, 1], // witness requirement count
[0x2b0n, 16], // nominal type descriptor kind
[0x518n, 1], // function witness requirement
]);
const budget: DispatchRecordBudget = {
expect(facts.issues).toMatchObject([
{
code: "vtable_decode_failed",
message: "Unsupported Swift type metadata record tag 2",
},
]);
expect(facts.issues[0]?.message).not.toContain("Null Swift");
});
it("preserves a required conformance pointer's unknown reason", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const facts = decodeSwiftDispatchFacets({
sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }],
segments: [],
readers: {
u32: () => 0,
pointer: () => 0n,
resolvePointer: () => ({ kind: "raw", target: 0n }),
string: () => "unused",
location: (address) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: () => [],
offset: (address) => Number(address),
},
relative: () => ({
kind: "unknown",
reason: "Unsupported Swift conformance record tag 3",
}),
budget: {
truncated: false,
isFull: () => false,
markTruncated: () => {
budget.truncated = true;
},
markTruncated: () => {},
admit: () => true,
};
const facts = decodeSwiftDispatchFacets({
sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }],
segments: [],
readers: {
u32: (address) => values.get(address) ?? 0,
pointer: (address) => (address === 0x380n ? 0x200n : 0x900n),
string: (address) => (address === 0x600n ? "Proto" : "Widget"),
location: (address) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: (address, description) => [
{
kind: "binary_metadata",
description,
location: {
address: `0x${address.toString(16)}`,
file_offset: Number(address),
},
artifact_path: "/fixture",
artifact_sha256: "a".repeat(64),
},
result,
});
expect(facts.issues).toMatchObject([
{
code: "conformance_decode_failed",
message: "Unsupported Swift conformance record tag 3",
},
]);
});
it("counts examined slots and reports an unresolved async slot", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const values = new Map<bigint, number>([
[0x200n, 0x80000010], // class descriptor with a vtable
[0x22cn, 0], // table offset
[0x230n, 2], // slot count
[0x21cn, 2], // descriptor positive words
[0x234n, 0x40], // async method flag
]);
const facts = decodeSwiftClassVtables({
entries: [0x100n],
result,
readers: {
u32: (address) => values.get(address) ?? 0,
relative: (address) => ({
kind: "target",
target:
address === 0x100n ? 0x200n : address === 0x208n ? 0x300n : 0x400n,
}),
string: () => "Widget",
location: (address) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: (address, description) => [
{
kind: "binary_metadata",
description,
location: {
address: `0x${address.toString(16)}`,
file_offset: Number(address),
},
],
offset: (address) => Number(address),
},
relative: (field) =>
artifact_path: "/fixture",
artifact_sha256: "a".repeat(64),
},
],
executable: (address) => address === 0x400n,
admit: () => true,
},
});
expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial");
expect(result.swift_dispatch_slots[1]?.decode.status).toBe("decoded");
expect(facts).toMatchObject({
facet: "swift_class_vtable_descriptors",
examined: 2,
decoded: 1,
exhaustive: false,
issues: [{ code: "vtable_implementation_unresolved", location: "0x234" }],
});
pushDispatchCoverage({
result,
failures: [],
categoryIssues: [],
examined: 0,
categoriesExamined: 0,
truncated: false,
objcListScanTruncated: false,
objcClassListScanTruncated: false,
fixups: {
kind: "none",
formats: [],
failures: [],
issues: [],
decode: (_address, raw) => ({ kind: "rebase", target: raw }),
examined: 0,
decoded: 0,
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
},
swift: {
facet: "swift_conformances_static_witness_slots",
examined: 0,
decoded: 0,
exhaustive: true,
issues: [],
},
vtables: facts,
});
expect(
result.coverage.find(
({ facet }) => facet === "swift_class_vtable_descriptors",
),
).toMatchObject({
status: "partial",
examined: 2,
decoded: 1,
reason: expect.stringContaining("0x234"),
});
});
it("returns a located issue for an emitted unresolved witness slot", () => {
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
const values = new Map<bigint, number>([
[0x20cn, 0], // simple, unconditional conformance
[0x500n, 3], // protocol descriptor kind
[0x50cn, 0], // signature count
[0x510n, 1], // witness requirement count
[0x2b0n, 16], // nominal type descriptor kind
[0x518n, 1], // function witness requirement
]);
const budget: DispatchRecordBudget = {
truncated: false,
isFull: () => false,
markTruncated: () => {
budget.truncated = true;
},
admit: () => true,
};
const facts = decodeSwiftDispatchFacets({
sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }],
segments: [],
readers: {
u32: (address) => values.get(address) ?? 0,
pointer: (address) => (address === 0x380n ? 0x200n : 0x900n),
resolvePointer: (address) => ({
kind: "raw",
target: address === 0x380n ? 0x200n : 0x900n,
}),
string: (address) => (address === 0x600n ? "Proto" : "Widget"),
location: (address) => ({
address: `0x${address.toString(16)}`,
file_offset: Number(address),
}),
evidence: (address, description) => [
{
kind: "binary_metadata",
description,
location: {
address: `0x${address.toString(16)}`,
file_offset: Number(address),
},
artifact_path: "/fixture",
artifact_sha256: "a".repeat(64),
},
],
offset: (address) => Number(address),
},
relative: (field) => ({
kind: "target",
target:
new Map<bigint, bigint>([
[0x100n, 0x200n],
[0x200n, 0x500n],
@@ -149,16 +228,16 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => {
[0x2b8n, 0x700n],
[0x208n, 0x380n],
]).get(field) ?? 0n,
budget,
result,
});
}),
budget,
result,
});
expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial");
expect(facts).toMatchObject({
facet: "swift_conformances_static_witness_slots",
exhaustive: false,
issues: [{ code: "witness_slot_unresolved", location: "0x388" }],
});
expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial");
expect(facts).toMatchObject({
facet: "swift_conformances_static_witness_slots",
exhaustive: false,
issues: [{ code: "witness_slot_unresolved", location: "0x388" }],
});
});
@@ -192,6 +271,8 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () =
}),
examined: 0,
decoded: 0,
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
},
swift: {
+44 -7
View File
@@ -3,12 +3,17 @@ import type {
NativeMetadataEvidence,
NativeMetadataLocation,
} from "../domain/native/objcSwiftMetadata.js";
import type {
SwiftRelativeField,
SwiftRelativeValue,
} from "./AppleSwiftDispatchFacets.js";
import { unresolvedSwiftRelativeReason } from "./AppleSwiftDispatchFacets.js";
import { issue, type FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js";
/** Byte readers shared with the validated Mach-O metadata boundary. */
export interface SwiftMetadataReaders {
u32(address: bigint): number;
relative(address: bigint): bigint;
relative(address: bigint, kind: SwiftRelativeField): SwiftRelativeValue;
string(address: bigint): string;
location(address: bigint): NativeMetadataLocation;
evidence(address: bigint, description: string): NativeMetadataEvidence[];
@@ -43,7 +48,12 @@ export const decodeSwiftClassVtables = (input: {
break;
}
try {
const descriptor = read.relative(field);
const descriptorValue = read.relative(field, "typeMetadataRecord");
if (descriptorValue.kind !== "target")
throw new RangeError(
unresolvedSwiftRelativeReason(descriptorValue, "typeMetadataRecord"),
);
const descriptor = descriptorValue.target;
const flags = read.u32(descriptor);
if ((flags & 31) !== 16) continue;
const specific = flags >>> 16;
@@ -66,7 +76,13 @@ export const decodeSwiftClassVtables = (input: {
throw new RangeError(
"Swift vtable count or offset exceeds declared metadata bounds",
);
const owner = read.string(read.relative(descriptor + 8n));
const ownerValue = read.relative(descriptor + 8n, "classDescriptorName");
if (ownerValue.kind !== "target")
throw new RangeError(
unresolvedSwiftRelativeReason(ownerValue, "classDescriptorName"),
);
const ownerAddress = ownerValue.target;
const owner = read.string(ownerAddress);
if (owner.length === 0)
throw new RangeError(
"Swift class vtable descriptor class name is empty",
@@ -90,8 +106,25 @@ export const decodeSwiftClassVtables = (input: {
// Async slots reference async descriptors; coroutine and future kinds need separate decoders.
const synchronous =
(methodFlags & 0x40) === 0 && (methodFlags & 15) <= 3;
const implementation = read.relative(entry + 4n);
const resolved = synchronous && read.executable(implementation);
const implementationValue = read.relative(
entry + 4n,
"methodImplementation",
);
const implementation =
implementationValue.kind === "target"
? implementationValue.target
: null;
const implementationReason =
implementationValue.kind === "target"
? null
: unresolvedSwiftRelativeReason(
implementationValue,
"methodImplementation",
);
const resolved =
implementation !== null &&
synchronous &&
read.executable(implementation);
result.swift_dispatch_slots.push({
owner,
table_kind: "class_vtable",
@@ -108,7 +141,9 @@ export const decodeSwiftClassVtables = (input: {
: {
status: "partial",
reason:
"Async, coroutine, external or unmapped vtable implementation is unresolved",
implementation === null
? implementationReason
: "Async, coroutine, external or unmapped vtable implementation is unresolved",
},
evidence: read.evidence(
entry,
@@ -121,7 +156,9 @@ export const decodeSwiftClassVtables = (input: {
issue(
"swift_class_vtable_descriptors",
"vtable_implementation_unresolved",
"Async, coroutine, external or unmapped vtable implementation is unresolved",
implementation === null
? implementationReason!
: "Async, coroutine, external or unmapped vtable implementation is unresolved",
`0x${entry.toString(16)}`,
),
);
+130 -2
View File
@@ -1,9 +1,17 @@
import { describe, expect, it } from "vitest";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
CAPINFOS_COMMAND_ENV,
TSHARK_COMMAND_ENV,
locateWiresharkTool,
parseWiresharkVersion,
resolvePcapTools,
selectWiresharkTool,
wiresharkExecutableIdentity,
} from "./PcapConfiguration.js";
import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js";
@@ -20,13 +28,51 @@ describe("resolvePcapTools", () => {
});
});
it("falls back to PATH resolution without the environment keys", () => {
expect(resolvePcapTools({})).toEqual({
it("keeps bare PATH selections for the Windows PATH/PATHEXT launcher", () => {
expect(resolvePcapTools({}, "win32")).toEqual({
tshark: { command: "tshark", source: "path" },
capinfos: { command: "capinfos", source: "path" },
});
});
it("fails closed when no absolute PATH entry holds the tool", () => {
expect(() => resolvePcapTools({ PATH: "relative-only" }, "linux")).toThrow(
/tshark was not found in an absolute PATH entry/u,
);
});
// Windows X_OK reports existence only, so executable-bit selection is POSIX-only.
it.skipIf(process.platform === "win32")(
"selects the same executable as the packet tools ahead of a relative PATH entry",
async () => {
const root = await createTestTempDirectory("rea-wireshark-shared-");
const decoy = join(root, "relative-decoy");
const installed = join(root, "installed");
for (const directory of [decoy, installed]) {
await mkdir(directory);
for (const tool of ["tshark", "capinfos"]) {
await writeFile(join(directory, tool), "");
await chmod(join(directory, tool), 0o700);
}
}
const environment = {
PATH: [relative(process.cwd(), decoy), installed].join(":"),
};
const pcap = resolvePcapTools(environment, "linux");
expect(pcap).toEqual({
tshark: { command: join(installed, "tshark"), source: "path" },
capinfos: { command: join(installed, "capinfos"), source: "path" },
});
expect(pcap.tshark.command).toBe(
locateWiresharkTool(
selectWiresharkTool(environment, "tshark"),
environment,
),
);
},
);
it("rejects a relative tshark path explicitly", () => {
expect(() =>
resolvePcapTools({ [TSHARK_COMMAND_ENV]: "bin/tshark" }),
@@ -40,6 +86,88 @@ describe("resolvePcapTools", () => {
});
});
describe("locateWiresharkTool", () => {
it("keeps an environment selection as the configured absolute path", () => {
expect(
locateWiresharkTool(
{ command: "/opt/wireshark/tshark", source: "environment" },
{ PATH: "/usr/bin" },
),
).toBe("/opt/wireshark/tshark");
});
// Windows X_OK reports existence only, so executable-bit selection is POSIX-only.
it.skipIf(process.platform === "win32")(
"uses the first absolute PATH entry holding an executable regular file",
async () => {
const root = await createTestTempDirectory("rea-wireshark-path-");
const relative = join(root, "relative");
const notExecutable = join(root, "not-executable");
const directoryEntry = join(root, "directory-entry");
const selected = join(root, "selected");
const later = join(root, "later");
for (const directory of [notExecutable, directoryEntry, selected, later])
await mkdir(directory);
await writeFile(join(notExecutable, "tshark"), "");
await chmod(join(notExecutable, "tshark"), 0o600);
await mkdir(join(directoryEntry, "tshark"));
for (const directory of [selected, later]) {
await writeFile(join(directory, "tshark"), "");
await chmod(join(directory, "tshark"), 0o700);
}
const PATH = [
"relative-entry",
relative,
notExecutable,
directoryEntry,
selected,
later,
].join(":");
expect(
locateWiresharkTool({ command: "tshark", source: "path" }, { PATH }),
).toBe(join(selected, "tshark"));
expect(
locateWiresharkTool(
{ command: "tshark", source: "path" },
{ PATH: [relative, notExecutable, directoryEntry].join(":") },
),
).toBeUndefined();
expect(
locateWiresharkTool({ command: "tshark", source: "path" }, {}),
).toBeUndefined();
},
);
});
describe("parseWiresharkVersion", () => {
it("admits Wireshark 4.x banners for either tool and rejects other majors", () => {
expect(
parseWiresharkVersion(
"\nTShark (Wireshark) 4.6.9 (Git v4.6.9 packaged as 4.6.9-1)\n",
"tshark",
),
).toBe("TShark (Wireshark) 4.6.9 (Git v4.6.9 packaged as 4.6.9-1)");
expect(
parseWiresharkVersion("Capinfos (Wireshark) 4.0.17\n", "capinfos"),
).toBe("Capinfos (Wireshark) 4.0.17");
expect(parseWiresharkVersion("tcpdump 4.99.4\n", "tshark")).toBeNull();
expect(() =>
parseWiresharkVersion("TShark (Wireshark) 3.6.2\n", "tshark"),
).toThrow(/requires Wireshark 4\.x/u);
});
});
it("projects executable snapshots to compact identity facts", () => {
const bytes = new Uint8Array(64);
const identity = wiresharkExecutableIdentity({
bytes,
sha256: "a".repeat(64),
});
expect(identity).toEqual({ bytes: 64, sha256: "a".repeat(64) });
expect(Object.values(identity)).not.toContain(bytes);
});
const REAL_CAPINFOS = [
"File name: /work/rich-capture.pcap",
"File type: Wireshark/tcpdump/... - pcap",
+130 -56
View File
@@ -1,10 +1,43 @@
import { isAbsolute } from "node:path";
import { accessSync, constants, statSync } from "node:fs";
import { delimiter, isAbsolute, join } from "node:path";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js";
export interface WiresharkExecutableIdentity {
readonly bytes: number;
readonly sha256: string;
}
/** Retain only identity facts after a bounded executable read. */
export const wiresharkExecutableIdentity = (snapshot: {
readonly bytes: Uint8Array;
readonly sha256: string;
}): WiresharkExecutableIdentity => ({
bytes: snapshot.bytes.byteLength,
sha256: snapshot.sha256,
});
/** Shared identity owner used to verify each caller-selected tool run. */
export const assertWiresharkExecutableIdentity = (
operation: string,
path: string,
expected: WiresharkExecutableIdentity,
actual: WiresharkExecutableIdentity,
options?: AnalysisErrorOptions,
): void => {
if (actual.bytes !== expected.bytes || actual.sha256 !== expected.sha256)
throw new AnalysisArtifactChangedError(
operation,
path,
"The selected Wireshark executable changed during analysis.",
options,
);
};
/** Environment keys selecting the caller's tshark and capinfos binaries. */
export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND";
@@ -27,64 +60,99 @@ export const pcapToolUnavailable = (
userMessage: `${/[.!?]$/u.test(reason) ? reason : `${reason}.`} ${TOOL_REMEDIATION}`,
});
/** How each tool was selected for this provider instance. */
export interface PcapToolSelection {
readonly tshark: { command: string; source: "environment" | "path" };
readonly capinfos: { command: string; source: "environment" | "path" };
/** How one caller-supplied Wireshark tool was selected. */
export interface WiresharkToolSelection {
readonly command: string;
readonly source: "environment" | "path";
}
/** How each tool was selected for this provider instance. */
export interface PcapToolSelection {
readonly tshark: WiresharkToolSelection;
readonly capinfos: WiresharkToolSelection;
}
const TOOL_COMMAND_ENV = {
tshark: TSHARK_COMMAND_ENV,
capinfos: CAPINFOS_COMMAND_ENV,
} as const;
/**
* Resolve the caller-supplied tshark and capinfos binaries. Each accepts an
* absolute environment path or falls back to PATH resolution, mirroring the
* mitmdump pattern. REA never installs the Wireshark suite.
* The one selection rule for a caller-supplied Wireshark tool: an absolute
* environment path, otherwise the bare tool name resolved through PATH. A
* relative environment value is configuration to fix, never a PATH lookup.
* REA never installs the Wireshark suite.
*/
export const selectWiresharkTool = (
environment: Readonly<Record<string, string | undefined>>,
tool: "tshark" | "capinfos",
): WiresharkToolSelection => {
const key = TOOL_COMMAND_ENV[tool];
const configured = environment[key];
if (configured === undefined || configured.trim() === "")
return { command: tool, source: "path" };
if (!isAbsolute(configured))
throw new PcapConfigurationFailure(
`${tool}_not_absolute`,
`${key} must be an absolute path to the ${tool} binary`,
{ configured },
);
return { command: configured, source: "environment" };
};
/**
* Resolve the caller-supplied tshark and capinfos binaries to the executables
* the packet tools would also select. Windows keeps PATH/PATHEXT resolution in
* the launcher, which the POSIX locator does not model.
*/
export const resolvePcapTools = (
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform = process.platform,
): PcapToolSelection => {
const tsharkEnv = environment[TSHARK_COMMAND_ENV];
const capinfosEnv = environment[CAPINFOS_COMMAND_ENV];
if (
tsharkEnv !== undefined &&
tsharkEnv.trim() !== "" &&
!isAbsolute(tsharkEnv)
)
throw new PcapConfigurationFailure(
"tshark_not_absolute",
`${TSHARK_COMMAND_ENV} must be an absolute path to the tshark binary`,
{ configured: tsharkEnv },
);
if (
capinfosEnv !== undefined &&
capinfosEnv.trim() !== "" &&
!isAbsolute(capinfosEnv)
)
throw new PcapConfigurationFailure(
"capinfos_not_absolute",
`${CAPINFOS_COMMAND_ENV} must be an absolute path to the capinfos binary`,
{ configured: capinfosEnv },
);
return {
tshark: {
command:
tsharkEnv === undefined || tsharkEnv.trim() === ""
? "tshark"
: tsharkEnv,
source:
tsharkEnv === undefined || tsharkEnv.trim() === ""
? "path"
: "environment",
},
capinfos: {
command:
capinfosEnv === undefined || capinfosEnv.trim() === ""
? "capinfos"
: capinfosEnv,
source:
capinfosEnv === undefined || capinfosEnv.trim() === ""
? "path"
: "environment",
},
// Report every configuration error before consulting PATH for either tool.
const selected = {
tshark: selectWiresharkTool(environment, "tshark"),
capinfos: selectWiresharkTool(environment, "capinfos"),
};
const locate = (tool: "tshark" | "capinfos"): WiresharkToolSelection => {
const selection = selected[tool];
if (selection.source === "environment" || platform === "win32")
return selection;
const command = locateWiresharkTool(selection, environment);
if (command === undefined)
throw new PcapConfigurationFailure(
`${tool}_missing`,
`${tool} was not found in an absolute PATH entry; add it to PATH or set ${TOOL_COMMAND_ENV[tool]} to its absolute path`,
);
return { command, source: "path" };
};
return { tshark: locate("tshark"), capinfos: locate("capinfos") };
};
/**
* Locate a selected tool as an absolute executable so its identity can be
* hashed. Environment selections are already absolute. PATH selections use the
* first absolute PATH entry holding an executable regular file; relative PATH
* entries are skipped because they would depend on the working directory.
* Returns undefined when PATH holds no such executable.
*/
export const locateWiresharkTool = (
selection: WiresharkToolSelection,
environment: Readonly<Record<string, string | undefined>>,
): string | undefined => {
if (selection.source === "environment" || isAbsolute(selection.command))
return selection.command;
for (const directory of (environment.PATH ?? "").split(delimiter)) {
if (!isAbsolute(directory)) continue;
const candidate = join(directory, selection.command);
try {
accessSync(candidate, constants.X_OK);
if (statSync(candidate).isFile()) return candidate;
} catch {
// Absent or non-executable candidates leave later PATH entries eligible.
}
}
return undefined;
};
export type PcapConfigurationFailureCode =
@@ -108,8 +176,11 @@ export class PcapConfigurationFailure extends Error {
}
}
/** Recognize the selected Wireshark tool's version banner. */
const parseVersion = (
/**
* Recognize the selected Wireshark tool's version banner: null when the first
* line is not a Wireshark banner, a failure when its major version is not 4.
*/
export const parseWiresharkVersion = (
stdout: string,
tool: "tshark" | "capinfos",
): string | null => {
@@ -127,7 +198,7 @@ const parseVersion = (
if (parsed[1] !== "4")
throw new PcapConfigurationFailure(
`${tool}_version_unsupported`,
"Packet summary inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.",
"Packet capture inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.",
{ version: line },
);
return line;
@@ -143,8 +214,11 @@ export const inspectPcapClient = async (
selection: PcapToolSelection,
probe: (command: string) => Promise<string>,
): Promise<PcapClientIdentity> => ({
tsharkVersion: parseVersion(await probe(selection.tshark.command), "tshark"),
capinfosVersion: parseVersion(
tsharkVersion: parseWiresharkVersion(
await probe(selection.tshark.command),
"tshark",
),
capinfosVersion: parseWiresharkVersion(
await probe(selection.capinfos.command),
"capinfos",
),
+144 -20
View File
@@ -23,11 +23,15 @@ import { err, ok, type Result } from "../domain/result.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import {
PcapConfigurationFailure,
assertWiresharkExecutableIdentity,
inspectPcapClient,
locateWiresharkTool,
resolvePcapTools,
pcapConfigurationAvailability,
pcapToolUnavailable,
wiresharkExecutableIdentity,
type PcapToolSelection,
type WiresharkExecutableIdentity,
} from "./PcapConfiguration.js";
import {
parseCapinfosOutput,
@@ -46,8 +50,11 @@ import { createPcapEvidence } from "../domain/pcap/pcapEvidence.js";
import { preparePcapNativeBound } from "./PcapNativeBound.js";
import { PcapSnapshot } from "./PcapSnapshot.js";
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js";
import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js";
import { realpath } from "node:fs/promises";
export const PCAP_PROVIDER_IDENTITY = {
id: "pcap",
@@ -128,6 +135,7 @@ const pcapCommandCleanupError = (
export interface PcapProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
readonly launcher?: PcapCommandLauncher;
readonly artifactReader?: typeof readStableArtifact;
}
interface RetainedPcapCommand {
@@ -148,18 +156,13 @@ interface RetainedSnapshotFailure {
/** Inspect saved captures through bounded, owned tool processes and one stable input copy. */
export class PcapProvider {
readonly #resources = new ArtifactAnalysisResources(
"pcap",
"inspect_pcap_capture",
readStableArtifact,
readLinuxResourceLimits,
{
message: "A packet summary is already active; retry after it completes.",
details: { max_concurrent_inspections: 1 },
},
);
readonly #resources: ArtifactAnalysisResources;
private readonly environment: Readonly<Record<string, string | undefined>>;
private readonly launcher: PcapCommandLauncher | undefined;
private readonly wiresharkIdentities = new Map<
string,
{ readonly path: string; readonly identity: WiresharkExecutableIdentity }
>();
private readonly snapshots = new Set<PcapSnapshot>();
private readonly snapshotFailures = new Map<
PcapSnapshot,
@@ -173,6 +176,17 @@ export class PcapProvider {
constructor(options: PcapProviderOptions = {}) {
this.environment = options.environment ?? process.env;
this.launcher = options.launcher;
this.#resources = new ArtifactAnalysisResources(
"pcap",
"inspect_pcap_capture",
options.artifactReader ?? readStableArtifact,
readLinuxResourceLimits,
{
message:
"A packet summary is already active; retry after it completes.",
details: { max_concurrent_inspections: 1 },
},
);
}
private async run(
@@ -187,10 +201,24 @@ export class PcapProvider {
let cleanup: PcapCommandCleanup | undefined;
let retainedCommand: RetainedPcapCommand | undefined;
let output: Awaited<ReturnType<typeof runPcapCommand>>;
const selected = this.wiresharkIdentities.get(command);
try {
if (selected !== undefined) {
const before = await this.#resources.read(
selected.path,
64 * 1024 * 1024,
signal,
);
assertWiresharkExecutableIdentity(
operation,
selected.path,
selected.identity,
wiresharkExecutableIdentity(before),
);
}
output = await runPcapCommand({
operation,
command,
command: selected?.path ?? command,
arguments: args,
limits,
signal,
@@ -219,6 +247,25 @@ export class PcapProvider {
cleanup = { reason, resources, capturedOutput };
},
});
if (selected !== undefined) {
const after = await this.#resources.read(
selected.path,
64 * 1024 * 1024,
);
assertWiresharkExecutableIdentity(
operation,
selected.path,
selected.identity,
wiresharkExecutableIdentity(after),
{
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.cleanup?.capturedOutput.truncated ?? false,
},
},
);
}
} catch (cause) {
if (
retainedCommand !== undefined &&
@@ -263,6 +310,40 @@ export class PcapProvider {
private async client(operation: PcapOperation, signal?: AbortSignal) {
const selection = resolvePcapTools(this.environment);
this.wiresharkIdentities.clear();
for (const [name, selected] of [
["tshark", selection.tshark],
["capinfos", selection.capinfos],
] as const) {
const path = locateWiresharkTool(selected, this.environment);
if (path === undefined) continue;
let resolvedPath: string;
try {
resolvedPath = await realpath(path);
} catch (cause) {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
)
throw new PcapConfigurationFailure(
`${name}_missing`,
`The selected ${name} executable could not be found at its configured path.`,
{ command: selected.command },
{ cause },
);
throw cause;
}
const snapshot = await this.#resources.read(
resolvedPath,
64 * 1024 * 1024,
signal,
);
this.wiresharkIdentities.set(selected.command, {
path: resolvedPath,
identity: wiresharkExecutableIdentity(snapshot),
});
}
const identity = await inspectPcapClient(
selection,
async (command) =>
@@ -292,9 +373,30 @@ export class PcapProvider {
tshark_command: selection.tshark.command,
tshark_source: selection.tshark.source,
tshark_version: identity.tsharkVersion,
tshark_resolved_path:
this.wiresharkIdentities.get(selection.tshark.command)?.path ?? null,
tshark_sha256:
this.wiresharkIdentities.get(selection.tshark.command)?.identity
.sha256 ?? null,
tshark_identity_status: this.wiresharkIdentities.has(
selection.tshark.command,
)
? "verified"
: "unresolved",
capinfos_command: selection.capinfos.command,
capinfos_source: selection.capinfos.source,
capinfos_version: identity.capinfosVersion,
capinfos_resolved_path:
this.wiresharkIdentities.get(selection.capinfos.command)?.path ??
null,
capinfos_sha256:
this.wiresharkIdentities.get(selection.capinfos.command)?.identity
.sha256 ?? null,
capinfos_identity_status: this.wiresharkIdentities.has(
selection.capinfos.command,
)
? "verified"
: "unresolved",
},
};
}
@@ -567,17 +669,39 @@ export class PcapProvider {
);
}
} catch (cause) {
result = err(
cause instanceof AnalysisError
? cause
: cause instanceof PcapConfigurationFailure
? pcapToolUnavailable(request.operation, cause.message, {
cause,
const classifyArtifactFailure = (failure: unknown): AnalysisError =>
failure instanceof AnalysisError
? failure
: failure instanceof ArtifactReaderFailure &&
failure.reason === "cancelled"
? new AnalysisCancelledError(request.operation, {
cause: artifactFileCancellationCause(failure),
...(failure.partialObservation === undefined
? {}
: { partialObservation: failure.partialObservation }),
})
: new AnalysisProtocolError(
`Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
`Packet-capture analysis failed: ${failure instanceof Error ? failure.message : String(failure)}`,
{ cause: failure },
);
result = err(
cause instanceof ArtifactReaderFailure
? this.#resources.cleanupFailure(
cause,
classifyArtifactFailure,
undefined,
request.operation,
)
: cause instanceof AnalysisError
? cause
: cause instanceof PcapConfigurationFailure
? pcapToolUnavailable(request.operation, cause.message, {
cause,
})
: new AnalysisProtocolError(
`Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
}
if (signal?.aborted && result.ok)
+20 -1
View File
@@ -114,6 +114,10 @@ import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysis
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import {
locateWiresharkTool,
selectWiresharkTool,
} from "../pcap/PcapConfiguration.js";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js";
import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js";
@@ -198,6 +202,21 @@ export interface CreateServerOptions {
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
}
/** Apply the shared TShark selection rule without starting the tool. */
const tsharkLocatable = (environment: Readonly<NodeJS.ProcessEnv>): boolean => {
try {
return (
locateWiresharkTool(
selectWiresharkTool(environment, "tshark"),
environment,
) !== undefined
);
} catch {
// A relative REA_TSHARK_COMMAND is configuration the tool reports at execution.
return false;
}
};
const installSessionToolAvailability = (
session: BinarySessionPort | undefined,
options: CreateServerOptions,
@@ -214,7 +233,7 @@ const installSessionToolAvailability = (
optionalFeatures: {
packetCaptureEnabled:
options.packetCapture !== undefined ||
(linux && isAbsolute(environment.REA_TSHARK_COMMAND ?? "")),
(linux && tsharkLocatable(environment)),
signalRecordingEnabled:
options.signalRecording !== undefined ||
(linux && isAbsolute(environment.REA_SIGROK_COMMAND ?? "")),
@@ -41,6 +41,7 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr
const configuredCommand =
mode === "symlink" ? join(directory, "configured-tool") : script;
if (mode === "symlink") await symlink(script, configuredCommand);
if (mode === "path") await symlink(script, join(directory, "tshark"));
const controller = new AbortController();
let calls = 0;
let mayClean = !failCleanup;
@@ -49,7 +50,9 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr
const provider = new TsharkCaptureProvider(
{
...process.env,
REA_TSHARK_COMMAND: configuredCommand,
...(mode === "path"
? { PATH: directory, REA_TSHARK_COMMAND: undefined }
: { REA_TSHARK_COMMAND: configuredCommand }),
REA_TSHARK_PRLIMIT_COMMAND: configuredCommand,
},
async (input) => {
@@ -408,6 +411,41 @@ it("refuses unsupported hosts before launching or allocating a workspace", async
});
});
it("selects TShark from PATH with the shared rule and records the selection", async () => {
const f = await fixture("path");
const result = await f.service.inspect({ path: f.path, fields: ["ip.addr"] });
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
tool: {
configured_path: join(f.directory, "tshark"),
selection_source: "path",
path: join(f.directory, "producer.mjs"),
},
});
});
it("refuses a relative REA_TSHARK_COMMAND instead of searching PATH", async () => {
const provider = new TsharkCaptureProvider(
{ REA_TSHARK_COMMAND: "tshark", PATH: "/usr/bin" },
async () => {
throw new Error("Must not launch");
},
async () => {
throw new Error("Must not allocate");
},
"linux",
);
const result = await new PacketCaptureService(provider).inspect({
path: "/recording.pcap",
fields: ["ip.addr"],
});
if (result.ok) throw new Error("Expected configuration refusal");
expect(result.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: "REA_TSHARK_COMMAND must be an absolute path to the tshark binary",
});
});
it("preserves complete Evidence when aborted during post-run integrity checks", async () => {
const f = await fixture("post-run-abort");
const result = await f.service.inspect(
@@ -435,6 +473,7 @@ it("preserves configured symlink paths separately from resolved tool identities"
expect(result.value.normalized_result).toMatchObject({
tool: {
configured_path: join(f.directory, "configured-tool"),
selection_source: "environment",
path: join(f.directory, "producer.mjs"),
},
resource_limiter: {
+6
View File
@@ -15,9 +15,15 @@ const clientIdentity = {
tshark_command: "/usr/bin/tshark",
tshark_source: "path",
tshark_version: "TShark (Wireshark) 4.6.6",
tshark_resolved_path: null,
tshark_sha256: null,
tshark_identity_status: "unresolved",
capinfos_command: "/usr/bin/capinfos",
capinfos_source: "path",
capinfos_version: "Capinfos (Wireshark) 4.6.6",
capinfos_resolved_path: null,
capinfos_sha256: null,
capinfos_identity_status: "unresolved",
};
it("advertises exact valid pcap schemas and records inline evidence through MCP", async () => {
+40 -1
View File
@@ -141,7 +141,7 @@ const captureFixture = async (root: string): Promise<string> => {
};
it("inspects both tools without reading a capture", async () => {
const { provider } = await providerWith();
const { provider, root } = await providerWith();
const result = await evidenceResult(provider, {
operation: "inspect_pcap_client",
input: {},
@@ -149,11 +149,50 @@ it("inspects both tools without reading a capture", async () => {
expect(result.client).toMatchObject({
tshark_source: "environment",
tshark_version: "TShark (Wireshark) 4.6.6-stub",
tshark_sha256: createHash("sha256")
.update(await readFile(join(root, "tshark")))
.digest("hex"),
tshark_identity_status: "verified",
capinfos_source: "environment",
capinfos_version: "Capinfos (Wireshark) 4.6.6-stub",
capinfos_sha256: createHash("sha256")
.update(await readFile(join(root, "capinfos")))
.digest("hex"),
capinfos_identity_status: "verified",
});
});
it("rejects a selected TShark whose bytes change during its version probe", async () => {
const root = await createTestTempDirectory("rea-pcap-tool-identity-");
const { tshark, capinfos } = await writeStubTools(root);
await writeFile(
tshark,
`#!/bin/sh
if [ "$1" = "--version" ]; then
printf 'TShark (Wireshark) 4.6.6-stub\\n'
printf '\\n# changed during version probe\\n' >> "$0"
exit 0
fi
exit 2
`,
{ mode: 0o755 },
);
const provider = new PcapProvider({
environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
});
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_pcap_client",
input: {},
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_changed",
details: { path: tshark },
});
});
it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => {
const { provider, root } = await providerWith();
const path = await captureFixture(root);
@@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
import { expect, onTestFinished, test, vi } from "vitest";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { resolvedAnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
@@ -62,6 +64,7 @@ const fixture = async (
reason: unknown,
failedCleanupAttempts: number,
activeVersion = false,
artifactReader: typeof readStableArtifact = readStableArtifact,
) => {
const root = await createTestTempDirectory("rea-pcap-provider-cancel-");
const tools = await writeTools(root, mode, activeVersion);
@@ -100,6 +103,7 @@ const fixture = async (
REA_CAPINFOS_COMMAND: tools.capinfos,
},
launcher,
artifactReader,
});
onTestFinished(async () => provider.close());
return { provider, root, capture, cleanupAttempts: () => cleanupAttempts };
@@ -123,6 +127,31 @@ it("availability preserves the producer's exact cancellation reason", async () =
expect(failure).toMatchObject({ cause: reason });
});
it("executable identity hashing preserves artifact-read cancellation", async () => {
const controller = new AbortController();
const reason = { caller: "cancel executable identity read" };
const { provider } = await fixture(
"active",
controller,
reason,
0,
false,
async () => {
throw new ArtifactReaderFailure("cancelled", "identity read cancelled", {
cancellation: { reason },
});
},
);
const result = await provider.execute(
{ operation: "inspect_pcap_client", input: {} },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
});
it("active cancellation stays primary through retained command and snapshot cleanup retry", async () => {
const controller = new AbortController();
const reason = "caller cancellation token";
@@ -7,7 +7,7 @@
* refusal paths, and cancellation before spawn.
*/
import { createHash } from "node:crypto";
import { open, writeFile } from "node:fs/promises";
import { open, readFile, realpath, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -52,6 +52,41 @@ const evidenceResult = async (
return result.value.result as Record<string, unknown>;
};
// Windows keeps PATH/PATHEXT resolution in the launcher; POSIX hosts locate the file.
it.skipIf(isWindows)(
"runs and reports the tools located on PATH rather than bare names",
async () => {
const directory = await createTestTempDirectory("rea-pcap-path-");
await symlink(tsharkStub, join(directory, "tshark"));
await symlink(capinfosStub, join(directory, "capinfos"));
const provider = new PcapProvider({
environment: { PATH: `relative-entry:${directory}` },
});
onTestFinished(() => provider.close());
const result = await evidenceResult(provider, {
operation: "inspect_pcap_client",
input: {},
});
expect(result.client).toMatchObject({
tshark_command: join(directory, "tshark"),
tshark_source: "path",
tshark_version: expect.stringContaining("4.6.6") as unknown,
tshark_resolved_path: await realpath(tsharkStub),
tshark_sha256: createHash("sha256")
.update(await readFile(join(directory, "tshark")))
.digest("hex"),
tshark_identity_status: "verified",
capinfos_command: join(directory, "capinfos"),
capinfos_source: "path",
capinfos_resolved_path: await realpath(capinfosStub),
capinfos_sha256: createHash("sha256")
.update(await readFile(join(directory, "capinfos")))
.digest("hex"),
capinfos_identity_status: "verified",
});
},
);
it("inspects both tools through cross-platform stubs", async () => {
const result = await evidenceResult(providerOver(), {
operation: "inspect_pcap_client",
@@ -60,11 +95,81 @@ it("inspects both tools through cross-platform stubs", async () => {
expect(result.client).toMatchObject({
tshark_source: "environment",
tshark_version: expect.stringContaining("4.6.6") as unknown,
tshark_resolved_path: await realpath(tsharkStub),
capinfos_source: "environment",
capinfos_version: expect.stringContaining("4.6.6") as unknown,
capinfos_resolved_path: await realpath(capinfosStub),
tshark_sha256: createHash("sha256")
.update(await readFile(tsharkStub))
.digest("hex"),
tshark_identity_status: "verified",
capinfos_sha256: createHash("sha256")
.update(await readFile(capinfosStub))
.digest("hex"),
capinfos_identity_status: "verified",
});
});
it("projects a missing configured tool as a capability gap", async () => {
const provider = new PcapProvider({
environment: {
REA_TSHARK_COMMAND: tsharkStub,
REA_CAPINFOS_COMMAND: join(import.meta.dirname, "missing-capinfos"),
},
});
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_pcap_client",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "capability_unavailable",
message: expect.stringContaining(
"Supply Wireshark 4.x tshark and capinfos",
),
});
});
it.skipIf(isWindows)(
"rejects TShark that changes during its version probe",
async () => {
const directory = await createTestTempDirectory("rea-pcap-mutated-tool-");
const tshark = join(directory, "tshark");
const capinfos = join(directory, "capinfos");
await writeFile(
tshark,
`#!/bin/sh
if [ "$1" = "--version" ]; then
printf 'TShark (Wireshark) 4.6.6-stub\\n'
printf '\\n# changed during version probe\\n' >> "$0"
exit 0
fi
exit 2
`,
{ mode: 0o755 },
);
await symlink(capinfosStub, capinfos);
const provider = new PcapProvider({
environment: {
REA_TSHARK_COMMAND: tshark,
REA_CAPINFOS_COMMAND: capinfos,
},
});
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_pcap_client",
input: {},
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_changed",
});
},
);
it("resolves a retained tool-probe error after its process owner closes", async () => {
let mayClean = false;
let launches = 0;