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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct SbeConfig {
17 #[serde(default)]
19 pub profiles: HashMap<String, ProfileConfig>,
20}
21
22#[derive(Debug, Default, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ProfileConfig {
26 #[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)]
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 #[serde(default)]
55 pub allow_fetch: Vec<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub allow_all_network: Option<bool>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub enable_proxy: Option<bool>,
64
65 #[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 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 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 if dir.join(".git").exists() {
107 return None;
108 }
109 dir = dir.parent()?;
110 }
111 }
112
113 pub fn global_config_path() -> Option<PathBuf> {
115 dirs::config_dir().map(|d| d.join("sbe/config.yaml"))
116 }
117}
118
119impl ProfileConfig {
120 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub enum PathKind {
167 Subpath,
169 Literal,
171 Regex,
174}
175
176#[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 pub fn regex(pattern: PathBuf) -> Self {
205 Self {
206 path: pattern,
207 kind: PathKind::Regex,
208 }
209 }
210
211 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
223pub 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
261pub 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 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 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 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
300pub 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 if let Some(pc) = config.profiles.get(&profile_name) {
307 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}