//! Minimal hand-rolled ELF64 reader for CS2 Linux `.so` files.
//!
//! Two jobs: (1) expose executable code for signature scanning, and (2) expose the metadata
//! the vtable/RTTI resolver needs — sections by name, dynamic symbols, and a relocation map
//! (vtable slots in `.data.rel.ro` are 0 on disk and supplied by `.rela.dyn` at load, so we
//! reconstruct their values here). No external ELF crate; the ELF64 layout is fixed.

use crate::sig::Pattern;
use anyhow::{Context, Result, ensure};
use std::collections::HashMap;
use std::path::Path;

#[derive(Default)]
struct Sec {
    typ: u32,
    flags: u64,
    addr: u64,
    off: usize,
    size: usize,
    link: usize,
    entsize: usize,
}

pub struct CodeImage {
    data: Vec<u8>,
    exec: Vec<(usize, u64, usize)>, // (file_off, vaddr, size) of executable sections
    secs: Vec<Sec>,
    sym_addr: HashMap<String, u64>,       // symbol name -> vaddr
    reloc: HashMap<u64, u64>,             // vaddr slot -> resolved pointer value
    reloc_by_val: HashMap<u64, Vec<u64>>, // pointer value -> slot vaddrs holding it
    kind_at: HashMap<u64, KindTag>, // typeinfo vaddr -> its Itanium kind (by reloc symbol name)
}

// These read attacker-controlled offsets, so they are bounds- AND overflow-safe: an out-of-range read
// returns 0 (a truncated field is treated as zero, which downstream validity checks reject) rather
// than panicking. This alone removes the largest class of malformed-input panics.
fn u16le(b: &[u8], o: usize) -> u16 {
    o.checked_add(2)
        .and_then(|e| b.get(o..e))
        .and_then(|s| s.try_into().ok())
        .map_or(0, u16::from_le_bytes)
}
fn u32le(b: &[u8], o: usize) -> u32 {
    o.checked_add(4)
        .and_then(|e| b.get(o..e))
        .and_then(|s| s.try_into().ok())
        .map_or(0, u32::from_le_bytes)
}
fn u64le(b: &[u8], o: usize) -> u64 {
    o.checked_add(8)
        .and_then(|e| b.get(o..e))
        .and_then(|s| s.try_into().ok())
        .map_or(0, u64::from_le_bytes)
}
fn cstr(b: &[u8], o: usize) -> String {
    let Some(sub) = b.get(o..) else {
        return String::new();
    };
    let end = sub.iter().position(|&c| c == 0).unwrap_or(sub.len());
    String::from_utf8_lossy(&sub[..end]).into_owned()
}

/// Byte width of a DWARF exception-handling pointer encoding (its low nibble is the value format).
/// Returns 0 for LEB128 / unsupported formats, which callers treat as "give up, use the fallback".
fn dw_ptr_size(enc: u8) -> usize {
    match enc & 0x0f {
        0x02 | 0x0a => 2, // udata2 / sdata2
        0x03 | 0x0b => 4, // udata4 / sdata4
        0x04 | 0x0c => 8, // udata8 / sdata8
        0x00 => 8,        // absptr (LP64)
        _ => 0,           // uleb128 / sleb128 / unknown
    }
}

const SHF_WRITE: u64 = 0x1;
const SHF_EXECINSTR: u64 = 0x4;
const SHF_ALLOC: u64 = 0x2;
const SHT_NOBITS: u32 = 8;
const SHT_DYNSYM: u32 = 11;
const SHT_SYMTAB: u32 = 2;
const SHT_RELA: u32 = 4;
const R_X86_64_64: u32 = 1;
const R_X86_64_RELATIVE: u32 = 8;
const R_X86_64_GLOB_DAT: u32 = 6;

/// The Itanium `type_info` "kind" a typeinfo's `+0` field references. Recovered by the referenced SYMBOL
/// NAME rather than its pointer value, because when the C++ runtime is DYNAMICALLY linked (old Source-2
/// builds `DT_NEEDED libstdc++`) the three kind vtables are UND imports with value 0 — so their `+0` reloc
/// resolves offline to the same `0 + addend` for all three and can't be told apart (or found) by value.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum KindTag {
    Class, // __class_type_info   — no bases
    Si,    // __si_class_type_info — one public base at offset 0
    Vmi,   // __vmi_class_type_info — multiple / virtual / non-public bases
}

