Skip to main content

starla_common/
paths.rs

1//! Path resolution for Starla
2//!
3//! Directory resolution priority:
4//!
5//! **Config directory** (for config.toml):
6//! 1. `$CONFIGURATION_DIRECTORY` (systemd)
7//! 2. Container: `/config`
8//! 3. `$XDG_CONFIG_HOME/starla`
9//! 4. Root: `/etc/starla`, non-root: `~/.config/starla`
10//!
11//! **State directory** (for keys, probe_id, known_hosts):
12//! 1. CLI `--state-dir` (via override)
13//! 2. `$STATE_DIRECTORY` (systemd)
14//! 3. Container: `/state`
15//! 4. `$XDG_STATE_HOME/starla`
16//! 5. Root: `/var/lib/starla`, non-root: `~/.local/state/starla`
17//!
18//! **Runtime directory** (for ephemeral databases, caches):
19//! 1. `$RUNTIME_DIRECTORY` (systemd)
20//! 2. Container: `/run/starla`
21//! 3. `$XDG_RUNTIME_DIR/starla`
22//! 4. Root: `/run/starla`, non-root: `/tmp/starla-<uid>`
23
24use std::path::PathBuf;
25use std::sync::OnceLock;
26
27/// Application name used in subdirectories
28const APP_NAME: &str = "starla";
29
30/// Detect if running inside a container (Docker, Podman, etc.)
31///
32/// Checks for:
33/// - `container` env var (set by podman, systemd-nspawn)
34/// - `/.dockerenv` file (Docker)
35/// - `/run/.containerenv` file (Podman)
36fn is_container() -> bool {
37    std::env::var("container").is_ok()
38        || std::path::Path::new("/.dockerenv").exists()
39        || std::path::Path::new("/run/.containerenv").exists()
40}
41
42/// CLI override for state directory (set once at startup via `set_state_dir`)
43static STATE_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
44
45/// CLI override for runtime directory (set once at startup via
46/// `set_runtime_dir`)
47static RUNTIME_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
48
49/// Set the state directory override (from `--state-dir` CLI arg).
50/// Must be called before any `state_dir()` calls. Subsequent calls are ignored.
51pub fn set_state_dir(path: PathBuf) {
52    let _ = STATE_DIR_OVERRIDE.set(path);
53}
54
55/// Set the runtime directory override (from `--runtime-dir` CLI arg).
56/// Must be called before any `runtime_dir()` calls. Subsequent calls are
57/// ignored.
58pub fn set_runtime_dir(path: PathBuf) {
59    let _ = RUNTIME_DIR_OVERRIDE.set(path);
60}
61
62/// Get the configuration directory path
63///
64/// Priority:
65/// 1. `$CONFIGURATION_DIRECTORY` (systemd)
66/// 2. Container: `/config`
67/// 3. `$XDG_CONFIG_HOME/starla`
68/// 4. Root: `/etc/starla`, non-root: `~/.config/starla`
69pub fn config_dir() -> PathBuf {
70    if let Ok(dir) = std::env::var("CONFIGURATION_DIRECTORY") {
71        return PathBuf::from(dir);
72    }
73
74    if is_container() {
75        return PathBuf::from("/config");
76    }
77
78    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
79        return PathBuf::from(xdg_config).join(APP_NAME);
80    }
81
82    if is_root() {
83        return PathBuf::from("/etc").join(APP_NAME);
84    }
85
86    if let Some(home) = home_dir() {
87        return home.join(".config").join(APP_NAME);
88    }
89
90    PathBuf::from("/etc").join(APP_NAME)
91}
92
93/// Get the state directory path (for databases, keys, etc.)
94///
95/// Priority:
96/// 1. CLI override (set via `set_state_dir`)
97/// 2. `$STATE_DIRECTORY` (systemd)
98/// 3. Container: `/state`
99/// 4. `$XDG_STATE_HOME/starla`
100/// 5. Root: `/var/lib/starla`, non-root: `~/.local/state/starla`
101pub fn state_dir() -> PathBuf {
102    if let Some(override_dir) = STATE_DIR_OVERRIDE.get() {
103        return override_dir.clone();
104    }
105
106    if let Ok(dir) = std::env::var("STATE_DIRECTORY") {
107        return PathBuf::from(dir);
108    }
109
110    if is_container() {
111        return PathBuf::from("/state");
112    }
113
114    if let Ok(xdg_state) = std::env::var("XDG_STATE_HOME") {
115        return PathBuf::from(xdg_state).join(APP_NAME);
116    }
117
118    if is_root() {
119        return PathBuf::from("/var/lib").join(APP_NAME);
120    }
121
122    if let Some(home) = home_dir() {
123        return home.join(".local").join("state").join(APP_NAME);
124    }
125
126    PathBuf::from("/var/lib").join(APP_NAME)
127}
128
129/// Get the runtime directory path (for ephemeral data: databases, caches)
130///
131/// Priority:
132/// 1. CLI override (set via `set_runtime_dir`)
133/// 2. `$RUNTIME_DIRECTORY` (systemd)
134/// 3. Container: `/run/starla`
135/// 4. `$XDG_RUNTIME_DIR/starla`
136/// 5. Root: `/run/starla`, non-root: `/tmp/starla-<uid>`
137pub fn runtime_dir() -> PathBuf {
138    if let Some(override_dir) = RUNTIME_DIR_OVERRIDE.get() {
139        return override_dir.clone();
140    }
141
142    if let Ok(dir) = std::env::var("RUNTIME_DIRECTORY") {
143        return PathBuf::from(dir);
144    }
145
146    if is_container() {
147        return PathBuf::from("/run").join(APP_NAME);
148    }
149
150    if let Ok(xdg_runtime) = std::env::var("XDG_RUNTIME_DIR") {
151        return PathBuf::from(xdg_runtime).join(APP_NAME);
152    }
153
154    if is_root() {
155        return PathBuf::from("/run").join(APP_NAME);
156    }
157
158    // Per-user temp directory
159    let uid = {
160        #[cfg(unix)]
161        {
162            unsafe { libc::getuid() }
163        }
164        #[cfg(not(unix))]
165        {
166            0u32
167        }
168    };
169    std::env::temp_dir().join(format!("{}-{}", APP_NAME, uid))
170}
171
172/// Get the default config file path
173pub fn config_file() -> PathBuf {
174    config_dir().join("config.toml")
175}
176
177/// Get the default probe key path
178pub fn probe_key_path() -> PathBuf {
179    state_dir().join("probe_key")
180}
181
182/// Get the default probe public key path
183pub fn probe_pubkey_path() -> PathBuf {
184    state_dir().join("probe_key.pub")
185}
186
187/// Get the known SSH host keys path
188pub fn known_hosts_path() -> PathBuf {
189    state_dir().join("known_hosts")
190}
191
192/// Get the status socket path (for tray app communication)
193pub fn status_socket_path() -> PathBuf {
194    runtime_dir().join("starla.sock")
195}
196
197/// Get the probe ID file path
198pub fn probe_id_path() -> PathBuf {
199    state_dir().join("probe_id")
200}
201
202/// Path of the file recording the active pause-until state.
203pub fn paused_until_path() -> PathBuf {
204    state_dir().join("paused_until")
205}
206
207/// Read the probe ID from the state directory
208///
209/// Returns None if the file doesn't exist or can't be parsed
210pub fn read_probe_id() -> Option<u32> {
211    let path = probe_id_path();
212    match std::fs::read_to_string(&path) {
213        Ok(content) => content.trim().parse().ok(),
214        Err(_) => None,
215    }
216}
217
218/// Write the probe ID to the state directory
219///
220/// Creates the state directory if it doesn't exist
221pub fn write_probe_id(probe_id: u32) -> std::io::Result<()> {
222    let dir = state_dir();
223    ensure_dir(&dir)?;
224    let path = probe_id_path();
225    std::fs::write(&path, probe_id.to_string())
226}
227
228/// Check if the current process is running as root (UID 0)
229#[cfg(unix)]
230fn is_root() -> bool {
231    // Safety: getuid() is a simple syscall with no safety concerns
232    unsafe { libc::getuid() == 0 }
233}
234
235#[cfg(not(unix))]
236fn is_root() -> bool {
237    false
238}
239
240/// Get the user's home directory
241fn home_dir() -> Option<PathBuf> {
242    if let Ok(home) = std::env::var("HOME") {
243        return Some(PathBuf::from(home));
244    }
245
246    #[cfg(windows)]
247    if let Ok(home) = std::env::var("USERPROFILE") {
248        return Some(PathBuf::from(home));
249    }
250
251    None
252}
253
254/// Ensure a directory exists, creating it if necessary
255pub fn ensure_dir(path: &PathBuf) -> std::io::Result<()> {
256    if !path.exists() {
257        std::fs::create_dir_all(path)?;
258    }
259    Ok(())
260}
261
262/// Ensure the config directory exists
263pub fn ensure_config_dir() -> std::io::Result<PathBuf> {
264    let dir = config_dir();
265    ensure_dir(&dir)?;
266    Ok(dir)
267}
268
269/// Ensure the state directory exists
270pub fn ensure_state_dir() -> std::io::Result<PathBuf> {
271    let dir = state_dir();
272    ensure_dir(&dir)?;
273    Ok(dir)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::sync::Mutex;
280
281    /// Mutex to serialize tests that modify environment variables.
282    static ENV_LOCK: Mutex<()> = Mutex::new(());
283
284    #[test]
285    fn test_config_dir_with_env() {
286        let _guard = ENV_LOCK.lock().unwrap();
287
288        let original = std::env::var("CONFIGURATION_DIRECTORY").ok();
289
290        unsafe { std::env::set_var("CONFIGURATION_DIRECTORY", "/test/config") };
291        assert_eq!(config_dir(), PathBuf::from("/test/config"));
292
293        if let Some(val) = original {
294            unsafe { std::env::set_var("CONFIGURATION_DIRECTORY", val) };
295        } else {
296            unsafe { std::env::remove_var("CONFIGURATION_DIRECTORY") };
297        }
298    }
299
300    #[test]
301    fn test_state_dir_with_env() {
302        let _guard = ENV_LOCK.lock().unwrap();
303
304        let original = std::env::var("STATE_DIRECTORY").ok();
305
306        unsafe { std::env::set_var("STATE_DIRECTORY", "/test/state") };
307        assert_eq!(state_dir(), PathBuf::from("/test/state"));
308
309        if let Some(val) = original {
310            unsafe { std::env::set_var("STATE_DIRECTORY", val) };
311        } else {
312            unsafe { std::env::remove_var("STATE_DIRECTORY") };
313        }
314    }
315
316    #[test]
317    fn test_xdg_config_fallback() {
318        let _guard = ENV_LOCK.lock().unwrap();
319
320        let orig_conf_dir = std::env::var("CONFIGURATION_DIRECTORY").ok();
321        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
322
323        unsafe { std::env::remove_var("CONFIGURATION_DIRECTORY") };
324        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/home/test/.config") };
325
326        assert_eq!(config_dir(), PathBuf::from("/home/test/.config/starla"));
327
328        if let Some(val) = orig_conf_dir {
329            unsafe { std::env::set_var("CONFIGURATION_DIRECTORY", val) };
330        }
331        if let Some(val) = orig_xdg {
332            unsafe { std::env::set_var("XDG_CONFIG_HOME", val) };
333        } else {
334            unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
335        }
336    }
337
338    #[test]
339    fn test_xdg_state_fallback() {
340        let _guard = ENV_LOCK.lock().unwrap();
341
342        let orig_state_dir = std::env::var("STATE_DIRECTORY").ok();
343        let orig_xdg = std::env::var("XDG_STATE_HOME").ok();
344
345        unsafe { std::env::remove_var("STATE_DIRECTORY") };
346        unsafe { std::env::set_var("XDG_STATE_HOME", "/home/test/.local/state") };
347
348        assert_eq!(state_dir(), PathBuf::from("/home/test/.local/state/starla"));
349
350        if let Some(val) = orig_state_dir {
351            unsafe { std::env::set_var("STATE_DIRECTORY", val) };
352        }
353        if let Some(val) = orig_xdg {
354            unsafe { std::env::set_var("XDG_STATE_HOME", val) };
355        } else {
356            unsafe { std::env::remove_var("XDG_STATE_HOME") };
357        }
358    }
359
360    #[test]
361    fn test_default_file_paths() {
362        let config = config_file();
363        assert!(config.to_string_lossy().contains("config.toml"));
364
365        let key = probe_key_path();
366        assert!(key.to_string_lossy().contains("probe_key"));
367
368        let pid = probe_id_path();
369        assert!(pid.to_string_lossy().contains("probe_id"));
370
371        let kh = known_hosts_path();
372        assert!(kh.to_string_lossy().contains("known_hosts"));
373    }
374
375    #[test]
376    fn test_probe_id_read_write() {
377        let temp_dir =
378            std::env::temp_dir().join(format!("starla-test-probe-id-{}", std::process::id()));
379        let _ = std::fs::remove_dir_all(&temp_dir);
380        std::fs::create_dir_all(&temp_dir).unwrap();
381
382        let probe_id_file = temp_dir.join("probe_id");
383        assert!(!probe_id_file.exists());
384
385        std::fs::write(&probe_id_file, "1014036").unwrap();
386
387        let content = std::fs::read_to_string(&probe_id_file).unwrap();
388        let parsed: Option<u32> = content.trim().parse().ok();
389        assert_eq!(parsed, Some(1014036));
390
391        let _ = std::fs::remove_dir_all(&temp_dir);
392    }
393}