Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@
All notable changes to OffsetScan are documented in this file.
The project follows semantic versioning.

## [0.1.4] - 2026-07-21

### Fixed

- **Structurally-damaged PEs are no longer rejected outright.** goblin does strict
full-structure validation, so a truncated or carved sample — headers intact but section
data cut off, common with partial downloads and file-carving — was reported as
`IsPE: false` with every PE field null, losing all triage value. `parse_pe` now falls
back to a lenient header-salvage parse (mirroring OffsetInspect's `ConvertTo-OIPEImage`)
when goblin rejects a file, recovering machine, sections, entry point, overlay, and any
reachable imports/imphash. Valid PEs are unaffected — they always take the goblin path,
verified unchanged across a 150-file corpus vs pefile. Across a 34-file malformed corpus
(truncations, bit-flips, corrupted directories, bogus section counts, pure garbage),
`IsPE` now agrees with OffsetInspect on all 34 (was 22/34), and neither engine crashes or
hangs on any input. Genuinely unparseable inputs (no MZ/PE signature, or a section count
Comment on lines +17 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (typo): Consider correcting "neither engine crashes or hangs" to use "nor" for proper grammar.

Standard usage is "neither … nor …"; consider "neither engine crashes nor hangs on any input" or "no engine crashes or hangs on any input."

Suggested change
verified unchanged across a 150-file corpus vs pefile. Across a 34-file malformed corpus
(truncations, bit-flips, corrupted directories, bogus section counts, pure garbage),
`IsPE` now agrees with OffsetInspect on all 34 (was 22/34), and neither engine crashes or
hangs on any input. Genuinely unparseable inputs (no MZ/PE signature, or a section count
verified unchanged across a 150-file corpus vs pefile. Across a 34-file malformed corpus
(truncations, bit-flips, corrupted directories, bogus section counts, pure garbage),
`IsPE` now agrees with OffsetInspect on all 34 (was 22/34), and neither engine crashes nor
hangs on any input. Genuinely unparseable inputs (no MZ/PE signature, or a section count

that cannot fit) are still rejected, matching OffsetInspect.

### Added

- Lenient-parse unit tests: header-only PE salvage, and rejection of non-PE buffers and
unfittable section counts.

## [0.1.3] - 2026-07-20

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "offsetscan"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
authors = ["DreadHost Research"]
description = "Standalone native corpus-scale engine for PE parsing, entropy, string extraction, and IOC panels — schema-compatible with OffsetInspect.Result/ThreatScanResult JSON output."
Expand Down
292 changes: 292 additions & 0 deletions src/pe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,20 @@ fn strip_lib_ext(dll_lower: &str) -> &str {
dll_lower
}

/// Parse a PE, preferring goblin's strict full-structure validation. If that rejects the
/// file, fall back to a lenient header-salvage parse that mirrors OffsetInspect's
/// `ConvertTo-OIPEImage`: a structurally-damaged sample (e.g. a truncated or carved
/// binary whose headers are intact) still yields machine/sections/imphash, matching
/// OffsetInspect instead of giving up. Valid PEs always take the goblin path, so their
/// output is unchanged.
pub fn parse_pe(data: &[u8], file_path: &str) -> Result<PeInfo, String> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider introducing small shared abstractions (e.g., a PeCoreView, header view, and helpers) so strict and lenient parsing reuse common builders instead of duplicating low-level PE parsing and PeInfo assembly logic.

You can keep the new functionality while cutting a lot of localized complexity with a few small abstractions and shared builders.

1. Centralize PeInfo construction

Both strict and lenient paths manually assemble PeInfo. You can normalize the input to a simple view and share the builder:

struct PeCoreView<'a> {
    file_path: &'a str,
    file_len: u64,
    machine_id: u16,
    is_pe32_plus: bool,
    entry_point_rva: u32,
    image_base: u64,
    sections: Vec<Section>,
    imports: Vec<Import>,
    imp_hash: Option<String>,
    resource_size: u32,
    has_overlay: bool,
    overlay_offset: Option<u64>,
    overlay_size: u64,
}

fn build_pe_info(core: PeCoreView<'_>) -> PeInfo {
    PeInfo {
        file: core.file_path.to_string(),
        file_size: core.file_len,
        machine: machine_name(core.machine_id),
        is_pe32_plus: core.is_pe32_plus,
        entry_point_rva: core.entry_point_rva,
        entry_point_hex: format!("0x{:X}", core.entry_point_rva),
        image_base: core.image_base,
        section_count: core.sections.len() as u32,
        imported_dll_count: core.imports.len() as u32,
        sections: core.sections,
        imports: core.imports,
        imp_hash: core.imp_hash,
        resource_size: core.resource_size,
        has_overlay: core.has_overlay,
        overlay_offset: core.overlay_offset,
        overlay_size: core.overlay_size,
        mapped_offset: None,
        mapped_section: None,
        warnings: Vec::new(),
    }
}

Then parse_pe_strict and parse_pe_lenient only need to produce PeCoreView and call build_pe_info, which removes the duplicated PeInfo assembly and keeps changes to PeInfo localized.

2. Encapsulate header offset math

The lenient path’s magic offsets can be hidden behind a simple header view, making parse_pe_lenient more declarative:

struct LenientHeaderView<'a> {
    data: &'a [u8],
}

impl<'a> LenientHeaderView<'a> {
    fn dos_magic(&self) -> Option<u16> { rd_u16(self.data, 0) }
    fn pe_offset(&self) -> Option<usize> { rd_u32(self.data, 0x3C).map(|o| o as usize) }

    fn coff_base(&self) -> Option<usize> {
        let pe = self.pe_offset()?;
        (self.data.get(pe..pe+4) == Some(&[0x50, 0x45, 0x00, 0x00])).then_some(pe + 4)
    }

    fn machine_id(&self) -> Option<u16> {
        let coff = self.coff_base()?;
        rd_u16(self.data, coff)
    }

    fn section_count(&self) -> Option<u16> {
        let coff = self.coff_base()?;
        rd_u16(self.data, coff + 2)
    }

    fn optional_base(&self) -> Option<usize> {
        let coff = self.coff_base()?;
        Some(coff + 20)
    }

    fn is_pe32_plus(&self) -> Option<bool> {
        let opt = self.optional_base()?;
        rd_u16(self.data, opt).map(|m| m == 0x20B)
    }

    fn entry_point_rva(&self) -> Option<u32> {
        let opt = self.optional_base()?;
        rd_u32(self.data, opt + 16)
    }

    fn image_base(&self) -> Option<u64> {
        let opt = self.optional_base()?;
        match self.is_pe32_plus()? {
            true => rd_u64(self.data, opt + 24),
            false => rd_u32(self.data, opt + 28).map(|v| v as u64),
        }
    }

    fn data_directory(&self, index: usize) -> Option<(u32, u32)> {
        let opt = self.optional_base()?;
        let is_plus = self.is_pe32_plus()?;
        let dd_base = if is_plus { opt + 112 } else { opt + 96 };
        let rva_count = rd_u32(self.data, dd_base.saturating_sub(4))?;
        if (index as u32) >= rva_count {
            return None;
        }
        let off = dd_base + index * 8;
        Some((rd_u32(self.data, off)?, rd_u32(self.data, off + 4)?))
    }

    fn section_table_base(&self) -> Option<usize> {
        let coff = self.coff_base()?;
        let opt_size = rd_u16(self.data, coff + 16)? as usize;
        Some(coff + 20 + opt_size)
    }
}

Then parse_pe_lenient becomes:

fn parse_pe_lenient(data: &[u8], file_path: &str) -> Result<PeInfo, String> {
    let hdr = &data[..data.len().min(0x10000)];
    let hv = LenientHeaderView { data: hdr };

    if hv.dos_magic() != Some(0x5A4D) {
        return Err("Not a PE image: missing MZ (DOS) signature.".into());
    }
    let machine_id = hv.machine_id().ok_or("truncated COFF header")?;
    let section_count = hv.section_count().ok_or("truncated COFF header")?;
    let is_pe32_plus = hv.is_pe32_plus().ok_or("truncated optional header")?;
    let entry_point_rva = hv.entry_point_rva().ok_or("truncated optional header")?;
    let image_base = hv.image_base().ok_or("truncated optional header")?;
    let (import_rva, _) = hv.data_directory(1).unwrap_or((0, 0));
    let (_, resource_size) = hv.data_directory(2).unwrap_or((0, 0));

    let sec_table = hv.section_table_base().ok_or("truncated section table")?;
    // section parsing & imports as below...
}

This still uses the same offsets but hides them in one place, making the lenient path easier to audit and keeping test helpers in sync by reusing LenientHeaderView.

3. Extract a read_section helper

The section loop is manual offset math. A small helper keeps it compact and reusable:

fn read_section(hdr: &[u8], base: usize) -> Option<Section> {
    if base + 40 > hdr.len() {
        return None;
    }
    Some(Section {
        name: read_ascii_cstr(hdr, base, 8),
        virtual_size: rd_u32(hdr, base + 8).unwrap_or(0),
        virtual_address: rd_u32(hdr, base + 12).unwrap_or(0),
        size_of_raw_data: rd_u32(hdr, base + 16).unwrap_or(0),
        pointer_to_raw_data: rd_u32(hdr, base + 20).unwrap_or(0),
    })
}

And in parse_pe_lenient:

let mut sections = Vec::new();
let sec_base = hv.section_table_base().ok_or("truncated section table")?;
for i in 0..section_count as usize {
    let entry_off = sec_base + i * 40;
    let sec = read_section(hdr, entry_off).ok_or(
        "The header buffer is truncated before the section table."
    )?;
    sections.push(sec);
}

4. Split lenient_imports into smaller helpers and isolate imphash

lenient_imports currently handles descriptor walking, library name resolution, thunk reading, and imphash. Small focused helpers make it easier to reason about and test:

struct ImportDescriptor {
    name_rva: u32,
    thunk_rva: u32,
}

fn read_import_descriptors(
    data: &[u8],
    sections: &[Section],
    import_rva: u64,
) -> Vec<ImportDescriptor> {
    let mut descs = Vec::new();
    let desc_off = match rva_to_offset(sections, import_rva) {
        Some(o) => o as usize,
        None => return descs,
    };
    for di in 0..4096usize {
        let base = desc_off + di * 20;
        let oft = match rd_u32(data, base) {
            Some(v) => v,
            None => break,
        };
        let name_rva = rd_u32(data, base + 12).unwrap_or(0);
        let first_thunk = rd_u32(data, base + 16).unwrap_or(0);
        if oft == 0 && name_rva == 0 && first_thunk == 0 {
            break;
        }
        descs.push(ImportDescriptor {
            name_rva,
            thunk_rva: if oft != 0 { oft } else { first_thunk },
        });
    }
    descs
}

fn read_import_library_name(
    data: &[u8],
    sections: &[Section],
    name_rva: u32,
) -> String {
    rva_to_offset(sections, name_rva as u64)
        .map(|o| read_ascii_cstr(data, o as usize, 256))
        .unwrap_or_default()
}

fn read_import_functions(
    data: &[u8],
    sections: &[Section],
    thunk_rva: u32,
    is_pe32_plus: bool,
    lib_lower: &str,
) -> (Vec<String>, Vec<String>) {
    let mut functions = Vec::new();
    let mut imphash_entries = Vec::new();
    let ptr_size = if is_pe32_plus { 8usize } else { 4 };
    let ordinal_flag: u64 = if is_pe32_plus { 0x8000_0000_0000_0000 } else { 0x8000_0000 };

    let to = match rva_to_offset(sections, thunk_rva as u64) {
        Some(o) => o as usize,
        None => return (functions, imphash_entries),
    };
    let lib_base = strip_lib_ext(&lib_lower.to_lowercase()).to_string();

    for ti in 0..100_000usize {
        let toff = to + ti * ptr_size;
        let tv = if is_pe32_plus {
            match rd_u64(data, toff) { Some(v) => v, None => break }
        } else {
            match rd_u32(data, toff) { Some(v) => v as u64, None => break }
        };
        if tv == 0 {
            break;
        }
        let func = if tv & ordinal_flag != 0 {
            let ord = (tv & 0xFFFF) as u16;
            crate::ordinals::special_ordinal_name(lib_lower, ord)
                .map(str::to_string)
                .unwrap_or_else(|| format!("ord{}", ord))
        } else {
            rva_to_offset(sections, tv & 0xFFFF_FFFF)
                .map(|bn| read_ascii_cstr(data, bn as usize + 2, 256))
                .unwrap_or_default()
        };
        if !func.is_empty() {
            imphash_entries.push(format!("{}.{}", lib_base, func.to_lowercase()));
            functions.push(func);
        }
    }
    (functions, imphash_entries)
}

fn compute_imphash(entries: &[String]) -> Option<String> {
    if entries.is_empty() {
        return None;
    }
    let mut hasher = Md5::new();
    hasher.update(entries.join(",").as_bytes());
    Some(format!("{:x}", hasher.finalize()))
}

Then lenient_imports becomes a simple orchestrator, separating data extraction from imphash construction:

fn lenient_imports(
    data: &[u8],
    sections: &[Section],
    import_rva: u64,
    is_pe32_plus: bool,
) -> (Vec<Import>, Option<String>) {
    if import_rva == 0 {
        return (Vec::new(), None);
    }

    let mut imports = Vec::new();
    let mut imphash_entries = Vec::new();

    for desc in read_import_descriptors(data, sections, import_rva) {
        let lib_name = read_import_library_name(data, sections, desc.name_rva);
        let lib_lower = lib_name.to_lowercase();
        let (functions, entry_chunks) =
            read_import_functions(data, sections, desc.thunk_rva, is_pe32_plus, &lib_lower);

        imphash_entries.extend(entry_chunks);
        imports.push(Import { dll: lib_name, functions });
    }

    let imp_hash = compute_imphash(&imphash_entries);
    (imports, imp_hash)
}

This preserves the current behavior (including bounds and loop limits) but makes each piece testable independently and significantly lowers cyclomatic complexity.

5. Reuse header abstractions in tests

minimal_pe32plus_header duplicates magic offsets. Once you have LenientHeaderView, you can:

  • Expose constants for key offsets (DOS_E_LFANEW, PE_SIGNATURE_OFFSET, etc.), or
  • Provide a tiny builder that uses the same layout assumptions:
const DOS_E_LFANEW: usize = 0x3C;
const PE_HEADER_OFFSET: usize = 0x80;
const PE_SIGNATURE: [u8; 4] = [0x50, 0x45, 0x00, 0x00];

fn minimal_pe32plus_header(section_count: u16) -> Vec<u8> {
    let mut b = vec![0u8; 0x200];
    b[0] = 0x4D;
    b[1] = 0x5A; // MZ
    b[DOS_E_LFANEW..DOS_E_LFANEW + 4].copy_from_slice(&(PE_HEADER_OFFSET as u32).to_le_bytes());
    b[PE_HEADER_OFFSET..PE_HEADER_OFFSET + 4].copy_from_slice(&PE_SIGNATURE);
    // rest of fields as before...
    b
}

By centralizing these offsets and using the same constants / layout abstractions in tests and production, you avoid drift and keep header knowledge in one place.

match parse_pe_strict(data, file_path) {
Ok(info) => Ok(info),
Err(_) => parse_pe_lenient(data, file_path),
}
}

