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