Skip to main content

ghostscope_process/
proc_maps.rs

1use anyhow::Result;
2use std::fs::{self, File};
3use std::io::{BufRead, BufReader};
4use std::ops::ControlFlow;
5use std::os::unix::fs::MetadataExt;
6use std::path::Path;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct ProcMapEntry<'a> {
10    pub start: u64,
11    pub end: u64,
12    pub perms: &'a str,
13    pub offset: u64,
14    pub dev_major: u64,
15    pub dev_minor: u64,
16    pub inode: u64,
17    path: Option<&'a str>,
18}
19
20impl<'a> ProcMapEntry<'a> {
21    pub fn path(&self) -> Option<&'a str> {
22        self.path
23    }
24
25    pub fn normalized_path(&self) -> Option<&'a str> {
26        self.path.map(normalize_mapped_module_path)
27    }
28
29    pub fn executable(&self) -> bool {
30        self.perms.contains('x')
31    }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct OwnedProcMapEntry {
36    pub start: u64,
37    pub end: u64,
38    pub perms: String,
39    pub offset: u64,
40    pub dev_major: u64,
41    pub dev_minor: u64,
42    pub inode: u64,
43    path: Option<String>,
44}
45
46impl OwnedProcMapEntry {
47    pub fn path(&self) -> Option<&str> {
48        self.path.as_deref()
49    }
50
51    pub fn normalized_path(&self) -> Option<&str> {
52        self.path().map(normalize_mapped_module_path)
53    }
54
55    pub fn executable(&self) -> bool {
56        self.perms.contains('x')
57    }
58}
59
60impl From<ProcMapEntry<'_>> for OwnedProcMapEntry {
61    fn from(entry: ProcMapEntry<'_>) -> Self {
62        Self {
63            start: entry.start,
64            end: entry.end,
65            perms: entry.perms.to_owned(),
66            offset: entry.offset,
67            dev_major: entry.dev_major,
68            dev_minor: entry.dev_minor,
69            inode: entry.inode,
70            path: entry.path().map(str::to_owned),
71        }
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct ModuleIdentity {
77    dev_major: Option<u64>,
78    dev_minor: Option<u64>,
79    inode: Option<u64>,
80    normalized_path: String,
81}
82
83impl ModuleIdentity {
84    pub fn from_path(path: &Path) -> Self {
85        let path_str = path.to_string_lossy();
86        // Fallback path matching against /proc/<pid>/maps must normalize "/./";
87        // otherwise equivalent paths can miss the same mapped module when
88        // metadata is unavailable.
89        let normalized_path = normalize_mapped_module_path(&path_str).replace("/./", "/");
90        let (dev_major, dev_minor, inode) = fs::metadata(path)
91            .map(|meta| {
92                let dev = meta.dev() as libc::dev_t;
93                (
94                    Some(libc::major(dev) as u64),
95                    Some(libc::minor(dev) as u64),
96                    Some(meta.ino()),
97                )
98            })
99            .unwrap_or((None, None, None));
100
101        Self {
102            dev_major,
103            dev_minor,
104            inode,
105            normalized_path,
106        }
107    }
108
109    pub fn normalized_path(&self) -> &str {
110        &self.normalized_path
111    }
112
113    pub fn matches(&self, entry: &ProcMapEntry<'_>) -> bool {
114        if let (Some(maj), Some(min), Some(ino)) = (self.dev_major, self.dev_minor, self.inode) {
115            if entry.dev_major == maj && entry.dev_minor == min && entry.inode == ino {
116                return true;
117            }
118
119            // overlayfs compatibility: the same mapped file can keep the same inode while
120            // surfacing under a different device number across mount namespaces, so relax
121            // the match to inode+path before giving up.
122            return entry.inode == ino && entry.normalized_path() == Some(self.normalized_path());
123        }
124
125        entry.normalized_path() == Some(self.normalized_path())
126    }
127}
128
129pub fn parse_maps_line(line: &str) -> Option<ProcMapEntry<'_>> {
130    let (range, rest) = take_field(line)?;
131    let (perms, rest) = take_field(rest)?;
132    let (offset, rest) = take_field(rest)?;
133    let (dev, rest) = take_field(rest)?;
134    let (inode, rest) = take_field(rest)?;
135
136    let (start_s, end_s) = range.split_once('-')?;
137    let (dev_major_s, dev_minor_s) = dev.split_once(':')?;
138    let path = rest.trim_start();
139
140    Some(ProcMapEntry {
141        start: u64::from_str_radix(start_s, 16).ok()?,
142        end: u64::from_str_radix(end_s, 16).ok()?,
143        perms,
144        offset: u64::from_str_radix(offset, 16).ok()?,
145        dev_major: u64::from_str_radix(dev_major_s, 16).ok()?,
146        dev_minor: u64::from_str_radix(dev_minor_s, 16).ok()?,
147        inode: inode.parse::<u64>().ok()?,
148        path: (!path.is_empty()).then_some(path),
149    })
150}
151
152pub fn visit_proc_maps<F>(pid: u32, visitor: F) -> Result<()>
153where
154    F: FnMut(ProcMapEntry<'_>) -> ControlFlow<()>,
155{
156    let maps_path = format!("/proc/{pid}/maps");
157    let file = File::open(&maps_path)?;
158    visit_maps_reader(BufReader::new(file), visitor)
159}
160
161pub fn read_proc_maps(pid: u32) -> Result<Vec<OwnedProcMapEntry>> {
162    let maps_path = format!("/proc/{pid}/maps");
163    let file = File::open(&maps_path)?;
164    read_maps_reader(BufReader::new(file))
165}
166
167fn visit_maps_reader<R, F>(mut reader: R, mut visitor: F) -> Result<()>
168where
169    R: BufRead,
170    F: FnMut(ProcMapEntry<'_>) -> ControlFlow<()>,
171{
172    let mut line = String::new();
173
174    loop {
175        line.clear();
176        if reader.read_line(&mut line)? == 0 {
177            break;
178        }
179
180        let line = line.trim_end_matches(['\n', '\r']);
181        if let Some(entry) = parse_maps_line(line) {
182            if matches!(visitor(entry), ControlFlow::Break(())) {
183                break;
184            }
185        }
186    }
187
188    Ok(())
189}
190
191fn read_maps_reader<R>(reader: R) -> Result<Vec<OwnedProcMapEntry>>
192where
193    R: BufRead,
194{
195    let mut entries = Vec::new();
196    visit_maps_reader(reader, |entry| {
197        entries.push(entry.into());
198        ControlFlow::Continue(())
199    })?;
200    Ok(entries)
201}
202
203pub fn normalize_mapped_module_path(path: &str) -> &str {
204    if let Some(idx) = path.find(" (deleted)") {
205        &path[..idx]
206    } else {
207        path
208    }
209}
210
211pub fn is_filtered_module_prefix(path: &str) -> bool {
212    matches!(path, "/proc" | "/sys") || path.starts_with("/proc/") || path.starts_with("/sys/")
213}
214
215pub fn should_skip_mapped_module_path(path: &str) -> bool {
216    let path = normalize_mapped_module_path(path);
217    path.starts_with('[')
218        || is_filtered_module_prefix(path)
219        || matches!(fs::metadata(path), Ok(meta) if !meta.file_type().is_file())
220}
221
222fn take_field(input: &str) -> Option<(&str, &str)> {
223    let input = input.trim_start();
224    if input.is_empty() {
225        return None;
226    }
227
228    let split_at = input.find(char::is_whitespace).unwrap_or(input.len());
229    Some((&input[..split_at], &input[split_at..]))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::path::PathBuf;
236    use std::time::{SystemTime, UNIX_EPOCH};
237
238    fn dev_pair_differs_from(meta: &std::fs::Metadata, salt: u64) -> (u64, u64) {
239        let dev = meta.dev() as libc::dev_t;
240        let actual_major = libc::major(dev) as u64;
241        let actual_minor = libc::minor(dev) as u64;
242        let major = actual_major ^ (0x40 + salt);
243        let minor = actual_minor ^ (0x80 + salt);
244        if major == actual_major && minor == actual_minor {
245            (actual_major + 1, actual_minor)
246        } else {
247            (major, minor)
248        }
249    }
250
251    #[test]
252    fn parse_maps_line_handles_deleted_paths_and_spaces() {
253        let line = "7f1234500000-7f1234510000 r-xp 00000000 08:02 12345 /tmp/lib demo.so (deleted)";
254        let entry = parse_maps_line(line).unwrap();
255
256        assert_eq!(entry.start, 0x7f1234500000);
257        assert_eq!(entry.end, 0x7f1234510000);
258        assert_eq!(entry.offset, 0);
259        assert_eq!(entry.dev_major, 0x08);
260        assert_eq!(entry.dev_minor, 0x02);
261        assert_eq!(entry.inode, 12345);
262        assert_eq!(entry.normalized_path(), Some("/tmp/lib demo.so"));
263    }
264
265    #[test]
266    fn skips_virtual_and_pseudo_filesystem_mappings() {
267        assert!(should_skip_mapped_module_path("[heap]"));
268        assert!(should_skip_mapped_module_path("/dev/null"));
269        assert!(should_skip_mapped_module_path("/sys/kernel/tracing"));
270        assert!(should_skip_mapped_module_path("/proc/123/maps"));
271        assert!(!is_filtered_module_prefix("/dev/shm/ghostscope-module.so"));
272        assert!(!should_skip_mapped_module_path(
273            "/dev/shm/ghostscope-module.so"
274        ));
275        assert!(!should_skip_mapped_module_path("/usr/lib/libc.so.6"));
276    }
277
278    #[test]
279    fn module_identity_matches_by_path_when_metadata_is_missing() {
280        let missing = PathBuf::from("/tmp/./ghostscope-missing-lib.so");
281        let identity = ModuleIdentity::from_path(&missing);
282        let entry = parse_maps_line(
283            "7f1234500000-7f1234510000 r-xp 00000000 00:00 0 /tmp/ghostscope-missing-lib.so (deleted)",
284        )
285        .unwrap();
286
287        assert!(identity.matches(&entry));
288    }
289
290    #[test]
291    fn module_identity_falls_back_to_inode_and_path_when_dev_differs() {
292        let suffix = SystemTime::now()
293            .duration_since(UNIX_EPOCH)
294            .unwrap()
295            .as_nanos();
296        let path = std::env::temp_dir().join(format!("ghostscope-overlayfs-{suffix}.so"));
297        std::fs::write(&path, b"current").unwrap();
298
299        let meta = std::fs::metadata(&path).unwrap();
300        let inode = meta.ino();
301        let path_str = path.to_string_lossy().to_string();
302        let identity = ModuleIdentity::from_path(&path);
303        let (dev_major, dev_minor) = dev_pair_differs_from(&meta, 1);
304        let line = format!(
305            "7f1234500000-7f1234510000 r-xp 00000000 {dev_major:02x}:{dev_minor:02x} {inode} {path_str}"
306        );
307        let entry = parse_maps_line(&line).unwrap();
308
309        assert!(identity.matches(&entry));
310
311        let _ = std::fs::remove_file(path);
312    }
313
314    #[test]
315    fn module_identity_does_not_fallback_without_path_match() {
316        let suffix = SystemTime::now()
317            .duration_since(UNIX_EPOCH)
318            .unwrap()
319            .as_nanos();
320        let path = std::env::temp_dir().join(format!("ghostscope-overlayfs-{suffix}.so"));
321        std::fs::write(&path, b"current").unwrap();
322
323        let meta = std::fs::metadata(&path).unwrap();
324        let inode = meta.ino();
325        let identity = ModuleIdentity::from_path(&path);
326        let (dev_major, dev_minor) = dev_pair_differs_from(&meta, 2);
327        let line = format!(
328            "7f1234500000-7f1234510000 r-xp 00000000 {dev_major:02x}:{dev_minor:02x} {inode} /tmp/other-{suffix}.so"
329        );
330        let entry = parse_maps_line(&line).unwrap();
331
332        assert!(!identity.matches(&entry));
333
334        let _ = std::fs::remove_file(path);
335    }
336
337    #[test]
338    fn visit_proc_maps_stops_when_visitor_breaks() {
339        use std::cell::Cell;
340
341        let lines =
342            b"7f1-7f2 r-xp 00000000 08:02 1 /tmp/a.so\n7f2-7f3 r-xp 00000000 08:02 2 /tmp/b.so\n";
343        let mut reader = BufReader::new(&lines[..]);
344        let seen = Cell::new(0usize);
345
346        visit_maps_reader(&mut reader, |entry| {
347            seen.set(seen.get() + 1);
348            if entry.inode == 1 {
349                return ControlFlow::Break(());
350            }
351            ControlFlow::Continue(())
352        })
353        .unwrap();
354
355        assert_eq!(seen.get(), 1);
356    }
357
358    #[test]
359    fn read_maps_reader_collects_owned_entries() {
360        let lines =
361            b"7f1-7f2 r-xp 00000000 08:02 1 /tmp/a.so\n7f2-7f3 rw-p 00001000 08:02 1 /tmp/a.so\n";
362        let reader = BufReader::new(&lines[..]);
363
364        let entries = read_maps_reader(reader).unwrap();
365
366        assert_eq!(entries.len(), 2);
367        assert_eq!(entries[0].path(), Some("/tmp/a.so"));
368        assert!(entries[0].executable());
369        assert_eq!(entries[1].offset, 0x1000);
370    }
371}