/// Map a `_ZTVN10__cxxabiv1…` kind-vtable symbol name to its [`KindTag`]. The dynstr stores the undecorated
/// mangled name (symbol versioning lives in a separate table), so an exact match is correct.
fn kind_tag_of(sym: &str) -> Option<KindTag> {
    match sym {
        "_ZTVN10__cxxabiv117__class_type_infoE" => Some(KindTag::Class),
        "_ZTVN10__cxxabiv120__si_class_type_infoE" => Some(KindTag::Si),
        "_ZTVN10__cxxabiv121__vmi_class_type_infoE" => Some(KindTag::Vmi),
        _ => None,
    }
}
// Program-header type marking the `.eh_frame_hdr` FDE lookup table. The loader locates it this way,
// so we do too — no dependence on section names, which stripping can remove.
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;

impl CodeImage {
    pub fn load(path: &Path) -> Result<Self> {
        let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
        Self::from_bytes(data)
    }

    /// Parse an in-memory ELF64 image — the filesystem-free core of `load`. This is the fuzz/property
    /// surface: it consumes fully attacker-controlled bytes (a Valve `.so`, or a fuzzer mutation) and
    /// MUST return `Err` on any malformed input, never panic (no out-of-bounds index, no overflow).
    pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
        ensure!(
            data.len() > 64 && &data[0..4] == b"\x7fELF",
            "not an ELF file"
        );
        ensure!(data[4] == 2, "only ELF64 is supported");

        let shoff = u64le(&data, 40) as usize;
        let shentsize = u16le(&data, 58) as usize;
        let shnum = u16le(&data, 60) as usize;
        ensure!(
            shentsize >= 64,
            "unexpected section header size {shentsize}"
        );
        // Section headers (matched by type/flags, never by name). Every field is attacker-controlled:
        // compute the header offset with checked arithmetic and keep only sections whose file range and
        // virtual range don't overflow / exceed the file. A malformed section becomes an inert empty
        // placeholder (so `link` indices stay aligned and every downstream slice/address stays in
        // bounds). A valid ELF skips none of this — its headers are all in range.
        let mut secs: Vec<Sec> = Vec::with_capacity(shnum);
        for i in 0..shnum {
            let hdr_ok = i
                .checked_mul(shentsize)
                .and_then(|x| shoff.checked_add(x))
                .filter(|&o| o.checked_add(64).is_some_and(|e| e <= data.len()));
            let Some(o) = hdr_ok else {
                secs.push(Sec::default());
                continue;
            };
            let typ = u32le(&data, o + 4);
            let off = u64le(&data, o + 24) as usize;
            let size = u64le(&data, o + 32) as usize;
            let addr = u64le(&data, o + 16);
            let file_ok =
                typ == SHT_NOBITS || off.checked_add(size).is_some_and(|e| e <= data.len());
            let addr_ok = addr.checked_add(size as u64).is_some();
            if file_ok && addr_ok {
                secs.push(Sec {
                    typ,
                    flags: u64le(&data, o + 8),
                    addr,
                    off,
                    size,
                    link: u32le(&data, o + 40) as usize,
                    entsize: u64le(&data, o + 56) as usize,
                });
            } else {
                secs.push(Sec::default());
            }
        }

        let exec: Vec<(usize, u64, usize)> = secs
            .iter()
            .filter(|s| s.flags & SHF_EXECINSTR != 0 && s.typ != SHT_NOBITS)
            .map(|s| (s.off, s.addr, s.size))
            .collect();
        ensure!(!exec.is_empty(), "no executable sections found");

