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]
127 pub fn get_allowed_prefixes() -> Vec<String> {
128 let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
129
130 if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
132 for p in env_paths.split(':').filter(|s| !s.is_empty()) {
133 let trimmed = p.trim().to_string();
134 if trimmed.is_empty() {
135 continue;
136 }
137 if !prefixes.contains(&trimmed) {
138 prefixes.push(trimmed);
139 }
140 }
141 }
142
143 let config = Self::load();
145 for p in &config.allowed_paths {
146 let trimmed = p.trim().to_string();
147 if trimmed.is_empty() {
148 continue;
149 }
150 if !prefixes.contains(&trimmed) {
151 prefixes.push(trimmed);
152 }
153 }
154
155 prefixes
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::sync::Mutex;
163
164 static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
167
168 #[test]
169 fn config_path_is_absolute() {
170 let path = RuntimoConfig::config_path();
171 assert!(path.is_absolute());
172 }
173
174 #[test]
175 fn load_returns_defaults_when_no_file() {
176 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
177 let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
178 let _ = std::fs::remove_dir_all(&tmp);
179 std::env::set_var("XDG_CONFIG_HOME", &tmp);
180
181 let config = RuntimoConfig::load();
182 assert!(config.allowed_paths.is_empty());
183
184 let _ = std::fs::remove_dir_all(&tmp);
185 std::env::remove_var("XDG_CONFIG_HOME");
186 }
187
188 #[test]
189 fn get_allowed_prefixes_includes_defaults() {
190 let prefixes = RuntimoConfig::get_allowed_prefixes();
191 assert!(prefixes.iter().any(|p| p == "/tmp"));
192 assert!(prefixes.iter().any(|p| p == "/var/tmp"));
193 assert!(prefixes.iter().any(|p| p == "/home"));
194 }
195
196 #[test]
197 fn save_and_load_roundtrip() {
198 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
199 let tmp = std::env::temp_dir().join("runtimo_test_config");
201 std::env::set_var("XDG_CONFIG_HOME", &tmp);
202
203 let mut config = RuntimoConfig::default();
204 config.allowed_paths.push("/srv".to_string());
205 config.allowed_paths.push("/opt".to_string());
206 config.save().expect("save failed");
207
208 let loaded = RuntimoConfig::load();
209 assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
210
211 let prefixes = RuntimoConfig::get_allowed_prefixes();
212 assert!(prefixes.contains(&"/srv".to_string()));
213 assert!(prefixes.contains(&"/opt".to_string()));
214
215 let _ = std::fs::remove_dir_all(&tmp);
217 std::env::remove_var("XDG_CONFIG_HOME");
218 }
219
220 #[test]
221 fn test_toml_parse_failure_returns_defaults() {
222 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
223 let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
225 let config_dir = tmp.join("runtimo");
226 let _ = std::fs::remove_dir_all(&tmp);
227 std::fs::create_dir_all(&config_dir).unwrap();
228 let config_path = config_dir.join("config.toml");
229
230 std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
232 std::env::set_var("XDG_CONFIG_HOME", &tmp);
233
234 let config = RuntimoConfig::load();
235 assert!(
237 config.allowed_paths.is_empty(),
238 "Corrupt TOML should return defaults"
239 );
240
241 let _ = std::fs::remove_dir_all(&tmp);
242 std::env::remove_var("XDG_CONFIG_HOME");
243 }
244
245 #[test]
246 fn test_empty_config_file_returns_defaults() {
247 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
248 let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
250 let config_dir = tmp.join("runtimo");
251 let _ = std::fs::remove_dir_all(&tmp);
252 std::fs::create_dir_all(&config_dir).unwrap();
253 let config_path = config_dir.join("config.toml");
254
255 std::fs::write(&config_path, "").unwrap();
257 std::env::set_var("XDG_CONFIG_HOME", &tmp);
258
259 let config = RuntimoConfig::load();
260 assert!(
261 config.allowed_paths.is_empty(),
262 "Empty config should return defaults"
263 );
264
265 let _ = std::fs::remove_dir_all(&tmp);
266 std::env::remove_var("XDG_CONFIG_HOME");
267 }
268
269 #[test]
270 fn test_toml_missing_section_returns_defaults() {
271 let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
272 let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
274 let config_dir = tmp.join("runtimo");
275 let _ = std::fs::remove_dir_all(&tmp);
276 std::fs::create_dir_all(&config_dir).unwrap();
277 let config_path = config_dir.join("config.toml");
278
279 std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
281 std::env::set_var("XDG_CONFIG_HOME", &tmp);
282
283 let config = RuntimoConfig::load();
284 assert!(
285 config.allowed_paths.is_empty(),
286 "Missing section should return defaults"
287 );
288
289 let _ = std::fs::remove_dir_all(&tmp);
290 std::env::remove_var("XDG_CONFIG_HOME");
291 }
292}