Skip to main content

sbe_core/profile/
mod.rs

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/// Embedded default profiles YAML, compiled into the binary.
15///
16/// Selection is `cfg(target_os = ...)` so each binary ships exactly the
17/// defaults that match its sandbox backend. Both files deserialize through
18/// the same [`DefaultsFile`] schema (verified in tests).
19#[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/// A pattern for matching domain names.
29///
30/// Supports exact match (`"registry.npmjs.org"`) and wildcard prefix
31/// (`"*.npmjs.org"` matches any subdomain).
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct DomainPattern(pub String);
35
36impl DomainPattern {
37    /// Check whether a given hostname matches this pattern.
38    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/// The resolved set of sandbox permissions for a single execution.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct SandboxProfile {
64    /// Human-readable name (e.g., "node", "rust", "custom:my-app").
65    pub name: String,
66
67    /// Paths allowed for writing (expanded, absolute).
68    #[serde(default)]
69    pub allow_write: Vec<SandboxPath>,
70
71    /// Paths denied for reading (expanded, absolute).
72    ///
73    /// On macOS this is a subtractive `(deny file-read* …)` rule. On Linux
74    /// Landlock cannot subtract from an allowed subtree, so the backend
75    /// instead treats this list as a *sealed forbidden-list*: paths here are
76    /// guaranteed never to be silently added to [`Self::allow_read`], and
77    /// any user config that would overlap is rejected.
78    #[serde(default)]
79    pub deny_read: Vec<SandboxPath>,
80
81    /// Read-allowlist extensions on Linux (no-op on macOS).
82    ///
83    /// macOS uses an "allow all reads then subtract" model, so this field
84    /// goes unused there. On Linux the backend merges these into the
85    /// curated read-anchors and runs the [`Self::deny_read`] forbidden-list
86    /// lint against the merged set.
87    #[serde(default)]
88    pub allow_read: Vec<SandboxPath>,
89
90    /// Domains allowed for outbound HTTPS.
91    #[serde(default)]
92    pub allow_domains: Vec<DomainPattern>,
93
94    /// Binary paths denied for execution.
95    #[serde(default)]
96    pub deny_exec: Vec<SandboxPath>,
97
98    /// Binary paths explicitly allowed for execution.
99    #[serde(default)]
100    pub allow_exec: Vec<SandboxPath>,
101
102    /// Whether to enable the domain-filtering proxy.
103    #[serde(default = "default_true")]
104    pub enable_proxy: bool,
105
106    /// Whether to allow all network (disables proxy, allows all outbound).
107    #[serde(default)]
108    pub allow_all_network: bool,
109
110    /// Domains that build scripts are allowed to fetch from.
111    ///
112    /// When non-empty, `curl` and `wget` are added to `allow_exec` and these
113    /// domains are merged into the proxy allowlist.
114    #[serde(default)]
115    pub allow_fetch: Vec<DomainPattern>,
116
117    /// Additional environment variables to inject.
118    #[serde(default)]
119    pub env: HashMap<String, String>,
120
121    /// Proceed under a less-capable kernel even when a requested feature
122    /// (currently: Landlock ABI v4 net filter) is unavailable. See §13 D1
123    /// of the cross-platform backend design.
124    #[serde(default)]
125    pub allow_degraded: bool,
126
127    /// Per-field boundary marker: indices `< first_user_*` were populated
128    /// from the curated per-OS defaults; indices `>=` came from user
129    /// `.sbe.yaml` or CLI overrides. The Linux backend's `denyRead`
130    /// forbidden-list seal lint only inspects user additions so that
131    /// intentional default overlaps (e.g. `$PWD/` covers `$PWD/.env`)
132    /// don't trip on every project.
133    #[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    /// Build the default profile for an ecosystem from the embedded YAML defaults.
147    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        // Build allow_exec: common + ecosystem-specific
159        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        // Build deny_exec: from common (also resolve symlinks for deny rules
167        // on macOS — on Linux denyExec is a no-op so symlinks don't matter).
168        #[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        // Build deny_read: from common
178        let deny_read: Vec<SandboxPath> = common
179            .deny_read
180            .iter()
181            .map(|p| expand_path(p, home, pwd))
182            .collect();
183
184        // Build allow_write: from ecosystem
185        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        // Build allow_domains: from ecosystem
192        let allow_domains: Vec<DomainPattern> = eco_cfg
193            .allow_domains
194            .iter()
195            .map(|d| DomainPattern(d.clone()))
196            .collect();
197
198        // Node-specific: monorepos hoist node_modules and lock files to the
199        // workspace root. Only allow writes to specific paths npm needs —
200        // NOT the entire git root, which would let a malicious postinstall
201        // script modify source files in sibling packages or CI configs.
202        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        // Rust-specific: resolve cargo target dir for write + exec.
217        // Cargo also creates atomic-rename temp dirs as siblings of target
218        // (e.g., ~/.targetXXXXXX), so we need a regex allow for those.
219        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                // Allow sibling temp dirs created by cargo for atomic rename
224                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        // Java-specific: allow JAVA_HOME
234        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        // Resolve symlinks: SBPL on macOS checks the real path after kernel
241        // symlink resolution, so /opt/homebrew/bin/zig (a symlink to
242        // /opt/homebrew/Cellar/.../zig) won't match unless we also allow the
243        // resolved Cellar path. Landlock on Linux dereferences via the
244        // preopened FD; symlink resolution there is a non-issue.
245        #[cfg(target_os = "macos")]
246        resolve_symlinks(&mut allow_exec);
247
248        // Linux read-allowlist additions from defaults (macOS ignores).
249        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        // After this point everything appended to allow_* is treated as
257        // user-supplied. Snapshot the lengths now so the seal lint can
258        // identify user additions later.
259        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    /// Merge CLI overrides into this profile.
283    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    /// Finalize the profile: apply allow_fetch effects to allow_exec and allow_domains.
318    ///
319    /// Must be called after all merging is complete, before SBPL generation.
320    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/// For each path in the list, if it's a symlink, also add the resolved real path.
341///
342/// macOS sandbox-exec resolves symlinks before checking SBPL rules, so
343/// `/opt/homebrew/bin/zig` (a symlink to `/opt/homebrew/Cellar/zig/.../zig`)
344/// requires the Cellar path to be in the allow list too.
345///
346/// For Homebrew Cellar paths, we add the package root directory (e.g.,
347/// `/opt/homebrew/Cellar/zig/0.15.2/`) rather than just the binary, because
348/// tools like zig spawn sub-tools from their lib/ directory.
349#[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            // For Homebrew Cellar paths, allow the entire package directory.
360            // Structure: /opt/homebrew/Cellar/<pkg>/<version>/bin/<binary>
361            // We want:   /opt/homebrew/Cellar/<pkg>/<version>/
362            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            // Preserve the original kind for non-Cellar symlinks
377            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
387/// Escape regex metacharacters in a literal string.
388fn 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
402/// Find the git root by walking up from `start`.
403fn 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/// Overrides from CLI flags that get merged into the resolved profile.
414#[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// --- Embedded YAML deserialization types ---
431
432#[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    /// Whether to start the domain-filtering proxy. Some ecosystems whose
463    /// HTTP stack does not respect `HTTP_PROXY` env (notably JVM tools like
464    /// Maven and Gradle) cannot benefit from the proxy and need the kernel
465    /// to open port 443 directly. Set this to `false` in those profiles —
466    /// kernel TCP filter still enforces "egress on port 443 only", but
467    /// domain filtering is delegated to the proxy when set to true.
468    /// Defaults to `true`.
469    #[serde(default)]
470    enable_proxy: Option<bool>,
471}
472
473// --- Rust-specific cargo target dir resolution ---
474
475/// Resolve the cargo target directory from environment or cargo config.
476fn 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            // denyExec is macOS-only — Linux Landlock is allowlist-only.
552            #[cfg(target_os = "macos")]
553            assert!(!profile.deny_exec.is_empty(), "no deny_exec for {eco}");
554        }
555    }
556
557    /// Both YAML defaults files must deserialize through [`DefaultsFile`]
558    /// (regression guard for the macOS/Linux schema split).
559    #[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    /// Helper: check if a path list contains a given path (ignoring is_dir).
572    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            // osascript deny only exists in the macOS defaults.
603            #[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}