fn parse_pe_strict(data: &[u8], file_path: &str) -> Result<PeInfo, String> {
let pe = PE::parse(data).map_err(|e| format!("PE parse error: {e}"))?;

let machine = machine_name(pe.header.coff_header.machine);
Expand Down Expand Up @@ -147,6 +160,240 @@ pub fn parse_pe(data: &[u8], file_path: &str) -> Result<PeInfo, String> {
})
}

fn rd_u16(d: &[u8], o: usize) -> Option<u16> {
d.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]))
}
fn rd_u32(d: &[u8], o: usize) -> Option<u32> {
d.get(o..o + 4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn rd_u64(d: &[u8], o: usize) -> Option<u64> {
d.get(o..o + 8)
.map(|b| u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]))
}

/// Read a NUL-terminated string decoded as ASCII (bytes >= 0x80 become '?'), matching
/// OffsetInspect's `Read-OINullTerminatedAscii` (`[Text.Encoding]::ASCII.GetString`).
fn read_ascii_cstr(d: &[u8], off: usize, max: usize) -> String {
if off >= d.len() {
return String::new();
}
let end = (off + max).min(d.len());
let mut s = String::new();
for &b in &d[off..end] {
if b == 0 {
break;
}
s.push(if b < 0x80 { b as char } else { '?' });
}
s
}

