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}