        // dynamic symbols (prefer .dynsym; fall back to .symtab if present)
        let mut sym_addr = HashMap::new();
        let mut sym_values: Vec<u64> = Vec::new();
        // Every symbol's name, pushed in lockstep with `sym_values` so a reloc's `r_sym` index recovers the
        // name even for UND (value-0) imports — the only way to identify the dynamically-linked kind vtables.
        let mut sym_names: Vec<String> = Vec::new();
        if let Some(symtab) = secs
            .iter()
            .find(|s| s.typ == SHT_DYNSYM)
            .or_else(|| secs.iter().find(|s| s.typ == SHT_SYMTAB))
        {
            let str_off = secs.get(symtab.link).map_or(0, |s| s.off);
            let n = if symtab.entsize >= 24 {
                symtab.size / symtab.entsize
            } else {
                0
            };
            for i in 0..n {
                let Some(o) = i
                    .checked_mul(symtab.entsize)
                    .and_then(|x| symtab.off.checked_add(x))
                    .filter(|&o| o.checked_add(24).is_some_and(|e| e <= data.len()))
                else {
                    break;
                };
                let name = cstr(&data, str_off.wrapping_add(u32le(&data, o) as usize));
                let value = u64le(&data, o + 8);
                sym_values.push(value);
                sym_names.push(name.clone()); // lockstep with sym_values, ALL symbols (incl. UND/value-0)
                if !name.is_empty() && value != 0 {
                    sym_addr.entry(name.clone()).or_insert(value);
                }
            }
        }

        // relocations: reconstruct the as-loaded pointer values for .data.rel.ro etc.
        let mut reloc = HashMap::new();
        let mut reloc_by_val: HashMap<u64, Vec<u64>> = HashMap::new();
        let mut kind_at: HashMap<u64, KindTag> = HashMap::new();
        for s in secs.iter().filter(|s| s.typ == SHT_RELA) {
            let n = if s.entsize >= 24 {
                s.size / s.entsize
            } else {
                0
            };
            for i in 0..n {
                let Some(o) = i
                    .checked_mul(s.entsize)
                    .and_then(|x| s.off.checked_add(x))
                    .filter(|&o| o.checked_add(24).is_some_and(|e| e <= data.len()))
                else {
                    break;
                };
                let r_offset = u64le(&data, o);
                let r_info = u64le(&data, o + 8);
                let r_addend = u64le(&data, o + 16);
                let r_type = (r_info & 0xffff_ffff) as u32;
                let r_sym = (r_info >> 32) as usize;
                // A typeinfo's `+0` field is a symbolic reloc against a `__cxxabiv1` kind vtable. Record the
                // kind by the referenced symbol NAME (keyed by `r_offset` = the typeinfo's base vaddr), so a
                // dynamically-linked runtime — where the value resolves to a useless `0 + 0x10` for all three
                // kinds — is still classifiable. Recorded regardless of the value gate below.
                if matches!(r_type, R_X86_64_64 | R_X86_64_GLOB_DAT)
                    && let Some(tag) = sym_names.get(r_sym).and_then(|n| kind_tag_of(n))
                {
                    kind_at.insert(r_offset, tag);
                }
                let val = match r_type {
                    R_X86_64_RELATIVE => r_addend,
                    R_X86_64_64 | R_X86_64_GLOB_DAT => sym_values
                        .get(r_sym)
                        .copied()
                        .unwrap_or(0)
                        .wrapping_add(r_addend),
                    _ => continue,
                };
                if val != 0 {
                    reloc.insert(r_offset, val);
                    reloc_by_val.entry(val).or_default().push(r_offset);
                }
            }
        }

