Skip to main content

sbe_core/
config.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    error::CoreError,
10    profile::{DomainPattern, SandboxProfile},
11};
12
13/// Top-level configuration file structure (`.sbe.yaml` or `~/.config/sbe/config.yaml`).
14#[derive(Debug, Default, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct SbeConfig {
17    /// Profile overrides keyed by profile name.
18    #[serde(default)]
19    pub profiles: HashMap<String, ProfileConfig>,
20}
21
22/// A single profile configuration block from the YAML file.
23#[derive(Debug, Default, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ProfileConfig {
26    /// Base profile to extend from.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub extends: Option<String>,
29
30    #[serde(default)]
31    pub allow_write: Vec<String>,
32
33    #[serde(default)]
34    pub deny_read: Vec<String>,
35
36    /// Linux read-allowlist extensions. macOS ignores this field.
37    #[serde(default)]
38    pub allow_read: Vec<String>,
39
40    #[serde(default)]
41    pub allow_domains: Vec<String>,
42
43    #[serde(default)]
44    pub deny_exec: Vec<String>,
45
46    #[serde(default)]
47    pub allow_exec: Vec<String>,
48
49    /// Domains that build scripts are allowed to fetch from.
50    ///
51    /// When non-empty, enables curl/wget execution and adds these domains
52    /// to the proxy allowlist. This is the intended way to allow build-time
53    /// downloads for specific crates (e.g., utoipa-swagger-ui, protobuf-src).
54    #[serde(default)]
55    pub allow_fetch: Vec<String>,
56
57    /// Whether to allow all network access (disables proxy and SBPL network restrictions).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub allow_all_network: Option<bool>,
60
61    /// Whether to enable the domain-filtering proxy.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub enable_proxy: Option<bool>,
64
65    /// Opt-in to proceed under a degraded kernel (Linux only). See
66    /// `cross-platform-backend-design.md` §13 D1.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub allow_degraded: Option<bool>,
69
70    #[serde(default)]
71    pub env: HashMap<String, String>,
72}
73
74impl SbeConfig {
75    /// Load config from a YAML file. Returns `Ok(None)` if the file does not exist.
76    pub async fn load(path: &Path) -> Result<Option<Self>, CoreError> {
77        match tokio::fs::read_to_string(path).await {
78            Ok(contents) => {
79                let config: Self =
80                    serde_yaml::from_str(&contents).map_err(|e| CoreError::ConfigLoad {
81                        path: path.to_path_buf(),
82                        source: Box::new(e),
83                    })?;
84                Ok(Some(config))
85            }
86            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
87            Err(e) => Err(CoreError::ConfigLoad {
88                path: path.to_path_buf(),
89                source: Box::new(e),
90            }),
91        }
92    }
93
94    /// Find the project config by walking up from `start` to the filesystem root,
95    /// stopping at a git repository boundary. Checks both `.sbe.yaml` and `.sbe.yml`.
96    pub fn find_project_config(start: &Path) -> Option<PathBuf> {
97        let mut dir = start;
98        loop {
99            for name in [".sbe.yaml", ".sbe.yml"] {
100                let candidate = dir.join(name);
101                if candidate.exists() {
102                    return Some(candidate);
103                }
104            }
105            // Stop at git root
106            if dir.join(".git").exists() {
107                return None;
108            }
109            dir = dir.parent()?;
110        }
111    }
112
113    /// The global config path: `~/.config/sbe/config.yaml`.
114    pub fn global_config_path() -> Option<PathBuf> {
115        dirs::config_dir().map(|d| d.join("sbe/config.yaml"))
116    }
117}
118
119impl ProfileConfig {
120    /// Apply this config's overrides onto a `SandboxProfile`.
121    ///
122    /// Paths are expanded relative to `home` (for `~`) and `pwd` (for `./`).
123    pub fn apply_to(&self, profile: &mut SandboxProfile, home: &Path, pwd: &Path) {
124        for p in &self.allow_write {
125            profile.allow_write.push(expand_path(p, home, pwd));
126        }
127        for p in &self.deny_read {
128            profile.deny_read.push(expand_path(p, home, pwd));
129        }
130        for p in &self.allow_read {
131            profile.allow_read.push(expand_path(p, home, pwd));
132        }
133        for d in &self.allow_domains {
134            profile.allow_domains.push(DomainPattern(d.clone()));
135        }
136        for p in &self.deny_exec {
137            profile.deny_exec.push(expand_path(p, home, pwd));
138        }
139        for p in &self.allow_exec {
140            profile.allow_exec.push(expand_path(p, home, pwd));
141        }
142        for d in &self.allow_fetch {
143            profile.allow_fetch.push(DomainPattern(d.clone()));
144        }
145        if let Some(allow_all) = self.allow_all_network {
146            profile.allow_all_network = allow_all;
147            if allow_all {
148                profile.enable_proxy = false;
149            }
150        }
151        if let Some(enable_proxy) = self.enable_proxy {
152            profile.enable_proxy = enable_proxy;
153        }
154        if let Some(allow_degraded) = self.allow_degraded {
155            profile.allow_degraded = allow_degraded;
156        }
157        for (k, v) in &self.env {
158            profile.env.insert(k.clone(), v.clone());
159        }
160    }
161}
162
163/// How a `SandboxPath` should be matched in SBPL.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub enum PathKind {
167    /// Match the directory and everything under it (SBPL `subpath`).
168    Subpath,
169    /// Exact file match (SBPL `literal`).
170    Literal,
171    /// Regex match against the absolute path (SBPL `regex`).
172    /// Used for prefix patterns like `<target>XXXXXX` temp dirs.
173    Regex,
174}
175
176/// A path with an explicit kind for SBPL generation.
177///
178/// Convention: in YAML configs, paths ending with `/` are directories
179/// (generate SBPL `subpath`), paths without trailing `/` are files
180/// (generate SBPL `literal`). Regex paths are only constructed
181/// programmatically (not from YAML).
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct SandboxPath {
184    pub path: PathBuf,
185    pub kind: PathKind,
186}
187
188impl SandboxPath {
189    pub fn dir(path: PathBuf) -> Self {
190        Self {
191            path,
192            kind: PathKind::Subpath,
193        }
194    }
195
196    pub fn file(path: PathBuf) -> Self {
197        Self {
198            path,
199            kind: PathKind::Literal,
200        }
201    }
202
203    /// Create a regex match. The path must be a valid regex pattern.
204    pub fn regex(pattern: PathBuf) -> Self {
205        Self {
206            path: pattern,
207            kind: PathKind::Regex,
208        }
209    }
210
211    /// Check if this sandbox path matches a given filesystem path.
212    pub fn has_path(&self, path: &Path) -> bool {
213        self.path == path
214    }
215}
216
217impl std::fmt::Display for SandboxPath {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        self.path.display().fmt(f)
220    }
221}
222
223/// Expand path placeholders and detect directory vs file from trailing `/`.
224///
225/// - `~/.ssh/` → directory (subpath)
226/// - `~/.npmrc` → file (literal)
227/// - `$PWD/` → directory
228pub fn expand_path(raw: &str, home: &Path, pwd: &Path) -> SandboxPath {
229    let kind = if raw.ends_with('/') {
230        PathKind::Subpath
231    } else {
232        PathKind::Literal
233    };
234    let raw = raw.strip_suffix('/').unwrap_or(raw);
235
236    let path = if raw == "$PWD" {
237        pwd.to_path_buf()
238    } else if let Some(rest) = raw.strip_prefix("$PWD/") {
239        pwd.join(rest)
240    } else if raw == "$HOME" {
241        home.to_path_buf()
242    } else if let Some(rest) = raw.strip_prefix("$HOME/") {
243        home.join(rest)
244    } else if let Some(rest) = raw.strip_prefix("~/") {
245        home.join(rest)
246    } else if raw == "~" {
247        home.to_path_buf()
248    } else if let Some(rest) = raw.strip_prefix("./") {
249        pwd.join(rest)
250    } else if raw == "." {
251        pwd.to_path_buf()
252    } else if raw.starts_with('/') {
253        PathBuf::from(raw)
254    } else {
255        pwd.join(raw)
256    };
257
258    SandboxPath { path, kind }
259}
260
261/// Load and merge configuration from all sources.
262///
263/// Resolution order (last wins):
264/// 1. Built-in ecosystem defaults
265/// 2. Global config (`~/.config/sbe/config.yaml`)
266/// 3. Project config (`.sbe.yaml` found by walking up from pwd)
267/// 4. Explicit config file (`--config` flag)
268///
269/// Returns the merged configs in order. The caller applies them to the profile.
270pub async fn load_configs(
271    pwd: &Path,
272    explicit_config: Option<&Path>,
273) -> Result<Vec<SbeConfig>, CoreError> {
274    let mut configs = Vec::new();
275
276    // Global config
277    if let Some(global_path) = SbeConfig::global_config_path()
278        && let Some(cfg) = SbeConfig::load(&global_path).await?
279    {
280        configs.push(cfg);
281    }
282
283    // Project config
284    if let Some(project_path) = SbeConfig::find_project_config(pwd)
285        && let Some(cfg) = SbeConfig::load(&project_path).await?
286    {
287        configs.push(cfg);
288    }
289
290    // Explicit config
291    if let Some(explicit) = explicit_config
292        && let Some(cfg) = SbeConfig::load(explicit).await?
293    {
294        configs.push(cfg);
295    }
296
297    Ok(configs)
298}
299
300/// Resolve the final `SandboxProfile` by merging configs into the ecosystem default.
301pub fn resolve_profile(base: &mut SandboxProfile, configs: &[SbeConfig], home: &Path, pwd: &Path) {
302    let profile_name = base.name.clone();
303
304    for config in configs {
305        // Apply matching profile config
306        if let Some(pc) = config.profiles.get(&profile_name) {
307            // Handle extends
308            if let Some(base_name) = &pc.extends
309                && let Some(base_pc) = config.profiles.get(base_name)
310            {
311                base_pc.apply_to(base, home, pwd);
312            }
313            pc.apply_to(base, home, pwd);
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_should_expand_home_path() {
324        let home = PathBuf::from("/Users/test");
325        let pwd = PathBuf::from("/Users/test/project");
326        let sp = expand_path("~/.ssh/", &home, &pwd);
327        assert_eq!(sp.path, PathBuf::from("/Users/test/.ssh"));
328        assert_eq!(sp.kind, PathKind::Subpath);
329    }
330
331    #[test]
332    fn test_should_expand_relative_path() {
333        let home = PathBuf::from("/Users/test");
334        let pwd = PathBuf::from("/Users/test/project");
335        let sp = expand_path("./node_modules/", &home, &pwd);
336        assert_eq!(sp.path, PathBuf::from("/Users/test/project/node_modules"));
337        assert_eq!(sp.kind, PathKind::Subpath);
338    }
339
340    #[test]
341    fn test_should_keep_absolute_path_as_file() {
342        let home = PathBuf::from("/Users/test");
343        let pwd = PathBuf::from("/Users/test/project");
344        let sp = expand_path("/usr/bin/osascript", &home, &pwd);
345        assert_eq!(sp.path, PathBuf::from("/usr/bin/osascript"));
346        assert_eq!(sp.kind, PathKind::Literal);
347    }
348
349    #[test]
350    fn test_should_detect_dir_from_trailing_slash() {
351        let home = PathBuf::from("/Users/test");
352        let pwd = PathBuf::from("/Users/test/project");
353
354        let dir = expand_path("~/.cargo/bin/", &home, &pwd);
355        assert_eq!(dir.kind, PathKind::Subpath);
356
357        let file = expand_path("~/.cargo/credentials.toml", &home, &pwd);
358        assert_eq!(file.kind, PathKind::Literal);
359
360        let pwd_dir = expand_path("$PWD/", &home, &pwd);
361        assert_eq!(pwd_dir.kind, PathKind::Subpath);
362    }
363
364    #[test]
365    fn test_should_parse_config_yaml() {
366        let yaml = r#"
367profiles:
368  node:
369    allowWrite:
370      - "./node_modules"
371      - "~/.npm"
372    denyRead:
373      - "~/.ssh"
374    allowDomains:
375      - "registry.npmjs.org"
376    env:
377      NODE_ENV: production
378  my-app:
379    extends: node
380    allowDomains:
381      - "api.mycompany.com"
382"#;
383        let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
384        assert_eq!(config.profiles.len(), 2);
385        assert_eq!(config.profiles["node"].allow_write.len(), 2);
386        assert_eq!(config.profiles["my-app"].extends.as_deref(), Some("node"));
387    }
388
389    #[test]
390    fn test_should_apply_profile_config() {
391        let home = PathBuf::from("/Users/test");
392        let pwd = PathBuf::from("/Users/test/project");
393        let pc = ProfileConfig {
394            allow_write: vec!["./extra".to_owned()],
395            allow_domains: vec!["extra.com".to_owned()],
396            ..Default::default()
397        };
398        let mut profile =
399            SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
400        let original_write = profile.allow_write.len();
401        let original_domains = profile.allow_domains.len();
402
403        pc.apply_to(&mut profile, &home, &pwd);
404
405        assert_eq!(profile.allow_write.len(), original_write + 1);
406        assert_eq!(profile.allow_domains.len(), original_domains + 1);
407    }
408}