/// Map an RVA to a file offset via the section table (max of virtual/raw size for the
/// span), mirroring `ConvertFrom-OIRvaToOffset`.
fn rva_to_offset(sections: &[Section], rva: u64) -> Option<u64> {
for s in sections {
let va = s.virtual_address as u64;
let span = std::cmp::max(s.virtual_size as u64, s.size_of_raw_data as u64);
if rva >= va && rva < va + span {
return Some(rva - va + s.pointer_to_raw_data as u64);
}
}
None
}

/// Lenient header-salvage parse mirroring OffsetInspect's `ConvertTo-OIPEImage`. Header and
/// section table are read from the first 64 KiB (as OffsetInspect does); import data is read
/// from the full file. Returns Err on the same conditions OffsetInspect throws on (missing
/// MZ/PE signatures, truncated fixed header fields, or a section table not fully present, so
/// a bogus 65535-section count is rejected rather than salvaged).
fn parse_pe_lenient(data: &[u8], file_path: &str) -> Result<PeInfo, String> {
let hdr = &data[..data.len().min(0x10000)];

if rd_u16(hdr, 0) != Some(0x5A4D) {
return Err("Not a PE image: missing MZ (DOS) signature.".into());
}
let pe_off = rd_u32(hdr, 0x3C).ok_or("truncated DOS header")? as usize;
if pe_off == 0 || hdr.get(pe_off..pe_off + 4) != Some(&[0x50, 0x45, 0x00, 0x00]) {
return Err("Not a PE image: missing PE signature.".into());
}

let coff = pe_off + 4;
let machine_id = rd_u16(hdr, coff).ok_or("truncated COFF header")?;
let section_count = rd_u16(hdr, coff + 2).ok_or("truncated COFF header")?;
let opt_hdr_size = rd_u16(hdr, coff + 16).ok_or("truncated COFF header")? as usize;

let opt = coff + 20;
let magic = rd_u16(hdr, opt).ok_or("truncated optional header")?;
let is_pe32_plus = magic == 0x20B;
let entry_point_rva = rd_u32(hdr, opt + 16).ok_or("truncated optional header")?;
let image_base = if is_pe32_plus {
rd_u64(hdr, opt + 24).ok_or("truncated optional header")?
} else {
rd_u32(hdr, opt + 28).ok_or("truncated optional header")? as u64
};

// Data directories: index 1 = import, index 2 = resource. NumberOfRvaAndSizes sits in
// the u32 immediately before the directory array.
let dd_base = if is_pe32_plus { opt + 112 } else { opt + 96 };
let rva_count = rd_u32(hdr, dd_base.saturating_sub(4)).unwrap_or(0);
let read_dd = |index: usize| -> (u32, u32) {
if (index as u32) < rva_count {
let off = dd_base + index * 8;
if let (Some(rva), Some(size)) = (rd_u32(hdr, off), rd_u32(hdr, off + 4)) {
return (rva, size);
}
}
(0, 0)
};
let (import_rva, _) = read_dd(1);
let (_, resource_size) = read_dd(2);

let sec_table = opt + opt_hdr_size;
let mut sections = Vec::new();
for i in 0..section_count as usize {
let e = sec_table + i * 40;
if e + 40 > hdr.len() {
return Err("The header buffer is truncated before the section table.".into());
}
sections.push(Section {
name: read_ascii_cstr(hdr, e, 8),
virtual_size: rd_u32(hdr, e + 8).unwrap_or(0),
virtual_address: rd_u32(hdr, e + 12).unwrap_or(0),
size_of_raw_data: rd_u32(hdr, e + 16).unwrap_or(0),
pointer_to_raw_data: rd_u32(hdr, e + 20).unwrap_or(0),
});
}

let (imports, imp_hash) = lenient_imports(data, &sections, import_rva as u64, is_pe32_plus);

let last_end = sections
.iter()
.filter(|s| s.size_of_raw_data > 0)
.map(|s| s.pointer_to_raw_data as u64 + s.size_of_raw_data as u64)
.max()
.unwrap_or(0);
let file_len = data.len() as u64;
let overlay_size = file_len.saturating_sub(last_end);
let has_overlay = overlay_size > 0;

Ok(PeInfo {
file: file_path.to_string(),
file_size: file_len,
machine: machine_name(machine_id),
is_pe32_plus,
entry_point_rva,
entry_point_hex: format!("0x{:X}", entry_point_rva),
image_base,
section_count: sections.len() as u32,
imported_dll_count: imports.len() as u32,
sections,
imports,
imp_hash,
resource_size,
has_overlay,
overlay_offset: if has_overlay { Some(last_end) } else { None },
overlay_size,
mapped_offset: None,
mapped_section: None,
warnings: Vec::new(),
})
}

