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. an LE entry at `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        // Widen with the post-fixup images of those pre-fixup values: an LE
402        // 07 record rewrites these dwords at load, so the RUNTIME address the
403        // port bakes (e.g. post 0x74ABA for a stored link-time 0x64ABA slot
404        // seed) is genuinely referenced even though the file bytes hold the
405        // link-time value. Without this, ORIGIN-UNREFERENCED rejects every
406        // slot-seeded callback (`mov dword [slot],pre; call [slot]`), while
407        // HALLUCINATED-ADDR already accepts the post value via the same
408        // `fixup_post_images` map -- an asymmetry the gate must not have.
409        // Widening the accept-set can never false-positive a real entry
410        // (same over-approximation invariant as the scans above).
411        if !self.fixup_posts.is_empty() {
412            let pres: BTreeSet<u64> = out.iter().map(|&v| v as u64).collect();
413            for post in self.fixup_post_images(&pres) {
414                if (lo..hi).contains(&post) {
415                    out.insert(post as u32);
416                }
417            }
418        }
419        out
420    }
421}
422
423/// DOS4GW Linear Executable (LE) support — real DOS4GW binaries are
424/// MZ+LE, not PE. We synthesize a PE-like image so the existing
425/// disassembler works unchanged:
426///
427/// * `image_base = 0`, `virtual_address = object reloc base`
428///   (entry VA = relocbase(obj1) + EIP, e.g. 0x10000+0x8DEB4 = 0x9DEB4).
429/// * First executable object is named `.text` so the existing
430///   `.text`-gated caches keep working; other objects become
431///   `.textN` / `.dataN`.
432/// * Page map entries are 4 bytes in LE: 24-bit big-endian physical page
433///   number + 8-bit flags (0 = legal). Pages in the observed sample are flag
434///   0 and sequential; iterated/zero pages are rejected for now.
435/// * `datapage` header field is a file-absolute offset (proven by
436///   datapage + pages*pagesize - (pagesize-lastpagesize) == file size).
437/// * Fixups are absorbed, not applied: direct CALL rel32 (the call graph)
438///   is unaffected, and absolute `globals`/`imm_addrs` harvest as pre-fixup
439///   bytes — with post-fixup images accepted wherever a 07 record maps them
440///   (`Pe::fixup_post_images`). Call-graph + CFG gates are sound; 08 records
441///   are validated, 02 selectors skipped, and novel record kinds skip the
442///   tables loudly (`FixupStatus::Skipped`) instead of failing the parse.
443fn parse_le(data: Vec<u8>, le_off: usize) -> Result<Pe, String> {
444    let sz = data.len();
445    let need = le_off + 0xAC;
446    if sz < need + 4 {
447        return Err(format!(
448            "truncated LE header: need {need:#x} bytes for header, file size = {sz} bytes"
449        ));
450    }
451    let at32 = |o: usize| -> Result<u32, String> {
452        if o + 4 > sz {
453            return Err(format!("LE header read past EOF at {o:#x}"));
454        }
455        Ok(u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]))
456    };
457    let at16 = |o: usize| -> Result<u16, String> {
458        if o + 2 > sz {
459            return Err(format!("LE header read past EOF at {o:#x}"));
460        }
461        Ok(u16::from_le_bytes([data[o], data[o + 1]]))
462    };
463    let cpu = at16(le_off + 8)?;
464    if cpu != 2 {
465        return Err(format!(
466            "unsupported LE cpu {cpu:#x} (expected 386 = 0x2); only 32-bit x86 LE is handled"
467        ));
468    }
469    let numpages = at32(le_off + 0x14)? as usize;
470    let pagesize = at32(le_off + 0x28)? as usize;
471    let lastpagesize = at32(le_off + 0x2C)? as usize;
472    let objtab_rel = at32(le_off + 0x40)? as usize;
473    let objcnt = at32(le_off + 0x44)? as usize;
474    let pagemap_rel = at32(le_off + 0x48)? as usize;
475    let itermap = at32(le_off + 0x4C)?;
476    let datapage_abs = at32(le_off + 0x80)? as usize;
477    if !(512..=16384).contains(&pagesize) {
478        return Err(format!("implausible LE pagesize {pagesize}"));
479    }
480    if numpages == 0 || numpages > 100_000 {
481        return Err(format!("implausible LE page count {numpages}"));
482    }
483    if objcnt == 0 || objcnt > 100 {
484        return Err(format!("implausible LE object count {objcnt}"));
485    }
486    if itermap != 0 {
487        return Err(format!(
488            "unsupported LE iterated-data map at {itermap:#x}: iterated pages not yet supported"
489        ));
490    }
491    let objtab = le_off.checked_add(objtab_rel).ok_or("LE objtab overflow")?;
492    let pagemap = le_off.checked_add(pagemap_rel).ok_or("LE pagemap overflow")?;
493    if objtab + objcnt * 24 > sz {
494        return Err(format!(
495            "LE object table runs past EOF: objtab={objtab:#x} + {objcnt}*24, file size {sz:#x}"
496        ));
497    }
498    if pagemap + numpages * 4 > sz {
499        return Err(format!(
500            "LE page map runs past EOF: pagemap={pagemap:#x} + {numpages}*4, file size {sz:#x}"
501        ));
502    }
503    if datapage_abs >= sz {
504        return Err(format!(
505            "LE data pages offset {datapage_abs:#x} past EOF (file size {sz:#x})"
506        ));
507    }
508    // Physical page N (1-based) lives at datapage + (N-1)*pagesize,
509    // except the last physical page which is lastpagesize bytes.
510    // Decode the whole map first so we can validate flags.
511    let mut phys_of_logical: Vec<(u32, u8)> = Vec::with_capacity(numpages);
512    for i in 0..numpages {
513        let o = pagemap + i * 4;
514        let b0 = data[o] as u32;
515        let b1 = data[o + 1] as u32;
516        let b2 = data[o + 2] as u32;
517        let flags = data[o + 3];
518        let page = (b0 << 16) | (b1 << 8) | b2;
519        if flags != 0 {
520            return Err(format!(
521                "unsupported LE page {i} flags={flags:#x} (page={page}): only flag-0 legal pages are supported; iterated/zero pages TODO"
522            ));
523        }
524        if page == 0 || page as usize > numpages {
525            return Err(format!(
526                "LE page map entry {i} has out-of-range physical page {page} (numpages={numpages})"
527            ));
528        }
529        phys_of_logical.push((page, flags));
530    }
531    let mut new_data: Vec<u8> = Vec::new();
532    let mut sections: Vec<Section> = Vec::with_capacity(objcnt);
533    // Retained for the fixup walk below: (reloc base, virtual size,
534    // 1-based first logical page, page count).
535    let mut objs: Vec<(u32, usize, usize, usize)> = Vec::with_capacity(objcnt);
536    let mut exec_seen = 0usize;
537    let mut data_seen = 0usize;
538    for oi in 0..objcnt {
539        let o = objtab + oi * 24;
540        let vsize = u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]) as usize;
541        let base =
542            u32::from_le_bytes([data[o + 4], data[o + 5], data[o + 6], data[o + 7]]) as u32;
543        let flags =
544            u32::from_le_bytes([data[o + 8], data[o + 9], data[o + 10], data[o + 11]]);
545        let page_idx =
546            u32::from_le_bytes([data[o + 12], data[o + 13], data[o + 14], data[o + 15]])
547                as usize;
548        let page_cnt =
549            u32::from_le_bytes([data[o + 16], data[o + 17], data[o + 18], data[o + 19]])
550                as usize;
551        if page_idx == 0 || page_idx - 1 + page_cnt > numpages {
552            return Err(format!(
553                "LE object {oi} page range [{page_idx}..{}) out of 1..={numpages}",
554                page_idx + page_cnt
555            ));
556        }
557        objs.push((base, vsize, page_idx, page_cnt));
558        let start = new_data.len();
559        for j in 0..page_cnt {
560            let (phys, _) = phys_of_logical[page_idx - 1 + j];
561            let is_last_phys = phys as usize == numpages;
562            let plen = if is_last_phys { lastpagesize } else { pagesize };
563            let foff = datapage_abs + (phys as usize - 1) * pagesize;
564            if foff + plen > sz {
565                return Err(format!(
566                    "LE object {oi} page {j} (phys {phys}) file range [{foff:#x}..{:#x}) past EOF {sz:#x}",
567                    foff + plen
568                ));
569            }
570            new_data.extend_from_slice(&data[foff..foff + plen]);
571        }
572        let file_len = new_data.len() - start;
573        let raw_size = vsize.min(file_len);
574        // Drop padding beyond vsize (trailing bytes of the last page that
575        // belong to no object); keep BSS tail virtual (read returns None).
576        new_data.truncate(start + raw_size);
577        let is_exec = flags & 0x4 != 0;
578        let (name, characteristics) = if is_exec {
579            let n = if exec_seen == 0 {
580                ".text".to_string()
581            } else {
582                format!(".text{exec_seen}")
583            };
584            exec_seen += 1;
585            (
586                n,
587                section_flags::CODE | section_flags::EXECUTE | section_flags::READ,
588            )
589        } else {
590            let n = if data_seen == 0 {
591                ".data".to_string()
592            } else {
593                format!(".data{data_seen}")
594            };
595            data_seen += 1;
596            (
597                n,
598                section_flags::READ | section_flags::WRITE,
599            )
600        };
601        sections.push(Section {
602            name,
603            virtual_address: base,
604            virtual_size: vsize as u32,
605            raw_offset: start as u32,
606            raw_size: raw_size as u32,
607            characteristics,
608        });
609    }
610    let (fixup_posts, fixup_status) = match collect_fixups(
611        &data, le_off, &objs, numpages, pagesize, &sections, &new_data,
612    ) {
613        Ok(pairs) => {
614            let n = pairs.len();
615            (pairs, FixupStatus::Applied { pairs: n })
616        }
617        Err(e) if e.starts_with(NOVEL_FIXUP_PREFIX) => {
618            (Vec::new(), FixupStatus::Skipped { reason: e })
619        }
620        Err(e) => return Err(e),
621    };
622    Ok(Pe {
623        data: new_data,
624        image_base: 0,
625        sections,
626        fixup_posts,
627        fixup_status,
628        id: next_pe_id(),
629    })
630}
631
632/// Marker prefix for the one non-fatal fixup-walk failure: a record kind
633/// outside the proven grammar (e.g. an import fixup another linker emits).
634/// `parse_le` matches on this prefix and skips the tables loudly (image
635/// stays pre-fixup, reason in `FixupStatus::Skipped`) instead of failing
636/// the whole parse. Everything else the walk reports is corruption
637/// (truncation, non-adjacent tables, out-of-range references) and stays
638/// fatal — a broken file must not load.
639const NOVEL_FIXUP_PREFIX: &str = "NOVEL-FIXUP-RECORD: ";
640
641/// Read-only LE fixup walk: `(pre, post)` content pairs for every internal
642/// 07 (absolute) record. The grammar mirrors the DOS4GW loader's
643/// `apply_fixups` (proven against real LE record tables): page table at
644/// `le+0x68`, record table at `le+0x6C`, single-source or u8-counted source
645/// lists, narrow (u16) or wide (u32) target offsets, and the wlink
646/// page-straddler wrap (`0xFFFD/0xFFFE/0xFFFF` source offsets file under the
647/// next page — unwrapped here). 08 (self-relative) records are parsed and
648/// validated but contribute no pairs (a relative offset names no address);
649/// 02 (16-bit selector) records are counted past (no flat-model value).
650/// `pre` is read from the pre-fixup image (file bytes where present, zero
651/// where the loader maps BSS); a record patching nowhere is malformed.
652/// Zeroed table offsets mean "no tables" (empty walk, not an error).
653#[allow(clippy::too_many_arguments)]
654fn collect_fixups(
655    data: &[u8],
656    le_off: usize,
657    objs: &[(u32, usize, usize, usize)],
658    numpages: usize,
659    pagesize: usize,
660    sections: &[Section],
661    image: &[u8],
662) -> Result<Vec<(u64, u64)>, String> {
663    let pre_of = |va: u64| -> Option<u32> {
664        for s in sections {
665            let lo = s.virtual_address as u64;
666            let hi = lo + s.virtual_size as u64;
667            if (lo..hi).contains(&va) {
668                let off = s.raw_offset as u64 + (va - lo);
669                if off + 4 <= s.raw_offset as u64 + s.raw_size as u64 {
670                    let o = off as usize;
671                    return Some(u32::from_le_bytes([
672                        image[o],
673                        image[o + 1],
674                        image[o + 2],
675                        image[o + 3],
676                    ]));
677                }
678                // In virtual range but past raw data: loader-zeroed BSS.
679                return Some(0);
680            }
681        }
682        None
683    };
684    let at32 = |o: usize| -> Result<u32, String> {
685        if o + 4 > data.len() {
686            return Err(format!("LE fixup header read past EOF at {o:#x}"));
687        }
688        Ok(u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]))
689    };
690    let fpt_field = le_off
691        .checked_add(0x68)
692        .ok_or("LE fixup page-table field overflow")?;
693    let frt_field = le_off
694        .checked_add(0x6C)
695        .ok_or("LE fixup record-table field overflow")?;
696    let fpt = le_off
697        .checked_add(at32(fpt_field)? as usize)
698        .ok_or("LE fixup page-table offset overflow")?;
699    let frt = le_off
700        .checked_add(at32(frt_field)? as usize)
701        .ok_or("LE fixup record-table offset overflow")?;
702    if fpt == le_off && frt == le_off {
703        // Zeroed table offsets: the linker emitted no fixup tables.
704        return Ok(Vec::new());
705    }
706    let tab_len = (numpages + 1)
707        .checked_mul(4)
708        .ok_or("LE fixup page-table size overflow")?;
709    // Structural proof both offsets are right: the (numpages+1)-entry table
710    // abuts the record table exactly.
711    if fpt.checked_add(tab_len) != Some(frt) {
712        return Err(format!(
713            "LE fixup tables not adjacent: pagetab={fpt:#x} + {tab_len:#x} != rectab={frt:#x}"
714        ));
715    }
716    if frt > data.len() {
717        return Err(format!(
718            "LE fixup record table {frt:#x} past EOF (file size {:#x})",
719            data.len()
720        ));
721    }
722    let mut bounds: Vec<(usize, usize)> = Vec::with_capacity(numpages);
723    let mut prev = 0usize;
724    let mut first = true;
725    for i in 0..=numpages {
726        let e = at32(fpt + i * 4)? as usize;
727        if !first && e < prev {
728            return Err(format!(
729                "LE fixup page table not monotonic at page {i}: {e:#x} < {prev:#x}"
730            ));
731        }
732        first = false;
733        prev = e;
734        if i < numpages {
735            bounds.push((e, 0));
736        } else if frt.checked_add(e).map_or(true, |end| end > data.len()) {
737            return Err(format!(
738                "LE fixup record table end {e:#x} past EOF (file size {:#x})",
739                data.len()
740            ));
741        }
742        if i > 0 {
743            bounds[i - 1].1 = e;
744        }
745    }
746    let mut out: Vec<(u64, u64)> = Vec::new();
747    for (pi, (start, end)) in bounds.iter().enumerate() {
748        let lp = pi + 1; // logical pages are 1-based
749        let (obase, _, opage, _) = objs
750            .iter()
751            .find(|(_, _, p, c)| lp >= *p && lp < p + c)
752            .ok_or(format!("LE fixup page {lp} belongs to no object"))?;
753        let lin_base = (*obase as usize)
754            .checked_add((lp - opage) * pagesize)
755            .ok_or(format!("LE fixup page {lp} linear base overflow"))?;
756        let mut p = frt
757            .checked_add(*start)
758            .ok_or("LE fixup record offset overflow")?;
759        let pend = frt
760            .checked_add(*end)
761            .ok_or("LE fixup record end overflow")?;
762        while p < pend {
763            let rec_off = p - frt;
764            let st = *data.get(p).ok_or(format!(
765                "LE fixup record {rec_off:#x} (page {lp}) truncated: missing srctype"
766            ))?;
767            let fl = *data.get(p + 1).ok_or(format!(
768                "LE fixup record {rec_off:#x} (page {lp}) truncated: missing tgtflags"
769            ))?;
770            p += 2;
771            let mut srcs: Vec<u16> = Vec::new();
772            if st & 0x20 != 0 {
773                let cnt = *data.get(p).ok_or(format!(
774                    "LE fixup record {rec_off:#x} (page {lp}) truncated: missing source count"
775                ))?;
776                p += 1;
777                for _ in 0..cnt {
778                    if p + 2 > pend || p + 2 > data.len() {
779                        return Err(format!(
780                            "LE fixup record {rec_off:#x} (page {lp}) source runs past page end"
781                        ));
782                    }
783                    srcs.push(u16::from_le_bytes([data[p], data[p + 1]]));
784                    p += 2;
785                }
786            } else {
787                if p + 2 > pend || p + 2 > data.len() {
788                    return Err(format!(
789                        "LE fixup record {rec_off:#x} (page {lp}) source runs past page end"
790                    ));
791                }
792                srcs.push(u16::from_le_bytes([data[p], data[p + 1]]));
793                p += 2;
794            }
795            let low4 = st & 0x0F;
796            let is_sel = (st == 0x02 || st == 0x22) && fl == 0x00;
797            let is_off = (low4 == 7 || low4 == 8)
798                && (st & 0xD0) == 0
799                && (fl == 0x00 || fl == 0x10);
800            if !is_sel && !is_off {
801                // Novelty, not corruption (see NOVEL_FIXUP_PREFIX): the
802                // tables may belong to a linker dialect with fixup kinds
803                // beyond the proven grammar (e.g. import fixups). The
804                // caller skips the tables loudly instead of failing.
805                return Err(format!(
806                    "{NOVEL_FIXUP_PREFIX}record {rec_off:#x} (page {lp}): srctype=0x{st:02X} tgtflags=0x{fl:02X}"
807                ));
808            }
809            if p + 1 > pend || p + 1 > data.len() {
810                return Err(format!(
811                    "LE fixup record {rec_off:#x} (page {lp}) truncated: missing target object"
812                ));
813            }
814            let obj = data[p] as usize;
815            p += 1;
816            if obj < 1 || obj > objs.len() {
817                return Err(format!(
818                    "LE fixup record {rec_off:#x} (page {lp}) has bad target object {obj}"
819                ));
820            }
821            let wide = fl & 0x10 != 0;
822            let toff: u32 = if is_sel {
823                0
824            } else if wide {
825                if p + 4 > pend || p + 4 > data.len() {
826                    return Err(format!(
827                        "LE fixup record {rec_off:#x} (page {lp}) truncated: missing wide target offset"
828                    ));
829                }
830                let v = u32::from_le_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
831                p += 4;
832                v
833            } else {
834                if p + 2 > pend || p + 2 > data.len() {
835                    return Err(format!(
836                        "LE fixup record {rec_off:#x} (page {lp}) truncated: missing narrow target offset"
837                    ));
838                }
839                let v = u16::from_le_bytes([data[p], data[p + 1]]) as u32;
840                p += 2;
841                v
842            };
843            let target = objs[obj - 1].0.wrapping_add(toff);
844            for s in srcs {
845                // Source offsets are page-relative, but a patched dword may
846                // straddle a page boundary: wlink files the record under the
847                // page holding the dword's final byte with the offset wrapped.
848                let loc = if (s as usize) < pagesize {
849                    lin_base.checked_add(s as usize)
850                } else if s >= 0xFFFD {
851                    lin_base
852                        .checked_add(s as usize)
853                        .and_then(|l| l.checked_sub(0x1_0000))
854                } else {
855                    None
856                };
857                let loc = loc.ok_or(format!(
858                    "LE fixup record {rec_off:#x} (page {lp}) bad source offset {s:#x} (pagesize {pagesize:#x})"
859                ))?;
860                let pre = pre_of(loc as u64).ok_or(format!(
861                    "LE fixup record {rec_off:#x} (page {lp}) location {loc:#x} outside image"
862                ))?;
863                if is_sel {
864                    continue; // deferred: no flat-model value (see field docs)
865                }
866                if low4 == 7 {
867                    out.push((pre as u64, target as u64));
868                }
869                // 08 self-relative: validated, not stored (a relative
870                // offset is not an address).
871            }
872        }
873        if p != pend {
874            return Err(format!(
875                "LE fixup page {lp} consumed to {:#x}, expected end {:#x}",
876                p - frt,
877                pend - frt
878            ));
879        }
880    }
881    Ok(out)
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    /// Minimal hand-built LE: MZ + LE header, one executable object
889    /// (base 0x10000, one 0x1000 page), page map, fixup tables, one data
890    /// page. The page holds `mov eax,[0x10400]; ret` plus three patched
891    /// dwords; the record table holds one narrow 07, one narrow 08, and
892    /// one source-list 07 (wide). Returns the raw file bytes.
893    fn build_fixup_le() -> Vec<u8> {
894        let le_off = 0x40usize;
895        let pagesize = 0x1000usize;
896        let mut data = vec![0u8; 0x1400];
897        data[0..2].copy_from_slice(b"MZ");
898        data[0x3c..0x40].copy_from_slice(&(le_off as u32).to_le_bytes());
899        data[le_off..le_off + 2].copy_from_slice(b"LE");
900        data[le_off + 8..le_off + 10].copy_from_slice(&2u16.to_le_bytes()); // cpu = 386
901        data[le_off + 0x14..le_off + 0x18].copy_from_slice(&1u32.to_le_bytes()); // numpages
902        data[le_off + 0x28..le_off + 0x2C]
903            .copy_from_slice(&(pagesize as u32).to_le_bytes()); // pagesize
904        data[le_off + 0x2C..le_off + 0x30]
905            .copy_from_slice(&(pagesize as u32).to_le_bytes()); // lastpagesize
906        data[le_off + 0x40..le_off + 0x44].copy_from_slice(&0x1C0u32.to_le_bytes()); // objtab_rel
907        data[le_off + 0x44..le_off + 0x48].copy_from_slice(&1u32.to_le_bytes()); // objcnt
908        data[le_off + 0x48..le_off + 0x4C].copy_from_slice(&0x1D8u32.to_le_bytes()); // pagemap_rel
909        data[le_off + 0x4C..le_off + 0x50].copy_from_slice(&0u32.to_le_bytes()); // itermap
910        data[le_off + 0x68..le_off + 0x6C].copy_from_slice(&0x2C0u32.to_le_bytes()); // fpt_rel
911        data[le_off + 0x6C..le_off + 0x70].copy_from_slice(&0x2C8u32.to_le_bytes()); // frt_rel
912        data[le_off + 0x80..le_off + 0x84].copy_from_slice(&0x400u32.to_le_bytes()); // datapage_abs
913        // Object table: vsize, base, flags(exec), page_idx, page_cnt, 12 reserved.
914        let o = 0x200usize;
915        data[o..o + 4].copy_from_slice(&0x1000u32.to_le_bytes());
916        data[o + 4..o + 8].copy_from_slice(&0x10000u32.to_le_bytes());
917        data[o + 8..o + 12].copy_from_slice(&0x4u32.to_le_bytes());
918        data[o + 12..o + 16].copy_from_slice(&1u32.to_le_bytes());
919        data[o + 16..o + 20].copy_from_slice(&1u32.to_le_bytes());
920        // Page map: physical page 1, flags 0.
921        data[0x218..0x21C].copy_from_slice(&[0x00, 0x00, 0x01, 0x00]);
922        // Fixup page table: page 1 owns [0..26).
923        data[0x300..0x304].copy_from_slice(&0u32.to_le_bytes());
924        data[0x304..0x308].copy_from_slice(&26u32.to_le_bytes());
925        // Record 1 (7 bytes @0x308): 07 narrow absolute, src 0x10, obj 1,
926        // toff 0x500 → loc 0x10010 (pre 0x10400), post 0x10500.
927        data[0x308..0x30F].copy_from_slice(&[0x07, 0x00, 0x10, 0x00, 0x01, 0x00, 0x05]);
928        // Record 2 (7 bytes @0x30F): 08 narrow self-relative, src 0x21
929        // (CALL rel32 at 0x20), obj 1, toff 0x100 → validated only, no pair.
930        data[0x30F..0x316].copy_from_slice(&[0x08, 0x00, 0x21, 0x00, 0x01, 0x00, 0x01]);
931        // Record 3 (12 bytes @0x316): 07 wide absolute, source-list
932        // [0x30, 0x34], obj 1, toff 0x600 → locs 0x10030/0x10034
933        // (pres 0x10410/0x10420), post 0x10600.
934        data[0x316..0x322].copy_from_slice(&[
935            0x27, 0x10, 0x02, 0x30, 0x00, 0x34, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00,
936        ]);
937        // Data page: `mov eax,[0x10400]; ret`, patched dwords, CALL stub.
938        let pg = 0x400usize;
939        data[pg..pg + 6].copy_from_slice(&[0xA1, 0x00, 0x04, 0x01, 0x00, 0xC3]);
940        data[pg + 0x10..pg + 0x14].copy_from_slice(&0x10400u32.to_le_bytes());
941        data[pg + 0x20..pg + 0x25].copy_from_slice(&[0xE8, 0x00, 0x00, 0x00, 0x00]);
942        data[pg + 0x30..pg + 0x34].copy_from_slice(&0x10410u32.to_le_bytes());
943        data[pg + 0x34..pg + 0x38].copy_from_slice(&0x10420u32.to_le_bytes());
944        data
945    }
946
947    #[test]
948    fn le_fixup_07_pairs_absorbed() {
949        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
950        assert_eq!(pe.fixup_count(), 3, "one narrow 07 + two listed 07 dwords");
951        let got = pe.fixup_post_images(&[0x10400].into_iter().collect());
952        assert_eq!(got, [0x10500].into_iter().collect());
953        let got = pe.fixup_post_images(&[0x10410, 0x10420].into_iter().collect());
954        assert_eq!(got, [0x10600].into_iter().collect());
955        // Unknown pres map to nothing; 08 records contribute no pairs.
956        let got = pe.fixup_post_images(&[0x99999].into_iter().collect());
957        assert!(got.is_empty());
958    }
959
960    #[test]
961    fn le_fixup_post_global_passes_spec() {
962        use crate::{analyze, Spec};
963        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
964        let cg = analyze(&pe, 0x10000).expect("analyze synthetic fn");
965        assert!(cg.globals.contains(&0x10400), "{cg:?}");
966        // Pre-fixup value: always accepted.
967        Spec::new(0x10000).globals(&[0x10400]).check(&pe).expect("pre global");
968        // Post-fixup image of the referenced pre value: accepted (the fix).
969        Spec::new(0x10000).globals(&[0x10500]).check(&pe).expect("post global");
970        // An address referenced nowhere, pre or post: still rejected.
971        assert!(Spec::new(0x10000).globals(&[0x10999]).check(&pe).is_err());
972    }
973
974    #[test]
975    fn le_referenced_addrs_include_fixup_post_images() {
976        let pe = Pe::parse(build_fixup_le()).expect("parse synthetic LE");
977        let refs = pe.referenced_addrs();
978        // Link-time values physically present in the image (instruction
979        // operand + stored data dwords).
980        assert!(refs.contains(&0x10400), "pre operand ref missing: {refs:x?}");
981        assert!(refs.contains(&0x10410), "pre stored dword ref missing");
982        assert!(refs.contains(&0x10420), "pre stored dword ref missing");
983        // Runtime images of those values (what a ported body bakes) must also
984        // count as referenced, or ORIGIN-UNREFERENCED rejects every real
985        // slot-seeded callback whose slot holds a link-time pointer.
986        assert!(refs.contains(&0x10500), "post image of 0x10400 missing");
987        assert!(
988            refs.contains(&0x10600),
989            "post image of 0x10410/0x10420 missing"
990        );
991        // Nothing invented: an address referenced neither pre nor post stays out.
992        assert!(
993            !refs.contains(&0x10999),
994            "garbage address must stay unreferenced"
995        );
996    }
997
998    #[test]
999    fn le_fixup_unknown_record_skips_loudly() {
1000        use super::FixupStatus;
1001        let mut data = build_fixup_le();
1002        data[0x308] = 0x09; // bogus srctype on record 1
1003        // Novelty skips the tables (image stays pre-fixup) — it must NOT
1004        // fail the parse, and the reason must be recorded, not silent.
1005        let pe = Pe::parse(data).expect("novel fixup kind must skip, not fail");
1006        assert_eq!(pe.fixup_count(), 0);
1007        match pe.fixup_status() {
1008            FixupStatus::Skipped { reason } => {
1009                assert!(reason.contains("srctype=0x09"), "{reason}")
1010            }
1011            other => panic!("expected Skipped, got {other:?}"),
1012        }
1013    }
1014
1015    #[test]
1016    fn le_fixup_tables_must_be_adjacent() {
1017        let mut data = build_fixup_le();
1018        data[0x40 + 0x68..0x40 + 0x6C].copy_from_slice(&0x2C4u32.to_le_bytes());
1019        match Pe::parse(data) {
1020            Ok(_) => panic!("moved page table must fail"),
1021            Err(err) => assert!(err.contains("not adjacent"), "{err}"),
1022        }
1023    }
1024}