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 paths.push(".cache".to_string());
185 paths.push(".local/share/Trash".to_string());
186
187 paths.push("*.o".to_string());
189 paths.push("*.a".to_string());
190 paths.push("*.so".to_string());
191 paths.push("*.dylib".to_string());
192 paths.push("*.rlib".to_string());
193 paths.push("*.rmeta".to_string());
194 paths.push("*.d".to_string());
195 paths.push("*.pyc".to_string());
196 paths.push("*.pyo".to_string());
197 paths.push("*.class".to_string());
198 paths.push("*.jar".to_string());
199 }
200
201 pub fn should_skip_path(&self, path: &Path) -> bool {
203 let path_str = path.to_string_lossy();
204 let patterns = self.skip_paths.read();
205
206 for pattern in patterns.iter() {
207 if let Some(ext_pattern) = pattern.strip_prefix("*.") {
209 if let Some(ext) = path.extension() {
210 if ext.to_string_lossy().eq_ignore_ascii_case(ext_pattern) {
211 return true;
212 }
213 }
214 continue;
215 }
216
217 if pattern.starts_with('/') {
219 if path_str.starts_with(pattern.as_str()) {
220 return true;
221 }
222 continue;
223 }
224
225 if path_str.contains(pattern.as_str()) {
228 return true;
229 }
230 }
231
232 false
233 }
234
235 pub fn should_skip_process(&self, name: &str) -> bool {
237 self.skip_processes.read().contains(name)
238 }
239
240 pub fn refresh(&self) {
242 {
243 let mut paths = self.skip_paths.write();
244 paths.clear();
245 }
246 {
247 let mut procs = self.skip_processes.write();
248 procs.clear();
249 }
250 if self.config.auto_detect {
251 self.detect_dev_environments();
252 }
253 let mut paths = self.skip_paths.write();
254 for p in &self.config.custom_allow_paths {
255 paths.push(p.clone());
256 }
257 let mut procs = self.skip_processes.write();
258 for p in &self.config.custom_allow_processes {
259 procs.insert(p.clone());
260 }
261 }
262
263 pub fn path_pattern_count(&self) -> usize {
265 self.skip_paths.read().len()
266 }
267
268 pub fn process_count(&self) -> usize {
270 self.skip_processes.read().len()
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn test_allowlist() -> DeveloperAllowlist {
279 DeveloperAllowlist::new(AllowlistConfig::default())
280 }
281
282 #[test]
283 fn node_modules_skipped() {
284 let al = test_allowlist();
285 assert!(al.should_skip_path(Path::new("/home/user/project/node_modules/express/index.js")));
286 assert!(al.should_skip_path(Path::new("/tmp/app/node_modules/.package-lock.json")));
287 }
288
289 #[test]
290 fn target_debug_skipped() {
291 let al = test_allowlist();
292 assert!(al.should_skip_path(Path::new("/home/user/project/target/debug/myapp")));
293 assert!(al.should_skip_path(Path::new("/opt/project/target/release/libfoo.so")));
294 }
295
296 #[test]
297 fn git_objects_skipped() {
298 let al = test_allowlist();
299 assert!(al.should_skip_path(Path::new("/home/user/repo/.git/objects/ab/cdef1234")));
300 assert!(al.should_skip_path(Path::new("/home/user/repo/.git/pack/pack-abc.idx")));
301 }
302
303 #[test]
304 fn object_file_extension_skipped() {
305 let al = test_allowlist();
306 assert!(al.should_skip_path(Path::new("/tmp/build/main.o")));
307 assert!(al.should_skip_path(Path::new("/tmp/lib/libcrypto.a")));
308 assert!(al.should_skip_path(Path::new("/tmp/lib/libssl.so")));
309 }
310
311 #[test]
312 fn normal_files_not_skipped() {
313 let al = test_allowlist();
314 assert!(!al.should_skip_path(Path::new("/home/user/Downloads/invoice.pdf")));
315 assert!(!al.should_skip_path(Path::new("/tmp/suspicious.exe")));
316 assert!(!al.should_skip_path(Path::new("/home/user/document.txt")));
317 }
318
319 #[test]
320 fn compiler_processes_skipped() {
321 let al = test_allowlist();
322 assert!(al.should_skip_process("gcc"));
323 assert!(al.should_skip_process("clang"));
324 assert!(al.should_skip_process("make"));
325 assert!(al.should_skip_process("gdb"));
326 }
327
328 #[test]
329 fn ide_processes_skipped() {
330 let al = test_allowlist();
331 assert!(al.should_skip_process("code"));
332 assert!(al.should_skip_process("nvim"));
333 assert!(al.should_skip_process("idea"));
334 }
335
336 #[test]
337 fn unknown_process_not_skipped() {
338 let al = test_allowlist();
339 assert!(!al.should_skip_process("totally-not-malware"));
340 assert!(!al.should_skip_process("xmrig"));
341 }
342
343 #[test]
344 fn custom_overrides_work() {
345 let config = AllowlistConfig {
346 auto_detect: false,
347 custom_allow_paths: vec!["my-special-dir".to_string()],
348 custom_allow_processes: vec!["my-tool".to_string()],
349 };
350 let al = DeveloperAllowlist::new(config);
351 assert!(al.should_skip_path(Path::new("/home/user/my-special-dir/file.bin")));
352 assert!(al.should_skip_process("my-tool"));
353 }
354
355 #[test]
356 fn refresh_redetects() {
357 let al = test_allowlist();
358 let count_before = al.path_pattern_count();
359 al.refresh();
360 let count_after = al.path_pattern_count();
361 assert_eq!(count_before, count_after);
362 }
363}