Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cffbdfdc | ||
|
|
5e046bb374 | ||
|
|
4924706e25 | ||
|
|
211d4b2219 | ||
|
|
cb6dc4f3ef | ||
|
|
658c29b7fc | ||
|
|
a22adc7809 | ||
|
|
d0067ba34a | ||
|
|
e74e4d8f0b | ||
|
|
f4eeba3654 | ||
|
|
bbecf6fb05 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user