Skip to main content

trusty_common/
data_dir.rs

1//! Data-directory resolution and filesystem utilities.
2//!
3//! Why: All trusty-* tools want a per-machine, per-app directory under the
4//! OS-standard data dir. macOS's `dirs::data_dir()` calls `NSFileManager`
5//! which ignores `HOME`/`XDG_DATA_HOME`, so tests need a separate bypass.
6//! What: `resolve_data_dir` finds/creates the app data dir; `sanitize_data_root`
7//! validates any candidate path; `is_dir` is a convenience predicate.
8//! Test: `cargo test -p trusty-common` covers the full battery of data-dir tests.
9
10use anyhow::{Context, Result};
11use std::path::{Path, PathBuf};
12
13/// Environment variable name for the data-directory test escape hatch.
14///
15/// Why: macOS's `dirs::data_dir()` delegates to `NSFileManager`, a native Cocoa
16/// API that ignores `HOME` and `XDG_DATA_HOME`. Setting `HOME` in a test process
17/// does **not** redirect `dirs::data_dir()` on macOS, making path isolation
18/// impossible without a separate bypass. This constant names that bypass.
19///
20/// What: When `TRUSTY_DATA_DIR_OVERRIDE` is set in the environment,
21/// [`resolve_data_dir`] uses its value as the base directory and skips the
22/// `dirs::data_dir()` call entirely. The final path is
23/// `${TRUSTY_DATA_DIR_OVERRIDE}/<app_name>`, identical in structure to the
24/// normal OS-standard path.
25///
26/// **Intended for tests only.** Do not set this variable in production; it
27/// bypasses the OS-standard application-data directory.
28///
29/// Test: All `resolve_data_dir` tests in this module set this var to a
30/// temporary directory so they run identically on macOS, Linux, and Windows.
31pub const DATA_DIR_OVERRIDE_ENV: &str = "TRUSTY_DATA_DIR_OVERRIDE";
32
33/// Validate and, if necessary, replace an unsafe data-root path.
34///
35/// Why: `dirs::data_dir()` and the HOME-relative fallback can return dangerous
36/// paths when the daemon environment is degenerate — e.g. `HOME="/"` on Linux
37/// yields `/.trusty-memory`, and `XDG_DATA_HOME="/"` yields `/trusty-memory`.
38/// Neither of those are literal `/`, but both scatter application data directly
39/// under the filesystem root. This pure helper applies post-resolution
40/// validation to any candidate path regardless of which branch produced it, and
41/// returns a known-safe fallback path if any guard fires. Being infallible
42/// (always returns a usable path) avoids adding an error return to the many
43/// existing `resolve_data_dir` call sites while still preventing root-scatter.
44///
45/// What: checks, in order:
46/// 1. `candidate` must be absolute. If not, falls back to
47///    `$TMPDIR/trusty-<app_name>` and emits `tracing::error!`.
48/// 2. `candidate` must not be exactly `/`. If so, falls back and logs error.
49/// 3. `candidate`'s parent must not be `/` unless `candidate` is a normal
50///    user-data path (guards against e.g. `/.trusty-memory` from `HOME=/`).
51///    Paths whose sole parent is `/` receive the safe-temp fallback.
52///
53/// The safe fallback is `std::env::temp_dir().join(format!("trusty-{app_name}"))`.
54/// This lets the daemon still start (and log a clear error) rather than
55/// crash-looping when the host environment is misconfigured.
56///
57/// Test: `sanitize_data_root_rejects_relative`, `sanitize_data_root_rejects_root`,
58/// `sanitize_data_root_rejects_bare_root_child`, `sanitize_data_root_passes_valid_path`.
59pub fn sanitize_data_root(candidate: PathBuf, app_name: &str) -> PathBuf {
60    let safe_fallback = || std::env::temp_dir().join(format!("trusty-{app_name}"));
61
62    if !candidate.is_absolute() {
63        tracing::error!(
64            path = %candidate.display(),
65            app = app_name,
66            "resolved data root is not absolute; \
67             falling back to temp dir to prevent CWD-relative palace creation. \
68             Check HOME and TRUSTY_DATA_DIR_OVERRIDE in the daemon environment."
69        );
70        return safe_fallback();
71    }
72
73    if candidate == Path::new("/") {
74        tracing::error!(
75            app = app_name,
76            "resolved data root is the filesystem root (/); \
77             falling back to temp dir. \
78             Check HOME and TRUSTY_DATA_DIR_OVERRIDE in the daemon environment."
79        );
80        return safe_fallback();
81    }
82
83    if candidate.parent() == Some(Path::new("/")) {
84        tracing::error!(
85            path = %candidate.display(),
86            app = app_name,
87            "resolved data root is a direct child of the filesystem root; \
88             this usually means HOME or XDG_DATA_HOME is set to '/'. \
89             Falling back to temp dir to prevent data scatter under /."
90        );
91        return safe_fallback();
92    }
93
94    candidate
95}
96
97/// Resolve `<data_dir>/<app_name>`, creating it if it doesn't exist.
98///
99/// Why: All trusty-* tools want a per-machine, per-app directory under the
100/// OS-standard data dir (`~/Library/Application Support/`, `~/.local/share/`,
101/// `%APPDATA%/`). If `dirs::data_dir()` is unavailable (rare — locked-down
102/// containers), falls back to `~/.<app_name>` so the tool still works.
103///
104/// The [`DATA_DIR_OVERRIDE_ENV`] (`TRUSTY_DATA_DIR_OVERRIDE`) environment
105/// variable provides a test escape hatch: when set to a *non-empty absolute
106/// path*, `dirs::data_dir()` is **never called** and the variable's value is
107/// used as the base directory instead. This is necessary because macOS's
108/// `dirs::data_dir()` calls `NSFileManager` — a native Cocoa API that
109/// resolves the application-support directory through the system rather than
110/// through the process environment — so setting `HOME` or `XDG_DATA_HOME` in
111/// a test process does not redirect it. `TRUSTY_DATA_DIR_OVERRIDE` is the
112/// only reliable cross-platform way to isolate test data paths. **It is
113/// intended for tests only; do not set it in production.**
114///
115/// Safety guards: an empty/whitespace-only override is treated as unset; a
116/// non-absolute override is rejected; a root `/` override is rejected. The
117/// final resolved path passes through [`sanitize_data_root`].
118///
119/// What: returns the absolute path `${base}/<app_name>` (created if absent).
120/// Resolution order:
121/// 1. `$TRUSTY_DATA_DIR_OVERRIDE/<app_name>` — when the env var is non-empty, absolute, and non-root.
122/// 2. `$(dirs::data_dir())/<app_name>` — normal OS-standard path.
123/// 3. `~/.<app_name>` — fallback when `dirs::data_dir()` returns `None`.
124///
125/// Test: `resolve_data_dir_creates_directory`, `resolve_data_dir_empty_override_uses_platform_dir`,
126/// `resolve_data_dir_whitespace_override_uses_platform_dir`,
127/// `resolve_data_dir_relative_override_errors`, `resolve_data_dir_root_override_errors`.
128pub fn resolve_data_dir(app_name: &str) -> Result<PathBuf> {
129    let base = match std::env::var(DATA_DIR_OVERRIDE_ENV) {
130        Ok(raw) if raw.trim().is_empty() => {
131            tracing::warn!(
132                env = DATA_DIR_OVERRIDE_ENV,
133                "TRUSTY_DATA_DIR_OVERRIDE is set but empty; ignoring and using \
134                 the platform data directory instead. An empty override would \
135                 produce a relative path that resolves against the daemon's \
136                 working directory (/ under launchd), which is never correct."
137            );
138            dirs::data_dir()
139                .or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
140                .context("could not resolve data directory or home directory")?
141        }
142        Ok(raw) => {
143            let p = PathBuf::from(&raw);
144            if !p.is_absolute() {
145                anyhow::bail!(
146                    "TRUSTY_DATA_DIR_OVERRIDE={raw:?} is a relative path; only \
147                     absolute paths are accepted to prevent the data directory \
148                     from depending on the daemon's working directory"
149                );
150            }
151            if p == Path::new("/") {
152                anyhow::bail!(
153                    "TRUSTY_DATA_DIR_OVERRIDE={raw:?} resolves to the filesystem \
154                     root (/); refusing to create palace directories directly \
155                     under / as that would scatter data across the root filesystem"
156                );
157            }
158            p
159        }
160        Err(_) => dirs::data_dir()
161            .or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
162            .context("could not resolve data directory or home directory")?,
163    };
164    let dir = if base.ends_with(format!(".{app_name}")) {
165        base
166    } else {
167        base.join(app_name)
168    };
169    let dir = sanitize_data_root(dir, app_name);
170    std::fs::create_dir_all(&dir)
171        .with_context(|| format!("create data directory {}", dir.display()))?;
172    Ok(dir)
173}
174
175/// Check whether a path exists and is a directory.
176///
177/// Why: tiny but commonly-needed shim — clearer at call sites than
178/// `path.exists() && path.is_dir()`.
179/// What: returns `true` iff the path exists and metadata reports a directory.
180/// Test: `is_dir_recognises_directories`.
181pub fn is_dir(path: &Path) -> bool {
182    path.metadata().map(|m| m.is_dir()).unwrap_or(false)
183}
184
185/// Mutex serialising all tests that mutate `TRUSTY_DATA_DIR_OVERRIDE`.
186///
187/// Why: `daemon_addr` tests also call `resolve_data_dir`, so tests across both
188/// modules race on the same env var. Sharing one lock (exported from the module
189/// that owns the constant) prevents spurious failures without pulling in an
190/// external crate.
191/// What: A `std::sync::Mutex<()>` that every env-mutating test locks before
192/// touching `TRUSTY_DATA_DIR_OVERRIDE`.
193/// Test: this is the synchronisation primitive itself — used by test helpers.
194#[cfg(test)]
195pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    use super::ENV_LOCK;
202
203    fn tempfile_like_dir() -> PathBuf {
204        let pid = std::process::id();
205        let nanos = std::time::SystemTime::now()
206            .duration_since(std::time::UNIX_EPOCH)
207            .map(|d| d.as_nanos())
208            .unwrap_or(0);
209        let p = std::env::temp_dir().join(format!("trusty-common-test-{pid}-{nanos}"));
210        std::fs::create_dir_all(&p).unwrap();
211        p
212    }
213
214    #[test]
215    fn resolve_data_dir_creates_directory() {
216        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
217        let tmp = tempfile_like_dir();
218        unsafe {
219            std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
220        }
221        let dir = resolve_data_dir("trusty-test-xyz").unwrap();
222        assert!(
223            dir.exists(),
224            "data dir should be created at {}",
225            dir.display()
226        );
227        assert!(dir.is_dir());
228        assert!(
229            dir.starts_with(&tmp),
230            "data dir {} should live under override {}",
231            dir.display(),
232            tmp.display()
233        );
234        unsafe {
235            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
236        }
237    }
238
239    /// Why: guard introduced in #503 — an empty override must not produce a
240    /// relative path that resolves under the daemon CWD.
241    /// What: sets TRUSTY_DATA_DIR_OVERRIDE="" and asserts the result is an
242    /// absolute path that does NOT start with "".
243    /// Test: this function.
244    #[test]
245    fn resolve_data_dir_empty_override_uses_platform_dir() {
246        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
247        unsafe {
248            std::env::set_var(DATA_DIR_OVERRIDE_ENV, "");
249        }
250        let result = resolve_data_dir("trusty-test-empty-override");
251        unsafe {
252            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
253        }
254        let dir = result.expect("empty override should fall back to platform dir");
255        assert!(
256            dir.is_absolute(),
257            "resolved dir should be absolute, got {}",
258            dir.display()
259        );
260        assert_ne!(
261            dir,
262            std::path::PathBuf::from("/"),
263            "resolved dir must not be filesystem root"
264        );
265    }
266
267    /// Why: whitespace-only overrides are as dangerous as empty ones.
268    /// What: sets TRUSTY_DATA_DIR_OVERRIDE="   " and asserts an absolute fallback.
269    /// Test: this function.
270    #[test]
271    fn resolve_data_dir_whitespace_override_uses_platform_dir() {
272        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
273        unsafe {
274            std::env::set_var(DATA_DIR_OVERRIDE_ENV, "   ");
275        }
276        let result = resolve_data_dir("trusty-test-ws-override");
277        unsafe {
278            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
279        }
280        let dir = result.expect("whitespace override should fall back to platform dir");
281        assert!(dir.is_absolute(), "resolved dir should be absolute");
282    }
283
284    /// Why: a relative override is non-deterministic (depends on daemon CWD).
285    /// What: sets TRUSTY_DATA_DIR_OVERRIDE="relative/path" and asserts an error.
286    /// Test: this function.
287    #[test]
288    fn resolve_data_dir_relative_override_errors() {
289        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
290        unsafe {
291            std::env::set_var(DATA_DIR_OVERRIDE_ENV, "relative/path");
292        }
293        let result = resolve_data_dir("trusty-test-relative");
294        unsafe {
295            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
296        }
297        assert!(
298            result.is_err(),
299            "relative override should be rejected, but got Ok({})",
300            result.unwrap().display()
301        );
302        let msg = result.unwrap_err().to_string();
303        assert!(
304            msg.contains("relative"),
305            "error should mention 'relative', got: {msg}"
306        );
307    }
308
309    /// Why: override set to "/" would create palace dirs directly under the
310    /// filesystem root, scattering data.
311    /// What: sets TRUSTY_DATA_DIR_OVERRIDE="/" and asserts an error.
312    /// Test: this function.
313    #[test]
314    fn resolve_data_dir_root_override_errors() {
315        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
316        unsafe {
317            std::env::set_var(DATA_DIR_OVERRIDE_ENV, "/");
318        }
319        let result = resolve_data_dir("trusty-test-root");
320        unsafe {
321            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
322        }
323        assert!(
324            result.is_err(),
325            "root '/' override should be rejected, but got Ok({})",
326            result.unwrap().display()
327        );
328        let msg = result.unwrap_err().to_string();
329        assert!(
330            msg.contains('/'),
331            "error should mention the path, got: {msg}"
332        );
333    }
334
335    /// Why: confirms that a valid absolute override is still honoured.
336    /// What: sets TRUSTY_DATA_DIR_OVERRIDE to a tempdir and asserts the resolved
337    /// path lives under it.
338    /// Test: this function (complements resolve_data_dir_creates_directory).
339    #[test]
340    fn resolve_data_dir_valid_absolute_override_is_honoured() {
341        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
342        let tmp = tempfile_like_dir();
343        unsafe {
344            std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
345        }
346        let result = resolve_data_dir("trusty-test-abs-override");
347        unsafe {
348            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
349        }
350        let dir = result.expect("valid absolute override should succeed");
351        assert!(
352            dir.starts_with(&tmp),
353            "resolved dir {} should be under override {}",
354            dir.display(),
355            tmp.display()
356        );
357        assert!(dir.is_absolute(), "resolved dir must be absolute");
358    }
359
360    /// Why: `sanitize_data_root` must catch a relative candidate.
361    /// What: passes `PathBuf::from("relative/path")` and asserts the returned
362    /// path is absolute and lives under `temp_dir()`.
363    /// Test: this function.
364    #[test]
365    fn sanitize_data_root_rejects_relative() {
366        let result = sanitize_data_root(PathBuf::from("relative/path"), "myapp");
367        assert!(result.is_absolute(), "fallback must be absolute");
368        let name = result.file_name().unwrap().to_string_lossy();
369        assert!(
370            name.starts_with("trusty-"),
371            "fallback dir name should start with trusty-, got {name}"
372        );
373    }
374
375    /// Why: a candidate equal to "/" must be replaced.
376    /// What: passes `PathBuf::from("/")` and asserts a safe fallback is returned.
377    /// Test: this function.
378    #[test]
379    fn sanitize_data_root_rejects_root() {
380        let result = sanitize_data_root(PathBuf::from("/"), "myapp");
381        assert!(result.is_absolute(), "fallback must be absolute");
382        assert_ne!(result, PathBuf::from("/"), "must not still be /");
383        let name = result.file_name().unwrap().to_string_lossy();
384        assert!(
385            name.starts_with("trusty-"),
386            "fallback should start with trusty-"
387        );
388    }
389
390    /// Why: `HOME="/"` on Linux yields `/.trusty-memory` — a bare root child
391    /// is as dangerous as `/` itself.
392    /// What: passes `/bare-child` (parent == "/") and asserts a safe fallback.
393    /// Test: this function.
394    #[test]
395    fn sanitize_data_root_rejects_bare_root_child() {
396        let result = sanitize_data_root(PathBuf::from("/bare-child"), "myapp");
397        assert!(result.is_absolute(), "fallback must be absolute");
398        assert_ne!(
399            result,
400            PathBuf::from("/bare-child"),
401            "bare root-child must be replaced"
402        );
403        let name = result.file_name().unwrap().to_string_lossy();
404        assert!(
405            name.starts_with("trusty-"),
406            "fallback should start with trusty-"
407        );
408    }
409
410    /// Why: valid paths must pass through unchanged.
411    /// What: passes a tempdir-based path and asserts it is returned unmodified.
412    /// Test: this function.
413    #[test]
414    fn sanitize_data_root_passes_valid_path() {
415        let tmp = tempfile_like_dir();
416        let candidate = tmp.join("trusty-myapp");
417        let result = sanitize_data_root(candidate.clone(), "myapp");
418        assert_eq!(
419            result, candidate,
420            "valid absolute path should be returned unchanged"
421        );
422    }
423
424    #[test]
425    fn is_dir_recognises_directories() {
426        let tmp = tempfile_like_dir();
427        assert!(is_dir(&tmp));
428        assert!(!is_dir(&tmp.join("nope")));
429    }
430}