1use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13const DEFAULT_PREFIXES: &[&str] = &["/tmp", "/var/tmp", "/home"];
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18#[allow(clippy::exhaustive_structs)]
19pub struct RuntimoConfig {
20 #[serde(default)]
22 pub allowed_paths: Vec<String>,
23}
24
25impl RuntimoConfig {
26 pub fn config_path() -> PathBuf {
34 let base = std::env::var("XDG_CONFIG_HOME")
35 .ok()
36 .map(PathBuf::from)
37 .or_else(|| {
38 std::env::var("HOME")
39 .ok()
40 .map(|h| PathBuf::from(h).join(".config"))
41 });
42 if let Some(dir) = base {
43 dir.join("runtimo/config.toml")
44 } else {
45 eprintln!(
46 "[runtimo] Warning: XDG_CONFIG_HOME and HOME unset — using /tmp/runtimo \
47 (config will not survive reboot)"
48 );
49 PathBuf::from("/tmp/runtimo/config.toml")
50 }
51 }
52
53 #[must_use]
59 pub fn load() -> Self {
60 match Self::load_result() {
61 Ok(config) => config,
62 Err(e) => {
63 eprintln!("[runtimo] Config load failed (using defaults): {}", e);
64 Self::default()
65 }
66 }
67 }
68
69 pub fn load_result() -> Result<Self, String> {
90 let path = Self::config_path();
91 if path.exists() {
92 let content = std::fs::read_to_string(&path)
93 .map_err(|e| format!("Cannot read config file '{}': {}", path.display(), e))?;
94 toml::from_str(&content)
95 .map_err(|e| format!("Cannot parse config file '{}': {}", path.display(), e))
96 } else {
97 Ok(Self::default())
98 }
99 }
100
101 pub fn save(&self) -> Result<(), String> {
108 let path = Self::config_path();
109 if let Some(parent) = path.parent() {
110 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
111 }
112 let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
113 std::fs::write(&path, content).map_err(|e| e.to_string())?;
114 Ok(())
115 }
116
117 #[must_use]
124 pub fn get_allowed_prefixes() -> Vec<String> {
125 let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
126
127 if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
129 for p in env_paths.split(':').filter(|s| !s.is_empty()) {
130 let trimmed = p.trim().to_string();
131 if !prefixes.contains(&trimmed) {
132 prefixes.push(trimmed);
133 }
134 }
135 }
136
137 let config = Self::load();
139 for p in &config.allowed_paths {
140 let trimmed = p.trim().to_string();
141 if !prefixes.contains(&trimmed) {
142 prefixes.push(trimmed);
143 }
144 }
145
146 prefixes
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::sync::Mutex;
154
155 static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
158
159 #[test]
160 fn config_path_is_absolute() {
161 let path = RuntimoConfig::config_path();
162 assert!(path.is_absolute());
163 }
164
165 #[test]
166 fn load_returns_defaults_when_no_file() {
167 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
168 let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
169 let _ = std::fs::remove_dir_all(&tmp);
170 std::env::set_var("XDG_CONFIG_HOME", &tmp);
171
172 let config = RuntimoConfig::load();
173 assert!(config.allowed_paths.is_empty());
174
175 let _ = std::fs::remove_dir_all(&tmp);
176 std::env::remove_var("XDG_CONFIG_HOME");
177 }
178
179 #[test]
180 fn get_allowed_prefixes_includes_defaults() {
181 let prefixes = RuntimoConfig::get_allowed_prefixes();
182 assert!(prefixes.iter().any(|p| p == "/tmp"));
183 assert!(prefixes.iter().any(|p| p == "/var/tmp"));
184 assert!(prefixes.iter().any(|p| p == "/home"));
185 }
186
187 #[test]
188 fn save_and_load_roundtrip() {
189 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
190 let tmp = std::env::temp_dir().join("runtimo_test_config");
192 std::env::set_var("XDG_CONFIG_HOME", &tmp);
193
194 let mut config = RuntimoConfig::default();
195 config.allowed_paths.push("/srv".to_string());
196 config.allowed_paths.push("/opt".to_string());
197 config.save().expect("save failed");
198
199 let loaded = RuntimoConfig::load();
200 assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
201
202 let prefixes = RuntimoConfig::get_allowed_prefixes();
203 assert!(prefixes.contains(&"/srv".to_string()));
204 assert!(prefixes.contains(&"/opt".to_string()));
205
206 let _ = std::fs::remove_dir_all(&tmp);
208 std::env::remove_var("XDG_CONFIG_HOME");
209 }
210
211 #[test]
212 fn test_toml_parse_failure_returns_defaults() {
213 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
214 let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
216 let config_dir = tmp.join("runtimo");
217 let _ = std::fs::remove_dir_all(&tmp);
218 std::fs::create_dir_all(&config_dir).unwrap();
219 let config_path = config_dir.join("config.toml");
220
221 std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
223 std::env::set_var("XDG_CONFIG_HOME", &tmp);
224
225 let config = RuntimoConfig::load();
226 assert!(
228 config.allowed_paths.is_empty(),
229 "Corrupt TOML should return defaults"
230 );
231
232 let _ = std::fs::remove_dir_all(&tmp);
233 std::env::remove_var("XDG_CONFIG_HOME");
234 }
235
236 #[test]
237 fn test_empty_config_file_returns_defaults() {
238 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
239 let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
241 let config_dir = tmp.join("runtimo");
242 let _ = std::fs::remove_dir_all(&tmp);
243 std::fs::create_dir_all(&config_dir).unwrap();
244 let config_path = config_dir.join("config.toml");
245
246 std::fs::write(&config_path, "").unwrap();
248 std::env::set_var("XDG_CONFIG_HOME", &tmp);
249
250 let config = RuntimoConfig::load();
251 assert!(
252 config.allowed_paths.is_empty(),
253 "Empty config should return defaults"
254 );
255
256 let _ = std::fs::remove_dir_all(&tmp);
257 std::env::remove_var("XDG_CONFIG_HOME");
258 }
259
260 #[test]
261 fn test_toml_missing_section_returns_defaults() {
262 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
263 let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
265 let config_dir = tmp.join("runtimo");
266 let _ = std::fs::remove_dir_all(&tmp);
267 std::fs::create_dir_all(&config_dir).unwrap();
268 let config_path = config_dir.join("config.toml");
269
270 std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
272 std::env::set_var("XDG_CONFIG_HOME", &tmp);
273
274 let config = RuntimoConfig::load();
275 assert!(
276 config.allowed_paths.is_empty(),
277 "Missing section should return defaults"
278 );
279
280 let _ = std::fs::remove_dir_all(&tmp);
281 std::env::remove_var("XDG_CONFIG_HOME");
282 }
283}