        Ok(Self {
            data,
            exec,
            secs,
            sym_addr,
            reloc,
            reloc_by_val,
            kind_at,
        })
    }

    /// Virtual addresses where `pat` matches inside any executable section.
    pub fn find(&self, pat: &Pattern) -> Vec<u64> {
        let mut hits = Vec::new();
        for &(off, vaddr, size) in &self.exec {
            let end = (off + size).min(self.data.len());
            if off >= end {
                continue;
            }
            for m in pat.find_all(&self.data[off..end]) {
                hits.push(vaddr + m as u64);
            }
        }
        hits
    }

    /// Executable bytes starting at virtual address `vaddr` (to the end of its section).
    pub fn code_at(&self, vaddr: u64) -> Option<&[u8]> {
        for &(off, sec_va, size) in &self.exec {
            if vaddr >= sec_va && vaddr < sec_va + size as u64 {
                let start = off + (vaddr - sec_va) as usize;
                let end = (off + size).min(self.data.len());
                if start < end {
                    return Some(&self.data[start..end]);
                }
            }
        }
        None
    }

    /// Executable bytes for the half-open virtual range `[start, end)` — used to disassemble a
    /// single function from its known `.eh_frame` boundary (so linear decode can't misalign on data
    /// between functions).
    pub fn code_range(&self, start: u64, end: u64) -> Option<&[u8]> {
        let all = self.code_at(start)?;
        let len = end.checked_sub(start)? as usize;
        Some(&all[..len.min(all.len())])
    }

    /// Is `vaddr` inside an executable section (i.e. plausibly a function pointer)?
    pub fn is_code(&self, vaddr: u64) -> bool {
        self.exec
            .iter()
            .any(|&(_, va, size)| vaddr >= va && vaddr < va + size as u64)
    }

    /// Executable sections as `(vaddr, bytes)` for linear disassembly.
    pub fn exec_blocks(&self) -> Vec<(u64, &[u8])> {
        self.exec
            .iter()
            .filter_map(|&(off, va, size)| {
                let end = (off + size).min(self.data.len());
                (off < end).then(|| (va, &self.data[off..end]))
            })
            .collect()
    }

    /// Relocation values that point into executable code — vtable slots and function pointers, i.e.
    /// a large set of real function entry addresses obtained without disassembling anything.
    pub fn code_pointer_targets(&self) -> Vec<u64> {
        let mut out: Vec<u64> = self
            .reloc
            .values()
            .copied()
            .filter(|&v| self.is_code(v))
            .collect();
        out.sort_unstable();
        out.dedup();
        out
    }

    /// Raw allocated bytes at `vaddr`, up to `len`.
    fn data_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
        for s in &self.secs {
            if s.flags & SHF_ALLOC != 0
                && s.typ != SHT_NOBITS
                && vaddr >= s.addr
                && vaddr < s.addr + s.size as u64
            {
                let start = s.off + (vaddr - s.addr) as usize;
                if start + len <= self.data.len() {
                    return Some(&self.data[start..start + len]);
                }
            }
        }
        None
    }

    /// Read-only initialised data bytes at `vaddr` (i.e. `.rodata`): allocated, not writable, not
    /// executable, not NOBITS. This is the build-invariant content — referenced strings, magic
    /// constants — so a fingerprint over it survives recompiles, unlike writable/relocated data.
    pub fn rodata_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
        for s in &self.secs {
            if s.flags & SHF_ALLOC != 0
                && s.flags & SHF_WRITE == 0
                && s.flags & SHF_EXECINSTR == 0
                && s.typ != SHT_NOBITS
                && vaddr >= s.addr
                && vaddr < s.addr + s.size as u64
            {
                let start = s.off + (vaddr - s.addr) as usize;
                let end = (start + len).min(s.off + s.size).min(self.data.len());
                if start < end {
                    return Some(&self.data[start..end]);
                }
            }
        }
        None
    }

    /// The pointer value stored at `vaddr` — from the relocation map if relocated, else the
    /// raw qword in the file.
    pub fn read_ptr(&self, vaddr: u64) -> Option<u64> {
        if let Some(&v) = self.reloc.get(&vaddr) {
            return Some(v);
        }
        self.data_at(vaddr, 8).map(|b| u64le(b, 0))
    }

    /// Slot vaddrs whose (relocated) pointer value equals `target`.
    pub fn ptrs_to(&self, target: u64) -> &[u64] {
        self.reloc_by_val.get(&target).map_or(&[], |v| v.as_slice())
    }

    /// The Itanium kind of the typeinfo at `ti`, recovered from its `+0` reloc's SYMBOL NAME. `Some` when
    /// the kind vtable is a named `__cxxabiv1` symbol (always so for a dynamically-linked runtime — the case
    /// the value-based check can't handle); `None` for a statically-linked build, where the caller falls
    /// back to comparing the resolved `+0` pointer against the in-image kind vtables.
    pub fn kind_at(&self, ti: u64) -> Option<KindTag> {
        self.kind_at.get(&ti).copied()
    }

    /// Iterate `(slot_vaddr, resolved_pointer)` over every relocation — the reloc-driven way to
    /// sweep for vtables/typeinfos without brute-scanning section bytes.
    pub fn reloc_slots(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
        self.reloc.iter().map(|(&k, &v)| (k, v))
    }

    /// Raw signed qword at `vaddr` from file bytes — for non-relocated integers (e.g. an Itanium
    /// vtable's offset-to-top), where `read_ptr`'s reloc lookup would be meaningless.
    pub fn read_i64(&self, vaddr: u64) -> Option<i64> {
        self.data_at(vaddr, 8)
            .map(|b| i64::from_le_bytes(b[..8].try_into().unwrap()))
    }

    /// Raw `u32` at `vaddr` from file bytes (e.g. an Itanium `__vmi` typeinfo's base count).
    pub fn read_u32(&self, vaddr: u64) -> Option<u32> {
        self.data_at(vaddr, 4).map(|b| u32le(b, 0))
    }

    /// Raw `i32` at `vaddr` from file bytes (e.g. a schema field's inheritance offset).
    pub fn read_i32(&self, vaddr: u64) -> Option<i32> {
        self.data_at(vaddr, 4)
            .map(|b| i32::from_le_bytes(b[..4].try_into().unwrap()))
    }

    /// Raw `u16` at `vaddr` from file bytes (e.g. a schema class's field count).
    pub fn read_u16(&self, vaddr: u64) -> Option<u16> {
        self.data_at(vaddr, 2).map(|b| u16le(b, 0))
    }

    /// Raw `u8` at `vaddr` from file bytes (e.g. a schema class's base count).
    pub fn read_u8(&self, vaddr: u64) -> Option<u8> {
        self.data_at(vaddr, 1).map(|b| b[0])
    }

    /// Is `vaddr` inside any allocated section (code or data)? The "does this pointer land in the
    /// image" test RTTI validation needs.
    pub fn contains(&self, vaddr: u64) -> bool {
        self.secs
            .iter()
            .any(|s| s.flags & SHF_ALLOC != 0 && vaddr >= s.addr && vaddr < s.addr + s.size as u64)
    }

    pub fn symbol_addr(&self, name: &str) -> Option<u64> {
        self.sym_addr.get(name).copied()
    }

    /// Virtual addresses of an exact byte string within allocated, non-executable sections
    /// (used to find RTTI name strings in `.rodata`).
    pub fn find_bytes(&self, needle: &[u8]) -> Vec<u64> {
        let mut out = Vec::new();
        if needle.is_empty() {
            return out;
        }
        for s in &self.secs {
            if s.flags & SHF_ALLOC == 0
                || s.typ == SHT_NOBITS
                || s.flags & SHF_EXECINSTR != 0
                || s.off + s.size > self.data.len()
            {
                continue;
            }
            let hay = &self.data[s.off..s.off + s.size];
            let mut i = 0;
            while let Some(p) = memchr::memmem::find(&hay[i..], needle) {
                out.push(s.addr + (i + p) as u64);
                i += p + 1;
            }
        }
        out
    }

    /// The NUL-terminated string at `vaddr` in any allocated, initialised section — RTTI `_ZTS`
    /// names, schema class/field names, referenced literals. Capped so a missing terminator (e.g.
    /// a bogus pointer into a non-string section) can't run to the end of the file.
    pub fn read_c_string(&self, vaddr: u64) -> Option<String> {
        for s in &self.secs {
            if s.flags & SHF_ALLOC != 0
                && s.typ != SHT_NOBITS
                && vaddr >= s.addr
                && vaddr < s.addr + s.size as u64
            {
                let start = s.off + (vaddr - s.addr) as usize;
                let end = (s.off + s.size).min(self.data.len()).min(start + 4096);
                if start >= end {
                    return None;
                }
                let rel = self.data[start..end].iter().position(|&c| c == 0)?;
                return Some(String::from_utf8_lossy(&self.data[start..start + rel]).into_owned());
            }
        }
        None
    }

    /// Virtual address + file offset of `.eh_frame_hdr`, via the PT_GNU_EH_FRAME program header.
    fn eh_frame_hdr(&self) -> Option<(u64, usize)> {
        let d = &self.data;
        let phoff = u64le(d, 32) as usize;
        let phentsize = u16le(d, 54) as usize;
        let phnum = u16le(d, 56) as usize;
        if phentsize < 56 {
            return None;
        }
        for i in 0..phnum {
            let Some(o) = i
                .checked_mul(phentsize)
                .and_then(|x| phoff.checked_add(x))
                .filter(|&o| o.checked_add(56).is_some_and(|e| e <= d.len()))
            else {
                break;
            };
            if u32le(d, o) == PT_GNU_EH_FRAME {
                return Some((u64le(d, o + 16), u64le(d, o + 8) as usize));
            }
        }
        None
    }

    /// Decode a DWARF-encoded value at `field_va`, applying its pcrel/datarel base. Returns
    /// `(value, byte_width)`. `datarel_base` is the `.eh_frame_hdr` vaddr (only used by datarel enc).
    fn read_enc(&self, enc: u8, field_va: u64, datarel_base: u64) -> Option<(u64, usize)> {
        let sz = dw_ptr_size(enc);
        if sz == 0 {
            return None;
        }
        let b = self.data_at(field_va, sz)?;
        let raw = match sz {
            2 => u16le(b, 0) as u64,
            4 => u32le(b, 0) as u64,
            _ => u64le(b, 0),
        };
        let base = match enc & 0x70 {
            0x00 => 0,            // absolute — no base (also how lengths/sizes are stored)
            0x10 => field_va,     // pcrel: relative to this field's own address
            0x30 => datarel_base, // datarel: relative to `.eh_frame_hdr`
            _ => return None,
        };
        let val = if matches!(enc & 0x0f, 0x0a..=0x0c) {
            let s = match sz {
                2 => raw as u16 as i16 as i64,
                4 => raw as u32 as i32 as i64,
                _ => raw as i64,
            };
            base.wrapping_add(s as u64)
        } else {
            base.wrapping_add(raw)
        };
        Some((val, sz))
    }

    /// Byte length of the LEB128 value at `va` (the value itself is unused here — we only skip it).
    fn leb_len(&self, va: u64) -> Option<u64> {
        let mut n = 0u64;
        loop {
            let byte = self.data_at(va.wrapping_add(n), 1)?[0];
            n += 1;
            if byte & 0x80 == 0 {
                return Some(n);
            }
            if n >= 16 {
                return None;
            }
        }
    }

    /// The FDE pointer encoding a CIE advertises — its `'R'` augmentation byte. Absptr (0) when the
    /// CIE carries no `z`/`R` augmentation (then FDE addresses are absolute).
    fn cie_fde_enc(&self, cie_va: u64) -> u8 {
        let Some(head) = self.data_at(cie_va, 9) else {
            return 0;
        };
        if u32le(head, 4) != 0 {
            return 0; // CIE id field must be 0
        }
        let version = head[8];
        let mut p = cie_va.wrapping_add(9);
        let Some(aug) = self.read_c_string(p) else {
            return 0;
        };
        p = p.wrapping_add(aug.len() as u64 + 1);
        if !aug.starts_with('z') {
            return 0;
        }
        // code_align (uleb), data_align (sleb), return-addr reg (uleb v>=3 else 1 byte), aug_len (uleb)
        for _ in 0..2 {
            match self.leb_len(p) {
                Some(n) => p = p.wrapping_add(n),
                None => return 0,
            }
        }
        if version >= 3 {
            match self.leb_len(p) {
                Some(n) => p = p.wrapping_add(n),
                None => return 0,
            }
        } else {
            p = p.wrapping_add(1);
        }
        match self.leb_len(p) {
            Some(n) => p = p.wrapping_add(n),
            None => return 0,
        }
        // The augmentation letters after 'z' name the aug-data fields, in order.
        for c in aug.bytes().skip(1) {
            match c {
                b'R' => return self.data_at(p, 1).map_or(0, |b| b[0]),
                b'L' => p = p.wrapping_add(1),
                b'P' => {
                    let Some(e) = self.data_at(p, 1).map(|b| b[0]) else {
                        return 0;
                    };
                    p = p.wrapping_add(1 + dw_ptr_size(e) as u64);
                }
                b'S' | b'B' | b'G' => {}
                _ => return 0,
            }
        }
        0
    }

    /// Parse the FDE at `fde_va` to `(pc_begin, pc_end)`, using its owning CIE's pointer encoding.
    /// `enc_cache` memoises CIE encodings (nearly all FDEs share one CIE).
    fn fde_range(&self, fde_va: u64, enc_cache: &mut HashMap<u64, u8>) -> Option<(u64, u64)> {
        let head = self.data_at(fde_va, 8)?;
        let len = u32le(head, 0);
        if len == 0 || len == 0xffff_ffff {
            return None; // terminator, or 64-bit DWARF (not emitted by the CS2 toolchain)
        }
        let cie_ptr = u32le(head, 4);
        if cie_ptr == 0 {
            return None; // a CIE, not an FDE
        }
        let cie_va = fde_va.wrapping_add(4).wrapping_sub(cie_ptr as u64);
        let enc = *enc_cache
            .entry(cie_va)
            .or_insert_with(|| self.cie_fde_enc(cie_va));
        let (pc_begin, sz) = self.read_enc(enc, fde_va.wrapping_add(8), 0)?;
        // PC_range follows PC_begin at the same width; it's an absolute size (no base applied).
        let rb = self.data_at(fde_va.wrapping_add(8).wrapping_add(sz as u64), sz)?;
        let range = match sz {
            2 => u16le(rb, 0) as u64,
            4 => u32le(rb, 0) as u64,
            _ => u64le(rb, 0),
        };
        Some((pc_begin, pc_begin.wrapping_add(range)))
    }

    /// Every function `.eh_frame` unwind data describes, as sorted `(start, end)` virtual-address
    /// pairs. This enumerates far more functions than the dynamic symbol table exposes (stripped
    /// internal functions still need unwind info), making it the completeness denominator for
    /// coverage audits and the source of exact byte extents for the content-locator.
    pub fn eh_frame_functions(&self) -> Vec<(u64, u64)> {
        let Some((hdr_va, hdr_off)) = self.eh_frame_hdr() else {
            return Vec::new();
        };
        let d = &self.data;
        // header: version(1) + eh_frame_ptr_enc(1) + fde_count_enc(1) + table_enc(1)
        let Some(hb) = hdr_off.checked_add(4).and_then(|e| d.get(hdr_off..e)) else {
            return Vec::new();
        };
        if hb[0] != 1 {
            return Vec::new();
        }
        let ptr_sz = dw_ptr_size(hb[1]); // eh_frame_ptr encoding (we skip the pointer)
        let count_sz = dw_ptr_size(hb[2]);
        let table_enc = hb[3];
        let esz = dw_ptr_size(table_enc);
        if ptr_sz == 0 || count_sz == 0 || esz == 0 {
            return Vec::new();
        }
        let Some(table_off) = hdr_off
            .checked_add(4)
            .and_then(|x| x.checked_add(ptr_sz))
            .and_then(|count_off| count_off.checked_add(count_sz).map(|t| (count_off, t)))
            .filter(|&(_, t)| t <= d.len())
        else {
            return Vec::new();
        };
        let (count_off, table_off) = table_off;
        // fde_count is attacker-controlled; each table entry is `2*esz` bytes, so a real count can't
        // exceed the file. Cap it BEFORE any allocation — a crafted count would otherwise drive an
        // out-of-memory abort.
        let fde_count = match count_sz {
            2 => u16le(d, count_off) as usize,
            4 => u32le(d, count_off) as usize,
            _ => u64le(d, count_off) as usize,
        }
        .min(d.len() / (2 * esz).max(1) + 1);
        let entry_bytes = 2 * esz;
        let table_va = hdr_va.wrapping_add(table_off.wrapping_sub(hdr_off) as u64);
        let mut starts: Vec<(u64, u64)> = Vec::new(); // (fn start, fde vaddr)
        for i in 0..fde_count {
            let field_va = table_va.wrapping_add(i.wrapping_mul(entry_bytes) as u64);
            let (Some((start, _)), Some((fde_va, _))) = (
                self.read_enc(table_enc, field_va, hdr_va),
                self.read_enc(table_enc, field_va.wrapping_add(esz as u64), hdr_va),
            ) else {
                break;
            };
            starts.push((start, fde_va));
        }
        starts.sort_unstable_by_key(|&(s, _)| s);
        let mut cache = HashMap::new();
        let mut out = Vec::with_capacity(starts.len());
        for (i, &(start, fde_va)) in starts.iter().enumerate() {
            // Prefer the FDE's own extent; fall back to the next function's start (padding included).
            let end = self
                .fde_range(fde_va, &mut cache)
                .map(|(_, e)| e)
                .filter(|&e| e > start)
                .unwrap_or_else(|| starts.get(i + 1).map_or(start, |&(s, _)| s));
            out.push((start, end));
        }
        out
    }
}
