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