Skip to main content

callgraph_verify/
pe.rs

1//! Minimal, dependency-free PE32 reader — just enough to map a virtual address
2//! (VA) to file bytes. We deliberately hand-parse the headers instead of pulling
3//! in `goblin`/`object`: the contract is "pure byte", and the only thing the
4//! disassembler needs is `va -> &[machine code bytes]`.
5
6use std::collections::BTreeSet;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9type FixupPair = (u64, u64);
10type FixupTables = (Vec<FixupPair>, Vec<FixupPair>);
11
12/// LE fixup-table outcome for one loaded image.
13#[derive(Debug, Clone)]
14pub enum FixupStatus {
15    /// No LE fixup tables (PE binaries, or an LE with zeroed table offsets).
16    Absent,
17    /// Tables parsed; N internal-07 pairs absorbed (possibly zero: tables
18    /// present but nothing absolute to relocate).
19    Applied { pairs: usize },
20    /// Tables present but held a record kind outside the proven grammar;
21    /// the image stays pre-fixup (the historical behavior) with the reason
22    /// recorded. Loud, not silent: the macro surfaces this as a NOTE once
23    /// per build, and `fixup_post_images` is empty.
24    Skipped { reason: String },
25}
26
27/// A loaded PE image: the raw file plus the parsed section table.
28pub struct Pe {
29    data: Vec<u8>,
30    pub image_base: u64,
31    is_le: bool,
32    sections: Vec<Section>,
33    /// LE fixup absorption (07 absolute records only): `(pre, post)` content
34    /// pairs for every patched dword — `pre` is the link-time dword in the
35    /// file bytes (what `--asm` shows and the harvesters record), `post` is
36    /// what DOS4GW maps at runtime. Always empty for PE (no LE fixup tables
37    /// there). Powers the fixup-aware address accept-set: a body may bake
38    /// the runtime value of an address the binary references pre-fixup.
39    /// Call-graph/CFG ground truth stays pre-fixup (code objects are not
40    /// relocated, so direct CALL rel32 is unaffected); only the data-address
41    /// accept-set widens. 08 (self-relative) and 02 (selector) records are
42    /// parsed and validated so the table walk stays proof-checked, but
43    /// contribute no pairs (relative offsets are not addresses; selectors
44    /// have no flat-model value).
45    fixup_posts: Vec<(u64, u64)>,
46    /// LE 08 self-relative records as `(operand_va, target_va)`: the loader
47    /// writes `target - (operand_va + 4)` into the rel32 field, so the
48    /// record's target is the runtime branch/call destination. Resolves
49    /// on-disk `rel32=0` placeholders (DOS4GW/Watcom `call $+5`), which are
50    /// load-time call sites, not get-PC no-ops.
51    fixup_rel32: Vec<(u64, u64)>,
52    /// What happened with the LE fixup tables (see `FixupStatus`).
53    fixup_status: FixupStatus,
54    /// Process-unique identity assigned at parse time. Whole-image derived
55    /// caches (instruction starts, branch targets) key on this so two
56    /// same-layout PEs with different bytes can never share an entry — the
57    /// layout-only key they used before was the input-ignoring-cache class
58    /// of BUG #9. In production the macro holds ONE `Arc<Pe>` per exe path,
59    /// so one id per binary; tests that build many synthetic PEs each get
60    /// their own entries.
61    id: u64,
62}
63
64fn next_pe_id() -> u64 {
65    static NEXT_ID: AtomicU64 = AtomicU64::new(1);
66    NEXT_ID.fetch_add(1, Ordering::Relaxed)
67}
68
69#[derive(Clone, Debug)]
70pub struct Section {
71    pub name: String,
72    pub virtual_address: u32, // RVA
73    pub virtual_size: u32,
74    pub raw_offset: u32,
75    pub raw_size: u32,
76    /// Section characteristics (PE32 section-table `Characteristics` field).
77    /// Use [`Section::is_executable`] / [`Section::is_code`] to test the
78    /// relevant flags without remembering the bit constants.
79    pub characteristics: u32,
80}
81
82/// Standard PE32 section characteristics. Kept here so callers don't have
83/// to memorize the bit values.
84pub mod section_flags {
85    /// 0x00000020 — section contains executable code (CNT_CODE).
86    pub const CODE: u32 = 0x0000_0020;
87    /// 0x02000000 — section can be discarded after load (.reloc, .debug).
88    pub const DISCARDABLE: u32 = 0x0200_0000;
89    /// 0x20000000 — section is executable (CNT_CODE in MEM_EXECUTE form).
90    pub const EXECUTE: u32 = 0x2000_0000;
91    /// 0x40000000 — section is readable.
92    pub const READ: u32 = 0x4000_0000;
93    /// 0x80000000 — section is writable (.data, .bss).
94    pub const WRITE: u32 = 0x8000_0000;
95}
96
97impl Section {
98    /// True if the section contains executable code (`.text`).
99    /// Code sections carry either `MEM_EXECUTE` or `CNT_CODE` (typically both).
100    pub fn is_executable(&self) -> bool {
101        self.characteristics & (section_flags::EXECUTE | section_flags::CODE) != 0
102    }
103}
104
105fn u16(d: &[u8], o: usize) -> u16 {
106    u16::from_le_bytes([d[o], d[o + 1]])
107}
108fn u32(d: &[u8], o: usize) -> u32 {
109    u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
110}
111
112impl Pe {
113    pub fn load(path: &str) -> Result<Pe, String> {
114        let data = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
115        Pe::parse(data)
116    }
117
118    pub fn parse(data: Vec<u8>) -> Result<Pe, String> {
119        let sz = data.len();
120        if sz < 0x40 || &data[0..2] != b"MZ" {
121            return Err(format!(
122                "not an MZ image: first 2 bytes are {:?} (expected b\"MZ\"), file size = {sz} bytes",
123                &data[..data.len().min(2)]
124            ));
125        }
126        let e_lfanew = u32(&data, 0x3c) as usize;
127        if data.len() >= e_lfanew + 2 && &data[e_lfanew..e_lfanew + 2] == b"LE" {
128            return parse_le(data, e_lfanew);
129        }
130        if data.len() < e_lfanew + 24 || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
131            // Show the e_lfanew value, the bytes at that offset, and what's there.
132            // That single line is enough to tell whether the user gave us an MZ
133            // wrapped around something exotic (a DOS driver, a NE/LE binary)
134            // or just a corrupted PE.
135            let there = data
136                .get(e_lfanew..e_lfanew + 4)
137                .map(|s| format!("{:02x?}", s))
138                .unwrap_or_else(|| "<past EOF>".into());
139            return Err(format!(
140                "bad PE signature: e_lfanew = {e_lfanew:#x}, bytes there = [{there}] (expected b\"PE\\0\\0\"); file size = {sz} bytes"
141            ));
142        }
143        let coff = e_lfanew + 4;
144        let machine = u16(&data, coff);
145        if machine != 0x014c {
146            return Err(format!(
147                "unsupported machine {machine:#06x} (expected i386 = 0x014c); this crate only decodes 32-bit x86"
148            ));
149        }
150        let num_sections = u16(&data, coff + 2) as usize;
151        let opt_size = u16(&data, coff + 16) as usize;
152        let opt = coff + 20;
153        let magic = u16(&data, opt);
154        if magic != 0x10b {
155            return Err(format!(
156                "unsupported optional-header magic {magic:#06x} (expected PE32 = 0x10b); PE32+ (64-bit) and ROM images are not handled"
157            ));
158        }
159        let image_base = u32(&data, opt + 28) as u64;
160
161        let sec_table = opt + opt_size;
162        let mut sections = Vec::with_capacity(num_sections);
163        for i in 0..num_sections {
164            let s = sec_table + i * 40;
165            if s + 40 > data.len() {
166                return Err(format!(
167                    "section table runs past EOF: section #{i} would start at file offset {s:#x} \
168                     (e_lfanew={e_lfanew:#x} + opt_size={opt_size:#x} + {i}*40), \
169                     but file size is only {sz} bytes — corrupt header or wrong format"
170                ));
171            }
172            let raw_name = &data[s..s + 8];
173            let end = raw_name.iter().position(|&b| b == 0).unwrap_or(8);
174            sections.push(Section {
175                name: String::from_utf8_lossy(&raw_name[..end]).into_owned(),
176                virtual_size: u32(&data, s + 8),
177                virtual_address: u32(&data, s + 12),
178                raw_size: u32(&data, s + 16),
179                raw_offset: u32(&data, s + 20),
180                characteristics: u32(&data, s + 36),
181            });
182        }
183        Ok(Pe {
184            data,
185            image_base,
186            is_le: false,
187            sections,
188            fixup_posts: Vec::new(),
189            fixup_rel32: Vec::new(),
190            fixup_status: FixupStatus::Absent,
191            id: next_pe_id(),
192        })
193    }
194
195    /// Process-unique id assigned at parse time (see the field docs).
196    pub fn id(&self) -> u64 {
197        self.id
198    }
199
200    /// Whether the loaded executable uses the DOS4GW LE container format.
201    pub fn is_le(&self) -> bool {
202        self.is_le
203    }
204
205    /// Runtime (post-fixup) images of link-time (pre-fixup) addresses: for
206    /// every `pre` in `pres` that an LE 07 fixup record patches, the mapped
207    /// runtime value. Empty for PE (no fixup tables) and for binaries whose
208    /// tables hold no 07 records. Used to widen address accept-sets (a body
209    /// may bake the runtime value of a referenced address); never a demand.
210    pub fn fixup_post_images(&self, pres: &BTreeSet<u64>) -> BTreeSet<u64> {
211        let mut out = BTreeSet::new();
212        if self.fixup_posts.is_empty() || pres.is_empty() {
213            return out;
214        }
215        for &(pre, post) in &self.fixup_posts {
216            if pres.contains(&pre) {
217                out.insert(post);
218            }
219        }
220        out
221    }
222
223    /// Runtime (post-fixup) image of a single link-time address, if that
224    /// address is an LE 07 fixup site. `None` for PE, for LE binaries with
225    /// no absolute fixups, and for pre values that are not fixup sites.
226    ///
227    /// Used by the jump-table reader: an LE table's base *and* its entries
228    /// are stored pre-fixup, so a displacement/entry must be translated to
229    /// its runtime VA before it can be range-checked or read through the
230    /// page map. Widening only: PE (and LE non-fixup values) are unchanged.
231    pub fn fixup_post_image(&self, pre: u64) -> Option<u64> {
232        self.fixup_posts
233            .iter()
234            .find_map(|&(p, post)| (p == pre).then_some(post))
235    }
236
237    /// How many 07 absolute fixup records were absorbed at load (0 for PE
238    /// and for LE binaries without absolute fixups). Diagnostic/test use.
239    pub fn fixup_count(&self) -> usize {
240        self.fixup_posts.len()
241    }
242
243    /// The absorbed `(pre, post)` content pairs for every 07 absolute fixup
244    /// (empty for PE). Diagnostic/test use; the gates go through
245    /// [`Pe::fixup_post_images`].
246    pub fn fixup_pairs(&self) -> &[(u64, u64)] {
247        &self.fixup_posts
248    }
249
250    /// The LE 08 self-relative records as `(operand_va, target_va)`.
251    pub fn fixup_rel32_pairs(&self) -> &[(u64, u64)] {
252        &self.fixup_rel32
253    }
254
255    /// Runtime target of a rel32 branch/call whose operand sits at `loc`, if
256    /// an LE 08 self-relative fixup patches it. `None` for PE and non-sites.
257    pub fn fixup_rel32_target(&self, loc: u64) -> Option<u64> {
258        self.fixup_rel32
259            .iter()
260            .find_map(|&(l, t)| (l == loc).then_some(t))
261    }
262
263    /// What happened with the LE fixup tables (see `FixupStatus`).
264    pub fn fixup_status(&self) -> &FixupStatus {
265        &self.fixup_status
266    }
267
268    /// The section containing `va`, if any.
269    pub fn section_of(&self, va: u64) -> Option<&Section> {
270        let rva = va.checked_sub(self.image_base)? as u32;
271        self.sections.iter().find(|s| {
272            // Use the larger of virtual/raw size so the boundary check never
273            // excludes a byte that is actually present on disk.
274            let span = s.virtual_size.max(s.raw_size);
275            rva >= s.virtual_address && rva < s.virtual_address + span
276        })
277    }
278
279    /// Read up to `len` bytes of file data starting at virtual address `va`.
280    /// Returns fewer bytes near the end of a section's raw data (never reads
281    /// across a section boundary or into uninitialized/BSS tail).
282    pub fn read(&self, va: u64, len: usize) -> Option<&[u8]> {
283        let sec = self.section_of(va)?;
284        let rva = (va - self.image_base) as u32;
285        let off_in_sec = rva - sec.virtual_address;
286        if off_in_sec >= sec.raw_size {
287            return None; // inside virtual_size but past raw data (BSS) → no bytes
288        }
289        let file_off = (sec.raw_offset + off_in_sec) as usize;
290        let avail = (sec.raw_size - off_in_sec) as usize;
291        let take = len.min(avail).min(self.data.len().saturating_sub(file_off));
292        Some(&self.data[file_off..file_off + take])
293    }
294
295    /// Iterate over all sections in the PE
296    pub fn sections(&self) -> &[Section] {
297        &self.sections
298    }
299
300    pub fn is_executable_va(&self, va: u64) -> bool {
301        // A VA is "decodeable" iff it lies inside some section that has
302        // readable bytes on disk. The PE32 `Characteristics` flags are
303        // available via [`Section::is_executable`] for callers that
304        // want the stricter check (e.g. to skip `.rdata` when enumerating
305        // functions), but the disassembler's own gate stays permissive:
306        // any section with bytes here is fair game, mirroring the
307        // pre-flag-based behavior so synthetic / oddball PEs without
308        // characteristic flags still decode. The OS loader rounds
309        // sections to page granularity regardless, so a `CALL [0xABCD]`
310        // into `.text` from inside `.text` works at runtime whether the
311        // section is flagged executable or not.
312        self.section_of(va).is_some()
313    }
314
315    /// True if `va` lies inside a section whose PE32 `Characteristics` mark it
316    /// as code (`CNT_CODE` or `MEM_EXECUTE`) — the STRICT counterpart of
317    /// [`Pe::is_executable_va`]. Use this when deciding whether an immediate /
318    /// stored dword plausibly denotes a FUNCTION (it lives in `.text`), as
319    /// opposed to data (a `.rdata` vtable VA, a string address) that merely
320    /// happens to sit inside the image.
321    pub fn is_executable_section_va(&self, va: u64) -> bool {
322        self.section_of(va)
323            .map(|s| s.is_executable())
324            .unwrap_or(false)
325    }
326
327    /// Pretty-print every section as `name [RVA..RVA+size)` with the image base
328    /// baked in, plus a hint pointing at the closest section to `va` (by VA
329    /// distance) so the user can immediately tell whether they mistyped an
330    /// address or used the wrong file.
331    pub fn describe_sections(&self, va: u64) -> String {
332        let mut s = String::new();
333        let mut best: Option<(&Section, u64)> = None;
334        for sec in &self.sections {
335            let start = self.image_base + sec.virtual_address as u64;
336            let span = sec.virtual_size.max(sec.raw_size) as u64;
337            let end = start + span;
338            s.push_str(&format!(
339                "    {name:<8} [0x{start:08x}..0x{end:08x}) ({span} bytes){flag}\n",
340                name = sec.name,
341                start = start,
342                end = end,
343                span = span,
344                flag = if sec.is_executable() { " code" } else { "" },
345            ));
346            let d = if va < start {
347                start - va
348            } else {
349                va.saturating_sub(end)
350            };
351            if best.map(|(_, bd)| d < bd).unwrap_or(true) {
352                best = Some((sec, d));
353            }
354        }
355        if let Some((sec, d)) = best {
356            s.push_str(&format!(
357                "  closest section to {va:#010x}: {name} (distance {d:#x} bytes) — \
358                 did you mean 0x{start:08x}?",
359                name = sec.name,
360                start = self.image_base + sec.virtual_address as u64,
361            ));
362        }
363        s
364    }
365
366    /// The image's VA range `[image_base, end)` (end = highest section end).
367    pub fn image_range(&self) -> (u64, u64) {
368        let end = self
369            .sections
370            .iter()
371            .map(|s| s.virtual_address as u64 + s.virtual_size.max(s.raw_size) as u64)
372            .max()
373            .unwrap_or(0);
374        (self.image_base, self.image_base + end)
375    }
376
377    /// Every VA the image itself "knows about": targets of every `rel32` CALL /
378    /// JMP / Jcc byte pattern in `.text`, plus every little-endian u32 (read at
379    /// EVERY byte offset of every section's raw data) that lands inside the image
380    /// — vtable slots, switch jump tables, baked function pointers, data refs.
381    ///
382    /// Purpose: catch a MISATTRIBUTED or invented `origin` address. A real
383    /// function entry is always reachable somehow (called, tail-jumped — long
384    /// OR short form — or its address stored in a table), so an address
385    /// referenced NOWHERE cannot be one. Over-approximate by construction
386    /// (a spurious `0xE8` inside another instruction adds a junk target; a
387    /// random data dword may collide) — that only ever widens the accept
388    /// set, so it can't false-positive a real entry.
389    pub fn referenced_addrs(&self) -> std::collections::HashSet<u32> {
390        let (lo, hi) = self.image_range();
391        let mut out: std::collections::HashSet<u32> = std::collections::HashSet::new();
392        for sec in &self.sections {
393            let start = sec.raw_offset as usize;
394            let end = (start + sec.raw_size as usize).min(self.data.len());
395            if start >= end {
396                continue;
397            }
398            let blob = &self.data[start..end];
399            let sec_va = self.image_base + sec.virtual_address as u64;
400            // rel8/rel32 branch targets — only meaningful in the code section.
401            // Scan `.text` plus every non-executable section: FALLE.EXE's
402            // main game code lives in a read/write object (obj2, 0xC0000+)
403            // that is mixed data+code, so its CALL/JMP targets must
404            // contribute to the accept-set or E006 falsely rejects every
405            // obj2 function. Over-approximation only widens acceptance.
406            if sec.name == ".text" || !sec.is_executable() {
407                for i in 0..blob.len().saturating_sub(4) {
408                    // `JMP short rel8` (0xEB): the scan used to index rel32
409                    // branches only, so a function reached solely via short
410                    // jump (e.g. an LE entry at `0x9dbe2`, reached only by `EB F0`
411                    // from `0x9dbeb`) was invisible to ORIGIN-UNREFERENCED.
412                    // Over-approximate like the rest: a stray 0xEB only
413                    // widens the set.
414                    if blob[i] == 0xEB && i + 2 <= blob.len() {
415                        let rel = blob[i + 1] as i8 as i64;
416                        let t = (sec_va + i as u64 + 2).wrapping_add(rel as u64);
417                        if (lo..hi).contains(&t) {
418                            out.insert(t as u32);
419                        }
420                    }
421                    let (op, rel_at) = match blob[i] {
422                        0xE8 | 0xE9 => (blob[i], i + 1), // CALL rel32 / JMP rel32
423                        0x0F if i + 6 <= blob.len() && (0x80..=0x8F).contains(&blob[i + 1]) => {
424                            (blob[i + 1], i + 2) // Jcc rel32
425                        }
426                        _ => continue,
427                    };
428                    let _ = op;
429                    if rel_at + 4 > blob.len() {
430                        continue;
431                    }
432                    let rel = i32::from_le_bytes([
433                        blob[rel_at],
434                        blob[rel_at + 1],
435                        blob[rel_at + 2],
436                        blob[rel_at + 3],
437                    ]) as i64;
438                    let t = (sec_va + (rel_at as u64 + 4)).wrapping_add(rel as u64);
439                    if (lo..hi).contains(&t) {
440                        out.insert(t as u32);
441                    }
442                }
443            }
444            // Absolute in-image dwords at every offset (vtables, jump tables, fn ptrs).
445            for w in blob.windows(4) {
446                let v = u32::from_le_bytes([w[0], w[1], w[2], w[3]]) as u64;
447                if (lo..hi).contains(&v) {
448                    out.insert(v as u32);
449                }
450            }
451        }
452        // Widen with the post-fixup images of those pre-fixup values: an LE
453        // 07 record rewrites these dwords at load, so the RUNTIME address the
454        // port bakes (e.g. post 0x74ABA for a stored link-time 0x64ABA slot
455        // seed) is genuinely referenced even though the file bytes hold the
456        // link-time value. Without this, ORIGIN-UNREFERENCED rejects every
457        // slot-seeded callback (`mov dword [slot],pre; call [slot]`), while
458        // HALLUCINATED-ADDR already accepts the post value via the same
459        // `fixup_post_images` map -- an asymmetry the gate must not have.
460        // Widening the accept-set can never false-positive a real entry
461        // (same over-approximation invariant as the scans above).
462        if !self.fixup_posts.is_empty() {
463            let pres: BTreeSet<u64> = out.iter().map(|&v| v as u64).collect();
464            for post in self.fixup_post_images(&pres) {
465                if (lo..hi).contains(&post) {
466                    out.insert(post as u32);
467                }
468            }
469        }
470        // Self-relative fixup targets: the loader rewrites an on-disk
471        // rel32=0 placeholder to `target-(loc+4)`, so the record target is
472        // a real branch/call destination even though the file bytes hold 0.
473        for &(_, t) in &self.fixup_rel32 {
474            if (lo..hi).contains(&t) {
475                out.insert(t as u32);
476            }
477        }
478        out
479    }
480}
481
482/// DOS4GW Linear Executable (LE) support — real DOS4GW binaries are
483/// MZ+LE, not PE. We synthesize a PE-like image so the existing
484/// disassembler works unchanged:
485///
486/// * `image_base = 0`, `virtual_address = object reloc base`
487///   (entry VA = relocbase(obj1) + EIP, e.g. 0x10000+0x8DEB4 = 0x9DEB4).
488/// * First executable object is named `.text` so the existing
489///   `.text`-gated caches keep working; other objects become
490///   `.textN` / `.dataN`.
491/// * Page map entries are 4 bytes in LE: 24-bit big-endian physical page
492///   number + 8-bit flags (0 = legal). Pages in the observed sample are flag
493///   0 and sequential; iterated/zero pages are rejected for now.
494/// * `datapage` header field is a file-absolute offset (proven by
495///   datapage + pages*pagesize - (pagesize-lastpagesize) == file size).
496/// * Fixups are absorbed, not applied: direct CALL rel32 (the call graph)
497///   is unaffected, and absolute `globals`/`imm_addrs` harvest as pre-fixup
498///   bytes — with post-fixup images accepted wherever a 07 record maps them
499///   (`Pe::fixup_post_images`). Call-graph + CFG gates are sound; 08 records
500///   are validated, 02 selectors skipped, and novel record kinds skip the
501///   tables loudly (`FixupStatus::Skipped`) instead of failing the parse.
502fn parse_le(data: Vec<u8>, le_off: usize) -> Result<Pe, String> {
503    let sz = data.len();
504    let need = le_off + 0xAC;
505    if sz < need + 4 {
506        return Err(format!(
507            "truncated LE header: need {need:#x} bytes for header, file size = {sz} bytes"
508        ));
509    }
510    let at32 = |o: usize| -> Result<u32, String> {
511        if o + 4 > sz {
512            return Err(format!("LE header read past EOF at {o:#x}"));
513        }
514        Ok(u32::from_le_bytes([
515            data[o],
516            data[o + 1],
517            data[o + 2],
518            data[o + 3],
519        ]))
520    };
521    let at16 = |o: usize| -> Result<u16, String> {
522        if o + 2 > sz {
523            return Err(format!("LE header read past EOF at {o:#x}"));
524        }
525        Ok(u16::from_le_bytes([data[o], data[o + 1]]))
526    };
527    let cpu = at16(le_off + 8)?;
528    if cpu != 2 {
529        return Err(format!(
530            "unsupported LE cpu {cpu:#x} (expected 386 = 0x2); only 32-bit x86 LE is handled"
531        ));
532    }
533    let numpages = at32(le_off + 0x14)? as usize;
534    let pagesize = at32(le_off + 0x28)? as usize;
535    let lastpagesize = at32(le_off + 0x2C)? as usize;
536    let objtab_rel = at32(le_off + 0x40)? as usize;
537    let objcnt = at32(le_off + 0x44)? as usize;
538    let pagemap_rel = at32(le_off + 0x48)? as usize;
539    let itermap = at32(le_off + 0x4C)?;
540    let datapage_abs = at32(le_off + 0x80)? as usize;
541    if !(512..=16384).contains(&pagesize) {
542        return Err(format!("implausible LE pagesize {pagesize}"));
543    }
544    if numpages == 0 || numpages > 100_000 {
545        return Err(format!("implausible LE page count {numpages}"));
546    }
547    if objcnt == 0 || objcnt > 100 {
548        return Err(format!("implausible LE object count {objcnt}"));
549    }
550    if itermap != 0 {
551        return Err(format!(
552            "unsupported LE iterated-data map at {itermap:#x}: iterated pages not yet supported"
553        ));
554    }
555    let objtab = le_off.checked_add(objtab_rel).ok_or("LE objtab overflow")?;
556    let pagemap = le_off
557        .checked_add(pagemap_rel)
558        .ok_or("LE pagemap overflow")?;
559    if objtab + objcnt * 24 > sz {
560        return Err(format!(
561            "LE object table runs past EOF: objtab={objtab:#x} + {objcnt}*24, file size {sz:#x}"
562        ));
563    }
564    if pagemap + numpages * 4 > sz {
565        return Err(format!(
566            "LE page map runs past EOF: pagemap={pagemap:#x} + {numpages}*4, file size {sz:#x}"
567        ));
568    }
569    if datapage_abs >= sz {
570        return Err(format!(
571            "LE data pages offset {datapage_abs:#x} past EOF (file size {sz:#x})"
572        ));
573    }
574    // Physical page N (1-based) lives at datapage + (N-1)*pagesize,
575    // except the last physical page which is lastpagesize bytes.
576    // Decode the whole map first so we can validate flags.
577    let mut phys_of_logical: Vec<(u32, u8)> = Vec::with_capacity(numpages);
578    for i in 0..numpages {
579        let o = pagemap + i * 4;
580        let b0 = data[o] as u32;
581        let b1 = data[o + 1] as u32;
582        let b2 = data[o + 2] as u32;
583        let flags = data[o + 3];
584        let page = (b0 << 16) | (b1 << 8) | b2;
585        if flags != 0 {
586            return Err(format!(
587                "unsupported LE page {i} flags={flags:#x} (page={page}): only flag-0 legal pages are supported; iterated/zero pages TODO"
588            ));
589        }
590        if page == 0 || page as usize > numpages {
591            return Err(format!(
592                "LE page map entry {i} has out-of-range physical page {page} (numpages={numpages})"
593            ));
594        }
595        phys_of_logical.push((page, flags));
596    }
597    let mut new_data: Vec<u8> = Vec::new();
598    let mut sections: Vec<Section> = Vec::with_capacity(objcnt);
599    // Retained for the fixup walk below: (reloc base, virtual size,
600    // 1-based first logical page, page count).
601    let mut objs: Vec<(u32, usize, usize, usize)> = Vec::with_capacity(objcnt);
602    let mut exec_seen = 0usize;
603    let mut data_seen = 0usize;
604    for oi in 0..objcnt {
605        let o = objtab + oi * 24;
606        let vsize = u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]) as usize;
607        let base = u32::from_le_bytes([data[o + 4], data[o + 5], data[o + 6], data[o + 7]]);
608        let flags = u32::from_le_bytes([data[o + 8], data[o + 9], data[o + 10], data[o + 11]]);
609        let page_idx =
610            u32::from_le_bytes([data[o + 12], data[o + 13], data[o + 14], data[o + 15]]) as usize;
611        let page_cnt =
612            u32::from_le_bytes([data[o + 16], data[o + 17], data[o + 18], data[o + 19]]) as usize;
613        if page_idx == 0 || page_idx - 1 + page_cnt > numpages {
614            return Err(format!(
615                "LE object {oi} page range [{page_idx}..{}) out of 1..={numpages}",
616                page_idx + page_cnt
617            ));
618        }
619        objs.push((base, vsize, page_idx, page_cnt));
620        let start = new_data.len();
621        for j in 0..page_cnt {
622            let (phys, _) = phys_of_logical[page_idx - 1 + j];
623            let is_last_phys = phys as usize == numpages;
624            let plen = if is_last_phys { lastpagesize } else { pagesize };
625            let foff = datapage_abs + (phys as usize - 1) * pagesize;
626            if foff + plen > sz {
627                return Err(format!(
628                    "LE object {oi} page {j} (phys {phys}) file range [{foff:#x}..{:#x}) past EOF {sz:#x}",
629                    foff + plen
630                ));
631            }
632            new_data.extend_from_slice(&data[foff..foff + plen]);
633        }
634        let file_len = new_data.len() - start;
635        let raw_size = vsize.min(file_len);
636        // Drop padding beyond vsize (trailing bytes of the last page that
637        // belong to no object); keep BSS tail virtual (read returns None).
638        new_data.truncate(start + raw_size);
639        let is_exec = flags & 0x4 != 0;
640        let (name, characteristics) = if is_exec {
641            let n = if exec_seen == 0 {
642                ".text".to_string()
643            } else {
644                format!(".text{exec_seen}")
645            };
646            exec_seen += 1;
647            (
648                n,
649                section_flags::CODE | section_flags::EXECUTE | section_flags::READ,
650            )
651        } else {
652            let n = if data_seen == 0 {
653                ".data".to_string()
654            } else {
655                format!(".data{data_seen}")
656            };
657            data_seen += 1;
658            (n, section_flags::READ | section_flags::WRITE)
659        };
660        sections.push(Section {
661            name,
662            virtual_address: base,
663            virtual_size: vsize as u32,
664            raw_offset: start as u32,
665            raw_size: raw_size as u32,
666            characteristics,
667        });
668    }
669    let (fixup_posts, fixup_rel32, fixup_status) = match collect_fixups(
670        &data, le_off, &objs, numpages, pagesize, &sections, &new_data,
671    ) {
672        Ok((pairs, rel32)) => {
673            let n = pairs.len();
674            (pairs, rel32, FixupStatus::Applied { pairs: n })
675        }
676        Err(e) if e.starts_with(NOVEL_FIXUP_PREFIX) => {
677            (Vec::new(), Vec::new(), FixupStatus::Skipped { reason: e })
678        }
679        Err(e) => return Err(e),
680    };
681    Ok(Pe {
682        data: new_data,
683        image_base: 0,
684        is_le: true,
685        sections,
686        fixup_posts,
687        fixup_rel32,
688        fixup_status,
689        id: next_pe_id(),
690    })
691}
692
693/// Marker prefix for the one non-fatal fixup-walk failure: a record kind
694/// outside the proven grammar (e.g. an import fixup another linker emits).
695/// `parse_le` matches on this prefix and skips the tables loudly (image
696/// stays pre-fixup, reason in `FixupStatus::Skipped`) instead of failing
697/// the whole parse. Everything else the walk reports is corruption
698/// (truncation, non-adjacent tables, out-of-range references) and stays
699/// fatal — a broken file must not load.
700const NOVEL_FIXUP_PREFIX: &str = "NOVEL-FIXUP-RECORD: ";
701
702/// Read-only LE fixup walk: `(pre, post)` content pairs for every internal
703/// 07 (absolute) record. The grammar mirrors the DOS4GW loader's
704/// `apply_fixups` (proven against real LE record tables): page table at
705/// `le+0x68`, record table at `le+0x6C`, single-source or u8-counted source
706/// lists, narrow (u16) or wide (u32) target offsets, and the wlink
707/// page-straddler wrap (`0xFFFD/0xFFFE/0xFFFF` source offsets file under the
708/// next page — unwrapped here). 08 (self-relative) records are parsed and
709/// validated but contribute no pairs (a relative offset names no address);
710/// 02 (16-bit selector) records are counted past (no flat-model value).
711/// `pre` is read from the pre-fixup image (file bytes where present, zero
712/// where the loader maps BSS); a record patching nowhere is malformed.
713/// Zeroed table offsets mean "no tables" (empty walk, not an error).
714#[allow(clippy::too_many_arguments)]
715fn collect_fixups(
716    data: &[u8],
717    le_off: usize,
718    objs: &[(u32, usize, usize, usize)],
719    numpages: usize,
720    pagesize: usize,
721    sections: &[Section],
722    image: &[u8],
723) -> Result<FixupTables, String> {
724    let pre_of = |va: u64| -> Option<u32> {
725        for s in sections {
726            let lo = s.virtual_address as u64;
727            let hi = lo + s.virtual_size as u64;
728            if (lo..hi).contains(&va) {
729                let off = s.raw_offset as u64 + (va - lo);
730                if off + 4 <= s.raw_offset as u64 + s.raw_size as u64 {
731                    let o = off as usize;
732                    return Some(u32::from_le_bytes([
733                        image[o],
734                        image[o + 1],
735                        image[o + 2],
736                        image[o + 3],
737                    ]));
738                }
739                // In virtual range but past raw data: loader-zeroed BSS.
740                return Some(0);
741            }
742        }
743        None
744    };
745    let at32 = |o: usize| -> Result<u32, String> {
746        if o + 4 > data.len() {
747            return Err(format!("LE fixup header read past EOF at {o:#x}"));
748        }
749        Ok(u32::from_le_bytes([
750            data[o],
751            data[o + 1],
752            data[o + 2],
753            data[o + 3],
754        ]))
755    };
756    let fpt_field = le_off
757        .checked_add(0x68)
758        .ok_or("LE fixup page-table field overflow")?;
759    let frt_field = le_off
760        .checked_add(0x6C)
761        .ok_or("LE fixup record-table field overflow")?;
762    let fpt = le_off
763        .checked_add(at32(fpt_field)? as usize)
764        .ok_or("LE fixup page-table offset overflow")?;
765    let frt = le_off
766        .checked_add(at32(frt_field)? as usize)
767        .ok_or("LE fixup record-table offset overflow")?;
768    if fpt == le_off && frt == le_off {
769        // Zeroed table offsets: the linker emitted no fixup tables.
770        return Ok((Vec::new(), Vec::new()));
771    }
772    let tab_len = (numpages + 1)
773        .checked_mul(4)
774        .ok_or("LE fixup page-table size overflow")?;
775    // Structural proof both offsets are right: the (numpages+1)-entry table
776    // abuts the record table exactly.
777    if fpt.checked_add(tab_len) != Some(frt) {
778        return Err(format!(
779            "LE fixup tables not adjacent: pagetab={fpt:#x} + {tab_len:#x} != rectab={frt:#x}"
780        ));
781    }
782    if frt > data.len() {
783        return Err(format!(
784            "LE fixup record table {frt:#x} past EOF (file size {:#x})",
785            data.len()
786        ));
787    }
788    let mut bounds: Vec<(usize, usize)> = Vec::with_capacity(numpages);
789    let mut prev = 0usize;
790    let mut first = true;
791    for i in 0..=numpages {
792        let e = at32(fpt + i * 4)? as usize;
793        if !first && e < prev {
794            return Err(format!(
795                "LE fixup page table not monotonic at page {i}: {e:#x} < {prev:#x}"
796            ));
797        }
798        first = false;
799        prev = e;
800        if i < numpages {
801            bounds.push((e, 0));
802        } else if frt.checked_add(e).map_or(true, |end| end > data.len()) {
803            return Err(format!(
804                "LE fixup record table end {e:#x} past EOF (file size {:#x})",
805                data.len()
806            ));
807        }
808        if i > 0 {
809            bounds[i - 1].1 = e;
810        }
811    }
812    let mut out: Vec<(u64, u64)> = Vec::new();
813    let mut out_rel32: Vec<(u64, u64)> = Vec::new();
814    for (pi, (start, end)) in bounds.iter().enumerate() {
815        let lp = pi + 1; // logical pages are 1-based
816        let (obase, _, opage, _) = objs
817            .iter()
818            .find(|(_, _, p, c)| lp >= *p && lp < p + c)
819            .ok_or(format!("LE fixup page {lp} belongs to no object"))?;
820        let lin_base = (*obase as usize)
821            .checked_add((lp - opage) * pagesize)
822            .ok_or(format!("LE fixup page {lp} linear base overflow"))?;
823        let mut p = frt
824            .checked_add(*start)
825            .ok_or("LE fixup record offset overflow")?;
826        let pend = frt
827            .checked_add(*end)
828            .ok_or("LE fixup record end overflow")?;
829        while p < pend {
830            let rec_off = p - frt;
831            let st = *data.get(p).ok_or(format!(
832                "LE fixup record {rec_off:#x} (page {lp}) truncated: missing srctype"
833            ))?;
834            let fl = *data.get(p + 1).ok_or(format!(
835                "LE fixup record {rec_off:#x} (page {lp}) truncated: missing tgtflags"
836            ))?;
837            p += 2;
838            let mut srcs: Vec<u16> = Vec::new();
839            if st & 0x20 != 0 {
840                let cnt = *data.get(p).ok_or(format!(
841                    "LE fixup record {rec_off:#x} (page {lp}) truncated: missing source count"
842                ))?;
843                p += 1;
844                for _ in 0..cnt {
845                    if p + 2 > pend || p + 2 > data.len() {
846                        return Err(format!(
847                            "LE fixup record {rec_off:#x} (page {lp}) source runs past page end"
848                        ));
849                    }
850                    srcs.push(u16::from_le_bytes([data[p], data[p + 1]]));
851                    p += 2;
852                }
853            } else {
854                if p + 2 > pend || p + 2 > data.len() {
855                    return Err(format!(
856                        "LE fixup record {rec_off:#x} (page {lp}) source runs past page end"
857                    ));
858                }
859                srcs.push(u16::from_le_bytes([data[p], data[p + 1]]));
860                p += 2;
861            }
862            let low4 = st & 0x0F;
863            let is_sel = (st == 0x02 || st == 0x22) && fl == 0x00;
864            let is_off = (low4 == 7 || low4 == 8) && (st & 0xD0) == 0 && (fl == 0x00 || fl == 0x10);
865            if !is_sel && !is_off {
866                // Novelty, not corruption (see NOVEL_FIXUP_PREFIX): the
867                // tables may belong to a linker dialect with fixup kinds
868                // beyond the proven grammar (e.g. import fixups). The
869                // caller skips the tables loudly instead of failing.
870                return Err(format!(
871                    "{NOVEL_FIXUP_PREFIX}record {rec_off:#x} (page {lp}): srctype=0x{st:02X} tgtflags=0x{fl:02X}"
872                ));
873            }
874            if p + 1 > pend || p + 1 > data.len() {
875                return Err(format!(
876                    "LE fixup record {rec_off:#x} (page {lp}) truncated: missing target object"
877                ));
878            }
879            let obj = data[p] as usize;
880            p += 1;
881            if obj < 1 || obj > objs.len() {
882                return Err(format!(
883                    "LE fixup record {rec_off:#x} (page {lp}) has bad target object {obj}"
884                ));
885            }
886            let wide = fl & 0x10 != 0;
887            let toff: u32 = if is_sel {
888                0
889            } else if wide {
890                if p + 4 > pend || p + 4 > data.len() {
891                    return Err(format!(
892                        "LE fixup record {rec_off:#x} (page {lp}) truncated: missing wide target offset"
893                    ));
894                }
895                let v = u32::from_le_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
896                p += 4;
897                v
898            } else {
899                if p + 2 > pend || p + 2 > data.len() {
900                    return Err(format!(
901                        "LE fixup record {rec_off:#x} (page {lp}) truncated: missing narrow target offset"
902                    ));
903                }
904                let v = u16::from_le_bytes([data[p], data[p + 1]]) as u32;
905                p += 2;
906                v
907            };
908            let target = objs[obj - 1].0.wrapping_add(toff);
909            for s in srcs {
910                // Source offsets are page-relative, but a patched dword may
911                // straddle a page boundary: wlink files the record under the
912                // page holding the dword's final byte with the offset wrapped.
913                let loc = if (s as usize) < pagesize {
914                    lin_base.checked_add(s as usize)
915                } else if s >= 0xFFFD {
916                    lin_base
917                        .checked_add(s as usize)
918                        .and_then(|l| l.checked_sub(0x1_0000))
919                } else {
920                    None
921                };
922                let loc = loc.ok_or(format!(
923                    "LE fixup record {rec_off:#x} (page {lp}) bad source offset {s:#x} (pagesize {pagesize:#x})"
924                ))?;
925                let pre = pre_of(loc as u64).ok_or(format!(
926                    "LE fixup record {rec_off:#x} (page {lp}) location {loc:#x} outside image"
927                ))?;
928                if is_sel {
929                    continue; // deferred: no flat-model value (see field docs)
930                }
931                if low4 == 7 {
932                    out.push((pre as u64, target as u64));
933                } else if low4 == 8 {
934                    // Self-relative: the loader writes target-(loc+4), so the
935                    // record target is the runtime branch/call destination.
936                    out_rel32.push((loc as u64, target as u64));
937                }
938            }
939        }
940        if p != pend {
941            return Err(format!(
942                "LE fixup page {lp} consumed to {:#x}, expected end {:#x}",
943                p - frt,
944                pend - frt
945            ));
946        }
947    }
948    Ok((out, out_rel32))
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    /// Minimal hand-built LE: MZ + LE header, one executable object
956    /// (base 0x10000, one 0x1000 page), page map, fixup tables, one data
957    /// page. The page holds `mov eax,[0x10400]; ret` plus three patched
958    /// dwords; the record table holds one narrow 07, one narrow 08, and
959    /// one source-list 07 (wide). Returns the raw file bytes.
960    fn build_fixup_le() -> Vec<u8> {
961        let le_off = 0x40usize;
962        let pagesize = 0x1000usize;
963        let mut data = vec![0u8; 0x1400];
964        data[0..2].copy_from_slice(b"MZ");
965        data[0x3c..0x40].copy_from_slice(&(le_off as u32).to_le_bytes());
966        data[le_off..le_off + 2].copy_from_slice(b"LE");
967        data[le_off + 8..le_off + 10].copy_from_slice(&2u16.to_le_bytes()); // cpu = 386
968        data[le_off + 0x14..le_off + 0x18].copy_from_slice(&1u32.to_le_bytes()); // numpages
969        data[le_off + 0x28..le_off + 0x2C].copy_from_slice(&(pagesize as u32).to_le_bytes()); // pagesize
970        data[le_off + 0x2C..le_off + 0x30].copy_from_slice(&(pagesize as u32).to_le_bytes()); // lastpagesize
971        data[le_off + 0x40..le_off + 0x44].copy_from_slice(&0x1C0u32.to_le_bytes()); // objtab_rel
972        data[le_off + 0x44..le_off + 0x48].copy_from_slice(&1u32.to_le_bytes()); // objcnt
973        data[le_off + 0x48..le_off + 0x4C].copy_from_slice(&0x1D8u32.to_le_bytes()); // pagemap_rel
974        data[le_off + 0x4C..le_off + 0x50].copy_from_slice(&0u32.to_le_bytes()); // itermap
975        data[le_off + 0x68..le_off + 0x6C].copy_from_slice(&0x2C0u32.to_le_bytes()); // fpt_rel
976        data[le_off + 0x6C..le_off + 0x70].copy_from_slice(&0x2C8u32.to_le_bytes()); // frt_rel
977        data[le_off + 0x80..le_off + 0x84].copy_from_slice(&0x400u32.to_le_bytes()); // datapage_abs
978                                                                                     // Object table: vsize, base, flags(exec), page_idx, page_cnt, 12 reserved.
979        let o = 0x200usize;
980        data[o..o + 4].copy_from_slice(&0x1000u32.to_le_bytes());
981        data[o + 4..o + 8].copy_from_slice(&0x10000u32.to_le_bytes());
982        data[o + 8..o + 12].copy_from_slice(&0x4u32.to_le_bytes());
983        data[o + 12..o + 16].copy_from_slice(&1u32.to_le_bytes());
984        data[o + 16..o + 20].copy_from_slice(&1u32.to_le_bytes());
985        // Page map: physical page 1, flags 0.
986        data[0x218..0x21C].copy_from_slice(&[0x00, 0x00, 0x01, 0x00]);
987        // Fixup page table: page 1 owns [0..26).
988        data[0x300..0x304].copy_from_slice(&0u32.to_le_bytes());
989        data[0x304..0x308].copy_from_slice(&26u32.to_le_bytes());
990        // Record 1 (7 bytes @0x308): 07 narrow absolute, src 0x10, obj 1,
991        // toff 0x500 → loc 0x10010 (pre 0x10400), post 0x10500.
992        data[0x308..0x30F].copy_from_slice(&[0x07, 0x00, 0x10, 0x00, 0x01, 0x00, 0x05]);
993        // Record 2 (7 bytes @0x30F): 08 narrow self-relative, src 0x21
994        // (CALL rel32 at 0x20), obj 1, toff 0x100 → validated only, no pair.
995        data[0x30F..0x316].copy_from_slice(&[0x08, 0x00, 0x21, 0x00, 0x01, 0x00, 0x01]);
996        // Record 3 (12 bytes @0x316): 07 wide absolute, source-list
997        // [0x30, 0x34], obj 1, toff 0x600 → locs 0x10030/0x10034
998        // (pres 0x10410/0x10420), post 0x10600.
999        data[0x316..0x322].copy_from_slice(&[
1000            0x27, 0x10, 0x02, 0x30, 0x00, 0x34, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00,
1001        ]);
1002        // Data page: `mov eax,[0x10400]; ret`, patched dwords, CALL stub.
1003        let pg = 0x400usize;
1004        data[pg..pg + 6].copy_from_slice(&[0xA1, 0x00, 0x04, 0x01, 0x00, 0xC3]);
1005        data[pg + 0x10..pg + 0x14].copy_from_slice(&0x10400u32.to_le_bytes());
1006        data[pg + 0x20..pg + 0x25].copy_from_slice(&[0xE8, 0x00, 0x00, 0x00, 0x00]);
1007        data[pg + 0x30..pg + 0x34].copy_from_slice(&0x10410u32.to_le_bytes());
1008        data[pg + 0x34..pg + 0x38].copy_from_slice(&0x10420u32.to_le_bytes());
1009        data
1010    }
1011
1012    #[test]
1013    fn le_fixup_07_pairs_absorbed() {
1014        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
1015        assert_eq!(pe.fixup_count(), 3, "one narrow 07 + two listed 07 dwords");
1016        let got = pe.fixup_post_images(&[0x10400].into_iter().collect());
1017        assert_eq!(got, [0x10500].into_iter().collect());
1018        let got = pe.fixup_post_images(&[0x10410, 0x10420].into_iter().collect());
1019        assert_eq!(got, [0x10600].into_iter().collect());
1020        // Unknown pres map to nothing; 08 records contribute no pairs.
1021        let got = pe.fixup_post_images(&[0x99999].into_iter().collect());
1022        assert!(got.is_empty());
1023    }
1024
1025    #[test]
1026    fn le_fixup_post_global_passes_spec() {
1027        use crate::{analyze, Spec};
1028        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
1029        let cg = analyze(&pe, 0x10000).expect("analyze synthetic fn");
1030        assert!(cg.globals.contains(&0x10400), "{cg:?}");
1031        // Pre-fixup value: always accepted.
1032        Spec::new(0x10000)
1033            .globals(&[0x10400])
1034            .check(&pe)
1035            .expect("pre global");
1036        // Post-fixup image of the referenced pre value: accepted (the fix).
1037        Spec::new(0x10000)
1038            .globals(&[0x10500])
1039            .check(&pe)
1040            .expect("post global");
1041        // An address referenced nowhere, pre or post: still rejected.
1042        assert!(Spec::new(0x10000).globals(&[0x10999]).check(&pe).is_err());
1043    }
1044
1045    #[test]
1046    fn le_fixup_post_image_single_lookup() {
1047        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
1048        assert_eq!(pe.fixup_post_image(0x10400), Some(0x10500));
1049        assert_eq!(pe.fixup_post_image(0x10410), Some(0x10600));
1050        assert_eq!(pe.fixup_post_image(0x10420), Some(0x10600));
1051        // Not a fixup site: no mapping (callers fall back to the raw value).
1052        assert_eq!(pe.fixup_post_image(0x99999), None);
1053    }
1054
1055    #[test]
1056    fn le_referenced_addrs_include_fixup_post_images() {
1057        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
1058        let refs = pe.referenced_addrs();
1059        // Link-time values physically present in the image (instruction
1060        // operand + stored data dwords).
1061        assert!(
1062            refs.contains(&0x10400),
1063            "pre operand ref missing: {refs:x?}"
1064        );
1065        assert!(refs.contains(&0x10410), "pre stored dword ref missing");
1066        assert!(refs.contains(&0x10420), "pre stored dword ref missing");
1067        // Runtime images of those values (what a ported body bakes) must also
1068        // count as referenced, or ORIGIN-UNREFERENCED rejects every real
1069        // slot-seeded callback whose slot holds a link-time pointer.
1070        assert!(refs.contains(&0x10500), "post image of 0x10400 missing");
1071        assert!(
1072            refs.contains(&0x10600),
1073            "post image of 0x10410/0x10420 missing"
1074        );
1075        // Nothing invented: an address referenced neither pre nor post stays out.
1076        assert!(
1077            !refs.contains(&0x10999),
1078            "garbage address must stay unreferenced"
1079        );
1080    }
1081
1082    #[test]
1083    fn le_fixup_unknown_record_skips_loudly() {
1084        use super::FixupStatus;
1085        let mut data = build_fixup_le();
1086        data[0x308] = 0x09; // bogus srctype on record 1
1087                            // Novelty skips the tables (image stays pre-fixup) — it must NOT
1088                            // fail the parse, and the reason must be recorded, not silent.
1089        let pe = Pe::parse(data).expect("novel fixup kind must skip, not fail");
1090        assert_eq!(pe.fixup_count(), 0);
1091        match pe.fixup_status() {
1092            FixupStatus::Skipped { reason } => {
1093                assert!(reason.contains("srctype=0x09"), "{reason}")
1094            }
1095            other => panic!("expected Skipped, got {other:?}"),
1096        }
1097    }
1098
1099    #[test]
1100    fn le_fixup_tables_must_be_adjacent() {
1101        let mut data = build_fixup_le();
1102        data[0x40 + 0x68..0x40 + 0x6C].copy_from_slice(&0x2C4u32.to_le_bytes());
1103        match Pe::parse(data) {
1104            Ok(_) => panic!("moved page table must fail"),
1105            Err(err) => assert!(err.contains("not adjacent"), "{err}"),
1106        }
1107    }
1108}