ghostscope_process/
offsets.rs

1use anyhow::Result;
2use object::{Object, ObjectSection, ObjectSegment};
3use std::collections::{BTreeSet, HashMap, HashSet};
4use std::fs;
5use std::os::unix::fs::MetadataExt;
6// no extra imports
7
8/// Per-module section offsets (runtime bias) computed from /proc/PID/maps
9#[derive(Debug, Clone, Copy, Default)]
10pub struct SectionOffsets {
11    pub text: u64,
12    pub rodata: u64,
13    pub data: u64,
14    pub bss: u64,
15}
16
17#[derive(Debug, Clone)]
18pub struct PidOffsetsEntry {
19    pub module_path: String,
20    pub cookie: u64,
21    pub offsets: SectionOffsets,
22    pub base: u64,
23    pub size: u64,
24}
25
26/// ProcessManager for precomputing and caching ASLR section offsets.
27#[derive(Debug)]
28pub struct ProcessManager {
29    module_cache: HashMap<String, Vec<CachedEntry>>,
30    prefilled_modules: HashSet<String>,
31    pid_cache: HashMap<u32, Vec<PidOffsetsEntry>>,
32    prefilled_pids: HashSet<u32>,
33}
34
35impl Default for ProcessManager {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41#[derive(Debug, Clone)]
42struct CachedEntry {
43    pid: u32,
44    cookie: u64,
45    offsets: SectionOffsets,
46}
47
48impl ProcessManager {
49    pub fn new() -> Self {
50        Self {
51            module_cache: HashMap::new(),
52            prefilled_modules: HashSet::new(),
53            pid_cache: HashMap::new(),
54            prefilled_pids: HashSet::new(),
55        }
56    }
57
58    pub fn ensure_prefill_module(&mut self, module_path: &str) -> Result<usize> {
59        if self.prefilled_modules.contains(module_path) {
60            return Ok(0);
61        }
62        let mut pids: BTreeSet<u32> = BTreeSet::new();
63
64        // 1) Fast path for executable targets: compare /proc/*/exe dev+ino
65        let (t_dev, t_ino) = if let Ok(meta) = fs::metadata(module_path) {
66            (Some(meta.dev()), Some(meta.ino()))
67        } else {
68            (None, None)
69        };
70        if let (Some(dev), Some(ino)) = (t_dev, t_ino) {
71            if let Ok(dir) = fs::read_dir("/proc") {
72                for ent in dir.flatten() {
73                    let fname = ent.file_name();
74                    if let Ok(pid) = fname.to_string_lossy().parse::<u32>() {
75                        let exe_path = format!("/proc/{pid}/exe");
76                        if is_same_executable_as_current(pid) {
77                            continue; // never treat ghostscope itself as a target process
78                        }
79                        if let Ok(st) = fs::metadata(&exe_path) {
80                            if st.dev() == dev && st.ino() == ino {
81                                pids.insert(pid);
82                            }
83                        }
84                    }
85                }
86            }
87        }
88
89        // 2) Shared libraries/modules: scan /proc/<pid>/maps and match dev:inode when metadata available
90        // Convert target st_dev to major/minor for comparing with maps 'dev' field
91        let (t_maj, t_min) = if let Some(dev) = t_dev {
92            let d = dev as libc::dev_t;
93            let maj = libc::major(d) as u64;
94            let min = libc::minor(d) as u64;
95            (Some(maj), Some(min))
96        } else {
97            (None, None)
98        };
99        if let (Some(tmaj), Some(tmin), Some(tino)) = (t_maj, t_min, t_ino) {
100            if let Ok(dir) = fs::read_dir("/proc") {
101                for ent in dir.flatten() {
102                    let fname = ent.file_name();
103                    if let Ok(pid) = fname.to_string_lossy().parse::<u32>() {
104                        if is_same_executable_as_current(pid) {
105                            continue; // skip our own processes even if they mmap the target for reading
106                        }
107                        let maps_path = format!("/proc/{pid}/maps");
108                        if let Ok(content) = fs::read_to_string(&maps_path) {
109                            let mut hit = false;
110                            for line in content.lines() {
111                                let parts: Vec<&str> = line.split_whitespace().collect();
112                                if parts.len() < 6 {
113                                    continue;
114                                }
115                                // Require execute permission on the mapping to avoid read-only mmaps
116                                // perms format example: r-xp, r--p, rw-p
117                                let perms = parts[1];
118                                if !perms.contains('x') {
119                                    continue;
120                                }
121                                // parts[3] = dev (maj:min), hex; parts[4] = inode (dec)
122                                let dev_str = parts[3];
123                                let inode_str = parts[4];
124                                if let Some((maj_s, min_s)) = dev_str.split_once(':') {
125                                    if let (Ok(maj), Ok(min), Ok(ino)) = (
126                                        u64::from_str_radix(maj_s, 16),
127                                        u64::from_str_radix(min_s, 16),
128                                        inode_str.parse::<u64>(),
129                                    ) {
130                                        if maj == tmaj && min == tmin && ino == tino {
131                                            hit = true;
132                                            break; // early stop per PID
133                                        }
134                                    }
135                                }
136                            }
137                            if hit {
138                                pids.insert(pid);
139                            }
140                        }
141                    }
142                }
143            }
144        } else {
145            // 3) Fallback: when metadata is unavailable (file removed/replaced),
146            // scan /proc/<pid>/maps and match pathname string (trim " (deleted)")
147            if let Ok(dir) = fs::read_dir("/proc") {
148                for ent in dir.flatten() {
149                    let fname = ent.file_name();
150                    if let Ok(pid) = fname.to_string_lossy().parse::<u32>() {
151                        if is_same_executable_as_current(pid) {
152                            continue;
153                        }
154                        let maps_path = format!("/proc/{pid}/maps");
155                        if let Ok(content) = fs::read_to_string(&maps_path) {
156                            let mut hit = false;
157                            for line in content.lines() {
158                                let parts: Vec<&str> = line.split_whitespace().collect();
159                                if parts.len() < 6 {
160                                    continue;
161                                }
162                                let perms = parts[1];
163                                if !perms.contains('x') {
164                                    continue;
165                                }
166                                let path = parts[5];
167                                if path.starts_with('[') {
168                                    continue;
169                                }
170                                let path_trim = if let Some(idx) = path.find(" (deleted)") {
171                                    &path[..idx]
172                                } else {
173                                    path
174                                };
175                                if path_trim == module_path {
176                                    hit = true;
177                                    break; // early stop per PID
178                                }
179                            }
180                            if hit {
181                                pids.insert(pid);
182                            }
183                        }
184                    }
185                }
186            }
187        }
188        let mut cached: Vec<CachedEntry> = Vec::new();
189        let mut new_count = 0usize;
190        // Intentionally keep PID list silent to avoid noisy logs in normal runs
191        for pid in pids {
192            match self.compute_section_offsets_for_process_with_retry(
193                pid,
194                module_path,
195                3,
196                std::time::Duration::from_millis(75),
197            ) {
198                Ok((cookie, offsets, _base, _size)) => {
199                    cached.push(CachedEntry {
200                        pid,
201                        cookie,
202                        offsets,
203                    });
204                    new_count += 1;
205                }
206                Err(e) => tracing::debug!(
207                    "ProcessManager: skip pid {} for module {} (offsets failed: {})",
208                    pid,
209                    module_path,
210                    e
211                ),
212            }
213        }
214        self.module_cache.insert(module_path.to_string(), cached);
215        self.prefilled_modules.insert(module_path.to_string());
216        Ok(new_count)
217    }
218
219    pub fn cached_offsets_for_module(&self, module_path: &str) -> Vec<(u32, u64, SectionOffsets)> {
220        self.module_cache
221            .get(module_path)
222            .map(|v| v.iter().map(|e| (e.pid, e.cookie, e.offsets)).collect())
223            .unwrap_or_default()
224    }
225
226    pub fn ensure_prefill_pid(&mut self, pid: u32) -> Result<usize> {
227        if self.prefilled_pids.contains(&pid) {
228            return Ok(0);
229        }
230        let maps_path = format!("/proc/{pid}/maps");
231        let content = fs::read_to_string(&maps_path)?;
232        let mut modules: BTreeSet<String> = BTreeSet::new();
233        for line in content.lines() {
234            let parts: Vec<&str> = line.split_whitespace().collect();
235            if parts.len() < 6 {
236                continue;
237            }
238            let path = parts[5];
239            if path.starts_with('[') {
240                continue;
241            }
242            let path_trim = if let Some(idx) = path.find(" (deleted)") {
243                &path[..idx]
244            } else {
245                path
246            };
247            modules.insert(path_trim.to_string());
248        }
249        let mut list: Vec<PidOffsetsEntry> = Vec::new();
250        for m in modules {
251            match self.compute_section_offsets_for_process(pid, &m) {
252                Ok((cookie, off, base, size)) => list.push(PidOffsetsEntry {
253                    module_path: m,
254                    cookie,
255                    offsets: off,
256                    base,
257                    size,
258                }),
259                Err(e) => {
260                    tracing::debug!("ProcessManager: skip module {} for pid {}: {}", m, pid, e)
261                }
262            }
263        }
264        self.pid_cache.insert(pid, list);
265        self.prefilled_pids.insert(pid);
266        Ok(self.pid_cache.get(&pid).map(|v| v.len()).unwrap_or(0))
267    }
268
269    fn compute_section_offsets_for_process(
270        &self,
271        pid: u32,
272        module_path: &str,
273    ) -> Result<(u64, SectionOffsets, u64, u64)> {
274        let maps = fs::read_to_string(format!("/proc/{pid}/maps"))?;
275        let mut candidates: Vec<(u64, u64)> = Vec::new();
276        let mut min_start: Option<u64> = None;
277        let mut max_end: Option<u64> = None;
278
279        // Prefer dev:inode matching; fallback to normalized path compare
280        let (t_dev, t_ino) = fs::metadata(module_path)
281            .map(|m| (Some(m.dev()), Some(m.ino())))
282            .unwrap_or((None, None));
283        let (tmaj, tmin) = if let Some(dev) = t_dev {
284            let d = dev as libc::dev_t;
285            (Some(libc::major(d) as u64), Some(libc::minor(d) as u64))
286        } else {
287            (None, None)
288        };
289        let norm_target = module_path.replace("/./", "/");
290
291        for line in maps.lines() {
292            let parts: Vec<&str> = line.split_whitespace().collect();
293            if parts.len() < 6 {
294                continue;
295            }
296            if parts[5].starts_with('[') {
297                continue;
298            }
299            let mut matched = false;
300            if let (Some(maj), Some(min), Some(ino)) = (tmaj, tmin, t_ino) {
301                if let Some((maj_s, min_s)) = parts[3].split_once(':') {
302                    if let (Ok(dm), Ok(dn), Ok(inode)) = (
303                        u64::from_str_radix(maj_s, 16),
304                        u64::from_str_radix(min_s, 16),
305                        parts[4].parse::<u64>(),
306                    ) {
307                        matched = dm == maj && dn == min && inode == ino;
308                    }
309                }
310            } else {
311                let p = parts[5];
312                let path_trim = if let Some(idx) = p.find(" (deleted)") {
313                    &p[..idx]
314                } else {
315                    p
316                };
317                matched = path_trim == norm_target;
318            }
319            if !matched {
320                continue;
321            }
322            let addrs: Vec<&str> = parts[0].split('-').collect();
323            if addrs.len() != 2 {
324                continue;
325            }
326            let start = u64::from_str_radix(addrs[0], 16).unwrap_or(0);
327            let end = u64::from_str_radix(addrs[1], 16).unwrap_or(start);
328            min_start = Some(min_start.map_or(start, |v| v.min(start)));
329            max_end = Some(max_end.map_or(end, |v| v.max(end)));
330            let file_off = u64::from_str_radix(parts[2], 16).unwrap_or(0);
331            candidates.push((file_off, start));
332        }
333        let data = fs::read(module_path)?;
334        let obj = object::File::parse(&data[..])?;
335        let page_mask: u64 = !0xfffu64;
336        let mut seg_bias: Vec<(u64, u64, u64)> = Vec::new();
337        for seg in obj.segments() {
338            let (file_off, _sz) = seg.file_range();
339            let vaddr = seg.address();
340            let key = file_off & page_mask;
341            if let Some((_, start)) = candidates
342                .iter()
343                .find(|(fo, _)| (*fo & page_mask) == key)
344                .copied()
345            {
346                let bias = start.saturating_sub(vaddr);
347                seg_bias.push((key, vaddr, bias));
348            }
349        }
350        let find_bias_for = |addr: u64| -> Option<u64> {
351            for seg in obj.segments() {
352                let vaddr = seg.address();
353                let vsize = seg.size();
354                if vsize == 0 {
355                    continue;
356                }
357                if addr >= vaddr && addr < vaddr + vsize {
358                    let (file_off, _sz) = seg.file_range();
359                    let key = file_off & page_mask;
360                    if let Some((_, _, b)) = seg_bias.iter().find(|(k, _, _)| *k == key) {
361                        return Some(*b);
362                    }
363                }
364            }
365            None
366        };
367        let mut text_addr: Option<u64> = None;
368        let mut rodata_addr: Option<u64> = None;
369        let mut data_addr: Option<u64> = None;
370        let mut bss_addr: Option<u64> = None;
371        for sect in obj.sections() {
372            if let Ok(name) = sect.name() {
373                let addr = sect.address();
374                if text_addr.is_none() && (name == ".text" || name.starts_with(".text")) {
375                    text_addr = Some(addr);
376                } else if rodata_addr.is_none()
377                    && (name == ".rodata" || name.starts_with(".rodata"))
378                {
379                    rodata_addr = Some(addr);
380                } else if data_addr.is_none() && (name == ".data" || name.starts_with(".data")) {
381                    data_addr = Some(addr);
382                } else if bss_addr.is_none() && (name == ".bss" || name.starts_with(".bss")) {
383                    bss_addr = Some(addr);
384                }
385            }
386        }
387        let mut offsets = SectionOffsets::default();
388        if let Some(a0) = text_addr.and_then(find_bias_for) {
389            offsets.text = a0;
390        }
391        if let Some(a1) = rodata_addr.and_then(find_bias_for) {
392            offsets.rodata = a1;
393        }
394        if let Some(a2) = data_addr.and_then(find_bias_for) {
395            offsets.data = a2;
396        }
397        if let Some(a3) = bss_addr.and_then(find_bias_for) {
398            offsets.bss = a3;
399        }
400        let cookie = crate::cookie::from_path(module_path);
401        let base = min_start.unwrap_or(0);
402        let size = max_end.unwrap_or(base).saturating_sub(base);
403        if offsets.text == 0 && offsets.rodata == 0 && offsets.data == 0 && offsets.bss == 0 {
404            if seg_bias.is_empty() {
405                // No segment matches at all: genuine failure to map offsets
406                tracing::error!(
407                    "Offsets all zero for pid={} module='{}' (cookie=0x{:016x}); no segment matches, maps matching failed (dev:inode/path)",
408                    pid, module_path, cookie
409                );
410                return Err(anyhow::anyhow!(
411                    "computed zero offsets (no segment matches)"
412                ));
413            } else {
414                // Segments matched but all biases are zero — likely ET_EXEC (Non-PIE) loaded at linked addresses.
415                // This is valid: no ASLR rebase is needed.
416                tracing::debug!(
417                    "Offsets zero with valid segment matches (treat as Non-PIE): pid={} module='{}' cookie=0x{:016x}",
418                    pid, module_path, cookie
419                );
420            }
421        }
422        tracing::debug!(
423            "computed offsets: pid={} module='{}' cookie=0x{:016x} base=0x{:x} size=0x{:x} text=0x{:x} rodata=0x{:x} data=0x{:x} bss=0x{:x}",
424            pid,
425            module_path,
426            cookie,
427            base,
428            size,
429            offsets.text,
430            offsets.rodata,
431            offsets.data,
432            offsets.bss
433        );
434        Ok((cookie, offsets, base, size))
435    }
436
437    fn compute_section_offsets_for_process_with_retry(
438        &self,
439        pid: u32,
440        module_path: &str,
441        attempts: usize,
442        backoff: std::time::Duration,
443    ) -> Result<(u64, SectionOffsets, u64, u64)> {
444        let mut last_err: Option<anyhow::Error> = None;
445        for i in 0..attempts {
446            match self.compute_section_offsets_for_process(pid, module_path) {
447                Ok(v) => return Ok(v),
448                Err(e) => {
449                    last_err = Some(e);
450                    if i + 1 < attempts {
451                        std::thread::sleep(backoff);
452                    }
453                }
454            }
455        }
456        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("offsets compute failed")))
457    }
458
459    pub fn cached_offsets_pairs_for_pid(&self, pid: u32) -> Option<Vec<(u64, SectionOffsets)>> {
460        self.pid_cache
461            .get(&pid)
462            .map(|v| v.iter().map(|e| (e.cookie, e.offsets)).collect())
463    }
464
465    pub fn cached_offsets_with_paths_for_pid(&self, pid: u32) -> Option<&[PidOffsetsEntry]> {
466        self.pid_cache.get(&pid).map(|v| v.as_slice())
467    }
468}
469
470fn is_same_executable_as_current(pid: u32) -> bool {
471    // Strongest signal: dev+ino equality on /proc/*/exe
472    let self_meta = fs::metadata("/proc/self/exe");
473    let pid_meta = fs::metadata(format!("/proc/{pid}/exe"));
474    if let (Ok(sm), Ok(pm)) = (self_meta, pid_meta) {
475        if sm.dev() == pm.dev() && sm.ino() == pm.ino() {
476            return true;
477        }
478    }
479
480    // Fallback: compare canonicalized exe symlink targets
481    let self_path = fs::read_link("/proc/self/exe")
482        .ok()
483        .and_then(|p| fs::canonicalize(p).ok());
484    let pid_path = fs::read_link(format!("/proc/{pid}/exe"))
485        .ok()
486        .and_then(|p| fs::canonicalize(p).ok());
487    if let (Some(sp), Some(pp)) = (self_path, pid_path) {
488        if sp == pp {
489            return true;
490        }
491    }
492
493    // Last resort: process name check via /proc/<pid>/comm (best-effort)
494    if let Ok(name) = fs::read_to_string(format!("/proc/{pid}/comm")) {
495        let n = name.trim();
496        if n.eq("ghostscope") {
497            return true;
498        }
499    }
500
501    false
502}