Skip to main content

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