Skip to main content

exec_pe_core/
parser.rs

1//! Core PE parser: `parse_pe(&[u8]) -> Result<PeFile, PeError>`.
2
3use std::path::Path;
4
5use crate::error::PeError;
6use crate::rich_header::RichHeader;
7
8/// All forensically-relevant fields extracted from a PE binary.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct PeFile {
11    // ── COFF header ──────────────────────────────────────────────────────────
12    /// COFF machine type (0x8664 AMD64, 0x014C x86, 0xAA64 ARM64).
13    pub machine: u16,
14    /// COFF compile timestamp (Unix seconds; note: frequently zeroed or faked).
15    pub compile_timestamp: u32,
16    /// True when IMAGE_FILE_DLL characteristic is set.
17    pub is_dll: bool,
18    /// True when IMAGE_FILE_EXECUTABLE_IMAGE characteristic is set.
19    pub is_exe: bool,
20
21    // ── Optional header ───────────────────────────────────────────────────────
22    /// Entry-point as an RVA (relative virtual address from image base).
23    /// Zero for DLLs with no explicit entry point.
24    pub entry_point_rva: u32,
25    /// Preferred load address for the image.
26    pub image_base: u64,
27    /// Optional header `CheckSum` field (0 = not set; OS drivers must have a valid checksum).
28    pub checksum: u32,
29
30    // ── Data-directory presence flags ─────────────────────────────────────────
31    /// True when the CLR runtime header (`directory[14]`) is present — the binary is a .NET assembly.
32    pub is_dotnet: bool,
33    /// Number of TLS callback functions registered.  > 0 means code runs before the entry point.
34    pub tls_callback_count: usize,
35    /// True when a base-relocation table is present.
36    pub has_reloc: bool,
37    /// True when a non-empty Authenticode certificate table is present.
38    pub is_signed: bool,
39
40    // ── Debug information ─────────────────────────────────────────────────────
41    /// PDB file path embedded in the CodeView debug directory entry.
42    /// Contains the full build-machine path, e.g. `C:\Users\attacker\Desktop\payload.pdb`.
43    pub pdb_path: Option<String>,
44
45    // ── Overlay (data after last section) ─────────────────────────────────────
46    /// File offset where overlay data begins (byte immediately after the last section's raw data).
47    pub overlay_offset: Option<u64>,
48    /// Size of the overlay in bytes.
49    pub overlay_size: Option<u64>,
50
51    // ── Rich header (compiler fingerprint) ───────────────────────────────────
52    /// Decoded Rich header, if present.  `None` means the header was absent or
53    /// deliberately stripped — a potential anti-attribution indicator on large files.
54    pub rich_header: Option<RichHeader>,
55
56    // ── Import / export / section tables ─────────────────────────────────────
57    /// Flat list of imported symbol names from all import descriptors.
58    pub imports: Vec<String>,
59    /// Exported symbol names (populated for DLLs).
60    pub exports: Vec<String>,
61    /// Section table with per-section attributes and entropy.
62    pub sections: Vec<PeSection>,
63
64    // ── String extraction ─────────────────────────────────────────────────────
65    /// ASCII strings (≥ 6 printable chars) extracted from all raw data.
66    pub ascii_strings: Vec<String>,
67    /// UTF-16LE strings (≥ 6 printable chars) extracted from all raw data.
68    pub utf16_strings: Vec<String>,
69
70    // ── File-level metadata ───────────────────────────────────────────────────
71    /// SHA-256 hash of the full binary (hex-encoded).
72    pub sha256: String,
73    /// Size of the binary in bytes.
74    pub size: usize,
75}
76
77impl PeFile {
78    /// Combined string table: all ASCII and UTF-16 strings.
79    pub fn all_strings(&self) -> impl Iterator<Item = &str> {
80        self.ascii_strings
81            .iter()
82            .chain(self.utf16_strings.iter())
83            .map(String::as_str)
84    }
85}
86
87/// A single PE section with computed Shannon entropy.
88#[derive(Debug, Clone, serde::Serialize)]
89pub struct PeSection {
90    /// Section name (up to 8 bytes, null-terminated, lossy UTF-8).
91    pub name: String,
92    /// Virtual size in bytes as reported in the section header.
93    pub virtual_size: u32,
94    /// Size of raw data on disk (may be 0 for BSS-style sections).
95    pub raw_size: u32,
96    /// Virtual address (RVA) relative to the image base.
97    pub virtual_address: u32,
98    /// Shannon entropy of the raw section data (0.0 – 8.0).
99    pub entropy: f32,
100    /// True when IMAGE_SCN_MEM_EXECUTE (0x2000_0000) is set.
101    pub is_executable: bool,
102    /// True when IMAGE_SCN_MEM_WRITE (0x8000_0000) is set.
103    pub is_writable: bool,
104    /// True when IMAGE_SCN_MEM_READ (0x4000_0000) is set.
105    pub is_readable: bool,
106}
107
108/// Parse a PE binary from raw bytes.
109///
110/// Returns [`PeError::NotPe`] for non-PE inputs (empty, wrong magic, truncated header).
111/// Returns [`PeError::Structure`] for PEs that pass the magic check but are malformed.
112pub fn parse_pe(bytes: &[u8]) -> Result<PeFile, PeError> {
113    use forensicnomicon::heuristics::pe::MZ_MAGIC;
114    use goblin::pe::PE;
115    use sha2::{Digest, Sha256};
116
117    if bytes.len() < 2 || bytes[0..2] != MZ_MAGIC {
118        return Err(PeError::NotPe);
119    }
120
121    let pe = PE::parse(bytes).map_err(|e| PeError::Structure(e.to_string()))?;
122
123    let machine = pe.header.coff_header.machine;
124    let compile_timestamp = pe.header.coff_header.time_date_stamp;
125    let characteristics = pe.header.coff_header.characteristics;
126    let is_dll = characteristics & 0x2000 != 0;
127    let is_exe = characteristics & 0x0002 != 0;
128
129    let imports: Vec<String> = pe.imports.iter().map(|i| i.name.to_string()).collect();
130    let exports: Vec<String> = pe
131        .exports
132        .iter()
133        .filter_map(|e| e.name.map(str::to_string))
134        .collect();
135
136    let sections = pe
137        .sections
138        .iter()
139        .map(|sec| {
140            let name = String::from_utf8_lossy(&sec.name)
141                .trim_end_matches('\0')
142                .to_string();
143            let offset = sec.pointer_to_raw_data as usize;
144            let raw_size = sec.size_of_raw_data;
145            let data = bytes
146                .get(offset..offset.saturating_add(raw_size as usize))
147                .unwrap_or(&[]);
148            let entropy = crate::strings::compute_entropy(data);
149            PeSection {
150                name,
151                virtual_size: sec.virtual_size,
152                raw_size,
153                virtual_address: sec.virtual_address,
154                entropy,
155                is_executable: sec.characteristics & 0x2000_0000 != 0,
156                is_writable: sec.characteristics & 0x8000_0000 != 0,
157                is_readable: sec.characteristics & 0x4000_0000 != 0,
158            }
159        })
160        .collect();
161
162    let ascii_strings = crate::strings::extract_ascii(bytes, crate::strings::MIN_STRING_LEN);
163    let utf16_strings = crate::strings::extract_utf16le(bytes, crate::strings::MIN_STRING_LEN);
164
165    let sha256 = {
166        let mut hasher = Sha256::new();
167        hasher.update(bytes);
168        hex::encode(hasher.finalize())
169    };
170
171    // ── Optional header fields ────────────────────────────────────────────────
172    let (entry_point_rva, image_base, checksum) = if let Some(oh) = pe.header.optional_header {
173        (
174            oh.standard_fields.address_of_entry_point,
175            oh.windows_fields.image_base,
176            oh.windows_fields.check_sum,
177        )
178    } else {
179        (0, 0, 0)
180    };
181
182    // ── Data directory presence ───────────────────────────────────────────────
183    let is_dotnet = pe.clr_data.is_some();
184    let tls_callback_count = pe.tls_data.as_ref().map_or(0, |t| t.callbacks.len());
185    let has_reloc = pe.relocation_data.is_some();
186    let is_signed = !pe.certificates.is_empty();
187
188    // ── PDB path from CodeView debug directory ────────────────────────────────
189    let pdb_path = pe.debug_data.as_ref().and_then(|d| {
190        d.codeview_pdb70_debug_info.map(|cv| {
191            String::from_utf8_lossy(cv.filename)
192                .trim_end_matches('\0')
193                .to_string()
194        })
195    });
196
197    // ── Overlay detection ─────────────────────────────────────────────────────
198    let last_section_end: u64 = pe
199        .sections
200        .iter()
201        .filter(|s| s.size_of_raw_data > 0)
202        .map(|s| u64::from(s.pointer_to_raw_data) + u64::from(s.size_of_raw_data))
203        .max()
204        .unwrap_or(0);
205    let file_size = bytes.len() as u64;
206    let (overlay_offset, overlay_size) = if last_section_end > 0 && file_size > last_section_end {
207        (Some(last_section_end), Some(file_size - last_section_end))
208    } else {
209        (None, None)
210    };
211
212    // ── Rich header ───────────────────────────────────────────────────────────
213    let rich_header = crate::rich_header::parse_rich_header(bytes);
214
215    Ok(PeFile {
216        machine,
217        compile_timestamp,
218        is_dll,
219        is_exe,
220        entry_point_rva,
221        image_base,
222        checksum,
223        is_dotnet,
224        tls_callback_count,
225        has_reloc,
226        is_signed,
227        pdb_path,
228        overlay_offset,
229        overlay_size,
230        rich_header,
231        imports,
232        exports,
233        sections,
234        ascii_strings,
235        utf16_strings,
236        sha256,
237        size: bytes.len(),
238    })
239}
240
241/// Parse a PE binary from a file path.
242///
243/// Reads the entire file into memory then calls [`parse_pe`].
244pub fn parse_pe_path(path: &Path) -> Result<PeFile, PeError> {
245    let bytes = std::fs::read(path)?;
246    parse_pe(&bytes)
247}
248
249#[cfg(test)]
250pub(crate) mod test_helpers {
251    /// Build a minimal valid PE32+ (x64, 0 sections, no imports) for unit tests.
252    ///
253    /// Layout: DOS header (64 B) + PE sig (4 B) + COFF header (20 B) +
254    ///         Optional header PE32+ (240 B) = 328 B, padded to 512 B.
255    pub fn make_minimal_pe_x64(timestamp: u32, is_dll: bool) -> Vec<u8> {
256        let mut pe = vec![0u8; 512];
257
258        // DOS header
259        pe[0] = b'M';
260        pe[1] = b'Z';
261        pe[0x3C] = 0x40; // e_lfanew = 64
262
263        // PE signature at 0x40
264        pe[0x40] = b'P';
265        pe[0x41] = b'E';
266
267        // COFF header at 0x44 (20 bytes)
268        pe[0x44] = 0x64;
269        pe[0x45] = 0x86; // Machine = AMD64
270        pe[0x48..0x4C].copy_from_slice(&timestamp.to_le_bytes()); // TimeDateStamp
271        pe[0x54] = 0xF0; // SizeOfOptionalHeader = 240
272                         // Characteristics: IMAGE_FILE_EXECUTABLE_IMAGE = 0x0002,
273                         // IMAGE_FILE_LARGE_ADDRESS_AWARE = 0x0020, IMAGE_FILE_DLL = 0x2000
274        if is_dll {
275            let chars: u16 = 0x2022; // DLL | EXECUTABLE | LARGE_ADDRESS_AWARE
276            pe[0x56..0x58].copy_from_slice(&chars.to_le_bytes());
277        } else {
278            let chars: u16 = 0x0022;
279            pe[0x56..0x58].copy_from_slice(&chars.to_le_bytes());
280        }
281
282        // Optional header (PE32+) at 0x58 (240 bytes)
283        pe[0x58] = 0x0B;
284        pe[0x59] = 0x02; // Magic = PE32+
285                         // ImageBase (u64) at 0x58+24 = 0x70
286        pe[0x70] = 0x00;
287        pe[0x71] = 0x00;
288        pe[0x72] = 0x40; // 0x400000
289                         // SectionAlignment at 0x78
290        pe[0x78] = 0x00;
291        pe[0x79] = 0x10; // 0x1000
292                         // FileAlignment at 0x7C
293        pe[0x7C] = 0x00;
294        pe[0x7D] = 0x02; // 0x200
295                         // MajorSubsystemVersion at 0x88
296        pe[0x88] = 0x06;
297        // SizeOfImage at 0x90
298        pe[0x90] = 0x00;
299        pe[0x91] = 0x10; // 0x1000
300                         // SizeOfHeaders at 0x94
301        pe[0x94] = 0x00;
302        pe[0x95] = 0x02; // 0x200
303                         // Subsystem at 0x9C: 2 = GUI
304        pe[0x9C] = 0x02;
305        // SizeOfStackReserve at 0xA0
306        pe[0xA0] = 0x00;
307        pe[0xA1] = 0x00;
308        pe[0xA2] = 0x10; // 0x100000
309                         // SizeOfStackCommit at 0xA8
310        pe[0xA8] = 0x00;
311        pe[0xA9] = 0x10; // 0x1000
312                         // SizeOfHeapReserve at 0xB0
313        pe[0xB0] = 0x00;
314        pe[0xB1] = 0x00;
315        pe[0xB2] = 0x10; // 0x100000
316                         // SizeOfHeapCommit at 0xB8
317        pe[0xB8] = 0x00;
318        pe[0xB9] = 0x10; // 0x1000
319                         // NumberOfRvaAndSizes at 0xC4
320        pe[0xC4] = 0x10; // 16 data directories
321
322        pe
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use test_helpers::make_minimal_pe_x64;
330
331    // ── rejection tests ───────────────────────────────────────────────────────
332
333    #[test]
334    fn rejects_empty_slice() {
335        assert!(matches!(parse_pe(&[]), Err(PeError::NotPe)));
336    }
337
338    #[test]
339    fn rejects_single_byte() {
340        assert!(matches!(parse_pe(&[0x4D]), Err(PeError::NotPe)));
341    }
342
343    #[test]
344    fn rejects_random_bytes() {
345        assert!(parse_pe(b"this is not a PE file at all").is_err());
346    }
347
348    #[test]
349    fn rejects_elf_magic() {
350        let elf = [0x7F, b'E', b'L', b'F', 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
351        assert!(parse_pe(&elf).is_err());
352    }
353
354    #[test]
355    fn rejects_truncated_mz() {
356        assert!(parse_pe(b"MZ").is_err());
357    }
358
359    #[test]
360    fn rejects_mz_with_no_pe_sig() {
361        let mut buf = vec![0u8; 64];
362        buf[0] = b'M';
363        buf[1] = b'Z';
364        buf[0x3C] = 0x40; // e_lfanew points beyond buffer
365        assert!(parse_pe(&buf).is_err());
366    }
367
368    // ── successful parse tests ────────────────────────────────────────────────
369
370    #[test]
371    fn accepts_minimal_x64() {
372        let bytes = make_minimal_pe_x64(0, false);
373        assert!(
374            parse_pe(&bytes).is_ok(),
375            "minimal PE32+ must parse successfully"
376        );
377    }
378
379    #[test]
380    fn extracts_machine_amd64() {
381        let bytes = make_minimal_pe_x64(0, false);
382        let pe = parse_pe(&bytes).expect("minimal PE");
383        assert_eq!(pe.machine, 0x8664);
384    }
385
386    #[test]
387    fn extracts_compile_timestamp() {
388        let ts = 0x5F00_ABCD_u32;
389        let bytes = make_minimal_pe_x64(ts, false);
390        let pe = parse_pe(&bytes).expect("minimal PE");
391        assert_eq!(pe.compile_timestamp, ts);
392    }
393
394    #[test]
395    fn exe_is_not_dll() {
396        let bytes = make_minimal_pe_x64(0, false);
397        let pe = parse_pe(&bytes).expect("minimal PE");
398        assert!(!pe.is_dll);
399        assert!(pe.is_exe);
400    }
401
402    #[test]
403    fn dll_flag_detected() {
404        let bytes = make_minimal_pe_x64(0, true);
405        let pe = parse_pe(&bytes).expect("minimal DLL PE");
406        assert!(pe.is_dll);
407    }
408
409    #[test]
410    fn minimal_pe_has_no_imports() {
411        let bytes = make_minimal_pe_x64(0, false);
412        let pe = parse_pe(&bytes).expect("minimal PE");
413        assert!(pe.imports.is_empty());
414    }
415
416    #[test]
417    fn minimal_pe_has_no_sections() {
418        let bytes = make_minimal_pe_x64(0, false);
419        let pe = parse_pe(&bytes).expect("minimal PE");
420        assert!(pe.sections.is_empty());
421    }
422
423    #[test]
424    fn populates_sha256() {
425        let bytes = make_minimal_pe_x64(0, false);
426        let pe = parse_pe(&bytes).expect("minimal PE");
427        assert_eq!(pe.sha256.len(), 64, "SHA-256 hex string is 64 chars");
428        assert!(pe.sha256.chars().all(|c| c.is_ascii_hexdigit()));
429    }
430
431    #[test]
432    fn populates_size() {
433        let bytes = make_minimal_pe_x64(0, false);
434        let expected_size = bytes.len();
435        let pe = parse_pe(&bytes).expect("minimal PE");
436        assert_eq!(pe.size, expected_size);
437    }
438
439    // ── parse_pe_path tests ───────────────────────────────────────────────────
440
441    #[test]
442    fn parse_pe_path_nonexistent_returns_io_error() {
443        let result = parse_pe_path(Path::new("/nonexistent/rbcw.exe"));
444        assert!(result.is_err());
445    }
446
447    #[test]
448    fn parse_pe_path_non_pe_file_returns_not_pe() {
449        use std::io::Write;
450        let mut tmp = tempfile::NamedTempFile::new().expect("tmp file");
451        tmp.write_all(b"this is plain text, not a PE")
452            .expect("write");
453        let result = parse_pe_path(tmp.path());
454        assert!(result.is_err());
455    }
456
457    // ── new field extraction tests (RED: placeholder values fail) ─────────────
458
459    #[test]
460    fn image_base_extracted_from_optional_header() {
461        let bytes = make_minimal_pe_x64(0, false);
462        let pe = parse_pe(&bytes).expect("minimal PE");
463        // make_minimal_pe_x64 sets ImageBase = 0x400000 at offset 0x70.
464        assert_eq!(
465            pe.image_base, 0x0040_0000,
466            "image_base must be extracted from optional header"
467        );
468    }
469
470    #[test]
471    fn entry_point_rva_is_zero_for_minimal_pe() {
472        let bytes = make_minimal_pe_x64(0, false);
473        let pe = parse_pe(&bytes).expect("minimal PE");
474        assert_eq!(pe.entry_point_rva, 0, "minimal PE has no entry point");
475    }
476
477    #[test]
478    fn minimal_pe_has_no_dotnet() {
479        let bytes = make_minimal_pe_x64(0, false);
480        let pe = parse_pe(&bytes).expect("minimal PE");
481        assert!(!pe.is_dotnet);
482    }
483
484    #[test]
485    fn minimal_pe_has_zero_tls_callbacks() {
486        let bytes = make_minimal_pe_x64(0, false);
487        let pe = parse_pe(&bytes).expect("minimal PE");
488        assert_eq!(pe.tls_callback_count, 0);
489    }
490
491    #[test]
492    fn minimal_pe_has_no_reloc() {
493        let bytes = make_minimal_pe_x64(0, false);
494        let pe = parse_pe(&bytes).expect("minimal PE");
495        assert!(!pe.has_reloc);
496    }
497
498    #[test]
499    fn minimal_pe_is_unsigned() {
500        let bytes = make_minimal_pe_x64(0, false);
501        let pe = parse_pe(&bytes).expect("minimal PE");
502        assert!(!pe.is_signed);
503    }
504
505    #[test]
506    fn minimal_pe_has_no_pdb_path() {
507        let bytes = make_minimal_pe_x64(0, false);
508        let pe = parse_pe(&bytes).expect("minimal PE");
509        assert!(pe.pdb_path.is_none());
510    }
511
512    #[test]
513    fn minimal_pe_has_no_overlay() {
514        let bytes = make_minimal_pe_x64(0, false);
515        let pe = parse_pe(&bytes).expect("minimal PE");
516        assert!(pe.overlay_offset.is_none());
517        assert!(pe.overlay_size.is_none());
518    }
519
520    #[test]
521    fn minimal_pe_has_no_rich_header() {
522        // Our minimal test PE has no DOS stub code, so no Rich header.
523        let bytes = make_minimal_pe_x64(0, false);
524        let pe = parse_pe(&bytes).expect("minimal PE");
525        assert!(pe.rich_header.is_none());
526    }
527
528    // ── section / overlay / debug / no-optional-header extraction ──────────────
529
530    /// Write a 40-byte section header at the section table (0x148, immediately
531    /// after the 240-byte PE32+ optional header) into a minimal PE.
532    fn write_section(
533        pe: &mut [u8],
534        name: &[u8],
535        virtual_size: u32,
536        virtual_address: u32,
537        raw_size: u32,
538        raw_ptr: u32,
539        characteristics: u32,
540    ) {
541        let sh = 0x148;
542        pe[sh..sh + name.len()].copy_from_slice(name);
543        pe[sh + 0x08..sh + 0x0C].copy_from_slice(&virtual_size.to_le_bytes());
544        pe[sh + 0x0C..sh + 0x10].copy_from_slice(&virtual_address.to_le_bytes());
545        pe[sh + 0x10..sh + 0x14].copy_from_slice(&raw_size.to_le_bytes());
546        pe[sh + 0x14..sh + 0x18].copy_from_slice(&raw_ptr.to_le_bytes());
547        pe[sh + 0x24..sh + 0x28].copy_from_slice(&characteristics.to_le_bytes());
548    }
549
550    #[test]
551    fn section_fields_and_overlay_extracted() {
552        // One executable section [raw 0x200..0x300) plus 16 trailing overlay bytes.
553        let mut pe = make_minimal_pe_x64(0, false);
554        pe[0x46] = 1; // NumberOfSections = 1
555        pe[0x90] = 0x00;
556        pe[0x91] = 0x20; // SizeOfImage = 0x2000
557        write_section(&mut pe, b".text", 0x100, 0x1000, 0x100, 0x200, 0x6000_0020);
558        pe.resize(0x300, 0); // section raw data
559        pe.extend_from_slice(&[0xAA; 0x10]); // overlay
560
561        let parsed = parse_pe(&pe).expect("section-bearing PE");
562        assert_eq!(parsed.sections.len(), 1);
563        let sec = &parsed.sections[0];
564        assert_eq!(sec.name, ".text");
565        assert_eq!(sec.virtual_address, 0x1000);
566        assert_eq!(sec.raw_size, 0x100);
567        assert!(sec.is_executable);
568        assert!(sec.is_readable);
569        assert!(!sec.is_writable);
570        // Overlay begins right after the last section's raw data.
571        assert_eq!(parsed.overlay_offset, Some(0x300));
572        assert_eq!(parsed.overlay_size, Some(0x10));
573    }
574
575    #[test]
576    fn pe_without_optional_header_defaults_entry_base_checksum_to_zero() {
577        // A PE with SizeOfOptionalHeader = 0 parses without an optional header;
578        // entry-point / image-base / checksum then fall back to zero.
579        let mut pe = make_minimal_pe_x64(0, false);
580        pe[0x54] = 0;
581        pe[0x55] = 0; // SizeOfOptionalHeader = 0
582        for b in &mut pe[0x58..0x148] {
583            *b = 0;
584        }
585        let parsed = parse_pe(&pe).expect("PE with no optional header");
586        assert_eq!(parsed.entry_point_rva, 0);
587        assert_eq!(parsed.image_base, 0);
588        assert_eq!(parsed.checksum, 0);
589    }
590
591    #[test]
592    fn pdb_path_extracted_from_codeview_debug_directory() {
593        // Build a .rdata section holding an IMAGE_DEBUG_DIRECTORY (Type=CODEVIEW)
594        // that points at an RSDS CV_INFO_PDB70 record carrying the PDB path.
595        let mut pe = make_minimal_pe_x64(0, false);
596        pe[0x46] = 1;
597        pe[0x90] = 0x00;
598        pe[0x91] = 0x20; // SizeOfImage = 0x2000
599        write_section(&mut pe, b".rdata", 0x200, 0x1000, 0x200, 0x200, 0x4000_0040);
600        // Data directory[6] (DEBUG): RVA 0x1000, size 28 (one debug dir entry).
601        let dd = 0xC8 + 6 * 8;
602        pe[dd..dd + 4].copy_from_slice(&0x1000u32.to_le_bytes());
603        pe[dd + 4..dd + 8].copy_from_slice(&28u32.to_le_bytes());
604        pe.resize(0x400, 0);
605
606        // IMAGE_DEBUG_DIRECTORY at file 0x200 (RVA 0x1000).
607        let d = 0x200;
608        pe[d + 12..d + 16].copy_from_slice(&2u32.to_le_bytes()); // Type = CODEVIEW
609        let cv_rva = 0x1000u32 + 28;
610        let cv_ptr = 0x200u32 + 28;
611        pe[d + 16..d + 20].copy_from_slice(&64u32.to_le_bytes()); // SizeOfData (covers full path)
612        pe[d + 20..d + 24].copy_from_slice(&cv_rva.to_le_bytes()); // AddressOfRawData
613        pe[d + 24..d + 28].copy_from_slice(&cv_ptr.to_le_bytes()); // PointerToRawData
614
615        // CV_INFO_PDB70 at file 0x21C: "RSDS" + GUID(16) + Age(4) + NUL-term path.
616        let c = 0x21C;
617        pe[c..c + 4].copy_from_slice(&0x5344_5352u32.to_le_bytes()); // "RSDS"
618        for (i, b) in pe[c + 4..c + 20].iter_mut().enumerate() {
619            *b = i as u8; // GUID bytes
620        }
621        pe[c + 20..c + 24].copy_from_slice(&1u32.to_le_bytes()); // Age
622        let name = b"C:\\build\\payload.pdb\0";
623        pe[c + 24..c + 24 + name.len()].copy_from_slice(name);
624
625        let parsed = parse_pe(&pe).expect("PE with CodeView debug dir");
626        assert_eq!(parsed.pdb_path.as_deref(), Some("C:\\build\\payload.pdb"));
627    }
628}