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