callgraph_verify/lib.rs
1//! Pure-byte call-graph + fact verifier for the Rust rebuild.
2//!
3//! When a rebuild function is tagged `origin: 0xADDR`, it claims to be a 1:1
4//! port of the function at that VA in the target binary. This crate establishes
5//! ground truth by disassembling the actual bytes (no Ghidra, no decompiled C)
6//! and lets a port declare what it should structurally match:
7//!
8//! * direct CALL targets (the call graph)
9//! * struct field offsets (`[reg+0xNN]` accesses)
10//! * global / static VAs (`[0x00bXXXXX]` data refs)
11//! * vtable / immediate VAs (`MOV [esi],0x00a7e38c`)
12//! * virtual-call slots (`CALL [reg+0x84]`)
13//! * IAT import targets (`CALL [0x00a28xxx]`)
14//!
15//! Use the [`verify_callgraph!`] macro to turn declarations into `#[test]`s.
16
17pub mod disasm;
18pub mod pe;
19pub mod skel;
20
21pub use disasm::{analyze, analyze_opt, reg_params, resolve_thunk, tail_referenced, ArgKind, CallGraph, CallSite};
22pub use pe::{FixupStatus, Pe};
23pub use skel::{cfg_fingerprint, CfgFingerprint};
24
25use std::collections::BTreeSet;
26
27/// One declared expectation for a function, checked against the binary's facts.
28#[derive(Default)]
29pub struct Spec {
30 pub addr: u64,
31 calls: Option<(Vec<u64>, bool)>, // (declared, exact?)
32 fields: Vec<u64>,
33 globals: Vec<u64>,
34 vtables: Vec<u64>, // in-image immediates (vtable VAs, fn ptrs, string addrs)
35 vcall_slots: Vec<u64>,
36 /// Addresses this function reaches only by taking a register/memory
37 /// (stack-slot) indirect call. A faithful body spells them as direct
38 /// calls; acceptance-only (never a MISSING requirement).
39 indirect_targets: Vec<u64>,
40 iat: Vec<u64>,
41}
42
43/// The cached `.text` instruction-start sets, keyed by the PE's unique id
44/// plus `(image_base, text_rva, text_vsize)`. Each entry holds an `Arc` so the
45/// cached set can be cloned out without holding the MutexGuard. The id (not
46/// layout alone) is the key because two same-layout PEs with different bytes
47/// must never share a set — the input-ignoring-cache class of BUG #9.
48type TextStartsCache = std::sync::Mutex<
49 std::collections::HashMap<(u64, u64, u32, u32), std::sync::OnceLock<TextStarts>>,
50>;
51type TextStarts = std::sync::Arc<std::collections::BTreeSet<u64>>;
52
53/// The cached set of EVERY rel32 branch target (CALL/JMP/Jcc) whose opcode sits
54/// at a real instruction start in `.text`, keyed by PE id + layout. Built in
55/// ONE linear pass per PE instance so [`has_direct_branch_target`] is a hash
56/// lookup — the per-target scan it replaced walked the whole (multi-MB)
57/// `.text` blob on every call, making the macro's inline-marker and
58/// vtable-dispatch checks O(fns × markers × |.text|) per build. NOTE: the
59/// steady-state path must not touch the `.text` bytes at all — a content
60/// fingerprint per call (an earlier draft hashed 4 MB every lookup) is just a
61/// cheaper rescan and reproduces the same quadratic build time.
62type BranchTargetsCache = std::sync::Mutex<
63 std::collections::HashMap<(u64, u64, u32, u32), std::sync::OnceLock<BranchTargets>>,
64>;
65type BranchTargets = std::sync::Arc<std::collections::HashSet<u32>>;
66
67fn branch_targets_cache() -> &'static BranchTargetsCache {
68 static CACHE: std::sync::OnceLock<BranchTargetsCache> = std::sync::OnceLock::new();
69 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
70}
71
72fn text_starts_cache() -> &'static TextStartsCache {
73 static CACHE: std::sync::OnceLock<TextStartsCache> = std::sync::OnceLock::new();
74 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
75}
76
77/// Decode the entire `.text` section once and return the set of VAs that are
78/// the start of a valid x86 instruction. Used by [`has_direct_branch_target`]
79/// to gate its byte-scan so a `0xE8` byte sitting inside some other
80/// instruction's encoding (i.e. NOT at an instruction start) is rejected — the
81/// call/jmp pattern there is a spurious coincidence, not a real branch.
82///
83/// Steady-state is lock-free: the cache stores `&'static OnceLock` values, and
84/// once `get_or_init` has populated one, repeated `get()` calls return
85/// `&'static BTreeSet`s without touching the mutex. The mutex is only acquired
86/// on the very first request per `(image_base, text_rva, text_vsize)` triple.
87fn text_instruction_starts(pe: &Pe) -> TextStarts {
88 let key = || -> Option<(u64, u64, u32, u32)> {
89 let text = pe.sections().iter().find(|s| s.name == ".text")?;
90 Some((
91 pe.id(),
92 pe.image_base,
93 text.virtual_address,
94 text.virtual_size,
95 ))
96 };
97 let key = match key() {
98 Some(k) => k,
99 None => {
100 // No .text section: return an empty Arc.
101 static EMPTY: std::sync::OnceLock<TextStarts> = std::sync::OnceLock::new();
102 return EMPTY
103 .get_or_init(|| std::sync::Arc::new(std::collections::BTreeSet::new()))
104 .clone();
105 }
106 };
107 let cache = text_starts_cache();
108 // Fast path: lock-free read once the value exists. `Mutex::lock` returns
109 // `LockResult<MutexGuard>`; we ignore poisoning — `OnceLock::get_or_init`
110 // never panics inside the closure.
111 let mut lock = cache.lock().unwrap_or_else(|e| e.into_inner());
112 let entry = lock.entry(key).or_default();
113 let once = entry.get_or_init(|| {
114 use iced_x86::{Decoder, DecoderOptions};
115 let text = pe
116 .sections()
117 .iter()
118 .find(|s| s.name == ".text")
119 .expect("key was Some");
120 let text_va = pe.image_base + text.virtual_address as u64;
121 let bytes = match pe.read(text_va, text.virtual_size as usize) {
122 Some(b) => b,
123 None => return std::sync::Arc::new(std::collections::BTreeSet::new()),
124 };
125 let mut dec = Decoder::with_ip(32, bytes, text_va, DecoderOptions::NONE);
126 let mut starts = std::collections::BTreeSet::new();
127 starts.insert(text_va);
128 while dec.can_decode() {
129 let ip = dec.ip();
130 starts.insert(ip);
131 let _ = dec.decode();
132 }
133 std::sync::Arc::new(starts)
134 });
135 once.clone()
136}
137
138/// Does ANY instruction in `.text` statically target `target` via a relative branch
139/// (CALL/JMP/Jcc rel32)? Used by the macro to warn on vtable-only dispatch.
140///
141/// Implementation: the set of all such targets is built in ONE pass over `.text`
142/// (cached per PE — see [`branch_targets_cache`]) and this call is a hash lookup.
143/// The one-pass walk decodes `.text` once (also cached) to learn every valid
144/// instruction start, then byte-scans for `0xE8`/`0xE9`/`0x0F 0x8x` and only
145/// records a hit as a real branch if its opcode byte sits AT an instruction
146/// start. Without that gate (BUG #3), a `0xE8` byte anywhere inside another
147/// instruction's encoding was treated as a CALL, computing a junk rel32 target
148/// that could coincidentally equal `target` — a false positive that suppressed
149/// legitimate VTABLE-ONLY DISPATCH warnings.
150pub fn has_direct_branch_target(pe: &Pe, target: u64) -> bool {
151 all_branch_targets(pe).contains(&(target as u32))
152}
153
154/// Every rel32 branch target of `.text`, computed once per PE instance.
155/// Accept-set identical to the per-target scan this replaces: rel32
156/// CALL/JMP/Jcc opcodes gated on real instruction starts. Keyed by the PE's
157/// unique id — the steady-state lookup never touches the `.text` bytes.
158fn all_branch_targets(pe: &Pe) -> BranchTargets {
159 let Some(text) = pe.sections().iter().find(|s| s.name == ".text") else {
160 // No .text section: every lookup misses against an empty set.
161 static EMPTY: std::sync::OnceLock<BranchTargets> = std::sync::OnceLock::new();
162 return EMPTY
163 .get_or_init(|| std::sync::Arc::new(std::collections::HashSet::new()))
164 .clone();
165 };
166 let text_va = pe.image_base + text.virtual_address as u64;
167 let key = (
168 pe.id(),
169 pe.image_base,
170 text.virtual_address,
171 text.virtual_size,
172 );
173 let cache = branch_targets_cache();
174 let mut lock = cache.lock().unwrap_or_else(|e| e.into_inner());
175 let entry = lock.entry(key).or_default();
176 entry
177 .get_or_init(|| {
178 let blob = match pe.read(text_va, text.virtual_size as usize) {
179 Some(b) => b,
180 None => return std::sync::Arc::new(std::collections::HashSet::new()),
181 };
182 let starts = text_instruction_starts(pe);
183 let mut targets = std::collections::HashSet::new();
184 let mut i = 0;
185 while i + 4 < blob.len() {
186 let rel_at = match blob[i] {
187 0xE8 | 0xE9 => i + 1, // CALL rel32 / JMP rel32
188 0x0F if i + 6 <= blob.len() && (0x80..=0x8F).contains(&blob[i + 1]) => i + 2,
189 _ => {
190 i += 1;
191 continue;
192 }
193 };
194 // Gate: the branch opcode must sit at a real instruction start.
195 // Spurious `0xE8` bytes inside other instructions are ignored.
196 // (BUG #3 fix.)
197 let ip_va = text_va + i as u64;
198 if !starts.contains(&ip_va) {
199 i += 1;
200 continue;
201 }
202 if rel_at + 4 > blob.len() {
203 i += 1;
204 continue;
205 }
206 let rel = i32::from_le_bytes([
207 blob[rel_at],
208 blob[rel_at + 1],
209 blob[rel_at + 2],
210 blob[rel_at + 3],
211 ]) as i64;
212 let t = (text_va + (rel_at as u64 + 4)).wrapping_add(rel as u64) as u32;
213 targets.insert(t);
214 i += 1;
215 }
216 std::sync::Arc::new(targets)
217 })
218 .clone()
219}
220
221impl Spec {
222 pub fn new(addr: u64) -> Spec {
223 Spec {
224 addr,
225 ..Default::default()
226 }
227 }
228 /// Declared CALL set must EQUAL the binary's (flags hallucinated + missing).
229 pub fn calls_exact(mut self, t: &[u64]) -> Spec {
230 self.calls = Some((t.to_vec(), true));
231 self
232 }
233 /// No declared CALL may be absent (hallucination-only; tolerates inlined/unported).
234 pub fn calls_superset(mut self, t: &[u64]) -> Spec {
235 self.calls = Some((t.to_vec(), false));
236 self
237 }
238 /// Each declared field offset must be touched by the binary (present-check).
239 pub fn fields(mut self, t: &[u64]) -> Spec {
240 self.fields = t.to_vec();
241 self
242 }
243 pub fn globals(mut self, t: &[u64]) -> Spec {
244 self.globals = t.to_vec();
245 self
246 }
247 pub fn vtables(mut self, t: &[u64]) -> Spec {
248 self.vtables = t.to_vec();
249 self
250 }
251 pub fn vcall_slots(mut self, t: &[u64]) -> Spec {
252 self.vcall_slots = t.to_vec();
253 self
254 }
255 /// Declared stack-slot/register indirect-call targets (acceptance-only).
256 pub fn indirect_targets(mut self, t: &[u64]) -> Spec {
257 self.indirect_targets = t.to_vec();
258 self
259 }
260 pub fn iat(mut self, t: &[u64]) -> Spec {
261 self.iat = t.to_vec();
262 self
263 }
264
265 /// Check this spec against the binary; `Err` lists every violation.
266 pub fn check(&self, pe: &Pe) -> Result<(), String> {
267 let cg = analyze(pe, self.addr)?;
268 let mut v = Vec::new();
269
270 if let Some((declared, exact)) = &self.calls {
271 let real = cg.call_targets();
272 let dset: BTreeSet<u64> = declared.iter().copied().collect();
273 for h in dset.difference(&real) {
274 v.push(format!(
275 "HALLUCINATED declared CALL {h:#010x} — binary never directly calls it"
276 ));
277 }
278 if *exact {
279 for m in real.difference(&dset) {
280 v.push(format!(
281 "MISSING binary CALLs {m:#010x} — not declared"
282 ));
283 }
284 }
285 }
286 // The rest are present-checks: a declared item the binary doesn't exhibit
287 // is the hallucination; we don't require enumerating the binary's full set.
288 // Data addresses accept their runtime (post-fixup) images too: DOS4GW
289 // applies the LE fixup tables at load, so a declared post-fixup value
290 // of a referenced pre-fixup address is faithful, not invented.
291 let post = pe.fixup_post_images(
292 &cg.globals.union(&cg.imm_addrs).copied().collect(),
293 );
294 present_check("field offset", &self.fields, &cg.field_offsets, &mut v);
295 present_check_fixup("global", &self.globals, &cg.globals, &post, &mut v);
296 present_check_fixup("vtable/imm addr", &self.vtables, &cg.imm_addrs, &post, &mut v);
297 present_check("vcall slot", &self.vcall_slots, &cg.vcall_slots, &mut v);
298 for t in &self.indirect_targets {
299 if !pe.is_executable_section_va(*t) {
300 v.push(format!(
301 "indirect target {t:#010x} is not in an executable section"
302 ));
303 }
304 }
305 if !self.indirect_targets.is_empty() && cg.indirect_calls == 0 {
306 v.push(
307 "indirect_targets declared, but the binary makes no register/memory \
308 indirect call at this address"
309 .to_string(),
310 );
311 }
312 present_check("IAT import", &self.iat, &cg.iat_calls, &mut v);
313
314 if v.is_empty() {
315 Ok(())
316 } else {
317 Err(format!(
318 "{:#010x}: {} violation(s) vs pure-byte ground truth:\n {}\n (indirect calls: {}, indirect jumps: {})",
319 self.addr,
320 v.len(),
321 v.join("\n "),
322 cg.indirect_calls,
323 cg.indirect_jumps,
324 ))
325 }
326 }
327
328 /// Load `<manifest_dir>/<exe>` and panic on any violation (for `#[test]`s).
329 pub fn check_at(&self, manifest_dir: &str, exe: &str) {
330 let path = std::path::Path::new(manifest_dir).join(exe);
331 let pe = Pe::load(path.to_str().unwrap())
332 .unwrap_or_else(|e| panic!("load {}: {e}", path.display()));
333 if let Err(msg) = self.check(&pe) {
334 panic!("{msg}");
335 }
336 }
337}
338
339fn present_check(kind: &str, declared: &[u64], real: &BTreeSet<u64>, out: &mut Vec<String>) {
340 present_check_fixup(kind, declared, real, &BTreeSet::new(), out)
341}
342
343/// Present-check with an LE-fixup escape: `post` holds the runtime images
344/// of the binary's referenced pre-fixup addresses (see
345/// `Pe::fixup_post_images`). A declared post-fixup value is faithful.
346/// Widening only — nothing previously passing can fail.
347fn present_check_fixup(
348 kind: &str,
349 declared: &[u64],
350 real: &BTreeSet<u64>,
351 post: &BTreeSet<u64>,
352 out: &mut Vec<String>,
353) {
354 for d in declared {
355 if !real.contains(d) && !post.contains(d) {
356 out.push(format!(
357 "HALLUCINATED declared {kind} {d:#x} — binary never references it"
358 ));
359 }
360 }
361}
362
363/// Back-compat thin diff over direct calls only (used by the CLI / build gate).
364#[derive(Debug)]
365pub struct Diff {
366 pub entry: u64,
367 pub hallucinated: Vec<u64>,
368 pub missing: Vec<u64>,
369 pub indirect_calls: u32,
370 pub indirect_jumps: u32,
371}
372
373impl Diff {
374 pub fn is_clean(&self) -> bool {
375 self.hallucinated.is_empty() && self.missing.is_empty()
376 }
377}
378
379pub fn diff_calls(
380 pe: &Pe,
381 entry: u64,
382 declared: &[u64],
383 expect_complete: bool,
384) -> Result<Diff, String> {
385 let cg = analyze(pe, entry)?;
386 let real = cg.call_targets();
387 let dset: BTreeSet<u64> = declared.iter().copied().collect();
388 Ok(Diff {
389 entry,
390 hallucinated: dset.difference(&real).copied().collect(),
391 missing: if expect_complete {
392 real.difference(&dset).copied().collect()
393 } else {
394 Vec::new()
395 },
396 indirect_calls: cg.indirect_calls,
397 indirect_jumps: cg.indirect_jumps,
398 })
399}
400
401/// Result of diffing a Rust function's *resolved body calls* against the binary.
402pub struct CorpusDiff {
403 pub addr: u64,
404 /// Rust calls a ported fn the binary never calls (and didn't inline) — invented.
405 pub hallucinated: Vec<(String, Vec<u64>)>,
406 /// Rust calls a ported fn the binary inlines — reclassified faithful.
407 pub inlined_ok: Vec<String>,
408 /// Rust DIRECTLY calls a method the binary dispatches through a known (declared) vtable —
409 /// the faithful form is a `vcall`. Once the vtable is declared, the call CAN be made
410 /// virtually, so it MUST be one.
411 pub vtable_dispatch: Vec<(String, Vec<u64>)>,
412 /// Binary calls a ported fn the Rust body doesn't (often inlined; review).
413 pub missing: Vec<u64>,
414 /// Binary calls with NO corpus origin (unported callees). The gate lets
415 /// bodies omit these with a `TODO`, so they never fire MISSING — but they
416 /// are real edges, and silently dropping them is the MISSING-unknown gap.
417 /// Informational only (feeds a macro NOTE, never a fatal).
418 pub unported: Vec<u64>,
419 pub indirect_calls: u32,
420}
421
422fn is_inlined_into(pe: &Pe, callee: u64, caller_fp: &BTreeSet<u64>) -> bool {
423 match analyze(pe, callee) {
424 Ok(cg) => {
425 let fp = cg.footprint();
426 // Footprint-subset is only meaningful evidence with >= 2 distinct
427 // in-image references. A SINGLE shared global (an inlined refcount
428 // accessor) or a single shared call target is a subset of almost any
429 // caller that touches the same thing — that's coincidence, not
430 // inlining, and accepting it would excuse an invented call. Trivial
431 // single-element callees can't be footprint-confirmed, so the
432 // faithful port reproduces the inlined work inside the caller —
433 // it does not call the helper.
434 fp.len() >= 2 && fp.is_subset(caller_fp)
435 }
436 Err(_) => false,
437 }
438}
439
440/// Diff the calls a Rust body makes (already resolved to ported addresses) against
441/// the binary's real call set at `own_addrs`. `corpus` is the set of all ported
442/// origin addresses (only calls within it are diffable). Mirrors the logic of
443/// `tools/analysis/verify_callgraph.py`, but on bytes instead of Ghidra `.asm`.
444pub fn diff_body_calls(
445 pe: &Pe,
446 own_addrs: &[u64],
447 corpus: &BTreeSet<u64>,
448 rust_idents: &[(String, Vec<u64>)],
449 vtable_methods: &BTreeSet<u64>,
450 miss_satisfied: &BTreeSet<u64>,
451) -> Result<CorpusDiff, String> {
452 diff_body_calls_ext(
453 pe,
454 own_addrs,
455 corpus,
456 rust_idents,
457 vtable_methods,
458 miss_satisfied,
459 &BTreeSet::new(),
460 )
461}
462
463/// Like [`diff_body_calls`], plus `indirect_targets`: addresses the binary
464/// reaches through a register/memory indirect call at `own_addrs` (a stack-slot
465/// callback, a registered handler, an IAT thunk, ...). They widen the
466/// ACCEPTANCE set only: a body that spells one as a direct call is faithful,
467/// and they never become a MISSING requirement (the binary issues no direct
468/// CALL to them).
469pub fn diff_body_calls_ext(
470 pe: &Pe,
471 own_addrs: &[u64],
472 corpus: &BTreeSet<u64>,
473 rust_idents: &[(String, Vec<u64>)],
474 vtable_methods: &BTreeSet<u64>,
475 miss_satisfied: &BTreeSet<u64>,
476 indirect_targets: &BTreeSet<u64>,
477) -> Result<CorpusDiff, String> {
478 let primary = *own_addrs.first().ok_or("no origin address")?;
479 let mut bin_calls = BTreeSet::new();
480 // Direct CALL targets only (no tail jumps): feeds the self-recursion
481 // check below. A direct self-call is genuine recursion and must be
482 // spelled; a self tail-JMP is a loop (covered by the CFG gate), so it
483 // stays out of this set.
484 let mut bin_direct = BTreeSet::new();
485 let mut wide_extras = BTreeSet::new();
486 let mut footprint = BTreeSet::new();
487 let mut indirect = 0u32;
488 let mut tail_targets: BTreeSet<u64> = BTreeSet::new();
489 // 0.1.8: LE jump-table arms are harvested for ACCEPTANCE only. The demand
490 // analysis reads tables untranslated (`analyze_opt(.., false)`), so arms
491 // never become MISSING demands; the wide analysis translates them, so a
492 // body that spells an arm's CALL is faithful (fixes the old E012).
493 let mut wide_calls: BTreeSet<u64> = BTreeSet::new();
494 let mut wide_tail: BTreeSet<u64> = BTreeSet::new();
495 for &a in own_addrs {
496 let cg_d = analyze_opt(pe, a, false)?;
497 let cg_w = analyze_opt(pe, a, true)?;
498 bin_direct.extend(cg_d.call_targets());
499 bin_calls.extend(cg_d.call_targets());
500 // A tail-call `JMP 0xADDR` out of the body is a real call edge (e.g. a
501 // cache-wrapper that tail-calls the base getter), so count it as a call -
502 // a faithful port spells it as an ordinary call.
503 bin_calls.extend(&cg_d.tail_calls);
504 tail_targets.extend(&cg_d.tail_calls);
505 wide_calls.extend(cg_w.call_targets());
506 wide_tail.extend(&cg_w.tail_calls);
507 // Code-pointer immediates widen acceptance only (folded into
508 // `bin_calls_wide` below, never into the MISSING requirement); staged here
509 // so the walk stays single-pass.
510 wide_extras.extend(
511 cg_w
512 .imm_addrs
513 .iter()
514 .copied()
515 .filter(|&x| pe.is_executable_section_va(x)),
516 );
517 footprint.extend(cg_w.footprint());
518 indirect += cg_w.indirect_calls;
519 }
520 // Wide acceptance set: `bin_calls` plus, per function, its executable-section
521 // code-pointer immediates and one level of each tail-callee's direct calls.
522 // - A code-pointer immediate (`PUSH 0xADDR` / `MOV reg,0xADDR` with ADDR in a
523 // section flagged EXECUTE/CODE) is a function the binary hands to a higher-order
524 // helper that then INVOKES it — a CRT `_EH_vector_constructor_iterator` over an
525 // element array, a `qsort` comparator, a registered callback, etc. A faithful
526 // port that spells the per-element / callback work as a direct call to that
527 // function is not a hallucination. The characteristics-based filter
528 // (`is_executable_section_va`, NOT the permissive `is_executable_va`) excludes
529 // `.rdata` vtable VAs and string addresses — those are data, not call targets.
530 // - A tail-call `JMP G` transfers control to G, so the calls G makes are reachable
531 // as part of THIS function's execution. MSVC frequently splits a function across
532 // non-contiguous chunks linked by a `JMP`, or emits a tiny entry stub that
533 // tail-jumps to a shared body (e.g. `0x00590110` → `JMP 0x0058da00`, whose body
534 // `CALL 0x0058d1c0`; or the ICF-folded stubs that all `JMP 0x004524c0`). The
535 // decompiler shows that body inlined into the entry, so a faithful port spells
536 // those as ordinary calls.
537 // Both folds only ever ADD acceptable spellings (HALLUCINATED direction). They must
538 // NOT feed the MISSING requirement: the binary issues no CALL to an imm-addr, and
539 // no CALL here to the tail target's callees — demanding them forces ports to
540 // duplicate the tail target's body (previously misdiagnosed as E013 with an
541 // unactionable "add the call" fix; the suggested call was already spelled).
542 let mut bin_calls_wide = bin_calls.clone();
543 bin_calls_wide.extend(wide_calls);
544 bin_calls_wide.extend(wide_tail);
545 bin_calls_wide.extend(wide_extras);
546 // Declared indirect-call targets: a direct body call to one is faithful
547 // (the binary reaches it through `call [reg+slot]`, not a direct CALL).
548 bin_calls_wide.extend(indirect_targets.iter().copied());
549 for t in tail_targets {
550 if let Ok(tcg) = analyze(pe, t) {
551 bin_calls_wide.extend(tcg.call_targets());
552 }
553 }
554 // Follow one level of jmp-thunks: a call to a `JMP X` thunk (tail-jump stub / ICF forwarder)
555 // really targets X, so a faithful direct Rust call to X is not a hallucination.
556 // Resolved over the WIDE set (as before this split) so acceptance is unchanged;
557 // the results stay wide-only — a thunk is never itself a MISSING requirement.
558 let thunked: Vec<u64> = bin_calls_wide
559 .iter()
560 .filter_map(|&t| resolve_thunk(pe, t))
561 .collect();
562 bin_calls_wide.extend(thunked);
563 let own: BTreeSet<u64> = own_addrs.iter().copied().collect();
564 let bin_ported: BTreeSet<u64> = bin_calls
565 .intersection(corpus)
566 .copied()
567 .filter(|a| !own.contains(a))
568 .collect();
569
570 let (mut hallucinated, mut inlined_ok, mut vtable_dispatch, mut resolved) =
571 (Vec::new(), Vec::new(), Vec::new(), BTreeSet::new());
572 for (ident, addrs) in rust_idents {
573 let aset: Vec<u64> = addrs.iter().copied().filter(|a| !own.contains(a)).collect();
574 if aset.is_empty() {
575 continue;
576 }
577 resolved.extend(&aset);
578 // Satisfied-direction check reads the WIDE set: a body edge the binary
579 // reaches only through a tail-jump or a higher-order immediate is faithful.
580 // (`bin_calls` below stays narrow: MISSING must only require edges the binary
581 // actually CALLs here.)
582 if aset.iter().any(|a| bin_calls_wide.contains(a)) {
583 continue; // satisfied — binary makes (one of) this call
584 }
585 if aset.iter().any(|a| is_inlined_into(pe, *a, &footprint)) {
586 inlined_ok.push(ident.clone());
587 continue; // faithful: binary inlined the callee (footprint-confirmed)
588 }
589 // Known (declared) vtable method the binary doesn't directly call → it is dispatched
590 // virtually; the faithful form is a `vcall`. The vtable exists, so the call CAN and
591 // MUST be virtual — no other form is faithful.
592 if aset.iter().any(|a| vtable_methods.contains(a)) {
593 vtable_dispatch.push((ident.clone(), aset));
594 continue;
595 }
596 hallucinated.push((ident.clone(), aset));
597 }
598 // A MISSING is a binary call to a ported fn the body doesn't spell. But the
599 // verifier can only SEE a spelling when the body call name resolves through
600 // the strict table split + DENY filter — a qualified path `Self::m(..)` /
601 // `Type::m(..)` denotes an impl method yet resolves against free_fns only,
602 // and a generically-named ported fn (`mul`, `new`) is excluded outright, so
603 // its MISSING would be UNSATISFIABLE no matter how faithfully the body
604 // spells it. `miss_satisfied` (both tables, DENY-inclusive, split-agnostic)
605 // marks those addresses as satisfiable-by-name. Used ONLY here — never to
606 // excuse a HALLUCINATED, which keeps the strict split + DENY filter.
607 let mut missing: Vec<u64> = bin_ported
608 .difference(&resolved)
609 .filter(|a| !miss_satisfied.contains(a))
610 .copied()
611 .collect();
612 // Self-recursion: `own` addrs are excluded from MISSING above (a
613 // faithful recursive body spells the self-call, which would otherwise
614 // look hallucinated), so a body that drops a direct self-CALL passes
615 // silently. Pin it: a binary self-call the body doesn't spell by name
616 // is a MISSING. Only DIRECT calls count — a self tail-JMP is a loop,
617 // covered by the CFG gate (a `loop {}` port is faithful there).
618 for o in &own {
619 if bin_direct.contains(o)
620 && !rust_idents
621 .iter()
622 .any(|(_, addrs)| addrs.contains(o))
623 {
624 missing.push(*o);
625 }
626 }
627 // Unported inventory (MISSING-unknown gap): real binary edges with no
628 // corpus origin. Never fatal — bodies may omit these with a `TODO` — but
629 // listing them keeps the omission visible instead of silently green.
630 let unported: Vec<u64> = bin_calls
631 .difference(corpus)
632 .filter(|a| !own.contains(a))
633 .copied()
634 .collect();
635 Ok(CorpusDiff {
636 addr: primary,
637 hallucinated,
638 inlined_ok,
639 vtable_dispatch,
640 missing,
641 unported,
642 indirect_calls: indirect,
643 })
644}
645
646/// Declare expected facts for `origin:` functions and generate one `#[test]` per
647/// function, checked against the real binary.
648///
649/// Two forms:
650///
651/// ```ignore
652/// verify_callgraph! {
653/// exe: "../../target.exe";
654///
655/// // 1) shorthand — direct calls only
656/// fn set_name @ 0x006ff420 => exact [0x00401f00, 0x00401f20, 0x00984b6a];
657///
658/// // 2) block — any subset of clauses, each `;`-terminated
659/// fn node_ctor @ 0x0070b780 {
660/// calls: superset [0x004b2d30];
661/// fields: [0xbc, 0xc0, 0xc4, 0xc8]; // struct offsets it must touch
662/// vtables: [0x00a7e38c]; // NiNode vtable VA installed
663/// vcall_slots: [0x84]; // virtual call slot, if any
664/// }
665/// }
666/// ```
667///
668/// CALL clauses: `exact` (equality) or `superset` (hallucination-only). The other
669/// clauses are present-checks: each declared value must appear in the binary's
670/// harvested set, else it's a hallucination.
671#[macro_export]
672macro_rules! verify_callgraph {
673 (exe: $exe:expr; $($body:tt)*) => {
674 $crate::verify_callgraph!(@each $exe; $($body)*);
675 };
676
677 // shorthand: direct calls, exact
678 (@each $exe:expr;
679 $(#[$m:meta])* fn $name:ident @ $addr:expr => exact [ $($t:expr),* $(,)? ] ; $($rest:tt)*) => {
680 $(#[$m])*
681 #[test]
682 fn $name() {
683 $crate::Spec::new($addr).calls_exact(&[$($t),*]).check_at(env!("CARGO_MANIFEST_DIR"), $exe);
684 }
685 $crate::verify_callgraph!(@each $exe; $($rest)*);
686 };
687
688 // shorthand: direct calls, superset
689 (@each $exe:expr;
690 $(#[$m:meta])* fn $name:ident @ $addr:expr => superset [ $($t:expr),* $(,)? ] ; $($rest:tt)*) => {
691 $(#[$m])*
692 #[test]
693 fn $name() {
694 $crate::Spec::new($addr).calls_superset(&[$($t),*]).check_at(env!("CARGO_MANIFEST_DIR"), $exe);
695 }
696 $crate::verify_callgraph!(@each $exe; $($rest)*);
697 };
698
699 // block form: any subset of `;`-terminated clauses
700 (@each $exe:expr;
701 $(#[$m:meta])* fn $name:ident @ $addr:literal { $($clauses:tt)* } $($rest:tt)*) => {
702 $(#[$m])*
703 #[test]
704 fn $name() {
705 let spec = $crate::Spec::new($addr);
706 let spec = $crate::verify_callgraph!(@clauses spec; $($clauses)*);
707 spec.check_at(env!("CARGO_MANIFEST_DIR"), $exe);
708 }
709 $crate::verify_callgraph!(@each $exe; $($rest)*);
710 };
711
712 (@each $exe:expr;) => {};
713
714 // clause munchers — each consumes one `;`-terminated clause, returns the builder
715 (@clauses $s:expr; calls: exact [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
716 $crate::verify_callgraph!(@clauses $s.calls_exact(&[$($t),*]); $($r)*)
717 };
718 (@clauses $s:expr; calls: superset [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
719 $crate::verify_callgraph!(@clauses $s.calls_superset(&[$($t),*]); $($r)*)
720 };
721 (@clauses $s:expr; fields: [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
722 $crate::verify_callgraph!(@clauses $s.fields(&[$($t),*]); $($r)*)
723 };
724 (@clauses $s:expr; globals: [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
725 $crate::verify_callgraph!(@clauses $s.globals(&[$($t),*]); $($r)*)
726 };
727 (@clauses $s:expr; vtables: [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
728 $crate::verify_callgraph!(@clauses $s.vtables(&[$($t),*]); $($r)*)
729 };
730 (@clauses $s:expr; vcall_slots: [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
731 $crate::verify_callgraph!(@clauses $s.vcall_slots(&[$($t),*]); $($r)*)
732 };
733 (@clauses $s:expr; iat: [ $($t:expr),* $(,)? ] ; $($r:tt)*) => {
734 $crate::verify_callgraph!(@clauses $s.iat(&[$($t),*]); $($r)*)
735 };
736 (@clauses $s:expr;) => { $s };
737}