Skip to main content

ghostscope_process/
offsets.rs

1use crate::module_probe::ModuleProbe;
2use crate::proc_maps::{
3    normalize_mapped_module_path, read_proc_maps, should_skip_mapped_module_path, visit_proc_maps,
4    ModuleIdentity, OwnedProcMapEntry,
5};
6use anyhow::Result;
7use object::{Object, ObjectSection, ObjectSegment};
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9use std::fs;
10use std::ops::ControlFlow;
11use std::os::unix::fs::MetadataExt;
12use std::path::Path;
13// no extra imports
14
15/// Per-module section offsets (runtime bias) computed from /proc/PID/maps
16#[derive(Debug, Clone, Copy, Default)]
17pub struct SectionOffsets {
18    pub text: u64,
19    pub rodata: u64,
20    pub data: u64,
21    pub bss: u64,
22}
23
24#[derive(Debug, Clone)]
25pub struct PidOffsetsEntry {
26    pub module_path: String,
27    pub cookie: u64,
28    pub offsets: SectionOffsets,
29    pub base: u64,
30    pub size: u64,
31}
32
33/// ProcessManager for precomputing and caching ASLR section offsets.
34#[derive(Debug)]
35pub struct ProcessManager {
36    module_cache: HashMap<String, Vec<CachedEntry>>,
37    prefilled_modules: HashSet<String>,
38    pid_cache: HashMap<u32, Vec<PidOffsetsEntry>>,
39    prefilled_pids: HashSet<u32>,
40}
41
42impl Default for ProcessManager {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48#[derive(Debug, Clone)]
49struct CachedEntry {
50    pid: u32,
51    cookie: u64,
52    offsets: SectionOffsets,
53}
54
55#[derive(Debug, Clone, Default)]
56struct ModuleMapSummary {
57    candidates: Vec<(u64, u64)>,
58    min_start: Option<u64>,
59    max_end: Option<u64>,
60}
61
62impl ModuleMapSummary {
63    fn observe(&mut self, entry: &OwnedProcMapEntry) {
64        self.min_start = Some(self.min_start.map_or(entry.start, |v| v.min(entry.start)));
65        self.max_end = Some(self.max_end.map_or(entry.end, |v| v.max(entry.end)));
66        self.candidates.push((entry.offset, entry.start));
67    }
68
69    fn base(&self) -> u64 {
70        self.min_start.unwrap_or(0)
71    }
72
73    fn size(&self) -> u64 {
74        let base = self.base();
75        self.max_end.unwrap_or(base).saturating_sub(base)
76    }
77
78    fn merge(&mut self, other: &Self) {
79        self.candidates.extend(other.candidates.iter().copied());
80        if let Some(start) = other.min_start {
81            self.min_start = Some(self.min_start.map_or(start, |v| v.min(start)));
82        }
83        if let Some(end) = other.max_end {
84            self.max_end = Some(self.max_end.map_or(end, |v| v.max(end)));
85        }
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
90struct ModuleMapIdentityKey {
91    dev_major: u64,
92    dev_minor: u64,
93    inode: u64,
94}
95
96impl ModuleMapIdentityKey {
97    fn from_entry(entry: &OwnedProcMapEntry) -> Self {
98        Self {
99            dev_major: entry.dev_major,
100            dev_minor: entry.dev_minor,
101            inode: entry.inode,
102        }
103    }
104
105    fn from_metadata(meta: &fs::Metadata) -> Self {
106        let dev = meta.dev() as libc::dev_t;
107        Self {
108            dev_major: libc::major(dev) as u64,
109            dev_minor: libc::minor(dev) as u64,
110            inode: meta.ino(),
111        }
112    }
113}
114
115#[derive(Debug, Clone, Default)]
116struct ModulePathSummaries {
117    by_identity: BTreeMap<ModuleMapIdentityKey, ModuleMapSummary>,
118}
119
120impl ModulePathSummaries {
121    fn observe(&mut self, entry: &OwnedProcMapEntry) {
122        self.by_identity
123            .entry(ModuleMapIdentityKey::from_entry(entry))
124            .or_default()
125            .observe(entry);
126    }
127
128    fn summary_for_inode(&self, inode: u64) -> Option<ModuleMapSummary> {
129        let mut matches = self
130            .by_identity
131            .iter()
132            .filter(|(key, _)| key.inode == inode)
133            .map(|(_, summary)| summary);
134        let mut merged = matches.next()?.clone();
135        for summary in matches {
136            merged.merge(summary);
137        }
138        Some(merged)
139    }
140
141    fn summary_for_path(&self, module_path: &str) -> Option<ModuleMapSummary> {
142        match fs::metadata(module_path) {
143            Ok(meta) => self
144                .by_identity
145                .get(&ModuleMapIdentityKey::from_metadata(&meta))
146                .cloned()
147                // overlayfs compatibility: within the same normalized path bucket, the mapped
148                // file can keep the same inode while reporting a different device number.
149                .or_else(|| self.summary_for_inode(meta.ino())),
150            Err(_) => {
151                let mut merged = ModuleMapSummary::default();
152                for summary in self.by_identity.values() {
153                    merged.merge(summary);
154                }
155                (!merged.candidates.is_empty()).then_some(merged)
156            }
157        }
158    }
159}
160
161impl ProcessManager {
162    pub fn new() -> Self {
163        Self {
164            module_cache: HashMap::new(),
165            prefilled_modules: HashSet::new(),
166            pid_cache: HashMap::new(),
167            prefilled_pids: HashSet::new(),
168        }
169    }
170
171    pub fn ensure_prefill_module(&mut self, module_path: &str) -> Result<usize> {
172        if self.prefilled_modules.contains(module_path) {
173            return Ok(0);
174        }
175        let mut pids: BTreeSet<u32> = BTreeSet::new();
176
177        // 1) Fast path for executable targets: compare /proc/*/exe dev+ino
178        let (t_dev, t_ino) = if let Ok(meta) = fs::metadata(module_path) {
179            (Some(meta.dev()), Some(meta.ino()))
180        } else {
181            (None, None)
182        };
183        if let (Some(dev), Some(ino)) = (t_dev, t_ino) {
184            if let Ok(dir) = fs::read_dir("/proc") {
185                for ent in dir.flatten() {
186                    let fname = ent.file_name();
187                    if let Ok(pid) = fname.to_string_lossy().parse::<u32>() {
188                        let exe_path = format!("/proc/{pid}/exe");
189                        if is_same_executable_as_current(pid) {
190                            continue; // never treat ghostscope itself as a target process
191                        }
192                        if let Ok(st) = fs::metadata(&exe_path) {
193                            if st.dev() == dev && st.ino() == ino {
194                                pids.insert(pid);
195                            }
196                        }
197                    }
198                }
199            }
200        }
201
202        // 2) Shared libraries/modules: scan /proc/<pid>/maps for executable matches.
203        let target = ModuleIdentity::from_path(Path::new(module_path));
204        if let Ok(dir) = fs::read_dir("/proc") {
205            for ent in dir.flatten() {
206                let fname = ent.file_name();
207                if let Ok(pid) = fname.to_string_lossy().parse::<u32>() {
208                    if is_same_executable_as_current(pid) {
209                        continue; // skip our own processes even if they mmap the target for reading
210                    }
211                    let mut hit = false;
212                    if visit_proc_maps(pid, |entry| {
213                        if !entry.executable() {
214                            return ControlFlow::Continue(());
215                        }
216                        if target.matches(&entry) {
217                            hit = true;
218                            return ControlFlow::Break(());
219                        }
220                        ControlFlow::Continue(())
221                    })
222                    .is_ok()
223                        && hit
224                    {
225                        pids.insert(pid);
226                    }
227                }
228            }
229        }
230        let mut cached: Vec<CachedEntry> = Vec::new();
231        let mut new_count = 0usize;
232        // Intentionally keep PID list silent to avoid noisy logs in normal runs
233        for pid in pids {
234            match self.compute_section_offsets_for_process_with_retry(
235                pid,
236                module_path,
237                3,
238                std::time::Duration::from_millis(75),
239            ) {
240                Ok((cookie, offsets, _base, _size)) => {
241                    cached.push(CachedEntry {
242                        pid,
243                        cookie,
244                        offsets,
245                    });
246                    new_count += 1;
247                }
248                Err(e) => tracing::debug!(
249                    "ProcessManager: skip pid {} for module {} (offsets failed: {})",
250                    pid,
251                    module_path,
252                    e
253                ),
254            }
255        }
256        self.module_cache.insert(module_path.to_string(), cached);
257        self.prefilled_modules.insert(module_path.to_string());
258        Ok(new_count)
259    }
260
261    pub fn cached_offsets_for_module(&self, module_path: &str) -> Vec<(u32, u64, SectionOffsets)> {
262        self.module_cache
263            .get(module_path)
264            .map(|v| v.iter().map(|e| (e.pid, e.cookie, e.offsets)).collect())
265            .unwrap_or_default()
266    }
267
268    /// Force-refresh per-module cache (used when late-start targets appear after initial prefill).
269    pub fn refresh_prefill_module(&mut self, module_path: &str) -> Result<usize> {
270        self.prefilled_modules.remove(module_path);
271        self.ensure_prefill_module(module_path)
272    }
273
274    pub fn ensure_prefill_pid(&mut self, pid: u32) -> Result<usize> {
275        if self.prefilled_pids.contains(&pid) {
276            return Ok(0);
277        }
278        let maps = read_proc_maps(pid)?;
279        let mut module_summaries: BTreeMap<String, ModulePathSummaries> = BTreeMap::new();
280        for entry in &maps {
281            let Some(path) = entry.path() else {
282                continue;
283            };
284            if should_skip_mapped_module_path(path) {
285                continue;
286            }
287            let path_trim = normalize_mapped_module_path(path);
288            module_summaries
289                .entry(path_trim.to_string())
290                .or_default()
291                .observe(entry);
292        }
293        let mut list: Vec<PidOffsetsEntry> = Vec::new();
294        for (module_path, summaries) in module_summaries {
295            let Some(summary) = summaries.summary_for_path(&module_path) else {
296                tracing::debug!(
297                    "ProcessManager: skip module {} for pid {}: no maps matched current file identity",
298                    module_path,
299                    pid
300                );
301                continue;
302            };
303            match self.compute_section_offsets_from_candidates(
304                pid,
305                &module_path,
306                &summary.candidates,
307                summary.base(),
308                summary.size(),
309            ) {
310                Ok((cookie, off, base, size)) => list.push(PidOffsetsEntry {
311                    module_path,
312                    cookie,
313                    offsets: off,
314                    base,
315                    size,
316                }),
317                Err(e) => {
318                    tracing::debug!(
319                        "ProcessManager: skip module {} for pid {}: {}",
320                        module_path,
321                        pid,
322                        e
323                    )
324                }
325            }
326        }
327        self.pid_cache.insert(pid, list);
328        self.prefilled_pids.insert(pid);
329        Ok(self.pid_cache.get(&pid).map(|v| v.len()).unwrap_or(0))
330    }
331
332    /// Force-refresh per-PID cache (used when exec-time prefill raced with module mapping).
333    pub fn refresh_prefill_pid(&mut self, pid: u32) -> Result<usize> {
334        self.prefilled_pids.remove(&pid);
335        self.pid_cache.remove(&pid);
336        self.ensure_prefill_pid(pid)
337    }
338
339    fn compute_section_offsets_for_process(
340        &self,
341        pid: u32,
342        module_path: &str,
343    ) -> Result<(u64, SectionOffsets, u64, u64)> {
344        let module_path = normalize_mapped_module_path(module_path);
345        let mut candidates: Vec<(u64, u64)> = Vec::new();
346        let mut min_start: Option<u64> = None;
347        let mut max_end: Option<u64> = None;
348        let target = ModuleIdentity::from_path(Path::new(module_path));
349        visit_proc_maps(pid, |entry| {
350            if !target.matches(&entry) {
351                return ControlFlow::Continue(());
352            }
353            min_start = Some(min_start.map_or(entry.start, |v| v.min(entry.start)));
354            max_end = Some(max_end.map_or(entry.end, |v| v.max(entry.end)));
355            candidates.push((entry.offset, entry.start));
356            ControlFlow::Continue(())
357        })?;
358        let base = min_start.unwrap_or(0);
359        let size = max_end.unwrap_or(base).saturating_sub(base);
360        self.compute_section_offsets_from_candidates(pid, module_path, &candidates, base, size)
361    }
362
363    fn compute_section_offsets_from_candidates(
364        &self,
365        pid: u32,
366        module_path: &str,
367        candidates: &[(u64, u64)],
368        base: u64,
369        size: u64,
370    ) -> Result<(u64, SectionOffsets, u64, u64)> {
371        let probe = ModuleProbe::open(module_path)?;
372        let obj = probe.object()?;
373        let page_mask: u64 = !0xfffu64;
374        let mut seg_bias: Vec<(u64, u64, u64)> = Vec::new();
375        for seg in obj.segments() {
376            let (file_off, _sz) = seg.file_range();
377            let vaddr = seg.address();
378            let key = file_off & page_mask;
379            if let Some((_, start)) = candidates
380                .iter()
381                .find(|(fo, _)| (*fo & page_mask) == key)
382                .copied()
383            {
384                let bias = start.saturating_sub(vaddr);
385                seg_bias.push((key, vaddr, bias));
386            }
387        }
388        let find_bias_for = |addr: u64| -> Option<u64> {
389            for seg in obj.segments() {
390                let vaddr = seg.address();
391                let vsize = seg.size();
392                if vsize == 0 {
393                    continue;
394                }
395                if addr >= vaddr && addr < vaddr + vsize {
396                    let (file_off, _sz) = seg.file_range();
397                    let key = file_off & page_mask;
398                    if let Some((_, _, b)) = seg_bias.iter().find(|(k, _, _)| *k == key) {
399                        return Some(*b);
400                    }
401                }
402            }
403            None
404        };
405        let mut text_addr: Option<u64> = None;
406        let mut rodata_addr: Option<u64> = None;
407        let mut data_addr: Option<u64> = None;
408        let mut bss_addr: Option<u64> = None;
409        for sect in obj.sections() {
410            if let Ok(name) = sect.name() {
411                let addr = sect.address();
412                if text_addr.is_none() && (name == ".text" || name.starts_with(".text")) {
413                    text_addr = Some(addr);
414                } else if rodata_addr.is_none()
415                    && (name == ".rodata" || name.starts_with(".rodata"))
416                {
417                    rodata_addr = Some(addr);
418                } else if data_addr.is_none() && (name == ".data" || name.starts_with(".data")) {
419                    data_addr = Some(addr);
420                } else if bss_addr.is_none() && (name == ".bss" || name.starts_with(".bss")) {
421                    bss_addr = Some(addr);
422                }
423            }
424        }
425        let mut offsets = SectionOffsets::default();
426        // Each DW_OP_addr we encounter is an absolute link-time virtual address (e.g. 0x5798c for
427        // G_COUNTER). To rebase it we only need the ASLR bias `module_base`, not per-section
428        // runtime starts. Derive that bias from whichever segment we could match, then store it for
429        // all four slots so the eBPF helper can simply do `link_addr + bias`.
430        let module_base = text_addr
431            .and_then(find_bias_for)
432            .or_else(|| rodata_addr.and_then(find_bias_for))
433            .or_else(|| data_addr.and_then(find_bias_for))
434            .or_else(|| bss_addr.and_then(find_bias_for))
435            .unwrap_or(0);
436
437        offsets.text = module_base;
438        offsets.rodata = module_base;
439        offsets.data = module_base;
440        offsets.bss = module_base;
441        let cookie = probe.cookie_for_object(&obj);
442        if offsets.text == 0 && offsets.rodata == 0 && offsets.data == 0 && offsets.bss == 0 {
443            if seg_bias.is_empty() {
444                // No segment matches at all: genuine failure to map offsets
445                tracing::error!(
446                    "Offsets all zero for pid={} module='{}' (cookie=0x{:016x}); no segment matches, maps matching failed (dev:inode/path)",
447                    pid, module_path, cookie
448                );
449                return Err(anyhow::anyhow!(
450                    "computed zero offsets (no segment matches)"
451                ));
452            } else {
453                // Segments matched but all biases are zero — likely ET_EXEC (Non-PIE) loaded at linked addresses.
454                // This is valid: no ASLR rebase is needed.
455                tracing::debug!(
456                    "Offsets zero with valid segment matches (treat as Non-PIE): pid={} module='{}' cookie=0x{:016x}",
457                    pid, module_path, cookie
458                );
459            }
460        }
461        let runtime_text = text_addr
462            .map(|t| module_base.saturating_add(t))
463            .unwrap_or(0);
464        let runtime_ro = rodata_addr
465            .map(|r| module_base.saturating_add(r))
466            .unwrap_or(0);
467        let runtime_data = data_addr
468            .map(|d| module_base.saturating_add(d))
469            .unwrap_or(0);
470        let runtime_bss = bss_addr.map(|b| module_base.saturating_add(b)).unwrap_or(0);
471
472        tracing::debug!(
473            "computed offsets: pid={} module='{}' cookie=0x{:016x} base=0x{:x} size=0x{:x} module_bias=0x{:x} text=0x{:x} rodata=0x{:x} data=0x{:x} bss=0x{:x}",
474            pid,
475            module_path,
476            cookie,
477            base,
478            size,
479            offsets.text,
480            runtime_text,
481            runtime_ro,
482            runtime_data,
483            runtime_bss
484        );
485        Ok((cookie, offsets, base, size))
486    }
487
488    fn compute_section_offsets_for_process_with_retry(
489        &self,
490        pid: u32,
491        module_path: &str,
492        attempts: usize,
493        backoff: std::time::Duration,
494    ) -> Result<(u64, SectionOffsets, u64, u64)> {
495        let mut last_err: Option<anyhow::Error> = None;
496        for i in 0..attempts {
497            match self.compute_section_offsets_for_process(pid, module_path) {
498                Ok(v) => return Ok(v),
499                Err(e) => {
500                    last_err = Some(e);
501                    if i + 1 < attempts {
502                        std::thread::sleep(backoff);
503                    }
504                }
505            }
506        }
507        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("offsets compute failed")))
508    }
509
510    pub fn cached_offsets_pairs_for_pid(&self, pid: u32) -> Option<Vec<(u64, SectionOffsets)>> {
511        self.pid_cache
512            .get(&pid)
513            .map(|v| v.iter().map(|e| (e.cookie, e.offsets)).collect())
514    }
515
516    pub fn cached_offsets_with_paths_for_pid(&self, pid: u32) -> Option<&[PidOffsetsEntry]> {
517        self.pid_cache.get(&pid).map(|v| v.as_slice())
518    }
519
520    /// Drop per-PID caches when a process exits so PID reuse can prefill again.
521    pub fn forget_pid(&mut self, pid: u32) {
522        self.prefilled_pids.remove(&pid);
523        self.pid_cache.remove(&pid);
524        for entries in self.module_cache.values_mut() {
525            entries.retain(|entry| entry.pid != pid);
526        }
527    }
528}
529
530fn is_same_executable_as_current(pid: u32) -> bool {
531    // Strongest signal: dev+ino equality on /proc/*/exe
532    let self_meta = fs::metadata("/proc/self/exe");
533    let pid_meta = fs::metadata(format!("/proc/{pid}/exe"));
534    if let (Ok(sm), Ok(pm)) = (self_meta, pid_meta) {
535        if sm.dev() == pm.dev() && sm.ino() == pm.ino() {
536            return true;
537        }
538    }
539
540    // Fallback: compare canonicalized exe symlink targets
541    let self_path = fs::read_link("/proc/self/exe")
542        .ok()
543        .and_then(|p| fs::canonicalize(p).ok());
544    let pid_path = fs::read_link(format!("/proc/{pid}/exe"))
545        .ok()
546        .and_then(|p| fs::canonicalize(p).ok());
547    if let (Some(sp), Some(pp)) = (self_path, pid_path) {
548        if sp == pp {
549            return true;
550        }
551    }
552
553    // Last resort: process name check via /proc/<pid>/comm (best-effort)
554    if let Ok(name) = fs::read_to_string(format!("/proc/{pid}/comm")) {
555        let n = name.trim();
556        if n.eq("ghostscope") {
557            return true;
558        }
559    }
560
561    false
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::proc_maps::parse_maps_line;
568    use std::time::{SystemTime, UNIX_EPOCH};
569
570    fn dev_pair_differs_from(meta: &std::fs::Metadata, salt: u64) -> (u64, u64) {
571        let dev = meta.dev() as libc::dev_t;
572        let actual_major = libc::major(dev) as u64;
573        let actual_minor = libc::minor(dev) as u64;
574        let major = actual_major ^ (0x40 + salt);
575        let minor = actual_minor ^ (0x80 + salt);
576        if major == actual_major && minor == actual_minor {
577            (actual_major + 1, actual_minor)
578        } else {
579            (major, minor)
580        }
581    }
582
583    #[test]
584    fn forget_pid_clears_pid_caches_and_module_entries() {
585        let mut mgr = ProcessManager::new();
586        mgr.prefilled_pids.insert(42);
587        mgr.pid_cache.insert(
588            42,
589            vec![PidOffsetsEntry {
590                module_path: "/tmp/a.so".to_string(),
591                cookie: 1,
592                offsets: SectionOffsets::default(),
593                base: 0,
594                size: 0,
595            }],
596        );
597        mgr.module_cache.insert(
598            "/tmp/a.so".to_string(),
599            vec![
600                CachedEntry {
601                    pid: 42,
602                    cookie: 1,
603                    offsets: SectionOffsets::default(),
604                },
605                CachedEntry {
606                    pid: 7,
607                    cookie: 2,
608                    offsets: SectionOffsets::default(),
609                },
610            ],
611        );
612
613        mgr.forget_pid(42);
614
615        assert!(!mgr.prefilled_pids.contains(&42));
616        assert!(!mgr.pid_cache.contains_key(&42));
617        let module_entries = mgr.module_cache.get("/tmp/a.so").unwrap();
618        assert_eq!(module_entries.len(), 1);
619        assert_eq!(module_entries[0].pid, 7);
620    }
621
622    #[test]
623    fn path_summaries_prefer_current_file_identity_when_metadata_exists() {
624        let suffix = SystemTime::now()
625            .duration_since(UNIX_EPOCH)
626            .unwrap()
627            .as_nanos();
628        let path = std::env::temp_dir().join(format!("ghostscope-offsets-{suffix}.so"));
629        std::fs::write(&path, b"current").unwrap();
630
631        let meta = std::fs::metadata(&path).unwrap();
632        let dev = meta.dev() as libc::dev_t;
633        let dev_major = libc::major(dev) as u64;
634        let dev_minor = libc::minor(dev) as u64;
635        let inode = meta.ino();
636        let path_str = path.to_string_lossy().to_string();
637
638        let current_entry: OwnedProcMapEntry = parse_maps_line(&format!(
639            "2000-3000 r-xp 00001000 {dev_major:02x}:{dev_minor:02x} {inode} {path_str}"
640        ))
641        .unwrap()
642        .into();
643        let stale_entry: OwnedProcMapEntry = parse_maps_line(&format!(
644            "1000-2000 r-xp 00000000 {dev_major:02x}:{dev_minor:02x} {} {path_str}",
645            inode + 1
646        ))
647        .unwrap()
648        .into();
649
650        let mut summaries = ModulePathSummaries::default();
651        summaries.observe(&stale_entry);
652        summaries.observe(&current_entry);
653
654        let summary = summaries.summary_for_path(&path_str).unwrap();
655        assert_eq!(summary.candidates, vec![(0x1000, 0x2000)]);
656        assert_eq!(summary.base(), 0x2000);
657        assert_eq!(summary.size(), 0x1000);
658
659        let _ = std::fs::remove_file(path);
660    }
661
662    #[test]
663    fn path_summaries_merge_groups_when_metadata_is_unavailable() {
664        let suffix = SystemTime::now()
665            .duration_since(UNIX_EPOCH)
666            .unwrap()
667            .as_nanos();
668        let path = format!("/tmp/ghostscope-missing-{suffix}.so");
669
670        let first: OwnedProcMapEntry =
671            parse_maps_line(&format!("1000-2000 r-xp 00000000 08:01 10 {path}"))
672                .unwrap()
673                .into();
674        let second: OwnedProcMapEntry =
675            parse_maps_line(&format!("3000-5000 r-xp 00002000 08:01 11 {path}"))
676                .unwrap()
677                .into();
678
679        let mut summaries = ModulePathSummaries::default();
680        summaries.observe(&first);
681        summaries.observe(&second);
682
683        let summary = summaries.summary_for_path(&path).unwrap();
684        assert_eq!(summary.candidates, vec![(0, 0x1000), (0x2000, 0x3000)]);
685        assert_eq!(summary.base(), 0x1000);
686        assert_eq!(summary.size(), 0x4000);
687    }
688
689    #[test]
690    fn path_summaries_fallback_to_inode_when_device_differs() {
691        let suffix = SystemTime::now()
692            .duration_since(UNIX_EPOCH)
693            .unwrap()
694            .as_nanos();
695        let path = std::env::temp_dir().join(format!("ghostscope-offsets-overlayfs-{suffix}.so"));
696        std::fs::write(&path, b"current").unwrap();
697
698        let meta = std::fs::metadata(&path).unwrap();
699        let inode = meta.ino();
700        let path_str = path.to_string_lossy().to_string();
701        let (dev_major, dev_minor) = dev_pair_differs_from(&meta, 1);
702
703        let overlay_entry: OwnedProcMapEntry = parse_maps_line(&format!(
704            "2000-3000 r-xp 00001000 {dev_major:02x}:{dev_minor:02x} {inode} {path_str}"
705        ))
706        .unwrap()
707        .into();
708
709        let mut summaries = ModulePathSummaries::default();
710        summaries.observe(&overlay_entry);
711
712        let summary = summaries.summary_for_path(&path_str).unwrap();
713        assert_eq!(summary.candidates, vec![(0x1000, 0x2000)]);
714        assert_eq!(summary.base(), 0x2000);
715        assert_eq!(summary.size(), 0x1000);
716
717        let _ = std::fs::remove_file(path);
718    }
719
720    #[test]
721    fn path_summaries_merge_same_inode_groups_within_path_bucket() {
722        let suffix = SystemTime::now()
723            .duration_since(UNIX_EPOCH)
724            .unwrap()
725            .as_nanos();
726        let path = std::env::temp_dir().join(format!("ghostscope-offsets-overlayfs-{suffix}.so"));
727        std::fs::write(&path, b"current").unwrap();
728
729        let meta = std::fs::metadata(&path).unwrap();
730        let inode = meta.ino();
731        let path_str = path.to_string_lossy().to_string();
732        let (lower_dev_major, lower_dev_minor) = dev_pair_differs_from(&meta, 2);
733        let (upper_dev_major, upper_dev_minor) = dev_pair_differs_from(&meta, 3);
734
735        let lower_entry: OwnedProcMapEntry = parse_maps_line(&format!(
736            "1000-2000 r-xp 00000000 {lower_dev_major:02x}:{lower_dev_minor:02x} {inode} {path_str}"
737        ))
738        .unwrap()
739        .into();
740        let upper_entry: OwnedProcMapEntry = parse_maps_line(&format!(
741            "3000-5000 r-xp 00002000 {upper_dev_major:02x}:{upper_dev_minor:02x} {inode} {path_str}"
742        ))
743        .unwrap()
744        .into();
745
746        let mut summaries = ModulePathSummaries::default();
747        summaries.observe(&lower_entry);
748        summaries.observe(&upper_entry);
749
750        let summary = summaries.summary_for_path(&path_str).unwrap();
751        let mut candidates = summary.candidates.clone();
752        candidates.sort();
753        assert_eq!(candidates, vec![(0, 0x1000), (0x2000, 0x3000)]);
754        assert_eq!(summary.base(), 0x1000);
755        assert_eq!(summary.size(), 0x4000);
756
757        let _ = std::fs::remove_file(path);
758    }
759}