1use std::{
2 collections::HashMap,
3 fmt,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10 config::{SandboxPath, expand_path},
11 detect::Ecosystem,
12};
13
14#[cfg(target_os = "macos")]
20const DEFAULTS_YAML: &str = include_str!("defaults-macos.yaml");
21
22#[cfg(target_os = "linux")]
23const DEFAULTS_YAML: &str = include_str!("defaults-linux.yaml");
24
25#[cfg(not(any(target_os = "macos", target_os = "linux")))]
26const DEFAULTS_YAML: &str = include_str!("defaults-macos.yaml");
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct DomainPattern(pub String);
35
36impl DomainPattern {
37 pub fn matches(&self, host: &str) -> bool {
39 let pattern = &self.0;
40 if let Some(suffix) = pattern.strip_prefix("*.") {
41 host == suffix || host.ends_with(&format!(".{suffix}"))
42 } else {
43 host == pattern
44 }
45 }
46}
47
48impl fmt::Display for DomainPattern {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.write_str(&self.0)
51 }
52}
53
54impl From<&str> for DomainPattern {
55 fn from(s: &str) -> Self {
56 Self(s.to_owned())
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct SandboxProfile {
64 pub name: String,
66
67 #[serde(default)]
69 pub allow_write: Vec<SandboxPath>,
70
71 #[serde(default)]
79 pub deny_read: Vec<SandboxPath>,
80
81 #[serde(default)]
88 pub allow_read: Vec<SandboxPath>,
89
90 #[serde(default)]
92 pub allow_domains: Vec<DomainPattern>,
93
94 #[serde(default)]
96 pub deny_exec: Vec<SandboxPath>,
97
98 #[serde(default)]
100 pub allow_exec: Vec<SandboxPath>,
101
102 #[serde(default = "default_true")]
104 pub enable_proxy: bool,
105
106 #[serde(default)]
108 pub allow_all_network: bool,
109
110 #[serde(default)]
115 pub allow_fetch: Vec<DomainPattern>,
116
117 #[serde(default)]
119 pub env: HashMap<String, String>,
120
121 #[serde(default)]
125 pub allow_degraded: bool,
126
127 #[serde(skip)]
134 pub first_user_allow_write: usize,
135 #[serde(skip)]
136 pub first_user_allow_exec: usize,
137 #[serde(skip)]
138 pub first_user_allow_read: usize,
139}
140
141fn default_true() -> bool {
142 true
143}
144
145impl SandboxProfile {
146 pub fn for_ecosystem(ecosystem: Ecosystem, home: &Path, pwd: &Path) -> Self {
148 let defaults: DefaultsFile =
149 serde_yaml::from_str(DEFAULTS_YAML).expect("embedded defaults.yaml is invalid");
150
151 let common = &defaults.common;
152 let profile_name = ecosystem.to_string();
153 let eco_cfg = defaults
154 .profiles
155 .get(&profile_name)
156 .unwrap_or_else(|| panic!("missing profile '{profile_name}' in defaults.yaml"));
157
158 let mut allow_exec: Vec<SandboxPath> = common
160 .allow_exec
161 .iter()
162 .chain(eco_cfg.allow_exec.iter())
163 .map(|p| expand_path(p, home, pwd))
164 .collect();
165
166 #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
169 let mut deny_exec: Vec<SandboxPath> = common
170 .deny_exec
171 .iter()
172 .map(|p| expand_path(p, home, pwd))
173 .collect();
174 #[cfg(target_os = "macos")]
175 resolve_symlinks(&mut deny_exec);
176
177 let deny_read: Vec<SandboxPath> = common
179 .deny_read
180 .iter()
181 .map(|p| expand_path(p, home, pwd))
182 .collect();
183
184 let mut allow_write: Vec<SandboxPath> = eco_cfg
186 .allow_write
187 .iter()
188 .map(|p| expand_path(p, home, pwd))
189 .collect();
190
191 let allow_domains: Vec<DomainPattern> = eco_cfg
193 .allow_domains
194 .iter()
195 .map(|d| DomainPattern(d.clone()))
196 .collect();
197
198 if ecosystem == Ecosystem::Node
203 && let Some(git_root) = find_git_root(pwd)
204 && git_root != pwd
205 {
206 allow_exec.push(SandboxPath::dir(git_root.join("node_modules")));
207 allow_write.push(SandboxPath::dir(git_root.join("node_modules")));
208 allow_write.push(SandboxPath::file(git_root.join("package-lock.json")));
209 allow_write.push(SandboxPath::file(git_root.join("yarn.lock")));
210 allow_write.push(SandboxPath::file(git_root.join("pnpm-lock.yaml")));
211 allow_write.push(SandboxPath::dir(git_root.join(".yarn")));
212 allow_write.push(SandboxPath::file(git_root.join(".pnp.cjs")));
213 allow_write.push(SandboxPath::file(git_root.join(".pnp.loader.mjs")));
214 }
215
216 if ecosystem == Ecosystem::Rust {
220 if let Some(target_dir) = resolve_cargo_target_dir(home, pwd) {
221 allow_write.push(SandboxPath::dir(target_dir.clone()));
222 allow_exec.push(SandboxPath::dir(target_dir.clone()));
223 if let Some(target_str) = target_dir.to_str() {
225 let pattern = format!("^{}[A-Za-z0-9]*$", regex_escape(target_str));
226 allow_write.push(SandboxPath::regex(PathBuf::from(pattern)));
227 }
228 } else {
229 allow_exec.push(SandboxPath::dir(pwd.join("target")));
230 }
231 }
232
233 if ecosystem == Ecosystem::Java
235 && let Ok(java_home) = std::env::var("JAVA_HOME")
236 {
237 allow_exec.push(SandboxPath::dir(PathBuf::from(java_home)));
238 }
239
240 #[cfg(target_os = "macos")]
246 resolve_symlinks(&mut allow_exec);
247
248 let allow_read: Vec<SandboxPath> = common
250 .allow_read
251 .iter()
252 .chain(eco_cfg.allow_read.iter())
253 .map(|p| expand_path(p, home, pwd))
254 .collect();
255
256 let first_user_allow_write = allow_write.len();
260 let first_user_allow_exec = allow_exec.len();
261 let first_user_allow_read = allow_read.len();
262
263 SandboxProfile {
264 name: profile_name,
265 allow_write,
266 deny_read,
267 allow_read,
268 allow_domains,
269 deny_exec,
270 allow_exec,
271 enable_proxy: eco_cfg.enable_proxy.unwrap_or(true),
272 allow_all_network: false,
273 allow_fetch: vec![],
274 env: Default::default(),
275 allow_degraded: false,
276 first_user_allow_write,
277 first_user_allow_exec,
278 first_user_allow_read,
279 }
280 }
281
282 pub fn merge_overrides(&mut self, overrides: &ProfileOverrides) {
284 self.allow_write
285 .extend(overrides.allow_write.iter().cloned());
286 self.deny_read.extend(overrides.deny_read.iter().cloned());
287 self.allow_read.extend(overrides.allow_read.iter().cloned());
288 self.allow_domains
289 .extend(overrides.allow_domains.iter().cloned());
290 self.deny_exec.extend(overrides.deny_exec.iter().cloned());
291 self.allow_exec.extend(overrides.allow_exec.iter().cloned());
292
293 if !overrides.deny_domains.is_empty() {
294 self.allow_domains
295 .retain(|d| !overrides.deny_domains.iter().any(|denied| denied.0 == d.0));
296 }
297
298 self.allow_fetch
299 .extend(overrides.allow_fetch.iter().cloned());
300
301 if overrides.allow_all_network {
302 self.allow_all_network = true;
303 self.enable_proxy = false;
304 }
305 if overrides.no_proxy {
306 self.enable_proxy = false;
307 }
308 if overrides.allow_degraded {
309 self.allow_degraded = true;
310 }
311
312 for (k, v) in &overrides.env {
313 self.env.insert(k.clone(), v.clone());
314 }
315 }
316
317 pub fn finalize(&mut self) {
321 if !self.allow_fetch.is_empty() {
322 let curl = SandboxPath::file(PathBuf::from("/usr/bin/curl"));
323 let wget = SandboxPath::file(PathBuf::from("/usr/bin/wget"));
324 if !self.allow_exec.iter().any(|p| p.path == curl.path) {
325 self.allow_exec.push(curl);
326 }
327 if !self.allow_exec.iter().any(|p| p.path == wget.path) {
328 self.allow_exec.push(wget);
329 }
330
331 for domain in &self.allow_fetch {
332 if !self.allow_domains.iter().any(|d| d.0 == domain.0) {
333 self.allow_domains.push(domain.clone());
334 }
335 }
336 }
337 }
338}
339
340#[cfg(target_os = "macos")]
350#[allow(clippy::disallowed_methods)]
351fn resolve_symlinks(paths: &mut Vec<SandboxPath>) {
352 let additional: Vec<SandboxPath> = paths
353 .iter()
354 .filter_map(|sp| {
355 let resolved = std::fs::canonicalize(&sp.path).ok()?;
356 if resolved == sp.path {
357 return None;
358 }
359 let resolved_str = resolved.to_string_lossy();
363 if let Some(cellar_idx) = resolved_str.find("/Cellar/") {
364 let after_cellar = &resolved_str[cellar_idx + 8..];
365 let parts: Vec<&str> = after_cellar.splitn(3, '/').collect();
366 if parts.len() >= 2 {
367 let pkg_root = format!(
368 "{}/Cellar/{}/{}",
369 &resolved_str[..cellar_idx],
370 parts[0],
371 parts[1]
372 );
373 return Some(SandboxPath::dir(PathBuf::from(pkg_root)));
374 }
375 }
376 Some(SandboxPath {
378 path: resolved,
379 kind: sp.kind,
380 })
381 })
382 .filter(|resolved| !paths.iter().any(|p| p.path == resolved.path))
383 .collect();
384 paths.extend(additional);
385}
386
387fn regex_escape(s: &str) -> String {
389 let mut out = String::with_capacity(s.len());
390 for c in s.chars() {
391 if matches!(
392 c,
393 '.' | '\\' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
394 ) {
395 out.push('\\');
396 }
397 out.push(c);
398 }
399 out
400}
401
402fn find_git_root(start: &Path) -> Option<PathBuf> {
404 let mut dir = start;
405 loop {
406 if dir.join(".git").exists() {
407 return Some(dir.to_path_buf());
408 }
409 dir = dir.parent()?;
410 }
411}
412
413#[derive(Debug, Default, Clone)]
415pub struct ProfileOverrides {
416 pub allow_write: Vec<SandboxPath>,
417 pub deny_read: Vec<SandboxPath>,
418 pub allow_read: Vec<SandboxPath>,
419 pub allow_domains: Vec<DomainPattern>,
420 pub deny_domains: Vec<DomainPattern>,
421 pub allow_exec: Vec<SandboxPath>,
422 pub deny_exec: Vec<SandboxPath>,
423 pub allow_fetch: Vec<DomainPattern>,
424 pub allow_all_network: bool,
425 pub no_proxy: bool,
426 pub allow_degraded: bool,
427 pub env: HashMap<String, String>,
428}
429
430#[derive(Debug, Deserialize)]
433struct DefaultsFile {
434 common: CommonDefaults,
435 profiles: HashMap<String, EcosystemDefaults>,
436}
437
438#[derive(Debug, Deserialize)]
439#[serde(rename_all = "camelCase")]
440struct CommonDefaults {
441 #[serde(default)]
442 deny_read: Vec<String>,
443 #[serde(default)]
444 allow_read: Vec<String>,
445 #[serde(default)]
446 deny_exec: Vec<String>,
447 #[serde(default)]
448 allow_exec: Vec<String>,
449}
450
451#[derive(Debug, Deserialize)]
452#[serde(rename_all = "camelCase")]
453struct EcosystemDefaults {
454 #[serde(default)]
455 allow_write: Vec<String>,
456 #[serde(default)]
457 allow_read: Vec<String>,
458 #[serde(default)]
459 allow_domains: Vec<String>,
460 #[serde(default)]
461 allow_exec: Vec<String>,
462 #[serde(default)]
470 enable_proxy: Option<bool>,
471}
472
473fn resolve_cargo_target_dir(home: &Path, pwd: &Path) -> Option<PathBuf> {
477 if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
478 return Some(PathBuf::from(dir));
479 }
480 if let Ok(dir) = std::env::var("CARGO_BUILD_TARGET_DIR") {
481 return Some(PathBuf::from(dir));
482 }
483 if let Some(dir) = read_target_dir_from_cargo_config(&pwd.join(".cargo/config.toml")) {
484 return Some(dir);
485 }
486 if let Some(dir) = read_target_dir_from_cargo_config(&home.join(".cargo/config.toml")) {
487 return Some(dir);
488 }
489 None
490}
491
492#[allow(clippy::disallowed_methods)]
493fn read_target_dir_from_cargo_config(path: &Path) -> Option<PathBuf> {
494 let content = std::fs::read_to_string(path).ok()?;
495 let mut in_build_section = false;
496
497 for line in content.lines() {
498 let trimmed = line.trim();
499 if trimmed.starts_with('[') {
500 in_build_section = trimmed == "[build]";
501 continue;
502 }
503 if in_build_section && let Some(value) = trimmed.strip_prefix("target-dir") {
504 let value = value.trim().strip_prefix('=')?.trim();
505 let value = value
506 .strip_prefix('"')
507 .and_then(|v| v.strip_suffix('"'))
508 .unwrap_or(value);
509 return Some(PathBuf::from(value));
510 }
511 }
512 None
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
520 fn test_should_match_exact_domain() {
521 let p = DomainPattern::from("registry.npmjs.org");
522 assert!(p.matches("registry.npmjs.org"));
523 assert!(!p.matches("evil.com"));
524 assert!(!p.matches("sub.registry.npmjs.org"));
525 }
526
527 #[test]
528 fn test_should_match_wildcard_domain() {
529 let p = DomainPattern::from("*.npmjs.org");
530 assert!(p.matches("registry.npmjs.org"));
531 assert!(p.matches("npmjs.org"));
532 assert!(p.matches("deep.sub.npmjs.org"));
533 assert!(!p.matches("evil.com"));
534 }
535
536 #[test]
537 fn test_should_load_all_ecosystems_from_yaml() {
538 let home = PathBuf::from("/Users/test");
539 let pwd = PathBuf::from("/Users/test/project");
540
541 for eco in Ecosystem::ALL {
542 let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
543 assert_eq!(profile.name, eco.to_string());
544 assert!(!profile.allow_write.is_empty(), "no allow_write for {eco}");
545 assert!(!profile.deny_read.is_empty(), "no deny_read for {eco}");
546 assert!(
547 !profile.allow_domains.is_empty(),
548 "no allow_domains for {eco}"
549 );
550 assert!(!profile.allow_exec.is_empty(), "no allow_exec for {eco}");
551 #[cfg(target_os = "macos")]
553 assert!(!profile.deny_exec.is_empty(), "no deny_exec for {eco}");
554 }
555 }
556
557 #[test]
560 fn test_should_parse_both_defaults_files() {
561 let macos: DefaultsFile =
562 serde_yaml::from_str(include_str!("defaults-macos.yaml")).expect("macOS defaults");
563 let linux: DefaultsFile =
564 serde_yaml::from_str(include_str!("defaults-linux.yaml")).expect("Linux defaults");
565 for name in ["node", "rust", "python", "elixir", "java"] {
566 assert!(macos.profiles.contains_key(name), "macos missing {name}");
567 assert!(linux.profiles.contains_key(name), "linux missing {name}");
568 }
569 }
570
571 fn has(paths: &[SandboxPath], path: &str) -> bool {
573 paths.iter().any(|sp| sp.has_path(Path::new(path)))
574 }
575
576 #[test]
577 fn test_should_expand_paths_in_defaults() {
578 let home = PathBuf::from("/Users/test");
579 let pwd = PathBuf::from("/Users/test/project");
580 let profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
581
582 assert!(has(&profile.deny_read, "/Users/test/.ssh"));
583 assert!(has(&profile.allow_write, "/Users/test/project"));
584 assert!(has(&profile.allow_write, "/Users/test/.npm"));
585 }
586
587 #[test]
588 fn test_should_include_common_exec_in_all_profiles() {
589 let home = PathBuf::from("/Users/test");
590 let pwd = PathBuf::from("/Users/test/project");
591
592 for eco in Ecosystem::ALL {
593 let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
594 assert!(
595 has(&profile.allow_exec, "/bin/sh"),
596 "missing /bin/sh for {eco}"
597 );
598 assert!(
599 has(&profile.allow_exec, "/usr/bin/cc"),
600 "missing /usr/bin/cc for {eco}"
601 );
602 #[cfg(target_os = "macos")]
604 assert!(
605 has(&profile.deny_exec, "/usr/bin/osascript"),
606 "missing osascript deny for {eco}"
607 );
608 }
609 }
610
611 #[test]
612 fn test_should_merge_overrides() {
613 let home = PathBuf::from("/Users/test");
614 let pwd = PathBuf::from("/Users/test/project");
615 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
616 let original_write_count = profile.allow_write.len();
617
618 let overrides = ProfileOverrides {
619 allow_write: vec![SandboxPath::dir(PathBuf::from("/extra/path"))],
620 deny_domains: vec![DomainPattern::from("registry.npmmirror.com")],
621 ..Default::default()
622 };
623 profile.merge_overrides(&overrides);
624
625 assert_eq!(profile.allow_write.len(), original_write_count + 1);
626 assert!(
627 !profile
628 .allow_domains
629 .iter()
630 .any(|d| d.0 == "registry.npmmirror.com")
631 );
632 }
633
634 #[test]
635 fn test_should_finalize_allow_fetch() {
636 let home = PathBuf::from("/Users/test");
637 let pwd = PathBuf::from("/Users/test/project");
638 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
639
640 assert!(!has(&profile.allow_exec, "/usr/bin/curl"));
641
642 let overrides = ProfileOverrides {
643 allow_fetch: vec![DomainPattern::from("example.com")],
644 ..Default::default()
645 };
646 profile.merge_overrides(&overrides);
647 profile.finalize();
648
649 assert!(has(&profile.allow_exec, "/usr/bin/curl"));
650 assert!(has(&profile.allow_exec, "/usr/bin/wget"));
651 assert!(profile.allow_domains.iter().any(|d| d.0 == "example.com"));
652 }
653
654 #[test]
655 fn test_should_not_add_curl_without_allow_fetch() {
656 let home = PathBuf::from("/Users/test");
657 let pwd = PathBuf::from("/Users/test/project");
658 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
659 profile.finalize();
660 assert!(!has(&profile.allow_exec, "/usr/bin/curl"));
661 }
662
663 #[test]
664 fn test_should_not_duplicate_domains_on_finalize() {
665 let home = PathBuf::from("/Users/test");
666 let pwd = PathBuf::from("/Users/test/project");
667 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
668 let original_domain_count = profile.allow_domains.len();
669
670 let overrides = ProfileOverrides {
671 allow_fetch: vec![DomainPattern::from("github.com")],
672 ..Default::default()
673 };
674 profile.merge_overrides(&overrides);
675 profile.finalize();
676
677 assert_eq!(profile.allow_domains.len(), original_domain_count);
678 assert!(has(&profile.allow_exec, "/usr/bin/curl"));
679 }
680}