sandbox_runtime/config/
loader.rs

1//! Configuration loader from ~/.srt-settings.json.
2
3use std::path::{Path, PathBuf};
4
5use crate::config::schema::SandboxRuntimeConfig;
6use crate::error::{ConfigError, SandboxError};
7
8/// Default settings file name.
9const DEFAULT_SETTINGS_FILE: &str = ".srt-settings.json";
10
11/// Get the default settings file path.
12pub fn default_settings_path() -> Option<PathBuf> {
13    dirs::home_dir().map(|home| home.join(DEFAULT_SETTINGS_FILE))
14}
15
16/// Load configuration from a file path.
17pub fn load_config(path: &Path) -> Result<SandboxRuntimeConfig, SandboxError> {
18    if !path.exists() {
19        return Err(ConfigError::FileNotFound(path.display().to_string()).into());
20    }
21
22    let content = std::fs::read_to_string(path).map_err(|e| {
23        ConfigError::ParseError(format!("Failed to read config file: {}", e))
24    })?;
25
26    parse_config(&content)
27}
28
29/// Load configuration from the default path, or return default config if not found.
30pub fn load_default_config() -> Result<SandboxRuntimeConfig, SandboxError> {
31    match default_settings_path() {
32        Some(path) if path.exists() => load_config(&path),
33        _ => Ok(SandboxRuntimeConfig::default()),
34    }
35}
36
37/// Parse configuration from a JSON string.
38pub fn parse_config(json: &str) -> Result<SandboxRuntimeConfig, SandboxError> {
39    let config: SandboxRuntimeConfig = serde_json::from_str(json).map_err(|e| {
40        ConfigError::ParseError(format!("Failed to parse config JSON: {}", e))
41    })?;
42
43    // Validate the configuration
44    config.validate()?;
45
46    Ok(config)
47}
48
49/// Load and validate sandbox configuration from a string.
50/// Used for parsing config from control fd (JSON lines protocol).
51/// Returns None if the string is empty, invalid JSON, or fails validation.
52pub fn load_config_from_string(content: &str) -> Option<SandboxRuntimeConfig> {
53    let trimmed = content.trim();
54    if trimmed.is_empty() {
55        return None;
56    }
57
58    match parse_config(trimmed) {
59        Ok(config) => Some(config),
60        Err(e) => {
61            tracing::debug!("Failed to parse config from string: {}", e);
62            None
63        }
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn test_parse_minimal_config() {
73        let json = r#"{}"#;
74        let config = parse_config(json).unwrap();
75        assert!(config.network.allowed_domains.is_empty());
76        assert!(config.filesystem.allow_write.is_empty());
77    }
78
79    #[test]
80    fn test_parse_full_config() {
81        let json = r#"{
82            "network": {
83                "allowedDomains": ["github.com", "*.npmjs.org"],
84                "deniedDomains": ["evil.com"],
85                "allowLocalBinding": true,
86                "mitmProxy": {
87                    "socketPath": "/tmp/mitm.sock",
88                    "domains": ["api.example.com"]
89                }
90            },
91            "filesystem": {
92                "denyRead": ["/etc/passwd"],
93                "allowWrite": ["/tmp"],
94                "denyWrite": ["/tmp/secret"],
95                "allowGitConfig": false
96            },
97            "mandatoryDenySearchDepth": 5,
98            "allowPty": true
99        }"#;
100
101        let config = parse_config(json).unwrap();
102        assert_eq!(config.network.allowed_domains.len(), 2);
103        assert_eq!(config.network.denied_domains.len(), 1);
104        assert_eq!(config.network.allow_local_binding, Some(true));
105        assert!(config.network.mitm_proxy.is_some());
106        assert_eq!(config.filesystem.deny_read.len(), 1);
107        assert_eq!(config.filesystem.allow_write.len(), 1);
108        assert_eq!(config.filesystem.deny_write.len(), 1);
109        assert_eq!(config.mandatory_deny_search_depth, Some(5));
110        assert_eq!(config.allow_pty, Some(true));
111    }
112
113    #[test]
114    fn test_invalid_domain_pattern() {
115        let json = r#"{
116            "network": {
117                "allowedDomains": ["*.com"]
118            }
119        }"#;
120
121        let result = parse_config(json);
122        assert!(result.is_err());
123    }
124
125    #[test]
126    fn test_load_config_from_string_valid() {
127        let json = r#"{"network": {"allowedDomains": ["github.com"]}}"#;
128        let config = load_config_from_string(json);
129        assert!(config.is_some());
130        let config = config.unwrap();
131        assert_eq!(config.network.allowed_domains.len(), 1);
132        assert_eq!(config.network.allowed_domains[0], "github.com");
133    }
134
135    #[test]
136    fn test_load_config_from_string_empty() {
137        assert!(load_config_from_string("").is_none());
138        assert!(load_config_from_string("   ").is_none());
139        assert!(load_config_from_string("\n\t").is_none());
140    }
141
142    #[test]
143    fn test_load_config_from_string_invalid_json() {
144        assert!(load_config_from_string("not json").is_none());
145        assert!(load_config_from_string("{invalid}").is_none());
146        assert!(load_config_from_string("{\"network\": }").is_none());
147    }
148
149    #[test]
150    fn test_load_config_from_string_with_whitespace() {
151        let json = r#"   {"network": {"allowedDomains": ["example.com"]}}   "#;
152        let config = load_config_from_string(json);
153        assert!(config.is_some());
154        assert_eq!(config.unwrap().network.allowed_domains[0], "example.com");
155    }
156}