1use parking_lot::RwLock;
14use serde::{Deserialize, Serialize};
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AllowlistConfig {
21 pub auto_detect: bool,
23 pub custom_allow_paths: Vec<String>,
25 pub custom_allow_processes: Vec<String>,
27}
28
29impl Default for AllowlistConfig {
30 fn default() -> Self {
31 Self {
32 auto_detect: true,
33 custom_allow_paths: Vec::new(),
34 custom_allow_processes: Vec::new(),
35 }
36 }
37}
38
39pub struct DeveloperAllowlist {
41 config: AllowlistConfig,
42 skip_paths: RwLock<Vec<String>>,
44 skip_processes: RwLock<HashSet<String>>,
46}
47
48impl DeveloperAllowlist {
49 pub fn new(config: AllowlistConfig) -> Self {
50 let al = Self {
51 config: config.clone(),
52 skip_paths: RwLock::new(Vec::new()),
53 skip_processes: RwLock::new(HashSet::new()),
54 };
55 if config.auto_detect {
56 al.detect_dev_environments();
57 }
58 {
60 let mut paths = al.skip_paths.write();
61 for p in &config.custom_allow_paths {
62 paths.push(p.clone());
63 }
64 }
65 {
66 let mut procs = al.skip_processes.write();
67 for p in &config.custom_allow_processes {
68 procs.insert(p.clone());
69 }
70 }
71 al
72 }
73
74 pub fn detect_dev_environments(&self) {
76 let mut paths = self.skip_paths.write();
77 let mut procs = self.skip_processes.write();
78
79 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
80 let home_path = PathBuf::from(&home);
81
82 paths.push("target/debug".to_string());
84 paths.push("target/release".to_string());
85
86 if home_path.join(".cargo/bin").exists() || home_path.join(".rustup").exists() {
88 paths.push(".rustup".to_string());
89 paths.push(".cargo/registry".to_string());
90 for name in &["rustc", "cargo", "rust-analyzer", "clippy-driver", "rustfmt", "cargo-clippy", "rustup"] {
91 procs.insert(name.to_string());
92 }
93 }
94
95 if home_path.join(".nvm").exists()
97 || home_path.join(".npm").exists()
98 || Path::new("/usr/bin/node").exists()
99 || Path::new("/usr/local/bin/node").exists()
100 {
101 paths.push("node_modules".to_string());
102 paths.push(".npm".to_string());
103 paths.push(".nvm".to_string());
104 paths.push(".yarn".to_string());
105 paths.push(".pnpm-store".to_string());
106 for name in &["node", "npm", "npx", "yarn", "pnpm", "bun", "deno", "tsx", "ts-node"] {
107 procs.insert(name.to_string());
108 }
109 }
110
111 if Path::new("/usr/bin/python3").exists()
113 || Path::new("/usr/local/bin/python3").exists()
114 || home_path.join(".conda").exists()
115 {
116 paths.push("__pycache__".to_string());
117 paths.push(".venv".to_string());
118 paths.push("venv".to_string());
119 paths.push(".conda".to_string());
120 paths.push(".local/lib/python".to_string());
121 for name in &["python", "python3", "pip", "pip3", "conda", "jupyter", "ipython", "poetry", "pdm"] {
122 procs.insert(name.to_string());
123 }
124 }
125
126 if home_path.join("go").exists() || std::env::var("GOPATH").is_ok() {
128 paths.push("go/pkg".to_string());
129 paths.push("go/bin".to_string());
130 for name in &["go", "gopls", "dlv", "staticcheck"] {
131 procs.insert(name.to_string());
132 }
133 }
134
135 if Path::new("/usr/bin/docker").exists() || Path::new("/usr/local/bin/docker").exists() {
137 paths.push("/var/lib/docker".to_string());
138 for name in &["docker", "dockerd", "containerd", "containerd-shim", "runc", "docker-compose", "podman", "buildah"] {
139 procs.insert(name.to_string());
140 }
141 }
142
143 if Path::new("/usr/bin/javac").exists()
145 || Path::new("/usr/local/bin/javac").exists()
146 || std::env::var("JAVA_HOME").is_ok()
147 {
148 paths.push(".gradle".to_string());
149 paths.push(".m2/repository".to_string());
150 for name in &["java", "javac", "gradle", "gradlew", "mvn", "mvnw", "kotlin", "kotlinc", "scala", "sbt"] {
151 procs.insert(name.to_string());
152 }
153 }
154
155 for name in &[
157 "code", "code-server", "codium",
158 "idea", "idea64", "clion", "goland", "pycharm", "webstorm", "rider", "rustrover",
159 "vim", "nvim", "emacs", "nano", "helix", "zed",
160 "sublime_text", "atom",
161 ] {
162 procs.insert(name.to_string());
163 }
164
165 for name in &[
167 "gcc", "g++", "cc", "c++", "clang", "clang++",
168 "make", "cmake", "ninja", "meson",
169 "gdb", "lldb", "strace", "ltrace", "perf", "valgrind",
170 "ld", "as", "ar", "nm", "objdump", "strip",
171 ] {
172 procs.insert(name.to_string());
173 }
174
175 paths.push(".git/objects".to_string());
177 paths.push(".git/pack".to_string());
178 paths.push(".git/lfs".to_string());
179 for name in &["git", "git-lfs", "gh", "hub"] {
180 procs.insert(name.to_string());
181 }
182
183 let cache_dir = format!("{}/.cache", home);
186 paths.push(cache_dir);
187 paths.push(".local/share/Trash".to_string());
188
189 paths.push("*.rlib".to_string()); paths.push("*.rmeta".to_string()); paths.push("*.d".to_string()); paths.push("*.pyc".to_string()); paths.push("*.pyo".to_string()); }
198
199 pub fn should_skip_path(&self, path: &Path) -> bool {
201 let path_str = path.to_string_lossy();
202 let patterns = self.skip_paths.read();
203
204 for pattern in patterns.iter() {
205 if let Some(ext_pattern) = pattern.strip_prefix("*.") {
207 if let Some(ext) = path.extension() {
208 if ext.to_string_lossy().eq_ignore_ascii_case(ext_pattern) {
209 return true;
210 }
211 }
212 continue;
213 }
214
215 if pattern.starts_with('/') {
217 if path_str.starts_with(pattern.as_str()) {
218 return true;
219 }
220 continue;
221 }
222
223 if path_str.contains(pattern.as_str()) {
226 return true;
227 }
228 }
229
230 false
231 }
232
233 pub fn should_skip_process(&self, name: &str) -> bool {
235 self.skip_processes.read().contains(name)
236 }
237
238 pub fn refresh(&self) {
240 {
241 let mut paths = self.skip_paths.write();
242 paths.clear();
243 }
244 {
245 let mut procs = self.skip_processes.write();
246 procs.clear();
247 }
248 if self.config.auto_detect {
249 self.detect_dev_environments();
250 }
251 let mut paths = self.skip_paths.write();
252 for p in &self.config.custom_allow_paths {
253 paths.push(p.clone());
254 }
255 let mut procs = self.skip_processes.write();
256 for p in &self.config.custom_allow_processes {
257 procs.insert(p.clone());
258 }
259 }
260
261 pub fn path_pattern_count(&self) -> usize {
263 self.skip_paths.read().len()
264 }
265
266 pub fn process_count(&self) -> usize {
268 self.skip_processes.read().len()
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn test_allowlist() -> DeveloperAllowlist {
277 DeveloperAllowlist::new(AllowlistConfig::default())
278 }
279
280 #[test]
281 fn node_modules_skipped() {
282 let al = test_allowlist();
283 assert!(al.should_skip_path(Path::new("/home/user/project/node_modules/express/index.js")));
284 assert!(al.should_skip_path(Path::new("/tmp/app/node_modules/.package-lock.json")));
285 }
286
287 #[test]
288 fn target_debug_skipped() {
289 let al = test_allowlist();
290 assert!(al.should_skip_path(Path::new("/home/user/project/target/debug/myapp")));
291 assert!(al.should_skip_path(Path::new("/opt/project/target/release/libfoo.so")));
292 }
293
294 #[test]
295 fn git_objects_skipped() {
296 let al = test_allowlist();
297 assert!(al.should_skip_path(Path::new("/home/user/repo/.git/objects/ab/cdef1234")));
298 assert!(al.should_skip_path(Path::new("/home/user/repo/.git/pack/pack-abc.idx")));
299 }
300
301 #[test]
302 fn build_artifacts_in_dev_paths_skipped() {
303 let al = test_allowlist();
304 assert!(!al.should_skip_path(Path::new("/tmp/build/main.o")));
306 assert!(!al.should_skip_path(Path::new("/tmp/lib/libcrypto.a")));
307 assert!(!al.should_skip_path(Path::new("/tmp/lib/libssl.so")));
308 assert!(al.should_skip_path(Path::new("/tmp/build/dep.rlib")));
310 assert!(al.should_skip_path(Path::new("/tmp/build/dep.rmeta")));
311 }
312
313 #[test]
314 fn normal_files_not_skipped() {
315 let al = test_allowlist();
316 assert!(!al.should_skip_path(Path::new("/home/user/Downloads/invoice.pdf")));
317 assert!(!al.should_skip_path(Path::new("/tmp/suspicious.exe")));
318 assert!(!al.should_skip_path(Path::new("/home/user/document.txt")));
319 }
320
321 #[test]
322 fn compiler_processes_skipped() {
323 let al = test_allowlist();
324 assert!(al.should_skip_process("gcc"));
325 assert!(al.should_skip_process("clang"));
326 assert!(al.should_skip_process("make"));
327 assert!(al.should_skip_process("gdb"));
328 }
329
330 #[test]
331 fn ide_processes_skipped() {
332 let al = test_allowlist();
333 assert!(al.should_skip_process("code"));
334 assert!(al.should_skip_process("nvim"));
335 assert!(al.should_skip_process("idea"));
336 }
337
338 #[test]
339 fn unknown_process_not_skipped() {
340 let al = test_allowlist();
341 assert!(!al.should_skip_process("totally-not-malware"));
342 assert!(!al.should_skip_process("xmrig"));
343 }
344
345 #[test]
346 fn custom_overrides_work() {
347 let config = AllowlistConfig {
348 auto_detect: false,
349 custom_allow_paths: vec!["my-special-dir".to_string()],
350 custom_allow_processes: vec!["my-tool".to_string()],
351 };
352 let al = DeveloperAllowlist::new(config);
353 assert!(al.should_skip_path(Path::new("/home/user/my-special-dir/file.bin")));
354 assert!(al.should_skip_process("my-tool"));
355 }
356
357 #[test]
358 fn refresh_redetects() {
359 let al = test_allowlist();
360 let count_before = al.path_pattern_count();
361 al.refresh();
362 let count_after = al.path_pattern_count();
363 assert_eq!(count_before, count_after);
364 }
365}