1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14const DEFAULT_PREFIXES: &[&str] = &["/tmp", "/var/tmp", "/home"];
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19#[allow(clippy::exhaustive_structs)]
20pub struct RuntimoConfig {
21 #[serde(default)]
23 pub allowed_paths: Vec<String>,
24
25 #[serde(default)]
35 pub dal: Option<String>,
36
37 #[serde(default)]
43 pub blocklist_overrides: Vec<String>,
44
45 #[serde(default)]
51 pub capability_timeouts: HashMap<String, u64>,
52}
53
54impl RuntimoConfig {
55 pub fn config_path() -> PathBuf {
63 let base = std::env::var("XDG_CONFIG_HOME")
64 .ok()
65 .map(PathBuf::from)
66 .or_else(|| {
67 std::env::var("HOME")
68 .ok()
69 .map(|h| PathBuf::from(h).join(".config"))
70 });
71 if let Some(dir) = base {
72 dir.join("runtimo/config.toml")
73 } else {
74 eprintln!(
75 "[runtimo] Warning: XDG_CONFIG_HOME and HOME unset — using /tmp/runtimo \
76 (config will not survive reboot)"
77 );
78 PathBuf::from("/tmp/runtimo/config.toml")
79 }
80 }
81
82 #[must_use]
88 pub fn load() -> Self {
89 match Self::load_result() {
90 Ok(config) => config,
91 Err(e) => {
92 eprintln!("[runtimo] Config load failed (using defaults): {}", e);
93 Self::default()
94 }
95 }
96 }
97
98 pub fn load_result() -> Result<Self, String> {
119 let path = Self::config_path();
120 if path.exists() {
121 let content = std::fs::read_to_string(&path)
122 .map_err(|e| format!("Cannot read config file '{}': {}", path.display(), e))?;
123 toml::from_str(&content)
124 .map_err(|e| format!("Cannot parse config file '{}': {}", path.display(), e))
125 } else {
126 Ok(Self::default())
127 }
128 }
129
130 pub fn save(&self) -> Result<(), String> {
137 let path = Self::config_path();
138 if let Some(parent) = path.parent() {
139 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
140 }
141 let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
142 std::fs::write(&path, content).map_err(|e| e.to_string())?;
143 Ok(())
144 }
145
146 #[must_use]
153 pub fn get_dal() -> String {
154 if let Ok(env_dal) = std::env::var("RUNTIMO_DAL") {
156 return env_dal.to_uppercase();
157 }
158 let config = Self::load();
160 config.dal.unwrap_or_else(|| "A".to_string())
161 }
162
163 #[must_use]
168 pub fn get_blocklist_overrides() -> Vec<String> {
169 let config = Self::load();
170 config.blocklist_overrides
171 }
172
173 #[must_use]
178 pub fn get_capability_timeout(capability: &str, fallback: u64) -> u64 {
179 let config = Self::load();
180 config
181 .capability_timeouts
182 .get(capability)
183 .copied()
184 .unwrap_or(fallback)
185 }
186
187 #[must_use]
197 pub fn get_allowed_prefixes() -> Vec<String> {
198 let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
199
200 if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
202 for p in env_paths.split(':').filter(|s| !s.is_empty()) {
203 let trimmed = p.trim().to_string();
204 if trimmed.is_empty() {
205 continue;
206 }
207 if !prefixes.contains(&trimmed) {
208 prefixes.push(trimmed);
209 }
210 }
211 }
212
213 let config = Self::load();
215 for p in &config.allowed_paths {
216 let trimmed = p.trim().to_string();
217 if trimmed.is_empty() {
218 continue;
219 }
220 if !prefixes.contains(&trimmed) {
221 prefixes.push(trimmed);
222 }
223 }
224
225 prefixes
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::sync::Mutex;
233
234 static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
237
238 #[test]
239 fn config_path_is_absolute() {
240 let path = RuntimoConfig::config_path();
241 assert!(path.is_absolute());
242 }
243
244 #[test]
245 fn load_returns_defaults_when_no_file() {
246 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
247 let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
248 let _ = std::fs::remove_dir_all(&tmp);
249 std::env::set_var("XDG_CONFIG_HOME", &tmp);
250
251 let config = RuntimoConfig::load();
252 assert!(config.allowed_paths.is_empty());
253
254 let _ = std::fs::remove_dir_all(&tmp);
255 std::env::remove_var("XDG_CONFIG_HOME");
256 }
257
258 #[test]
259 fn get_allowed_prefixes_includes_defaults() {
260 let prefixes = RuntimoConfig::get_allowed_prefixes();
261 assert!(prefixes.iter().any(|p| p == "/tmp"));
262 assert!(prefixes.iter().any(|p| p == "/var/tmp"));
263 assert!(prefixes.iter().any(|p| p == "/home"));
264 }
265
266 #[test]
267 fn save_and_load_roundtrip() {
268 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
269 let tmp = std::env::temp_dir().join("runtimo_test_config");
271 std::env::set_var("XDG_CONFIG_HOME", &tmp);
272
273 let mut config = RuntimoConfig::default();
274 config.allowed_paths.push("/srv".to_string());
275 config.allowed_paths.push("/opt".to_string());
276 config.save().expect("save failed");
277
278 let loaded = RuntimoConfig::load();
279 assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
280
281 let prefixes = RuntimoConfig::get_allowed_prefixes();
282 assert!(prefixes.contains(&"/srv".to_string()));
283 assert!(prefixes.contains(&"/opt".to_string()));
284
285 let _ = std::fs::remove_dir_all(&tmp);
287 std::env::remove_var("XDG_CONFIG_HOME");
288 }
289
290 #[test]
291 fn test_toml_parse_failure_returns_defaults() {
292 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
293 let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
295 let config_dir = tmp.join("runtimo");
296 let _ = std::fs::remove_dir_all(&tmp);
297 std::fs::create_dir_all(&config_dir).unwrap();
298 let config_path = config_dir.join("config.toml");
299
300 std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
302 std::env::set_var("XDG_CONFIG_HOME", &tmp);
303
304 let config = RuntimoConfig::load();
305 assert!(
307 config.allowed_paths.is_empty(),
308 "Corrupt TOML should return defaults"
309 );
310
311 let _ = std::fs::remove_dir_all(&tmp);
312 std::env::remove_var("XDG_CONFIG_HOME");
313 }
314
315 #[test]
316 fn test_empty_config_file_returns_defaults() {
317 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
318 let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
320 let config_dir = tmp.join("runtimo");
321 let _ = std::fs::remove_dir_all(&tmp);
322 std::fs::create_dir_all(&config_dir).unwrap();
323 let config_path = config_dir.join("config.toml");
324
325 std::fs::write(&config_path, "").unwrap();
327 std::env::set_var("XDG_CONFIG_HOME", &tmp);
328
329 let config = RuntimoConfig::load();
330 assert!(
331 config.allowed_paths.is_empty(),
332 "Empty config should return defaults"
333 );
334
335 let _ = std::fs::remove_dir_all(&tmp);
336 std::env::remove_var("XDG_CONFIG_HOME");
337 }
338
339 #[test]
340 fn test_toml_missing_section_returns_defaults() {
341 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
342 let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
344 let config_dir = tmp.join("runtimo");
345 let _ = std::fs::remove_dir_all(&tmp);
346 std::fs::create_dir_all(&config_dir).unwrap();
347 let config_path = config_dir.join("config.toml");
348
349 std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
351 std::env::set_var("XDG_CONFIG_HOME", &tmp);
352
353 let config = RuntimoConfig::load();
354 assert!(
355 config.allowed_paths.is_empty(),
356 "Missing section should return defaults"
357 );
358
359 let _ = std::fs::remove_dir_all(&tmp);
360 std::env::remove_var("XDG_CONFIG_HOME");
361 }
362}