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