Skip to main content

sbe_core/
config.rs

1use std::{
2    collections::{HashMap, HashSet},
3    io::Read,
4    os::unix::fs::{MetadataExt, OpenOptionsExt},
5    path::{Component, Path, PathBuf},
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    error::CoreError,
12    profile::{DomainPattern, GrantKind, GrantOrigin, GrantRecord, SandboxProfile},
13};
14
15/// Top-level configuration file structure (`.sbe.yaml` or `~/.config/sbe/config.yaml`).
16#[derive(Debug, Default, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct SbeConfig {
19    /// Profile overrides keyed by profile name.
20    #[serde(default)]
21    pub profiles: HashMap<String, ProfileConfig>,
22}
23
24/// A single profile configuration block from the YAML file.
25#[derive(Debug, Default, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct ProfileConfig {
28    /// Base profile to extend from.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub extends: Option<String>,
31
32    #[serde(default)]
33    pub allow_write: Vec<String>,
34
35    #[serde(default)]
36    pub deny_read: Vec<String>,
37
38    /// Linux read-allowlist extensions. macOS ignores this field.
39    #[serde(default)]
40    pub allow_read: Vec<String>,
41
42    #[serde(default)]
43    pub allow_domains: Vec<String>,
44
45    /// Remove domains from grants established by lower-precedence sources.
46    #[serde(default)]
47    pub deny_domains: Vec<String>,
48
49    #[serde(default)]
50    pub deny_exec: Vec<String>,
51
52    #[serde(default)]
53    pub allow_exec: Vec<String>,
54
55    /// Domains that build scripts are allowed to fetch from.
56    ///
57    /// When non-empty, enables curl/wget execution and adds these domains
58    /// to the proxy allowlist. This is the intended way to allow build-time
59    /// downloads for specific crates (e.g., utoipa-swagger-ui, protobuf-src).
60    #[serde(default)]
61    pub allow_fetch: Vec<String>,
62
63    /// Whether to allow all network access (disables proxy and SBPL network restrictions).
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub allow_all_network: Option<bool>,
66
67    /// Whether to enable the domain-filtering proxy.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub enable_proxy: Option<bool>,
70
71    /// Opt-in to proceed under a degraded kernel (Linux only). See
72    /// `cross-platform-backend-design.md` §13 D1.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub allow_degraded: Option<bool>,
75
76    #[serde(default)]
77    pub env: HashMap<String, String>,
78}
79
80const MAX_CONFIG_BYTES: usize = 1024 * 1024;
81const MAX_PROFILES: usize = 128;
82const MAX_LIST_ITEMS: usize = 1024;
83const MAX_PATH_BYTES: usize = 4096;
84const MAX_ENV_VALUE_BYTES: usize = 8192;
85const MAX_ENV_VARS: usize = 128;
86
87const RESERVED_ENV: &[&str] = &[
88    "HTTP_PROXY",
89    "HTTPS_PROXY",
90    "NO_PROXY",
91    "http_proxy",
92    "https_proxy",
93    "no_proxy",
94    "TMPDIR",
95    "TMP",
96    "TEMP",
97    "XDG_RUNTIME_DIR",
98    "CARGO_TARGET_DIR",
99    "CARGO_BUILD_TARGET_DIR",
100    "CARGO_BUILD_BUILD_DIR",
101    "PIP_CACHE_DIR",
102    "UV_CACHE_DIR",
103    "MIX_BUILD_ROOT",
104    "MIX_DEPS_PATH",
105    "REBAR_CACHE_DIR",
106    "GRADLE_USER_HOME",
107    "COURSIER_CACHE",
108    "SBT_OPTS",
109    "JAVA_TOOL_OPTIONS",
110    "SBE_PROXY_TOKEN",
111];
112
113/// Trust provenance of a loaded configuration file.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub enum ConfigOrigin {
117    Global,
118    Project,
119    Explicit,
120}
121
122/// A configuration plus the provenance needed to enforce monotonic project
123/// policy and explain the final profile.
124#[derive(Debug, Clone)]
125pub struct LoadedConfig {
126    pub config: SbeConfig,
127    pub origin: ConfigOrigin,
128    pub path: PathBuf,
129    pub trusted: bool,
130}
131
132impl SbeConfig {
133    /// Load config from a YAML file. Returns `Ok(None)` if the file does not exist.
134    pub async fn load(path: &Path) -> Result<Option<Self>, CoreError> {
135        let owned_path = path.to_path_buf();
136        let contents = tokio::task::spawn_blocking(move || read_config_bytes(&owned_path))
137            .await
138            .map_err(|error| {
139                CoreError::Backend(format!("configuration reader failed: {error}"))
140            })??;
141        let Some(contents) = contents else {
142            return Ok(None);
143        };
144        let contents = String::from_utf8(contents).map_err(|error| CoreError::ConfigLoad {
145            path: path.to_path_buf(),
146            source: Box::new(error),
147        })?;
148        let config: Self =
149            serde_yaml::from_str(&contents).map_err(|error| CoreError::ConfigLoad {
150                path: path.to_path_buf(),
151                source: Box::new(error),
152            })?;
153        config.validate(path)?;
154        Ok(Some(config))
155    }
156
157    /// Find the project config by walking up from `start` to the filesystem root,
158    /// stopping at a git repository boundary. Checks both `.sbe.yaml` and `.sbe.yml`.
159    pub fn find_project_config(start: &Path) -> Option<PathBuf> {
160        let mut dir = start;
161        loop {
162            for name in [".sbe.yaml", ".sbe.yml"] {
163                let candidate = dir.join(name);
164                if candidate.exists() {
165                    return Some(candidate);
166                }
167            }
168            // Stop at git root
169            if dir.join(".git").exists() {
170                return None;
171            }
172            dir = dir.parent()?;
173        }
174    }
175
176    /// The global config path: `~/.config/sbe/config.yaml`.
177    pub fn global_config_path() -> Option<PathBuf> {
178        dirs::config_dir().map(|d| d.join("sbe/config.yaml"))
179    }
180
181    fn validate(&self, path: &Path) -> Result<(), CoreError> {
182        if self.profiles.len() > MAX_PROFILES {
183            return Err(config_policy(path, "too many profiles"));
184        }
185        for (name, profile) in &self.profiles {
186            if name.is_empty() || name.len() > 128 || name.chars().any(char::is_control) {
187                return Err(config_policy(
188                    path,
189                    format!("invalid profile name '{name}'"),
190                ));
191            }
192            profile.validate(path)?;
193        }
194        Ok(())
195    }
196}
197
198#[allow(clippy::disallowed_types)] // Unix flags require std OpenOptions in spawn_blocking.
199fn read_config_bytes(path: &Path) -> Result<Option<Vec<u8>>, CoreError> {
200    let file = match std::fs::OpenOptions::new()
201        .read(true)
202        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
203        .open(path)
204    {
205        Ok(file) => file,
206        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
207        Err(source) => {
208            return Err(CoreError::ConfigLoad {
209                path: path.to_path_buf(),
210                source: Box::new(source),
211            });
212        }
213    };
214    let metadata = file.metadata().map_err(|source| CoreError::ConfigLoad {
215        path: path.to_path_buf(),
216        source: Box::new(source),
217    })?;
218    if !metadata.is_file() {
219        return Err(config_policy(path, "configuration is not a regular file"));
220    }
221    if metadata.len() > MAX_CONFIG_BYTES as u64 {
222        return Err(config_policy(
223            path,
224            format!(
225                "file is {} bytes; maximum is {MAX_CONFIG_BYTES}",
226                metadata.len()
227            ),
228        ));
229    }
230    let mut contents = Vec::new();
231    file.take(MAX_CONFIG_BYTES as u64 + 1)
232        .read_to_end(&mut contents)
233        .map_err(|source| CoreError::ConfigLoad {
234            path: path.to_path_buf(),
235            source: Box::new(source),
236        })?;
237    if contents.len() > MAX_CONFIG_BYTES {
238        return Err(config_policy(path, "configuration grew beyond 1 MiB"));
239    }
240    Ok(Some(contents))
241}
242
243impl ProfileConfig {
244    /// Apply this config's overrides onto a `SandboxProfile`.
245    ///
246    /// Paths are expanded relative to `home` (for `~`) and `pwd` (for `./`).
247    pub fn apply_to(
248        &self,
249        profile: &mut SandboxProfile,
250        home: &Path,
251        pwd: &Path,
252        origin: &GrantOrigin,
253    ) -> Result<(), CoreError> {
254        if !self.allow_domains.is_empty()
255            || !self.deny_domains.is_empty()
256            || !self.allow_fetch.is_empty()
257            || self.allow_all_network.is_some()
258            || self.enable_proxy.is_some()
259        {
260            profile.network_origin = origin.clone();
261        }
262        for p in &self.allow_write {
263            let path = expand_path(p, home, pwd);
264            record_path(profile, GrantKind::AllowWrite, &path, origin);
265            profile.allow_write.push(path);
266        }
267        for p in &self.deny_read {
268            let path = expand_path(p, home, pwd);
269            record_path(profile, GrantKind::DenyRead, &path, origin);
270            profile.deny_read.push(path);
271        }
272        for p in &self.allow_read {
273            let path = expand_path(p, home, pwd);
274            record_path(profile, GrantKind::AllowRead, &path, origin);
275            profile.allow_read.push(path);
276        }
277        for d in &self.allow_domains {
278            let domain = DomainPattern::new(d).map_err(CoreError::ProfileLint)?;
279            profile.grant_origins.push(GrantRecord {
280                kind: GrantKind::AllowDomain,
281                value: domain.0.clone(),
282                origin: origin.clone(),
283            });
284            profile.allow_domains.push(domain);
285        }
286        for p in &self.deny_exec {
287            profile.add_deny_exec(expand_path(p, home, pwd), origin.clone());
288        }
289        for p in &self.allow_exec {
290            profile.add_allow_exec(expand_path(p, home, pwd), origin.clone());
291        }
292        for d in &self.allow_fetch {
293            let domain = DomainPattern::new(d).map_err(CoreError::ProfileLint)?;
294            profile.grant_origins.push(GrantRecord {
295                kind: GrantKind::AllowFetch,
296                value: domain.0.clone(),
297                origin: origin.clone(),
298            });
299            profile.allow_fetch.push(domain);
300        }
301        let denied_domains = self
302            .deny_domains
303            .iter()
304            .map(|domain| DomainPattern::new(domain).map_err(CoreError::ProfileLint))
305            .collect::<Result<Vec<_>, _>>()?;
306        profile.remove_denied_domains(&denied_domains);
307        if let Some(allow_all) = self.allow_all_network {
308            profile.allow_all_network = allow_all;
309        }
310        if let Some(enable_proxy) = self.enable_proxy {
311            profile.enable_proxy = enable_proxy;
312        }
313        if let Some(allow_degraded) = self.allow_degraded {
314            profile.allow_degraded = allow_degraded;
315        }
316        for (k, v) in &self.env {
317            profile.env.insert(k.clone(), v.clone());
318            profile.grant_origins.push(GrantRecord {
319                kind: GrantKind::Environment,
320                value: k.clone(),
321                origin: origin.clone(),
322            });
323        }
324        profile.recompute_network_mode();
325        Ok(())
326    }
327
328    fn validate(&self, path: &Path) -> Result<(), CoreError> {
329        if self.allow_degraded == Some(true) {
330            return Err(config_policy(
331                path,
332                "allowDegraded in configuration is no longer accepted; use the capability-specific CLI option --allow-insecure-linux-network for one trusted invocation",
333            ));
334        }
335        let lists = [
336            ("allowWrite", &self.allow_write),
337            ("denyRead", &self.deny_read),
338            ("allowRead", &self.allow_read),
339            ("allowDomains", &self.allow_domains),
340            ("denyDomains", &self.deny_domains),
341            ("denyExec", &self.deny_exec),
342            ("allowExec", &self.allow_exec),
343            ("allowFetch", &self.allow_fetch),
344        ];
345        for (name, values) in lists {
346            if values.len() > MAX_LIST_ITEMS {
347                return Err(config_policy(path, format!("{name} has too many entries")));
348            }
349            for value in values {
350                if value.is_empty()
351                    || value.len() > MAX_PATH_BYTES
352                    || value.contains('\0')
353                    || value.chars().any(char::is_control)
354                {
355                    return Err(config_policy(path, format!("invalid value in {name}")));
356                }
357            }
358        }
359        for (name, values) in [
360            ("allowWrite", &self.allow_write),
361            ("denyRead", &self.deny_read),
362            ("allowRead", &self.allow_read),
363            ("denyExec", &self.deny_exec),
364            ("allowExec", &self.allow_exec),
365        ] {
366            for value in values {
367                let without_directory_marker = value.strip_suffix('/').unwrap_or(value);
368                if Path::new(without_directory_marker)
369                    .components()
370                    .any(|component| component == Component::ParentDir)
371                {
372                    return Err(config_policy(
373                        path,
374                        format!("{name} path must not contain '..'"),
375                    ));
376                }
377            }
378        }
379        for domain in self
380            .allow_domains
381            .iter()
382            .chain(&self.deny_domains)
383            .chain(&self.allow_fetch)
384        {
385            DomainPattern::new(domain).map_err(|reason| config_policy(path, reason))?;
386        }
387        if self.env.len() > MAX_ENV_VARS {
388            return Err(config_policy(path, "too many environment variables"));
389        }
390        for (name, value) in &self.env {
391            if !is_valid_env_name(name) || RESERVED_ENV.contains(&name.as_str()) {
392                return Err(config_policy(
393                    path,
394                    format!("invalid or reserved environment variable '{name}'"),
395                ));
396            }
397            if value.len() > MAX_ENV_VALUE_BYTES || value.contains('\0') {
398                return Err(config_policy(
399                    path,
400                    format!("invalid value for environment variable '{name}'"),
401                ));
402            }
403        }
404        Ok(())
405    }
406
407    fn validate_untrusted_project(&self, path: &Path) -> Result<(), CoreError> {
408        let expands_authority = self.extends.is_some()
409            || !self.allow_write.is_empty()
410            || !self.allow_read.is_empty()
411            || !self.allow_domains.is_empty()
412            || !self.allow_exec.is_empty()
413            || !self.allow_fetch.is_empty()
414            || !self.env.is_empty()
415            || self.allow_all_network == Some(true)
416            || self.enable_proxy == Some(false)
417            || self.allow_degraded == Some(true);
418        if expands_authority {
419            return Err(config_policy(
420                path,
421                "project configuration may only add denyRead/denyExec or explicitly disable allowAllNetwork/allowDegraded; pass --trust-project-config to authorize expansion",
422            ));
423        }
424        Ok(())
425    }
426}
427
428fn record_path(
429    profile: &mut SandboxProfile,
430    kind: GrantKind,
431    path: &SandboxPath,
432    origin: &GrantOrigin,
433) {
434    profile.grant_origins.push(GrantRecord {
435        kind,
436        value: path.path.to_string_lossy().into_owned(),
437        origin: origin.clone(),
438    });
439}
440
441fn is_valid_env_name(name: &str) -> bool {
442    let mut bytes = name.bytes();
443    matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_'))
444        && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
445}
446
447fn config_policy(path: &Path, reason: impl Into<String>) -> CoreError {
448    CoreError::ConfigPolicy {
449        path: path.to_path_buf(),
450        reason: reason.into(),
451    }
452}
453
454/// How a `SandboxPath` should be matched in SBPL.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub enum PathKind {
458    /// Match the directory and everything under it (SBPL `subpath`).
459    Subpath,
460    /// Exact file match (SBPL `literal`).
461    Literal,
462    /// Regex match against the absolute path (SBPL `regex`).
463    /// Used for prefix patterns like `<target>XXXXXX` temp dirs.
464    Regex,
465}
466
467/// A path with an explicit kind for SBPL generation.
468///
469/// Convention: in YAML configs, paths ending with `/` are directories
470/// (generate SBPL `subpath`), paths without trailing `/` are files
471/// (generate SBPL `literal`). Regex paths are only constructed
472/// programmatically (not from YAML).
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct SandboxPath {
475    pub path: PathBuf,
476    pub kind: PathKind,
477}
478
479impl SandboxPath {
480    pub fn dir(path: PathBuf) -> Self {
481        Self {
482            path,
483            kind: PathKind::Subpath,
484        }
485    }
486
487    pub fn file(path: PathBuf) -> Self {
488        Self {
489            path,
490            kind: PathKind::Literal,
491        }
492    }
493
494    /// Create a regex match. The path must be a valid regex pattern.
495    pub fn regex(pattern: PathBuf) -> Self {
496        Self {
497            path: pattern,
498            kind: PathKind::Regex,
499        }
500    }
501
502    /// Check if this sandbox path matches a given filesystem path.
503    pub fn has_path(&self, path: &Path) -> bool {
504        self.path == path
505    }
506}
507
508impl std::fmt::Display for SandboxPath {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        self.path.display().fmt(f)
511    }
512}
513
514/// Expand path placeholders and detect directory vs file from trailing `/`.
515///
516/// - `~/.ssh/` → directory (subpath)
517/// - `~/.npmrc` → file (literal)
518/// - `$PWD/` → directory
519pub fn expand_path(raw: &str, home: &Path, pwd: &Path) -> SandboxPath {
520    let kind = if raw.ends_with('/') {
521        PathKind::Subpath
522    } else {
523        PathKind::Literal
524    };
525    let raw = raw.strip_suffix('/').unwrap_or(raw);
526
527    let path = if raw == "$PWD" {
528        pwd.to_path_buf()
529    } else if let Some(rest) = raw.strip_prefix("$PWD/") {
530        pwd.join(rest)
531    } else if raw == "$HOME" {
532        home.to_path_buf()
533    } else if let Some(rest) = raw.strip_prefix("$HOME/") {
534        home.join(rest)
535    } else if let Some(rest) = raw.strip_prefix("~/") {
536        home.join(rest)
537    } else if raw == "~" {
538        home.to_path_buf()
539    } else if let Some(rest) = raw.strip_prefix("./") {
540        pwd.join(rest)
541    } else if raw == "." {
542        pwd.to_path_buf()
543    } else if raw.starts_with('/') {
544        PathBuf::from(raw)
545    } else {
546        pwd.join(raw)
547    };
548
549    SandboxPath { path, kind }
550}
551
552/// Load and merge configuration from all sources.
553///
554/// Resolution order (last wins):
555/// 1. Built-in ecosystem defaults
556/// 2. Global config (`~/.config/sbe/config.yaml`)
557/// 3. Project config (`.sbe.yaml` found by walking up from pwd)
558/// 4. Explicit config file (`--config` flag)
559///
560/// Returns the merged configs in order. The caller applies them to the profile.
561pub async fn load_configs(
562    pwd: &Path,
563    explicit_config: Option<&Path>,
564    trust_project_config: bool,
565) -> Result<Vec<LoadedConfig>, CoreError> {
566    let mut configs = Vec::new();
567    let explicit_path = explicit_config.map(|path| {
568        if path.is_absolute() {
569            path.to_path_buf()
570        } else {
571            pwd.join(path)
572        }
573    });
574
575    // Global config
576    if let Some(global_path) = SbeConfig::global_config_path()
577        && let Some(cfg) = SbeConfig::load(&global_path).await?
578    {
579        configs.push(LoadedConfig {
580            config: cfg,
581            origin: ConfigOrigin::Global,
582            path: global_path,
583            trusted: true,
584        });
585    }
586
587    // Project config
588    if let Some(project_path) = SbeConfig::find_project_config(pwd) {
589        let selected_explicitly = if let Some(explicit) = &explicit_path {
590            same_configuration_file(&project_path, explicit).await
591        } else {
592            false
593        };
594        if !selected_explicitly && let Some(cfg) = SbeConfig::load(&project_path).await? {
595            configs.push(LoadedConfig {
596                config: cfg,
597                origin: ConfigOrigin::Project,
598                path: project_path,
599                trusted: trust_project_config,
600            });
601        }
602    }
603
604    // Explicit config
605    if let Some(explicit) = explicit_path
606        && let Some(cfg) = SbeConfig::load(&explicit).await?
607    {
608        configs.push(LoadedConfig {
609            config: cfg,
610            origin: ConfigOrigin::Explicit,
611            path: explicit,
612            trusted: true,
613        });
614    }
615
616    Ok(configs)
617}
618
619/// Compare file identities instead of spellings so relative paths and hard
620/// links cannot make one policy file appear as both an untrusted project
621/// source and a trusted explicit source. Any metadata error falls through to
622/// normal loading, which reports the appropriate path-specific error.
623async fn same_configuration_file(left: &Path, right: &Path) -> bool {
624    if left == right {
625        return true;
626    }
627    let (left_metadata, right_metadata) =
628        tokio::join!(tokio::fs::metadata(left), tokio::fs::metadata(right));
629    matches!(
630        (left_metadata, right_metadata),
631        (Ok(left), Ok(right)) if left.dev() == right.dev() && left.ino() == right.ino()
632    )
633}
634
635/// Resolve the final `SandboxProfile` by merging configs into the ecosystem default.
636pub fn resolve_profile(
637    base: &mut SandboxProfile,
638    configs: &[LoadedConfig],
639    home: &Path,
640    pwd: &Path,
641) -> Result<(), CoreError> {
642    let profile_name = base.name.clone();
643
644    for loaded in configs {
645        let config = &loaded.config;
646        // Apply matching profile config
647        if let Some(pc) = config.profiles.get(&profile_name) {
648            if loaded.origin == ConfigOrigin::Project && !loaded.trusted {
649                pc.validate_untrusted_project(&loaded.path)?;
650            }
651            let grant_origin = match &loaded.origin {
652                ConfigOrigin::Global => GrantOrigin::Global(loaded.path.clone()),
653                ConfigOrigin::Project => GrantOrigin::Project(loaded.path.clone()),
654                ConfigOrigin::Explicit => GrantOrigin::Explicit(loaded.path.clone()),
655            };
656            apply_profile_recursive(
657                &profile_name,
658                config,
659                base,
660                home,
661                pwd,
662                &grant_origin,
663                &mut HashSet::new(),
664            )?;
665        }
666    }
667    base.recompute_network_mode();
668    Ok(())
669}
670
671fn apply_profile_recursive(
672    name: &str,
673    config: &SbeConfig,
674    profile: &mut SandboxProfile,
675    home: &Path,
676    pwd: &Path,
677    origin: &GrantOrigin,
678    visiting: &mut HashSet<String>,
679) -> Result<(), CoreError> {
680    if !visiting.insert(name.to_owned()) {
681        return Err(CoreError::ProfileLint(format!(
682            "cyclic profile extends involving '{name}'"
683        )));
684    }
685    let pc = config
686        .profiles
687        .get(name)
688        .ok_or_else(|| CoreError::UnknownBaseProfile {
689            child: profile.name.clone(),
690            base: name.to_owned(),
691        })?;
692    if let Some(parent) = &pc.extends {
693        if !config.profiles.contains_key(parent) {
694            return Err(CoreError::UnknownBaseProfile {
695                child: name.to_owned(),
696                base: parent.clone(),
697            });
698        }
699        apply_profile_recursive(parent, config, profile, home, pwd, origin, visiting)?;
700    }
701    pc.apply_to(profile, home, pwd, origin)?;
702    visiting.remove(name);
703    Ok(())
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn test_should_expand_home_path() {
712        let home = PathBuf::from("/Users/test");
713        let pwd = PathBuf::from("/Users/test/project");
714        let sp = expand_path("~/.ssh/", &home, &pwd);
715        assert_eq!(sp.path, PathBuf::from("/Users/test/.ssh"));
716        assert_eq!(sp.kind, PathKind::Subpath);
717    }
718
719    #[test]
720    fn test_should_expand_relative_path() {
721        let home = PathBuf::from("/Users/test");
722        let pwd = PathBuf::from("/Users/test/project");
723        let sp = expand_path("./node_modules/", &home, &pwd);
724        assert_eq!(sp.path, PathBuf::from("/Users/test/project/node_modules"));
725        assert_eq!(sp.kind, PathKind::Subpath);
726    }
727
728    #[test]
729    fn test_should_keep_absolute_path_as_file() {
730        let home = PathBuf::from("/Users/test");
731        let pwd = PathBuf::from("/Users/test/project");
732        let sp = expand_path("/usr/bin/osascript", &home, &pwd);
733        assert_eq!(sp.path, PathBuf::from("/usr/bin/osascript"));
734        assert_eq!(sp.kind, PathKind::Literal);
735    }
736
737    #[test]
738    fn test_should_detect_dir_from_trailing_slash() {
739        let home = PathBuf::from("/Users/test");
740        let pwd = PathBuf::from("/Users/test/project");
741
742        let dir = expand_path("~/.cargo/bin/", &home, &pwd);
743        assert_eq!(dir.kind, PathKind::Subpath);
744
745        let file = expand_path("~/.cargo/credentials.toml", &home, &pwd);
746        assert_eq!(file.kind, PathKind::Literal);
747
748        let pwd_dir = expand_path("$PWD/", &home, &pwd);
749        assert_eq!(pwd_dir.kind, PathKind::Subpath);
750    }
751
752    #[test]
753    fn test_should_parse_config_yaml() {
754        let yaml = r#"
755profiles:
756  node:
757    allowWrite:
758      - "./node_modules"
759      - "~/.npm"
760    denyRead:
761      - "~/.ssh"
762    allowDomains:
763      - "registry.npmjs.org"
764    env:
765      NODE_ENV: production
766  my-app:
767    extends: node
768    allowDomains:
769      - "api.mycompany.com"
770"#;
771        let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
772        assert_eq!(config.profiles.len(), 2);
773        assert_eq!(config.profiles["node"].allow_write.len(), 2);
774        assert_eq!(config.profiles["my-app"].extends.as_deref(), Some("node"));
775    }
776
777    #[test]
778    fn test_should_apply_profile_config() {
779        let home = PathBuf::from("/Users/test");
780        let pwd = PathBuf::from("/Users/test/project");
781        let pc = ProfileConfig {
782            allow_write: vec!["./extra".to_owned()],
783            allow_domains: vec!["extra.com".to_owned()],
784            ..Default::default()
785        };
786        let mut profile =
787            SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
788        let original_write = profile.allow_write.len();
789        let original_domains = profile.allow_domains.len();
790
791        pc.apply_to(
792            &mut profile,
793            &home,
794            &pwd,
795            &GrantOrigin::Explicit(PathBuf::from("test.yaml")),
796        )
797        .unwrap();
798
799        assert_eq!(profile.allow_write.len(), original_write + 1);
800        assert_eq!(profile.allow_domains.len(), original_domains + 1);
801    }
802
803    #[test]
804    fn profile_config_denials_cover_wildcards_and_same_layer_fetches() {
805        let home = PathBuf::from("/Users/test");
806        let pwd = PathBuf::from("/Users/test/project");
807        let pc = ProfileConfig {
808            allow_domains: vec!["*.example.com".to_owned()],
809            deny_domains: vec!["bad.example.com".to_owned()],
810            allow_fetch: vec!["bad.example.com".to_owned()],
811            ..Default::default()
812        };
813        let mut profile =
814            SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
815
816        pc.apply_to(
817            &mut profile,
818            &home,
819            &pwd,
820            &GrantOrigin::Explicit(PathBuf::from("test.yaml")),
821        )
822        .unwrap();
823        profile.finalize();
824
825        assert!(profile.allow_fetch.is_empty());
826        assert!(
827            !profile
828                .allow_domains
829                .iter()
830                .any(|domain| domain.matches("bad.example.com"))
831        );
832    }
833
834    #[test]
835    fn test_should_reject_unknown_config_fields() {
836        let yaml = "profiles:\n  node:\n    allowNetwrok: true\n";
837        assert!(serde_yaml::from_str::<SbeConfig>(yaml).is_err());
838    }
839
840    #[tokio::test]
841    async fn test_should_refuse_symlinked_configuration() {
842        let directory = tempfile::tempdir().unwrap();
843        let target = directory.path().join("target.yaml");
844        tokio::fs::write(&target, "profiles: {}\n").await.unwrap();
845        let link = directory.path().join(".sbe.yaml");
846        std::os::unix::fs::symlink(target, &link).unwrap();
847        assert!(SbeConfig::load(&link).await.is_err());
848    }
849
850    #[tokio::test]
851    async fn test_should_reject_oversized_configuration() {
852        let directory = tempfile::tempdir().unwrap();
853        let path = directory.path().join("large.yaml");
854        tokio::fs::write(&path, vec![b' '; MAX_CONFIG_BYTES + 1])
855            .await
856            .unwrap();
857        assert!(SbeConfig::load(&path).await.is_err());
858    }
859
860    #[tokio::test]
861    async fn explicit_project_config_is_loaded_once_as_trusted() {
862        let project = tempfile::tempdir().unwrap();
863        let path = project.path().join(".sbe.yaml");
864        tokio::fs::write(
865            &path,
866            "profiles:\n  rust:\n    allowWrite:\n      - '$PWD/generated/'\n",
867        )
868        .await
869        .unwrap();
870
871        let configs = load_configs(project.path(), Some(Path::new(".sbe.yaml")), false)
872            .await
873            .unwrap();
874        let selected: Vec<_> = configs
875            .iter()
876            .filter(|config| config.path == path)
877            .collect();
878        assert_eq!(selected.len(), 1);
879        assert_eq!(selected[0].origin, ConfigOrigin::Explicit);
880        assert!(selected[0].trusted);
881    }
882
883    #[tokio::test]
884    async fn configuration_identity_recognizes_hard_links() {
885        let directory = tempfile::tempdir().unwrap();
886        let original = directory.path().join("original.yaml");
887        let alias = directory.path().join("alias.yaml");
888        tokio::fs::write(&original, "profiles: {}\n").await.unwrap();
889        tokio::fs::hard_link(&original, &alias).await.unwrap();
890
891        assert!(same_configuration_file(&original, &alias).await);
892    }
893
894    #[test]
895    fn test_should_reject_reserved_environment_variable() {
896        let yaml = r#"
897profiles:
898  rust:
899    env:
900      HTTPS_PROXY: http://attacker.invalid
901"#;
902        let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
903        assert!(config.validate(Path::new("config.yaml")).is_err());
904    }
905
906    #[test]
907    fn test_should_reject_parent_traversal_in_policy_paths() {
908        let yaml = r#"
909profiles:
910  rust:
911    denyRead:
912      - "$PWD/output/../.ssh/"
913"#;
914        let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
915        assert!(config.validate(Path::new("config.yaml")).is_err());
916    }
917
918    #[test]
919    fn test_should_reject_untrusted_project_expansion() {
920        let home = PathBuf::from("/home/test");
921        let pwd = PathBuf::from("/work/project");
922        let mut config = SbeConfig::default();
923        config.profiles.insert(
924            "node".to_owned(),
925            ProfileConfig {
926                allow_all_network: Some(true),
927                allow_write: vec!["$HOME/".to_owned()],
928                ..ProfileConfig::default()
929            },
930        );
931        let loaded = LoadedConfig {
932            config,
933            origin: ConfigOrigin::Project,
934            path: pwd.join(".sbe.yaml"),
935            trusted: false,
936        };
937        let mut profile =
938            SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
939        assert!(resolve_profile(&mut profile, &[loaded], &home, &pwd).is_err());
940    }
941
942    #[test]
943    fn test_should_allow_untrusted_project_restrictions() {
944        let home = PathBuf::from("/home/test");
945        let pwd = PathBuf::from("/work/project");
946        let mut config = SbeConfig::default();
947        config.profiles.insert(
948            "node".to_owned(),
949            ProfileConfig {
950                deny_exec: vec!["/usr/bin/git".to_owned()],
951                deny_domains: vec!["registry.npmjs.org".to_owned()],
952                allow_all_network: Some(false),
953                ..ProfileConfig::default()
954            },
955        );
956        let loaded = LoadedConfig {
957            config,
958            origin: ConfigOrigin::Project,
959            path: pwd.join(".sbe.yaml"),
960            trusted: false,
961        };
962        let mut profile =
963            SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
964        resolve_profile(&mut profile, &[loaded], &home, &pwd).unwrap();
965        assert!(
966            profile
967                .deny_exec
968                .iter()
969                .any(|path| path.path == Path::new("/usr/bin/git"))
970        );
971        assert!(
972            !profile
973                .allow_exec
974                .iter()
975                .any(|path| path.path == Path::new("/usr/bin/git"))
976        );
977        assert!(
978            !profile
979                .allow_domains
980                .iter()
981                .any(|domain| domain.0 == "registry.npmjs.org")
982        );
983    }
984
985    #[test]
986    fn test_should_reject_cyclic_extends() {
987        let home = PathBuf::from("/home/test");
988        let pwd = PathBuf::from("/work/project");
989        let mut config = SbeConfig::default();
990        config.profiles.insert(
991            "node".to_owned(),
992            ProfileConfig {
993                extends: Some("base".to_owned()),
994                ..ProfileConfig::default()
995            },
996        );
997        config.profiles.insert(
998            "base".to_owned(),
999            ProfileConfig {
1000                extends: Some("node".to_owned()),
1001                ..ProfileConfig::default()
1002            },
1003        );
1004        let loaded = LoadedConfig {
1005            config,
1006            origin: ConfigOrigin::Explicit,
1007            path: pwd.join("policy.yaml"),
1008            trusted: true,
1009        };
1010        let mut profile =
1011            SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
1012        assert!(resolve_profile(&mut profile, &[loaded], &home, &pwd).is_err());
1013    }
1014}