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)]
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 #[serde(default)]
51 pub allow_fetch: Vec<String>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub allow_all_network: Option<bool>,
56
57 #[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 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 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 if dir.join(".git").exists() {
98 return None;
99 }
100 dir = dir.parent()?;
101 }
102 }
103
104 pub fn global_config_path() -> Option<PathBuf> {
106 dirs::config_dir().map(|d| d.join("sbe/config.yaml"))
107 }
108}
109
110impl ProfileConfig {
111 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#[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 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
186pub 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
220pub 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 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 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 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
259pub 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 if let Some(pc) = config.profiles.get(&profile_name) {
266 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}