Skip to main content

running_process_probe/snapshot/
modules.rs

1//! Loaded-module inventory and in-memory PE section lookup (#635).
2//!
3//! Unwinding a captured stack needs, for every loaded module, its image base
4//! and the address ranges of specific sections — on Windows, `.pdata` and
5//! `.xdata` carry the unwind tables. This module supplies that inventory.
6//!
7//! # Why parse the mapped image rather than the file
8//!
9//! The module is already mapped into this process, so its headers are directly
10//! readable and no file I/O is needed. That matters because this inventory is
11//! built to interpret captures taken while threads were suspended: touching
12//! the filesystem here would make the capture path depend on disk
13//! availability, and a module can be deleted or replaced on disk while still
14//! mapped.
15//!
16//! # What this deliberately does not do
17//!
18//! No unwinding, and no symbolization. This is the address bookkeeping an
19//! unwinder consumes, split out so it can be verified on its own — the ranges
20//! it reports are checkable against known function addresses without any
21//! unwinder existing yet.
22
23#![allow(unsafe_code)] // Module enumeration and header reads are FFI/raw-pointer work.
24
25use std::ops::Range;
26
27#[cfg(any(target_os = "linux", target_os = "macos"))]
28const MAX_MODULE_IMAGE_BYTES: u64 = 512 * 1024 * 1024;
29
30#[cfg(windows)]
31use std::io;
32#[cfg(windows)]
33use winapi::shared::minwindef::{DWORD, HMODULE};
34#[cfg(windows)]
35use winapi::um::processthreadsapi::GetCurrentProcess;
36#[cfg(windows)]
37use winapi::um::psapi::{
38    EnumProcessModules, GetModuleFileNameExW, GetModuleInformation, MODULEINFO,
39};
40#[cfg(windows)]
41use winapi::um::winnt::{
42    IMAGE_DOS_HEADER, IMAGE_DOS_SIGNATURE, IMAGE_NT_HEADERS64, IMAGE_NT_SIGNATURE,
43    IMAGE_SECTION_HEADER,
44};
45
46fn hex_bytes(bytes: &[u8]) -> String {
47    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
48}
49
50#[cfg(target_os = "linux")]
51struct LoadedElfIdentity {
52    load_bias: u64,
53    mapped_ranges: Vec<Range<u64>>,
54    debug_id: String,
55}
56
57#[cfg(target_os = "linux")]
58fn loaded_elf_identities() -> Vec<LoadedElfIdentity> {
59    unsafe extern "C" fn visit(
60        info: *mut libc::dl_phdr_info,
61        _size: libc::size_t,
62        data: *mut libc::c_void,
63    ) -> libc::c_int {
64        const MAX_NOTE_BYTES: usize = 1024 * 1024;
65        let info = unsafe { &*info };
66        let out = unsafe { &mut *data.cast::<Vec<LoadedElfIdentity>>() };
67        if info.dlpi_phdr.is_null() || info.dlpi_phnum == 0 {
68            return 0;
69        }
70        // libc exposes Elf_Addr as u64 on our 64-bit CI hosts and as a
71        // narrower integer on 32-bit Linux; this widening keeps both valid.
72        #[allow(clippy::unnecessary_cast)]
73        let load_bias = info.dlpi_addr as u64;
74        let headers =
75            unsafe { std::slice::from_raw_parts(info.dlpi_phdr, usize::from(info.dlpi_phnum)) };
76        let mapped_ranges = headers
77            .iter()
78            .filter(|header| header.p_type == libc::PT_LOAD && header.p_memsz > 0)
79            .filter_map(|header| {
80                let start = load_bias.checked_add(header.p_vaddr)?;
81                let end = start.checked_add(header.p_memsz)?;
82                Some(start..end)
83            })
84            .collect::<Vec<_>>();
85        for header in headers {
86            if header.p_type != libc::PT_NOTE {
87                continue;
88            }
89            let Ok(length) = usize::try_from(header.p_memsz) else {
90                continue;
91            };
92            if length == 0 || length > MAX_NOTE_BYTES {
93                continue;
94            }
95            let Some(address) = load_bias.checked_add(header.p_vaddr) else {
96                continue;
97            };
98            let Some(note_end) = address.checked_add(length as u64) else {
99                continue;
100            };
101            let is_mapped = headers.iter().any(|load| {
102                if load.p_type != libc::PT_LOAD || load.p_flags & libc::PF_R == 0 {
103                    return false;
104                }
105                let Some(start) = load_bias.checked_add(load.p_vaddr) else {
106                    return false;
107                };
108                let Some(end) = start.checked_add(load.p_memsz) else {
109                    return false;
110                };
111                address >= start && note_end <= end
112            });
113            if address == 0 || !is_mapped {
114                continue;
115            }
116            let notes = unsafe { std::slice::from_raw_parts(address as *const u8, length) };
117            if let Some(build_id) = gnu_build_id_from_notes(notes) {
118                out.push(LoadedElfIdentity {
119                    load_bias,
120                    mapped_ranges,
121                    debug_id: format!("elf:{}", hex_bytes(build_id)),
122                });
123                break;
124            }
125        }
126        0
127    }
128
129    let mut out = Vec::new();
130    unsafe {
131        libc::dl_iterate_phdr(
132            Some(visit),
133            (&mut out as *mut Vec<LoadedElfIdentity>).cast::<libc::c_void>(),
134        );
135    }
136    out
137}
138
139#[cfg(target_os = "linux")]
140fn gnu_build_id_from_notes(mut notes: &[u8]) -> Option<&[u8]> {
141    fn aligned(value: usize) -> Option<usize> {
142        value.checked_add(3).map(|value| value & !3)
143    }
144
145    while notes.len() >= 12 {
146        let name_len = usize::try_from(u32::from_ne_bytes(notes[0..4].try_into().ok()?)).ok()?;
147        let desc_len = usize::try_from(u32::from_ne_bytes(notes[4..8].try_into().ok()?)).ok()?;
148        let kind = u32::from_ne_bytes(notes[8..12].try_into().ok()?);
149        let name_end = 12usize.checked_add(name_len)?;
150        let desc_start = 12usize.checked_add(aligned(name_len)?)?;
151        let desc_end = desc_start.checked_add(desc_len)?;
152        let next = desc_start.checked_add(aligned(desc_len)?)?;
153        if next > notes.len() || name_end > notes.len() || desc_end > notes.len() {
154            return None;
155        }
156        if kind == 3 && notes.get(12..name_end)?.starts_with(b"GNU") && desc_len > 0 {
157            return notes.get(desc_start..desc_end);
158        }
159        notes = &notes[next..];
160    }
161    None
162}
163
164#[cfg(windows)]
165unsafe fn loaded_pe_debug_info(base: u64, image_size: u64) -> Option<(String, String)> {
166    const DEBUG_DIRECTORY_INDEX: usize = 6;
167    const IMAGE_DEBUG_TYPE_CODEVIEW: u32 = 2;
168    const DEBUG_DIRECTORY_SIZE: usize = 28;
169
170    let dos = unsafe { &*(base as *const IMAGE_DOS_HEADER) };
171    if dos.e_magic != IMAGE_DOS_SIGNATURE {
172        return None;
173    }
174    let nt_offset = usize::try_from(dos.e_lfanew).ok()?;
175    if nt_offset.checked_add(std::mem::size_of::<IMAGE_NT_HEADERS64>())?
176        > usize::try_from(image_size).ok()?
177    {
178        return None;
179    }
180    let nt_address = (base as usize).checked_add(nt_offset)?;
181    let nt = unsafe { &*(nt_address as *const IMAGE_NT_HEADERS64) };
182    if nt.Signature != IMAGE_NT_SIGNATURE {
183        return None;
184    }
185    let directory = nt.OptionalHeader.DataDirectory[DEBUG_DIRECTORY_INDEX];
186    let directory_start = u64::from(directory.VirtualAddress);
187    let directory_size = usize::try_from(directory.Size).ok()?;
188    if directory_start
189        .checked_add(directory_size as u64)?
190        .gt(&image_size)
191    {
192        return None;
193    }
194    let directory_address = base.checked_add(directory_start)?;
195    let bytes =
196        unsafe { std::slice::from_raw_parts(directory_address as *const u8, directory_size) };
197    for entry in bytes.chunks_exact(DEBUG_DIRECTORY_SIZE) {
198        let kind = u32::from_le_bytes(entry[12..16].try_into().ok()?);
199        if kind != IMAGE_DEBUG_TYPE_CODEVIEW {
200            continue;
201        }
202        let size = usize::try_from(u32::from_le_bytes(entry[16..20].try_into().ok()?)).ok()?;
203        let rva = u64::from(u32::from_le_bytes(entry[20..24].try_into().ok()?));
204        if size < 24 || rva.checked_add(size as u64)?.gt(&image_size) {
205            continue;
206        }
207        let record_address = base.checked_add(rva)?;
208        let record = unsafe { std::slice::from_raw_parts(record_address as *const u8, size) };
209        if record.get(..4) != Some(b"RSDS") {
210            continue;
211        }
212        let mut guid: [u8; 16] = record.get(4..20)?.try_into().ok()?;
213        guid[0..4].reverse();
214        guid[4..6].reverse();
215        guid[6..8].reverse();
216        let age = u32::from_le_bytes(record.get(20..24)?.try_into().ok()?);
217        let path_bytes = record.get(24..)?;
218        let path_end = path_bytes
219            .iter()
220            .position(|byte| *byte == 0)
221            .unwrap_or(path_bytes.len());
222        let recorded = String::from_utf8_lossy(&path_bytes[..path_end]);
223        let pdb_name = recorded
224            .rsplit(['/', '\\'])
225            .find(|part| !part.is_empty())?
226            .to_owned();
227        if pdb_name == "."
228            || pdb_name == ".."
229            || pdb_name.chars().any(|character| {
230                matches!(
231                    character,
232                    '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
233                )
234            })
235        {
236            return None;
237        }
238        return Some((format!("pdb:{}-{age}", hex_bytes(&guid)), pdb_name));
239    }
240    None
241}
242
243#[cfg(target_os = "macos")]
244unsafe fn loaded_macho_uuid(header: *const u8) -> Option<[u8; 16]> {
245    const MH_MAGIC_64: u32 = 0xfeed_facf;
246    const LC_UUID: u32 = 0x1b;
247    const MACH_HEADER_64_SIZE: usize = 32;
248    const MAX_LOAD_COMMAND_BYTES: usize = 1024 * 1024;
249    const MAX_LOAD_COMMANDS: u32 = 4096;
250
251    let magic = unsafe { (header.cast::<u32>()).read_unaligned() };
252    if magic != MH_MAGIC_64 {
253        return None;
254    }
255    let ncmds = unsafe { header.add(16).cast::<u32>().read_unaligned() };
256    let sizeofcmds =
257        usize::try_from(unsafe { header.add(20).cast::<u32>().read_unaligned() }).ok()?;
258    if ncmds > MAX_LOAD_COMMANDS || sizeofcmds > MAX_LOAD_COMMAND_BYTES {
259        return None;
260    }
261    let commands =
262        unsafe { std::slice::from_raw_parts(header.add(MACH_HEADER_64_SIZE), sizeofcmds) };
263    let mut offset = 0usize;
264    for _ in 0..ncmds {
265        let prefix = commands.get(offset..offset.checked_add(8)?)?;
266        let command = u32::from_le_bytes(prefix[0..4].try_into().ok()?);
267        let size = usize::try_from(u32::from_le_bytes(prefix[4..8].try_into().ok()?)).ok()?;
268        if size < 8 || offset.checked_add(size)? > commands.len() {
269            return None;
270        }
271        if command == LC_UUID && size >= 24 {
272            return commands.get(offset + 8..offset + 24)?.try_into().ok();
273        }
274        offset += size;
275    }
276    None
277}
278
279/// One section of a mapped module.
280#[derive(Clone, Debug, PartialEq, Eq)]
281pub struct Section {
282    /// Section name as written in the PE header, e.g. `.text`.
283    ///
284    /// PE names are 8 bytes and are NOT NUL-terminated when exactly 8 long, so
285    /// this is the trimmed form rather than a raw C string.
286    pub name: String,
287    /// Address range of the section as mapped in this process.
288    pub range: Range<u64>,
289}
290
291/// A module loaded in this process.
292#[derive(Clone, Debug)]
293pub struct LoadedModule {
294    /// Base address the module is mapped at.
295    pub base: u64,
296    /// Total mapped size.
297    pub size: u64,
298    /// Actual mapped address ranges when they differ from `base..base+size`.
299    pub(crate) mapped_ranges: Vec<Range<u64>>,
300    /// Mapped ranges whose OS protection permits instruction execution.
301    #[cfg_attr(any(windows, not(target_arch = "x86_64")), allow(dead_code))]
302    pub(crate) executable_ranges: Vec<Range<u64>>,
303    /// Full path of the module on disk, when the OS could report it.
304    ///
305    /// Needed downstream to find the symbol file, which lives beside the
306    /// binary. `None` rather than a guess when the query fails: a wrong path
307    /// would load a *different* build's symbols and produce confidently wrong
308    /// function names.
309    pub path: Option<String>,
310    /// Build identity observed while this module inventory was taken.
311    ///
312    /// This is read from the loaded image (or, on Linux, from the exact
313    /// device/inode still backing the mapping), so later path replacement
314    /// cannot change which symbols the capture expects.
315    pub debug_id: Option<String>,
316    /// Sanitized native symbol filename captured from the loaded image.
317    pub debug_file: Option<String>,
318    /// Sections parsed from the mapped headers.
319    pub sections: Vec<Section>,
320}
321
322impl LoadedModule {
323    /// Address range covered by the whole module.
324    pub fn range(&self) -> Range<u64> {
325        match (self.mapped_ranges.first(), self.mapped_ranges.last()) {
326            (Some(first), Some(last)) => first.start..last.end,
327            _ => self.base..self.base + self.size,
328        }
329    }
330
331    /// Whether `address` falls inside this module.
332    pub fn contains(&self, address: u64) -> bool {
333        if self.mapped_ranges.is_empty() {
334            self.range().contains(&address)
335        } else {
336            self.mapped_ranges
337                .iter()
338                .any(|range| range.contains(&address))
339        }
340    }
341
342    /// Whether `address` falls inside an executable mapping for this module.
343    #[cfg_attr(any(windows, not(target_arch = "x86_64")), allow(dead_code))]
344    pub(crate) fn contains_executable(&self, address: u64) -> bool {
345        self.executable_ranges
346            .iter()
347            .any(|range| range.contains(&address))
348    }
349
350    /// Look up a section by name, e.g. `.text` or `.pdata`.
351    pub fn section(&self, name: &str) -> Option<&Section> {
352        self.sections.iter().find(|s| s.name == name)
353    }
354}
355
356/// Read the section table out of a module already mapped at `base`.
357///
358/// # Safety
359///
360/// `base` must be the base address of a PE image currently mapped into this
361/// process. Callers get that from [`enumerate_modules`], which obtains it from
362/// the OS.
363#[cfg(windows)]
364unsafe fn read_sections(base: u64) -> Option<Vec<Section>> {
365    let dos = base as *const IMAGE_DOS_HEADER;
366    if (*dos).e_magic != IMAGE_DOS_SIGNATURE {
367        return None;
368    }
369
370    // e_lfanew is a signed offset from the image base to the NT headers.
371    let lfanew = (*dos).e_lfanew;
372    if lfanew < 0 {
373        return None;
374    }
375    let nt = (base + lfanew as u64) as *const IMAGE_NT_HEADERS64;
376    if (*nt).Signature != IMAGE_NT_SIGNATURE {
377        return None;
378    }
379
380    let section_count = (*nt).FileHeader.NumberOfSections as usize;
381    // The section table follows the optional header, whose size is declared
382    // rather than fixed — using size_of::<IMAGE_OPTIONAL_HEADER64>() would
383    // silently misread images with a different optional-header size.
384    let opt_size = (*nt).FileHeader.SizeOfOptionalHeader as u64;
385    let opt_start = base + lfanew as u64 + 4 /* Signature */ + 20 /* FileHeader */;
386    let table = (opt_start + opt_size) as *const IMAGE_SECTION_HEADER;
387
388    let mut sections = Vec::with_capacity(section_count);
389    for i in 0..section_count {
390        let header = &*table.add(i);
391
392        // PE section names occupy exactly 8 bytes and are only NUL-terminated
393        // when shorter, so take bytes up to the first NUL rather than assuming
394        // one exists.
395        let raw = &header.Name;
396        let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
397        let name = String::from_utf8_lossy(&raw[..end]).into_owned();
398
399        let start = base + u64::from(header.VirtualAddress);
400        // VirtualSize is the in-memory size; SizeOfRawData is the on-disk one
401        // and can differ (BSS-like sections have raw size 0).
402        let size = u64::from(unsafe { *header.Misc.VirtualSize() });
403
404        sections.push(Section {
405            name,
406            range: start..start + size,
407        });
408    }
409    Some(sections)
410}
411
412/// Enumerate every module mapped into this process.
413#[cfg(windows)]
414pub fn enumerate_modules() -> io::Result<Vec<LoadedModule>> {
415    let process = unsafe { GetCurrentProcess() };
416
417    // Two-pass: ask how many bytes are needed, then fetch. A single fixed-size
418    // pass would silently truncate in a process with many DLLs loaded.
419    let mut needed: DWORD = 0;
420    let ok = unsafe { EnumProcessModules(process, std::ptr::null_mut(), 0, &mut needed) };
421    if ok == 0 {
422        return Err(io::Error::last_os_error());
423    }
424
425    let count = needed as usize / std::mem::size_of::<HMODULE>();
426    let mut handles: Vec<HMODULE> = vec![std::ptr::null_mut(); count];
427    let mut needed2: DWORD = 0;
428    let ok = unsafe {
429        EnumProcessModules(
430            process,
431            handles.as_mut_ptr(),
432            (handles.len() * std::mem::size_of::<HMODULE>()) as DWORD,
433            &mut needed2,
434        )
435    };
436    if ok == 0 {
437        return Err(io::Error::last_os_error());
438    }
439    // A module can load between the two calls; honor the smaller count.
440    let usable = (needed2 as usize / std::mem::size_of::<HMODULE>()).min(handles.len());
441
442    let mut modules = Vec::with_capacity(usable);
443    for handle in handles.into_iter().take(usable) {
444        let mut info: MODULEINFO = unsafe { std::mem::zeroed() };
445        let ok = unsafe {
446            GetModuleInformation(
447                process,
448                handle,
449                &mut info,
450                std::mem::size_of::<MODULEINFO>() as DWORD,
451            )
452        };
453        if ok == 0 {
454            // Unloaded between enumeration and query. Skip rather than fail
455            // the whole inventory.
456            continue;
457        }
458
459        let base = info.lpBaseOfDll as u64;
460        let sections = match unsafe { read_sections(base) } {
461            Some(s) => s,
462            None => continue,
463        };
464        let (debug_id, debug_file) = unsafe {
465            loaded_pe_debug_info(base, u64::from(info.SizeOfImage))
466                .map(|(identity, file)| (Some(identity), Some(file)))
467                .unwrap_or((None, None))
468        };
469
470        modules.push(LoadedModule {
471            base,
472            size: u64::from(info.SizeOfImage),
473            mapped_ranges: Vec::new(),
474            executable_ranges: Vec::new(),
475            path: unsafe { module_path(process, handle) },
476            debug_id,
477            debug_file,
478            sections,
479        });
480    }
481
482    Ok(modules)
483}
484
485/// Full path of a loaded module, or `None` if the OS would not say.
486///
487/// # Safety
488///
489/// `handle` must be a module handle obtained from `process`.
490#[cfg(windows)]
491unsafe fn module_path(process: winapi::um::winnt::HANDLE, handle: HMODULE) -> Option<String> {
492    let mut buffer = [0u16; 32768];
493    let len = GetModuleFileNameExW(process, handle, buffer.as_mut_ptr(), buffer.len() as DWORD);
494    if len == 0 {
495        return None;
496    }
497    Some(String::from_utf16_lossy(&buffer[..len as usize]))
498}
499
500/// Find the module containing `address`.
501pub fn module_for_address(modules: &[LoadedModule], address: u64) -> Option<&LoadedModule> {
502    modules.iter().find(|m| m.contains(address))
503}
504
505#[cfg(target_os = "linux")]
506fn next_maps_field(input: &str) -> Option<(&str, &str)> {
507    let input = input.trim_start();
508    let end = input.find(char::is_whitespace).unwrap_or(input.len());
509    (!input.is_empty()).then_some((&input[..end], &input[end..]))
510}
511
512#[cfg(target_os = "linux")]
513struct LinuxImage {
514    mapped_ranges: Vec<Range<u64>>,
515    executable_ranges: Vec<Range<u64>>,
516    path: String,
517    device_major: u64,
518    device_minor: u64,
519    inode: String,
520}
521
522#[cfg(target_os = "linux")]
523type LinuxImageKey = (String, String, String, u64);
524
525#[cfg(target_os = "linux")]
526struct LinuxMapping {
527    range: Range<u64>,
528    executable: bool,
529}
530
531#[cfg(target_os = "linux")]
532fn linux_images() -> std::io::Result<Vec<LinuxImage>> {
533    use std::collections::BTreeMap;
534
535    // (path, device, inode, load instance) -> individual mapped ranges.
536    let mut images: BTreeMap<LinuxImageKey, Vec<LinuxMapping>> = BTreeMap::new();
537    for line in std::fs::read_to_string("/proc/self/maps")?.lines() {
538        let Some((range, rest)) = next_maps_field(line) else {
539            continue;
540        };
541        let Some((perms, rest)) = next_maps_field(rest) else {
542            continue;
543        };
544        let Some((offset, rest)) = next_maps_field(rest) else {
545            continue;
546        };
547        let Some((dev, rest)) = next_maps_field(rest) else {
548            continue;
549        };
550        let Some((inode, rest)) = next_maps_field(rest) else {
551            continue;
552        };
553        let path = rest.trim_start();
554        if !path.starts_with('/') {
555            continue;
556        }
557        if path.ends_with(" (deleted)") {
558            // Reopening the same pathname could read a replacement build,
559            // producing plausible but wrong unwind rules. A deleted mapping
560            // is safer left raw.
561            continue;
562        }
563        let path = path.to_owned();
564        let Some((start, end)) = range.split_once('-') else {
565            continue;
566        };
567        let (Ok(start), Ok(end), Ok(offset)) = (
568            u64::from_str_radix(start, 16),
569            u64::from_str_radix(end, 16),
570            u64::from_str_radix(offset, 16),
571        ) else {
572            continue;
573        };
574        let candidate_base = start.saturating_sub(offset);
575        images
576            .entry((path, dev.to_owned(), inode.to_owned(), candidate_base))
577            .or_default()
578            .push(LinuxMapping {
579                range: start..end,
580                executable: perms.as_bytes().get(2) == Some(&b'x'),
581            });
582    }
583
584    Ok(images
585        .into_iter()
586        .filter_map(|((path, device, inode, _load_bias), mut mappings)| {
587            let (major, minor) = device.split_once(':')?;
588            let device_major = u64::from_str_radix(major, 16).ok()?;
589            let device_minor = u64::from_str_radix(minor, 16).ok()?;
590            mappings.sort_by_key(|mapping| mapping.range.start);
591            Some(LinuxImage {
592                mapped_ranges: mappings
593                    .iter()
594                    .map(|mapping| mapping.range.clone())
595                    .collect(),
596                executable_ranges: mappings
597                    .into_iter()
598                    .filter_map(|mapping| mapping.executable.then_some(mapping.range))
599                    .collect(),
600                path,
601                device_major,
602                device_minor,
603                inode,
604            })
605        })
606        .collect())
607}
608
609#[cfg(target_os = "linux")]
610/// Enumerate ELF images mapped in the current Linux process.
611pub fn enumerate_modules() -> std::io::Result<Vec<LoadedModule>> {
612    use object::{Object, ObjectKind, ObjectSection};
613    use std::io::Read as _;
614
615    let mut modules = Vec::new();
616    let loaded_identities = loaded_elf_identities();
617    for LinuxImage {
618        mapped_ranges,
619        executable_ranges,
620        path,
621        device_major,
622        device_minor,
623        inode,
624    } in linux_images()?
625    {
626        let Some(mapped_start) = mapped_ranges.first().map(|range| range.start) else {
627            continue;
628        };
629        let Some(mapped_end) = mapped_ranges.last().map(|range| range.end) else {
630            continue;
631        };
632        use std::os::unix::fs::MetadataExt as _;
633
634        let Ok(file_handle) = std::fs::File::open(&path) else {
635            continue;
636        };
637        let Ok(metadata) = file_handle.metadata() else {
638            continue;
639        };
640        if u64::from(libc::major(metadata.dev())) != device_major
641            || u64::from(libc::minor(metadata.dev())) != device_minor
642            || metadata.ino().to_string() != inode
643        {
644            // The pathname no longer names the object in /proc/self/maps.
645            continue;
646        }
647        if metadata.len() > MAX_MODULE_IMAGE_BYTES {
648            continue;
649        }
650        let mut data = Vec::new();
651        if file_handle
652            .take(MAX_MODULE_IMAGE_BYTES + 1)
653            .read_to_end(&mut data)
654            .is_err()
655            || data.len() as u64 > MAX_MODULE_IMAGE_BYTES
656        {
657            // A deleted/replaced mapping remains valid for raw capture but
658            // cannot safely provide unwind metadata from disk. Leave it out
659            // rather than attribute it to a different build.
660            continue;
661        }
662        let Ok(file) = object::File::parse(data.as_slice()) else {
663            continue;
664        };
665        let Some(loaded_identity) = loaded_identities.iter().find(|identity| {
666            identity.mapped_ranges.iter().any(|loaded| {
667                mapped_ranges
668                    .iter()
669                    .any(|mapped| loaded.start < mapped.end && mapped.start < loaded.end)
670            })
671        }) else {
672            continue;
673        };
674        let base = if file.kind() == ObjectKind::Executable {
675            0
676        } else {
677            loaded_identity.load_bias
678        };
679        let debug_id = loaded_identity.debug_id.clone();
680        let file_debug_id = file
681            .build_id()
682            .ok()
683            .flatten()
684            .map(|build_id| format!("elf:{}", hex_bytes(build_id)));
685        if file_debug_id.as_deref() != Some(debug_id.as_str()) {
686            // Device/inode stability is not enough: an in-place overwrite can
687            // preserve both. Never consume section metadata unless the file
688            // still carries the build-id observed in the mapped PT_NOTE.
689            continue;
690        }
691        let sections = file
692            .sections()
693            .filter_map(|section| {
694                let name = section.name().ok()?.to_owned();
695                let start = base.checked_add(section.address())?;
696                let end = start.checked_add(section.size())?;
697                Some(Section {
698                    name,
699                    range: start..end,
700                })
701            })
702            .collect();
703
704        modules.push(LoadedModule {
705            base,
706            size: mapped_end.saturating_sub(mapped_start),
707            mapped_ranges,
708            executable_ranges,
709            path: Some(path),
710            debug_id: Some(debug_id),
711            debug_file: None,
712            sections,
713        });
714    }
715    modules.sort_by_key(|module| module.base);
716    Ok(modules)
717}
718
719#[cfg(target_os = "macos")]
720unsafe extern "C" {
721    fn _dyld_image_count() -> u32;
722    fn _dyld_get_image_header(image_index: u32) -> *const libc::c_void;
723    fn _dyld_get_image_vmaddr_slide(image_index: u32) -> isize;
724    fn _dyld_get_image_name(image_index: u32) -> *const libc::c_char;
725}
726
727#[cfg(target_os = "macos")]
728fn add_slide(address: u64, slide: isize) -> Option<u64> {
729    if slide >= 0 {
730        address.checked_add(slide as u64)
731    } else {
732        address.checked_sub(slide.unsigned_abs() as u64)
733    }
734}
735
736#[cfg(target_os = "macos")]
737/// Enumerate Mach-O images loaded by dyld in the current macOS process.
738pub fn enumerate_modules() -> std::io::Result<Vec<LoadedModule>> {
739    use object::{Object, ObjectSection, ObjectSegment};
740    use std::ffi::CStr;
741    use std::io::Read as _;
742
743    let count = unsafe { _dyld_image_count() };
744    let mut modules = Vec::with_capacity(count as usize);
745    for index in 0..count {
746        let name = unsafe { _dyld_get_image_name(index) };
747        let header = unsafe { _dyld_get_image_header(index) };
748        if name.is_null() || header.is_null() {
749            continue;
750        }
751        let path = unsafe { CStr::from_ptr(name) }
752            .to_string_lossy()
753            .into_owned();
754        let Some(loaded_uuid) = (unsafe { loaded_macho_uuid(header.cast()) }) else {
755            continue;
756        };
757        let Ok(file_handle) = std::fs::File::open(&path) else {
758            // Some system images live only in the shared dyld cache. Their
759            // raw frames remain unattributed rather than being paired with
760            // metadata read from a different file.
761            continue;
762        };
763        let mut data = Vec::new();
764        if file_handle
765            .take(MAX_MODULE_IMAGE_BYTES + 1)
766            .read_to_end(&mut data)
767            .is_err()
768            || data.len() as u64 > MAX_MODULE_IMAGE_BYTES
769        {
770            continue;
771        }
772        let Ok(file) = object::File::parse(data.as_slice()) else {
773            continue;
774        };
775        if file.mach_uuid().ok().flatten() != Some(loaded_uuid) {
776            // The path can be replaced while dyld keeps the original image
777            // mapped. Only use on-disk sections for the exact loaded UUID.
778            continue;
779        }
780        let slide = unsafe { _dyld_get_image_vmaddr_slide(index) };
781        let debug_id = Some(format!("macho:{}", hex_bytes(&loaded_uuid)));
782        let base_svma = file.relative_address_base();
783        let base = add_slide(base_svma, slide).unwrap_or(header as u64);
784
785        let mut mapped_start = u64::MAX;
786        let mut mapped_end = 0u64;
787        let mut executable_ranges = Vec::new();
788        for segment in file.segments() {
789            if segment.name().ok().flatten() == Some("__PAGEZERO") {
790                continue;
791            }
792            let Some(start) = add_slide(segment.address(), slide) else {
793                continue;
794            };
795            let Some(end) = start.checked_add(segment.size()) else {
796                continue;
797            };
798            mapped_start = mapped_start.min(start);
799            mapped_end = mapped_end.max(end);
800            if matches!(
801                segment.flags(),
802                object::SegmentFlags::MachO { initprot, .. }
803                    if initprot & object::macho::VM_PROT_EXECUTE != 0
804            ) {
805                executable_ranges.push(start..end);
806            }
807        }
808        if mapped_start == u64::MAX || mapped_end <= base {
809            continue;
810        }
811
812        let sections = file
813            .sections()
814            .filter_map(|section| {
815                let name = section.name().ok()?.to_owned();
816                let start = add_slide(section.address(), slide)?;
817                let end = start.checked_add(section.size())?;
818                Some(Section {
819                    name,
820                    range: start..end,
821                })
822            })
823            .collect();
824        let _ = mapped_start;
825        modules.push(LoadedModule {
826            base,
827            size: mapped_end - base,
828            mapped_ranges: Vec::new(),
829            executable_ranges,
830            path: Some(path),
831            debug_id,
832            debug_file: None,
833            sections,
834        });
835    }
836    modules.sort_by_key(|module| module.base);
837    Ok(modules)
838}
839
840#[cfg(all(test, windows))]
841mod tests {
842    use super::*;
843
844    /// A distinctive function whose address is used to locate `.text` below.
845    #[inline(never)]
846    fn landmark() -> u64 {
847        // The black_box keeps this from being optimized into nothing, which
848        // would make its address meaningless.
849        std::hint::black_box(0xD1A6_0057_u64)
850    }
851
852    #[test]
853    fn enumeration_finds_at_least_the_executable_and_some_dlls() {
854        let modules = enumerate_modules().expect("enumerate");
855        assert!(
856            modules.len() >= 2,
857            "expected the exe plus at least one DLL, got {}",
858            modules.len()
859        );
860    }
861
862    #[test]
863    fn every_module_reports_a_nonempty_range_and_sections() {
864        for m in enumerate_modules().expect("enumerate") {
865            assert!(m.base != 0, "module with null base");
866            assert!(m.size > 0, "module with zero size at {:#x}", m.base);
867            assert!(
868                !m.sections.is_empty(),
869                "module at {:#x} parsed no sections",
870                m.base
871            );
872        }
873    }
874
875    /// The decisive check: a real function's address must land inside the
876    /// `.text` range of the module reporting it.
877    ///
878    /// This verifies the section arithmetic end-to-end without any unwinder —
879    /// a wrong base, a wrong optional-header size, or a misread VirtualAddress
880    /// all fail here.
881    #[test]
882    fn text_section_contains_a_known_function_address() {
883        let addr = (landmark as fn() -> u64) as usize as u64;
884        let modules = enumerate_modules().expect("enumerate");
885
886        let owner = module_for_address(&modules, addr)
887            .unwrap_or_else(|| panic!("no module contains {addr:#x}"));
888
889        let text = owner
890            .section(".text")
891            .unwrap_or_else(|| panic!("module at {:#x} has no .text", owner.base));
892
893        assert!(
894            text.range.contains(&addr),
895            "function at {addr:#x} is outside its module's .text ({:#x}..{:#x})",
896            text.range.start,
897            text.range.end
898        );
899        // Sanity: the landmark still evaluates, so it was not optimized away.
900        assert_eq!(landmark(), 0xD1A6_0057_u64);
901    }
902
903    #[test]
904    fn loaded_pe_owner_carries_its_codeview_identity() {
905        let addr = (landmark as fn() -> u64) as usize as u64;
906        let modules = enumerate_modules().expect("enumerate");
907        let owner = module_for_address(&modules, addr).expect("owning module");
908        assert!(
909            owner
910                .debug_id
911                .as_deref()
912                .is_some_and(|identity| identity.starts_with("pdb:")),
913            "loaded PE did not expose its mapped CodeView GUID+age: {:?}",
914            owner.debug_id
915        );
916    }
917
918    #[test]
919    fn module_lookup_rejects_an_address_outside_every_module() {
920        let modules = enumerate_modules().expect("enumerate");
921        // A deliberately implausible user-mode address.
922        assert!(module_for_address(&modules, 0x1).is_none());
923    }
924
925    /// Unwinding needs `.pdata`; confirm the inventory actually surfaces it for
926    /// the module holding our own code.
927    #[test]
928    fn own_module_exposes_unwind_sections() {
929        let addr = (landmark as fn() -> u64) as usize as u64;
930        let modules = enumerate_modules().expect("enumerate");
931        let owner = module_for_address(&modules, addr).expect("owning module");
932
933        assert!(
934            owner.section(".pdata").is_some(),
935            "x86_64 PE modules carry .pdata unwind tables; sections found: {:?}",
936            owner.sections.iter().map(|s| &s.name).collect::<Vec<_>>()
937        );
938    }
939
940    #[test]
941    fn sections_do_not_extend_past_their_module() {
942        for m in enumerate_modules().expect("enumerate") {
943            let module_end = m.base + m.size;
944            for s in &m.sections {
945                assert!(
946                    s.range.start >= m.base && s.range.start <= module_end,
947                    "section {} at {:#x} lies outside module {:#x}..{:#x}",
948                    s.name,
949                    s.range.start,
950                    m.base,
951                    module_end
952                );
953            }
954        }
955    }
956}
957
958#[cfg(all(test, target_os = "linux"))]
959mod linux_tests {
960    use super::*;
961
962    #[inline(never)]
963    fn landmark() {}
964
965    #[test]
966    fn maps_path_preserves_spaces_and_deleted_suffix() {
967        let line = "1000-2000 r-xp 00000000 08:01 42 /tmp/a file (deleted)";
968        let (_, rest) = next_maps_field(line).unwrap();
969        let (_, rest) = next_maps_field(rest).unwrap();
970        let (_, rest) = next_maps_field(rest).unwrap();
971        let (_, rest) = next_maps_field(rest).unwrap();
972        let (_, rest) = next_maps_field(rest).unwrap();
973        assert_eq!(rest.trim_start(), "/tmp/a file (deleted)");
974        assert!(rest.trim_start().ends_with(" (deleted)"));
975    }
976
977    #[test]
978    fn elf_load_bias_does_not_expand_mapped_coverage_to_zero() {
979        let module = LoadedModule {
980            base: 0,
981            size: 0x2000,
982            mapped_ranges: vec![0x400000..0x401000, 0x402000..0x403000],
983            executable_ranges: std::iter::once(0x400000..0x401000).collect(),
984            path: Some("/tmp/non-pie".into()),
985            debug_id: None,
986            debug_file: None,
987            sections: Vec::new(),
988        };
989        assert!(module.contains(0x400100));
990        assert!(module.contains(0x402100));
991        assert!(!module.contains(0x401100));
992        assert!(!module.contains(1));
993        assert!(module.contains_executable(0x400100));
994        assert!(!module.contains_executable(0x402100));
995    }
996
997    #[test]
998    fn loaded_elf_owner_carries_its_pt_note_build_id() {
999        let address = landmark as fn() as usize as u64;
1000        let modules = enumerate_modules().expect("enumerate");
1001        let owner = module_for_address(&modules, address).expect("owning module");
1002        assert!(
1003            owner
1004                .debug_id
1005                .as_deref()
1006                .is_some_and(|identity| identity.starts_with("elf:")),
1007            "loaded ELF did not expose its PT_NOTE build-id: {:?}",
1008            owner.debug_id
1009        );
1010    }
1011
1012    #[test]
1013    fn gnu_note_parser_extracts_the_build_id() {
1014        let mut note = Vec::new();
1015        note.extend_from_slice(&4u32.to_ne_bytes());
1016        note.extend_from_slice(&4u32.to_ne_bytes());
1017        note.extend_from_slice(&3u32.to_ne_bytes());
1018        note.extend_from_slice(b"GNU\0");
1019        note.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]);
1020        assert_eq!(
1021            gnu_build_id_from_notes(&note),
1022            Some(&[0xaa, 0xbb, 0xcc, 0xdd][..])
1023        );
1024    }
1025}
1026
1027#[cfg(all(test, target_os = "macos"))]
1028mod macos_tests {
1029    use super::*;
1030
1031    #[inline(never)]
1032    fn landmark() {}
1033
1034    #[test]
1035    fn loaded_macho_owner_carries_its_lc_uuid() {
1036        let address = landmark as fn() as usize as u64;
1037        let modules = enumerate_modules().expect("enumerate");
1038        let owner = module_for_address(&modules, address).expect("owning module");
1039        assert!(
1040            owner
1041                .debug_id
1042                .as_deref()
1043                .is_some_and(|identity| identity.starts_with("macho:")),
1044            "loaded Mach-O did not expose its LC_UUID: {:?}",
1045            owner.debug_id
1046        );
1047    }
1048}