ghostscope_process/
module_probe.rs1use crate::proc_maps::{is_filtered_module_prefix, normalize_mapped_module_path};
2use anyhow::Result;
3use memmap2::{Mmap, MmapOptions};
4use object::Object;
5use std::fs::{self, OpenOptions};
6use std::hash::{Hash, Hasher};
7use std::os::unix::fs::MetadataExt;
8use std::os::unix::fs::OpenOptionsExt;
9use std::path::PathBuf;
10
11pub struct ModuleProbe {
12 metadata_cookie: u64,
13 mmap: Mmap,
14}
15
16struct ValidatedModulePath {
17 resolved_path: PathBuf,
18 metadata: fs::Metadata,
19}
20
21impl ModuleProbe {
22 pub fn open(module_path: &str) -> Result<Self> {
23 let normalized_path = normalize_cookie_path(module_path);
24 let validated = validate_module_path(&normalized_path)?;
25
26 let file = OpenOptions::new()
31 .read(true)
32 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
33 .open(&validated.resolved_path)?;
34 let meta = file.metadata()?;
35 if !meta.file_type().is_file() {
36 anyhow::bail!(
37 "refusing to read non-regular file {}",
38 validated.resolved_path.display()
39 );
40 }
41 if meta.dev() != validated.metadata.dev() || meta.ino() != validated.metadata.ino() {
42 anyhow::bail!(
43 "module path changed while opening {}",
44 validated.resolved_path.display()
45 );
46 }
47
48 let dev = meta.dev();
49 let ino = meta.ino();
50 let metadata_cookie = ((dev & 0xffff_ffff) << 32) | (ino & 0xffff_ffff);
51
52 let mmap = unsafe { MmapOptions::new().map(&file)? };
56
57 Ok(Self {
58 metadata_cookie,
59 mmap,
60 })
61 }
62
63 pub fn object(&self) -> Result<object::File<'_>> {
64 Ok(object::File::parse(&self.mmap[..])?)
65 }
66
67 pub fn cookie_for_object(&self, obj: &object::File<'_>) -> u64 {
68 if let Ok(Some(build_id)) = obj.build_id() {
69 return stable_hash(&build_id);
70 }
71
72 self.fallback_cookie()
73 }
74
75 pub fn cookie(&self) -> u64 {
76 self.object()
77 .map(|obj| self.cookie_for_object(&obj))
78 .unwrap_or_else(|_| self.fallback_cookie())
79 }
80
81 fn fallback_cookie(&self) -> u64 {
82 self.metadata_cookie
83 }
84}
85
86pub fn cookie_for_path(module_path: &str) -> u64 {
87 ModuleProbe::open(module_path)
88 .map(|probe| probe.cookie())
89 .unwrap_or_else(|_| stable_hash(&normalize_cookie_path(module_path)))
90}
91
92fn validate_module_path(path: &str) -> Result<ValidatedModulePath> {
93 let input_is_proc_root = is_safe_proc_root_path(path);
97 if is_filtered_module_prefix(path) && !input_is_proc_root {
98 anyhow::bail!("refusing to read pseudo-filesystem path {}", path);
99 }
100
101 let resolved_path = fs::canonicalize(path)?;
102 let resolved_str = resolved_path.to_string_lossy();
103 let resolved_is_safe_proc_root = input_is_proc_root && is_safe_proc_root_path(&resolved_str);
104 if is_filtered_module_prefix(&resolved_str) && !resolved_is_safe_proc_root {
105 anyhow::bail!("refusing to read pseudo-filesystem path {}", resolved_str);
106 }
107 let meta = fs::metadata(&resolved_path)?;
108 if !meta.file_type().is_file() {
109 anyhow::bail!("refusing to read non-regular file {}", resolved_str);
110 }
111
112 Ok(ValidatedModulePath {
113 resolved_path,
114 metadata: meta,
115 })
116}
117
118fn is_safe_proc_root_path(path: &str) -> bool {
119 let Some(rest) = path.strip_prefix("/proc/") else {
120 return false;
121 };
122 let Some((pid, path)) = rest.split_once('/') else {
123 return false;
124 };
125 let Some(inner_path) = path.strip_prefix("root/") else {
126 return false;
127 };
128 !pid.is_empty()
129 && pid.bytes().all(|byte| byte.is_ascii_digit())
130 && !matches!(inner_path, "proc" | "sys")
131 && !inner_path.starts_with("proc/")
132 && !inner_path.starts_with("sys/")
133}
134
135fn normalize_cookie_path(module_path: &str) -> String {
136 normalize_mapped_module_path(module_path).replace("/./", "/")
137}
138
139fn stable_hash<T: Hash + ?Sized>(value: &T) -> u64 {
140 let mut hasher = std::collections::hash_map::DefaultHasher::new();
141 value.hash(&mut hasher);
142 hasher.finish()
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use std::os::unix::fs::symlink;
149
150 #[test]
151 fn cookie_path_fallback_normalizes_equivalent_paths() {
152 let a = cookie_for_path("/tmp/./ghostscope-missing-lib.so");
153 let b = cookie_for_path("/tmp/ghostscope-missing-lib.so");
154 assert_eq!(a, b);
155 }
156
157 #[test]
158 fn rejects_pseudo_filesystem_paths() {
159 let err = match ModuleProbe::open("/dev/null") {
160 Ok(_) => panic!("expected /dev/null probe to be rejected"),
161 Err(err) => err,
162 };
163 assert!(err
164 .to_string()
165 .contains("refusing to read non-regular file /dev/null"));
166
167 let err = match ModuleProbe::open("/proc/self/maps") {
168 Ok(_) => panic!("expected /proc/self/maps probe to be rejected"),
169 Err(err) => err,
170 };
171 assert!(err
172 .to_string()
173 .contains("refusing to read pseudo-filesystem path /proc/self/maps"));
174
175 let err = match ModuleProbe::open("/proc/self/root/proc/self/maps") {
176 Ok(_) => panic!("expected /proc/self/root/proc/self/maps probe to be rejected"),
177 Err(err) => err,
178 };
179 assert!(err.to_string().contains("pseudo-filesystem path"));
180 }
181
182 #[test]
183 fn allows_proc_root_regular_files() {
184 let base = std::env::temp_dir().join(format!(
185 "ghostscope-module-probe-proc-root-{}-{}",
186 std::process::id(),
187 std::time::SystemTime::now()
188 .duration_since(std::time::UNIX_EPOCH)
189 .unwrap()
190 .as_nanos()
191 ));
192 std::fs::write(&base, b"not an elf but still a regular file").unwrap();
193 let pid = std::process::id();
194 let proc_root_path = format!("/proc/{pid}/root{}", base.display());
195
196 ModuleProbe::open(&proc_root_path).unwrap();
197
198 let _ = std::fs::remove_file(&base);
199 }
200
201 #[test]
202 fn allows_symlinked_regular_files() {
203 let base = std::env::temp_dir().join(format!(
204 "ghostscope-module-probe-{}-{}",
205 std::process::id(),
206 std::time::SystemTime::now()
207 .duration_since(std::time::UNIX_EPOCH)
208 .unwrap()
209 .as_nanos()
210 ));
211 let target = base.with_extension("so");
212 let link = base.with_extension("link");
213 std::fs::write(&target, b"not an elf but still a regular file").unwrap();
214 symlink(&target, &link).unwrap();
215
216 let target_cookie = cookie_for_path(target.to_str().unwrap());
217 let link_cookie = cookie_for_path(link.to_str().unwrap());
218 assert_eq!(link_cookie, target_cookie);
219 ModuleProbe::open(link.to_str().unwrap()).unwrap();
220
221 let _ = std::fs::remove_file(&link);
222 let _ = std::fs::remove_file(&target);
223 }
224}