/// Best-effort import walk for the lenient path, mirroring `Get-OIPEImport` (including the
/// special-library ordinal resolution). A truncated or unmappable import table yields no
/// imports and a null imphash rather than failing the whole parse.
fn lenient_imports(
data: &[u8],
sections: &[Section],
import_rva: u64,
is_pe32_plus: bool,
) -> (Vec<Import>, Option<String>) {
let mut imports = Vec::new();
let mut imphash_entries: Vec<String> = Vec::new();
if import_rva == 0 {
return (imports, None);
}
let desc_off = match rva_to_offset(sections, import_rva) {
Some(o) => o as usize,
None => return (imports, None),
};
let ptr_size = if is_pe32_plus { 8usize } else { 4 };
let ordinal_flag: u64 = if is_pe32_plus {
0x8000_0000_0000_0000
} else {
0x8000_0000
};

for di in 0..4096usize {
let base = desc_off + di * 20;
let oft = match rd_u32(data, base) {
Some(v) => v,
None => break,
};
let name_rva = rd_u32(data, base + 12).unwrap_or(0);
let first_thunk = rd_u32(data, base + 16).unwrap_or(0);
if oft == 0 && name_rva == 0 && first_thunk == 0 {
break;
}

let lib_name = rva_to_offset(sections, name_rva as u64)
.map(|o| read_ascii_cstr(data, o as usize, 256))
.unwrap_or_default();
let lib_lower = lib_name.to_lowercase();
let lib_base = strip_lib_ext(&lib_lower).to_string();

let mut functions = Vec::new();
let thunk_rva = if oft != 0 { oft } else { first_thunk };
if let Some(to) = rva_to_offset(sections, thunk_rva as u64) {
for ti in 0..100_000usize {
let toff = to as usize + ti * ptr_size;
let tv = if is_pe32_plus {
match rd_u64(data, toff) {
Some(v) => v,
None => break,
}
} else {
match rd_u32(data, toff) {
Some(v) => v as u64,
None => break,
}
};
if tv == 0 {
break;
}
let func = if tv & ordinal_flag != 0 {
let ord = (tv & 0xFFFF) as u16;
crate::ordinals::special_ordinal_name(&lib_lower, ord)
.map(str::to_string)
.unwrap_or_else(|| format!("ord{}", ord))
} else {
rva_to_offset(sections, tv & 0xFFFF_FFFF)
.map(|bn| read_ascii_cstr(data, bn as usize + 2, 256))
.unwrap_or_default()
};
if !func.is_empty() {
imphash_entries.push(format!("{}.{}", lib_base, func.to_lowercase()));
functions.push(func);
}
}
}
imports.push(Import {
dll: lib_name,
functions,
});
}

let imp_hash = if imphash_entries.is_empty() {
None
} else {
let mut hasher = Md5::new();
hasher.update(imphash_entries.join(",").as_bytes());
Some(format!("{:x}", hasher.finalize()))
};
(imports, imp_hash)
}

