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