/// Map a byte offset to the containing PE section name (".text", ...), if any.
/// Wired to the `pe --offset` flag.
pub fn offset_to_section(data: &[u8], offset: u64) -> Result<Option<String>, String> {
Expand Down Expand Up @@ -185,4 +432,49 @@ mod tests {
fn rejects_a_non_pe_buffer() {
assert!(parse_pe(b"this is not a PE image at all", "sample").is_err());
}

/// Build a minimal PE32+ header (MZ + PE + COFF + optional header + one section),
/// no section data. Enough for the lenient parser to salvage.
fn minimal_pe32plus_header(section_count: u16) -> Vec<u8> {
let mut b = vec![0u8; 0x200];
b[0] = 0x4D;
b[1] = 0x5A; // MZ
b[0x3C..0x40].copy_from_slice(&0x80u32.to_le_bytes()); // e_lfanew
b[0x80] = 0x50;
b[0x81] = 0x45; // PE\0\0
b[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes()); // machine x64
b[0x86..0x88].copy_from_slice(&section_count.to_le_bytes());
b[0x94..0x96].copy_from_slice(&0xF0u16.to_le_bytes()); // SizeOfOptionalHeader
b[0x98..0x9A].copy_from_slice(&0x20Bu16.to_le_bytes()); // PE32+ magic
b[0xA8..0xAC].copy_from_slice(&0x1000u32.to_le_bytes()); // AddressOfEntryPoint
b[0xB0..0xB8].copy_from_slice(&0x140000000u64.to_le_bytes()); // ImageBase
let sec = 0x188; // opt(0x98) + SizeOfOptionalHeader(0xF0)
b[sec..sec + 5].copy_from_slice(b".text");
b[sec + 12..sec + 16].copy_from_slice(&0x1000u32.to_le_bytes()); // VirtualAddress
b[sec + 16..sec + 20].copy_from_slice(&0x200u32.to_le_bytes()); // SizeOfRawData
b[sec + 20..sec + 24].copy_from_slice(&0x200u32.to_le_bytes()); // PointerToRawData
b
}

#[test]
fn lenient_parse_salvages_a_header_only_pe() {
// A truncated/carved sample whose headers are intact: goblin rejects it, but the
// lenient fallback recovers machine/sections, matching OffsetInspect's salvage.
let info = parse_pe_lenient(&minimal_pe32plus_header(1), "x")
.expect("lenient parse should salvage a valid header");
assert_eq!(info.machine, "x64 (AMD64)");
assert!(info.is_pe32_plus);
assert_eq!(info.section_count, 1);
assert_eq!(info.sections[0].name, ".text");
assert_eq!(info.entry_point_rva, 0x1000);
assert!(info.imp_hash.is_none()); // no import directory
}

#[test]
fn lenient_parse_rejects_non_pe_and_unfittable_section_counts() {
assert!(parse_pe_lenient(b"not a pe", "x").is_err());
// A 65535-section count that cannot fit the buffer must be rejected, not salvaged,
// matching OffsetInspect's $requireBytes behavior.
assert!(parse_pe_lenient(&minimal_pe32plus_header(0xFFFF), "x").is_err());
}
}