Skip to main content

ai_memory/
log_paths.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! User-configurable log directory resolution (PR-5 addendum, issue #487).
5//!
6//! End users can set both `[logging] path` and `[audit] path` at every
7//! layer; the highest-priority value wins:
8//!
9//! 1. **CLI flag** (`--log-dir`, `--audit-dir`) — explicit override on
10//!    the `ai-memory logs` / `ai-memory audit` subcommands.
11//! 2. **Environment variable** (`AI_MEMORY_LOG_DIR`,
12//!    `AI_MEMORY_AUDIT_DIR`) — useful for `systemd` units, Docker
13//!    `-e`, and Kubernetes env injection.
14//! 3. **`config.toml`** (`[logging] path`, `[audit] path`) — the
15//!    long-lived per-host setting maintainers write once.
16//! 4. **Platform default** — picked per-OS so a fresh install works
17//!    out of the box without any configuration.
18//!
19//! Platform defaults:
20//!
21//! | OS | Logs | Audit |
22//! |---|---|---|
23//! | Linux | `${XDG_STATE_HOME:-$HOME/.local/state}/ai-memory/logs/` | `…/audit/` |
24//! | macOS | `~/Library/Logs/ai-memory/` | `~/Library/Logs/ai-memory/audit/` |
25//! | Windows | `%LOCALAPPDATA%\ai-memory\logs\` | `…\audit\` |
26//! | systemd-managed daemon | `/var/log/ai-memory/` (if writable) | `…/audit/` |
27//!
28//! ## systemd detection
29//!
30//! When `INVOCATION_ID` is present in the environment (set by `systemd`
31//! for unit-managed processes) and `/var/log/ai-memory/` is writable,
32//! the resolver picks the system-wide path. Otherwise it falls through
33//! to the per-user XDG path.
34//!
35//! ## Security guard
36//!
37//! The resolved directory must not be world-writable. If a 0777 path is
38//! configured (or selected by default on a malformed system), the
39//! resolver returns an error pointing at the resolution chain that
40//! landed there. Created parent directories use mode `0700` on Unix; on
41//! Windows the default ACL is sufficient.
42//!
43//! See `docs/security/audit-trail.md` §"Log directory resolution" for
44//! the operator guide.
45
46use std::ffi::OsString;
47use std::path::{Path, PathBuf};
48
49use anyhow::{Context, Result, anyhow};
50
51/// Environment variable consulted for the operational log directory
52/// override. Read with `std::env::var_os` so non-UTF-8 paths on Windows
53/// pass through unchanged.
54pub const LOG_DIR_ENV: &str = "AI_MEMORY_LOG_DIR";
55
56/// Environment variable consulted for the audit log directory override.
57pub const AUDIT_DIR_ENV: &str = "AI_MEMORY_AUDIT_DIR";
58
59/// Source layer that produced the resolved path. Returned alongside
60/// the [`PathBuf`] so error messages can name the precedence step that
61/// landed the user at a bad directory.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PathSource {
64    /// Explicit `--log-dir` / `--audit-dir` flag.
65    CliFlag,
66    /// `AI_MEMORY_LOG_DIR` / `AI_MEMORY_AUDIT_DIR` environment variable.
67    EnvVar,
68    /// `[logging] path` / `[audit] path` in `config.toml`.
69    ConfigToml,
70    /// Platform default selected by the OS detection logic.
71    PlatformDefault,
72    /// systemd-managed daemon path (`/var/log/ai-memory/...`).
73    SystemdLogsDir,
74}
75
76impl PathSource {
77    #[must_use]
78    pub fn as_str(self) -> &'static str {
79        match self {
80            Self::CliFlag => "CLI flag (--log-dir / --audit-dir)",
81            Self::EnvVar => "environment variable (AI_MEMORY_LOG_DIR / AI_MEMORY_AUDIT_DIR)",
82            Self::ConfigToml => "[logging]/[audit] path in config.toml",
83            Self::PlatformDefault => "platform default",
84            Self::SystemdLogsDir => "systemd LogsDirectory (/var/log/ai-memory/)",
85        }
86    }
87}
88
89/// Result of a directory-resolution call. The path itself plus the
90/// layer that produced it (used for error messages).
91#[derive(Debug, Clone)]
92pub struct ResolvedDir {
93    pub path: PathBuf,
94    pub source: PathSource,
95}
96
97/// What kind of log directory we're resolving — dictates the platform
98/// default suffix (`logs/` vs `audit/`).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum DirKind {
101    Log,
102    Audit,
103}
104
105impl DirKind {
106    fn suffix(self) -> &'static str {
107        match self {
108            Self::Log => "logs",
109            Self::Audit => "audit",
110        }
111    }
112}
113
114/// Resolve the operational log directory honouring the precedence
115/// ladder: CLI > env var > config > platform default.
116///
117/// `cli_override` — the parsed `--log-dir <PATH>` argument if any.
118/// `config_path` — the `[logging] path` value if any.
119///
120/// # Errors
121/// - The resolved directory exists but is world-writable.
122pub fn resolve_log_dir(
123    cli_override: Option<&Path>,
124    config_path: Option<&str>,
125) -> Result<ResolvedDir> {
126    resolve_dir(DirKind::Log, cli_override, LOG_DIR_ENV, config_path)
127}
128
129/// Resolve the audit log directory honouring the precedence ladder.
130/// Mirror of [`resolve_log_dir`] for the audit subsystem.
131///
132/// # Errors
133/// - The resolved directory exists but is world-writable.
134pub fn resolve_audit_dir(
135    cli_override: Option<&Path>,
136    config_path: Option<&str>,
137) -> Result<ResolvedDir> {
138    resolve_dir(DirKind::Audit, cli_override, AUDIT_DIR_ENV, config_path)
139}
140
141fn resolve_dir(
142    kind: DirKind,
143    cli_override: Option<&Path>,
144    env_var: &str,
145    config_path: Option<&str>,
146) -> Result<ResolvedDir> {
147    let resolved = if let Some(p) = cli_override {
148        ResolvedDir {
149            path: PathBuf::from(p),
150            source: PathSource::CliFlag,
151        }
152    } else if let Some(env_val) = std::env::var_os(env_var) {
153        if env_val.is_empty() {
154            // Treat an empty env var as "unset" so a misconfigured
155            // launcher doesn't silently route logs to the CWD.
156            fall_through_to_config_or_default(kind, config_path)?
157        } else {
158            ResolvedDir {
159                path: PathBuf::from(env_val),
160                source: PathSource::EnvVar,
161            }
162        }
163    } else {
164        fall_through_to_config_or_default(kind, config_path)?
165    };
166
167    enforce_not_world_writable(&resolved)?;
168    Ok(resolved)
169}
170
171fn fall_through_to_config_or_default(
172    kind: DirKind,
173    config_path: Option<&str>,
174) -> Result<ResolvedDir> {
175    if let Some(raw) = config_path
176        && !raw.is_empty()
177    {
178        return Ok(ResolvedDir {
179            path: PathBuf::from(expand_tilde(raw)),
180            source: PathSource::ConfigToml,
181        });
182    }
183    Ok(platform_default(kind))
184}
185
186/// Compute the platform default for `kind`. Pure — no filesystem touch
187/// other than reading `INVOCATION_ID` / `XDG_STATE_HOME` / `HOME` /
188/// `LOCALAPPDATA` env vars.
189#[must_use]
190pub fn platform_default(kind: DirKind) -> ResolvedDir {
191    // systemd-managed daemon: prefer /var/log/ai-memory if writable.
192    // Skip in tests so the resolver test suite is deterministic.
193    if std::env::var_os("INVOCATION_ID").is_some() {
194        let p = PathBuf::from("/var/log/ai-memory").join(kind.suffix());
195        if is_writable_dir(&p.parent().unwrap_or(&p)) {
196            return ResolvedDir {
197                path: p,
198                source: PathSource::SystemdLogsDir,
199            };
200        }
201    }
202
203    let p = if cfg!(target_os = "macos") {
204        macos_default(kind)
205    } else if cfg!(target_os = "windows") {
206        windows_default(kind)
207    } else {
208        // Linux + every other Unix (BSD, illumos, etc.) — XDG.
209        linux_xdg_default(kind)
210    };
211    ResolvedDir {
212        path: p,
213        source: PathSource::PlatformDefault,
214    }
215}
216
217fn linux_xdg_default(kind: DirKind) -> PathBuf {
218    let base = std::env::var_os("XDG_STATE_HOME")
219        .filter(|s| !s.is_empty())
220        .map_or_else(
221            || {
222                let home = home_dir_or_dot();
223                home.join(".local").join("state")
224            },
225            PathBuf::from,
226        );
227    base.join("ai-memory").join(kind.suffix())
228}
229
230fn macos_default(kind: DirKind) -> PathBuf {
231    let home = home_dir_or_dot();
232    let base = home.join("Library").join("Logs").join("ai-memory");
233    match kind {
234        DirKind::Log => base,
235        DirKind::Audit => base.join("audit"),
236    }
237}
238
239fn windows_default(kind: DirKind) -> PathBuf {
240    let base = std::env::var_os("LOCALAPPDATA")
241        .filter(|s| !s.is_empty())
242        .map_or_else(
243            || {
244                // Fallback if LOCALAPPDATA is unset (mostly tests / WSL).
245                home_dir_or_dot()
246                    .join("AppData")
247                    .join("Local")
248                    .join("ai-memory")
249            },
250            |s| PathBuf::from(s).join("ai-memory"),
251        );
252    base.join(kind.suffix())
253}
254
255fn home_dir_or_dot() -> PathBuf {
256    if let Some(h) = std::env::var_os("HOME").filter(|s| !s.is_empty()) {
257        return PathBuf::from(h);
258    }
259    if let Some(h) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
260        return PathBuf::from(h);
261    }
262    PathBuf::from(".")
263}
264
265fn is_writable_dir(p: &Path) -> bool {
266    if !p.exists() || !p.is_dir() {
267        return false;
268    }
269    // Probe: try to create a unique temp file in the directory; if it
270    // fails, treat the dir as not-writable. We keep this best-effort —
271    // the kernel is the source of truth and this is a hint for the
272    // resolution decision.
273    let probe = p.join(format!(".ai-memory-write-probe-{}", std::process::id()));
274    match std::fs::File::create(&probe) {
275        Ok(_) => {
276            let _ = std::fs::remove_file(&probe);
277            true
278        }
279        Err(_) => false,
280    }
281}
282
283/// Reject world-writable directories. Returns `Ok(())` if the path
284/// doesn't exist yet (we'll create it secure) or if it's safely
285/// permissioned.
286///
287/// # Errors
288/// - The path exists and `mode & 0o002 != 0` on Unix.
289pub fn enforce_not_world_writable(rd: &ResolvedDir) -> Result<()> {
290    #[cfg(unix)]
291    {
292        use std::os::unix::fs::PermissionsExt;
293        if !rd.path.exists() {
294            return Ok(());
295        }
296        let md = std::fs::metadata(&rd.path).with_context(|| {
297            format!(
298                "stat {} (resolved via {})",
299                rd.path.display(),
300                rd.source.as_str()
301            )
302        })?;
303        let mode = md.permissions().mode();
304        if mode & 0o002 != 0 {
305            return Err(anyhow!(
306                "log directory {} is world-writable (mode {:#o}); refusing for security. \
307                 Resolved via: {}. Pick a non-world-writable directory and re-run.",
308                rd.path.display(),
309                mode & 0o7777,
310                rd.source.as_str()
311            ));
312        }
313    }
314    #[cfg(not(unix))]
315    {
316        // Windows: default ACL on `LOCALAPPDATA` and user-created dirs
317        // is "Authenticated Users" only — no world-writable concept
318        // mapping, so this is a no-op.
319        let _ = rd;
320    }
321    Ok(())
322}
323
324/// Create `dir` (and missing parents) with mode `0700` on Unix. On
325/// Windows defers to `std::fs::create_dir_all` and the default ACL.
326///
327/// # Errors
328/// - The directory cannot be created.
329/// - On Unix: the resulting permissions cannot be applied.
330pub fn ensure_dir_secure(dir: &Path) -> Result<()> {
331    std::fs::create_dir_all(dir)
332        .with_context(|| format!("creating log directory {}", dir.display()))?;
333    #[cfg(unix)]
334    {
335        use std::os::unix::fs::PermissionsExt;
336        let perms = std::fs::Permissions::from_mode(0o700);
337        std::fs::set_permissions(dir, perms)
338            .with_context(|| format!("setting mode 0700 on log directory {}", dir.display()))?;
339    }
340    Ok(())
341}
342
343/// Tilde-expand a config string. Mirrors [`crate::audit::expand_tilde`]
344/// so this module stays self-contained for resolver-level tests.
345#[must_use]
346pub fn expand_tilde(raw: &str) -> String {
347    if let Some(rest) = raw.strip_prefix("~/")
348        && let Some(home) = std::env::var_os("HOME")
349    {
350        let mut buf = OsString::from(home);
351        buf.push("/");
352        buf.push(rest);
353        return buf.to_string_lossy().into_owned();
354    }
355    raw.to_string()
356}
357
358// ---------------------------------------------------------------------------
359// Tests
360// ---------------------------------------------------------------------------
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    /// Process-wide lock so tests that mutate env vars (LOG_DIR_ENV /
367    /// AUDIT_DIR_ENV / INVOCATION_ID / HOME / etc.) don't race.
368    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
369        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
370        LOCK.get_or_init(|| std::sync::Mutex::new(()))
371            .lock()
372            .unwrap_or_else(|p| p.into_inner())
373    }
374
375    /// Snapshot+restore an env var across a test body so we don't leak
376    /// state into sibling tests.
377    struct EnvGuard {
378        key: &'static str,
379        prev: Option<OsString>,
380    }
381    impl EnvGuard {
382        fn capture(key: &'static str) -> Self {
383            Self {
384                key,
385                prev: std::env::var_os(key),
386            }
387        }
388        fn set(&self, v: &str) {
389            // SAFETY: serialised via env_lock() at the test entry; no
390            // other thread is reading the env concurrently.
391            unsafe {
392                std::env::set_var(self.key, v);
393            }
394        }
395        fn unset(&self) {
396            // SAFETY: same as `set`.
397            unsafe {
398                std::env::remove_var(self.key);
399            }
400        }
401    }
402    impl Drop for EnvGuard {
403        fn drop(&mut self) {
404            // SAFETY: same as `set`.
405            unsafe {
406                if let Some(v) = &self.prev {
407                    std::env::set_var(self.key, v);
408                } else {
409                    std::env::remove_var(self.key);
410                }
411            }
412        }
413    }
414
415    #[test]
416    fn log_dir_cli_flag_overrides_env_var() {
417        let _g = env_lock();
418        let env = EnvGuard::capture(LOG_DIR_ENV);
419        env.set("/should/not/win");
420        let cli = PathBuf::from("/cli/wins");
421        let resolved = resolve_log_dir(Some(&cli), Some("/config/loses")).unwrap();
422        assert_eq!(resolved.path, cli);
423        assert_eq!(resolved.source, PathSource::CliFlag);
424    }
425
426    #[test]
427    fn log_dir_env_var_overrides_config_toml() {
428        let _g = env_lock();
429        let env = EnvGuard::capture(LOG_DIR_ENV);
430        env.set("/env/wins");
431        let resolved = resolve_log_dir(None, Some("/config/loses")).unwrap();
432        assert_eq!(resolved.path, PathBuf::from("/env/wins"));
433        assert_eq!(resolved.source, PathSource::EnvVar);
434    }
435
436    #[test]
437    fn log_dir_config_toml_overrides_platform_default() {
438        let _g = env_lock();
439        let env = EnvGuard::capture(LOG_DIR_ENV);
440        env.unset();
441        let _inv = EnvGuard::capture("INVOCATION_ID");
442        _inv.unset();
443        let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
444        assert_eq!(resolved.path, PathBuf::from("/config/wins"));
445        assert_eq!(resolved.source, PathSource::ConfigToml);
446    }
447
448    #[test]
449    fn log_dir_platform_default_resolves_per_os() {
450        let _g = env_lock();
451        let env = EnvGuard::capture(LOG_DIR_ENV);
452        env.unset();
453        let _inv = EnvGuard::capture("INVOCATION_ID");
454        _inv.unset();
455        let resolved = resolve_log_dir(None, None).unwrap();
456        assert_eq!(resolved.source, PathSource::PlatformDefault);
457        let s = resolved.path.to_string_lossy().to_string();
458        if cfg!(target_os = "macos") {
459            assert!(
460                s.contains("Library/Logs/ai-memory"),
461                "macOS default should be under Library/Logs/ai-memory, got {s}"
462            );
463        } else if cfg!(target_os = "windows") {
464            assert!(
465                s.to_lowercase().contains("ai-memory"),
466                "Windows default should contain ai-memory, got {s}"
467            );
468        } else {
469            // Linux + BSD + others fall through to XDG.
470            assert!(
471                s.contains("ai-memory") && s.contains("logs"),
472                "Linux/Unix XDG default should contain ai-memory/logs, got {s}"
473            );
474        }
475    }
476
477    #[test]
478    fn audit_dir_cli_flag_overrides_env_var() {
479        let _g = env_lock();
480        let env = EnvGuard::capture(AUDIT_DIR_ENV);
481        env.set("/should/not/win");
482        let cli = PathBuf::from("/cli/audit/wins");
483        let resolved = resolve_audit_dir(Some(&cli), Some("/config/loses")).unwrap();
484        assert_eq!(resolved.path, cli);
485        assert_eq!(resolved.source, PathSource::CliFlag);
486    }
487
488    #[test]
489    fn audit_dir_env_var_overrides_config_toml() {
490        let _g = env_lock();
491        let env = EnvGuard::capture(AUDIT_DIR_ENV);
492        env.set("/env/audit/wins");
493        let resolved = resolve_audit_dir(None, Some("/config/loses")).unwrap();
494        assert_eq!(resolved.path, PathBuf::from("/env/audit/wins"));
495        assert_eq!(resolved.source, PathSource::EnvVar);
496    }
497
498    #[test]
499    fn audit_dir_config_toml_overrides_platform_default() {
500        let _g = env_lock();
501        let env = EnvGuard::capture(AUDIT_DIR_ENV);
502        env.unset();
503        let _inv = EnvGuard::capture("INVOCATION_ID");
504        _inv.unset();
505        let resolved = resolve_audit_dir(None, Some("/config/audit/wins")).unwrap();
506        assert_eq!(resolved.path, PathBuf::from("/config/audit/wins"));
507        assert_eq!(resolved.source, PathSource::ConfigToml);
508    }
509
510    #[test]
511    fn audit_dir_platform_default_resolves_per_os() {
512        let _g = env_lock();
513        let env = EnvGuard::capture(AUDIT_DIR_ENV);
514        env.unset();
515        let _inv = EnvGuard::capture("INVOCATION_ID");
516        _inv.unset();
517        let resolved = resolve_audit_dir(None, None).unwrap();
518        assert_eq!(resolved.source, PathSource::PlatformDefault);
519        let s = resolved.path.to_string_lossy().to_string();
520        assert!(
521            s.contains("ai-memory") && s.contains("audit"),
522            "audit platform default should mention ai-memory and audit, got {s}"
523        );
524    }
525
526    #[test]
527    #[cfg(unix)]
528    fn log_dir_creates_directory_with_secure_permissions() {
529        use std::os::unix::fs::PermissionsExt;
530        let tmp = tempfile::tempdir().unwrap();
531        let target = tmp.path().join("nested").join("logs");
532        ensure_dir_secure(&target).unwrap();
533        let md = std::fs::metadata(&target).unwrap();
534        let mode = md.permissions().mode() & 0o7777;
535        assert_eq!(
536            mode, 0o700,
537            "ensure_dir_secure must apply mode 0700 (got {mode:#o})"
538        );
539    }
540
541    #[test]
542    #[cfg(unix)]
543    fn log_dir_refuses_world_writable_destination() {
544        use std::os::unix::fs::PermissionsExt;
545        let _g = env_lock();
546        let tmp = tempfile::tempdir().unwrap();
547        let bad = tmp.path().join("worldwrite");
548        std::fs::create_dir(&bad).unwrap();
549        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
550        let env = EnvGuard::capture(LOG_DIR_ENV);
551        env.unset();
552        let err = resolve_log_dir(Some(&bad), None).unwrap_err();
553        let msg = format!("{err}");
554        assert!(
555            msg.contains("world-writable"),
556            "error should mention world-writable, got: {msg}"
557        );
558        assert!(
559            msg.contains("CLI flag"),
560            "error should name resolution layer (CLI flag), got: {msg}"
561        );
562    }
563
564    #[test]
565    #[cfg(unix)]
566    fn audit_dir_refuses_world_writable_destination() {
567        use std::os::unix::fs::PermissionsExt;
568        let _g = env_lock();
569        let tmp = tempfile::tempdir().unwrap();
570        let bad = tmp.path().join("audit-worldwrite");
571        std::fs::create_dir(&bad).unwrap();
572        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
573        let env = EnvGuard::capture(AUDIT_DIR_ENV);
574        env.unset();
575        let err = resolve_audit_dir(Some(&bad), None).unwrap_err();
576        assert!(format!("{err}").contains("world-writable"));
577    }
578
579    #[test]
580    fn log_dir_systemd_mode_uses_var_log_when_writable() {
581        // Pure-logic test — we don't actually write to /var/log. We
582        // assert the resolver's INVOCATION_ID branch picks SystemdLogsDir
583        // when the writability probe succeeds. We use a tempdir
584        // symlinked into a custom platform_default_for_systemd helper
585        // tested via an env-var override path: setting INVOCATION_ID
586        // and pointing /var/log via cfg-gated test harness is not
587        // portable, so we instead test the underlying `is_writable_dir`
588        // helper plus the env-var detection independently.
589        let _g = env_lock();
590        let _inv = EnvGuard::capture("INVOCATION_ID");
591        _inv.set("test-invocation-id");
592
593        let tmp = tempfile::tempdir().unwrap();
594        // Confirm is_writable_dir matches reality on a fresh tempdir.
595        assert!(is_writable_dir(tmp.path()));
596        // Confirm a non-existent path is not "writable".
597        assert!(!is_writable_dir(&tmp.path().join("does-not-exist")));
598
599        // Exercise the platform_default branch with INVOCATION_ID set.
600        // We can't force /var/log writable in unit tests, so we accept
601        // either SystemdLogsDir (CI runners w/ /var/log root-writable
602        // still won't pass the probe) or PlatformDefault. The contract
603        // is: when INVOCATION_ID is unset, we never pick SystemdLogsDir.
604        let resolved = platform_default(DirKind::Log);
605        assert!(matches!(
606            resolved.source,
607            PathSource::SystemdLogsDir | PathSource::PlatformDefault
608        ));
609
610        _inv.unset();
611        let resolved2 = platform_default(DirKind::Log);
612        assert_eq!(
613            resolved2.source,
614            PathSource::PlatformDefault,
615            "without INVOCATION_ID, must never pick SystemdLogsDir"
616        );
617    }
618
619    #[test]
620    fn log_dir_empty_env_var_falls_through_to_config() {
621        let _g = env_lock();
622        let env = EnvGuard::capture(LOG_DIR_ENV);
623        env.set("");
624        let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
625        assert_eq!(resolved.source, PathSource::ConfigToml);
626    }
627
628    #[test]
629    fn expand_tilde_keeps_non_tilde_paths_unchanged() {
630        assert_eq!(expand_tilde("/abs/path"), "/abs/path");
631        assert_eq!(expand_tilde("relative/path"), "relative/path");
632    }
633
634    #[test]
635    fn path_source_strings_are_human_readable() {
636        for s in [
637            PathSource::CliFlag,
638            PathSource::EnvVar,
639            PathSource::ConfigToml,
640            PathSource::PlatformDefault,
641            PathSource::SystemdLogsDir,
642        ] {
643            assert!(!s.as_str().is_empty());
644        }
645    }
646
647    // ------------------------------------------------------------------
648    // PR-9e coverage uplift (issue #487): close the security-guard and
649    // tilde-expansion gaps flagged in the audit report.
650    // ------------------------------------------------------------------
651
652    #[test]
653    fn expand_tilde_expands_home_dir() {
654        let _g = env_lock();
655        let env = EnvGuard::capture("HOME");
656        env.set("/test-home");
657        assert_eq!(expand_tilde("~/state/log"), "/test-home/state/log");
658        // Bare `~` (no slash) is not expanded — matches the audit
659        // module's expand_tilde which strictly looks for the `~/` prefix.
660        assert_eq!(expand_tilde("~root"), "~root");
661    }
662
663    #[test]
664    fn expand_tilde_no_home_keeps_input_unchanged() {
665        let _g = env_lock();
666        let env = EnvGuard::capture("HOME");
667        env.unset();
668        // Without HOME set, expansion must be a no-op.
669        assert_eq!(expand_tilde("~/state"), "~/state");
670    }
671
672    #[cfg(unix)]
673    #[test]
674    fn enforce_not_world_writable_passes_through_on_nonexistent_path() {
675        let tmp = tempfile::tempdir().unwrap();
676        // Path under tempdir that does not exist — must succeed.
677        let r = ResolvedDir {
678            path: tmp.path().join("does-not-exist"),
679            source: PathSource::ConfigToml,
680        };
681        assert!(enforce_not_world_writable(&r).is_ok());
682    }
683
684    #[cfg(unix)]
685    #[test]
686    fn enforce_not_world_writable_passes_safe_dir() {
687        use std::os::unix::fs::PermissionsExt;
688        let tmp = tempfile::tempdir().unwrap();
689        let safe = tmp.path().join("safe");
690        std::fs::create_dir(&safe).unwrap();
691        std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o755)).unwrap();
692        let r = ResolvedDir {
693            path: safe,
694            source: PathSource::ConfigToml,
695        };
696        assert!(enforce_not_world_writable(&r).is_ok());
697    }
698
699    #[test]
700    fn is_writable_dir_returns_false_for_a_file_path() {
701        let tmp = tempfile::tempdir().unwrap();
702        let f = tmp.path().join("regular.txt");
703        std::fs::write(&f, b"hello").unwrap();
704        // A path that exists but is a file (not a dir) must fail
705        // is_writable_dir's "is_dir" guard.
706        assert!(!is_writable_dir(&f));
707    }
708
709    #[test]
710    fn is_writable_dir_returns_false_for_nonexistent_path() {
711        let tmp = tempfile::tempdir().unwrap();
712        assert!(!is_writable_dir(&tmp.path().join("nope")));
713    }
714
715    #[test]
716    fn dirkind_suffix_returns_logs_or_audit() {
717        // Pure-logic helper exposed via DirKind — covers both arms of
718        // the suffix() match.
719        assert_eq!(DirKind::Log.suffix(), "logs");
720        assert_eq!(DirKind::Audit.suffix(), "audit");
721    }
722
723    #[test]
724    fn ensure_dir_secure_creates_nested_path() {
725        let tmp = tempfile::tempdir().unwrap();
726        let target = tmp.path().join("a").join("b").join("c");
727        ensure_dir_secure(&target).unwrap();
728        assert!(target.is_dir());
729    }
730
731    #[test]
732    fn ensure_dir_secure_idempotent_on_existing_dir() {
733        let tmp = tempfile::tempdir().unwrap();
734        let target = tmp.path().join("present");
735        std::fs::create_dir(&target).unwrap();
736        // Second call must not error even though the dir already exists.
737        ensure_dir_secure(&target).unwrap();
738        ensure_dir_secure(&target).unwrap();
739    }
740
741    #[test]
742    fn fall_through_uses_config_when_set() {
743        // Indirect test: with no env override and a config path,
744        // resolve_log_dir must pick ConfigToml.
745        let _g = env_lock();
746        let env = EnvGuard::capture(LOG_DIR_ENV);
747        env.unset();
748        let r = resolve_log_dir(None, Some("/tmp/explicit-config")).unwrap();
749        assert_eq!(r.source, PathSource::ConfigToml);
750        assert_eq!(r.path, PathBuf::from("/tmp/explicit-config"));
751    }
752
753    #[test]
754    fn fall_through_expands_tilde_in_config_path() {
755        let _g = env_lock();
756        let env = EnvGuard::capture(LOG_DIR_ENV);
757        env.unset();
758        let home = EnvGuard::capture("HOME");
759        home.set("/test-tilde-home");
760        let r = resolve_log_dir(None, Some("~/state/logs")).unwrap();
761        // The tilde must be expanded to the test HOME.
762        assert_eq!(r.path, PathBuf::from("/test-tilde-home/state/logs"));
763        assert_eq!(r.source, PathSource::ConfigToml);
764    }
765
766    #[test]
767    fn fall_through_empty_config_path_uses_platform_default() {
768        let _g = env_lock();
769        let env = EnvGuard::capture(LOG_DIR_ENV);
770        env.unset();
771        let _inv = EnvGuard::capture("INVOCATION_ID");
772        _inv.unset();
773        // Empty config string must NOT short-circuit ConfigToml — it
774        // falls through to platform default.
775        let r = resolve_log_dir(None, Some("")).unwrap();
776        assert_eq!(r.source, PathSource::PlatformDefault);
777    }
778
779    #[test]
780    fn empty_audit_env_var_falls_through_to_config() {
781        let _g = env_lock();
782        let env = EnvGuard::capture(AUDIT_DIR_ENV);
783        env.set("");
784        let r = resolve_audit_dir(None, Some("/cfg/audit")).unwrap();
785        assert_eq!(r.source, PathSource::ConfigToml);
786        assert_eq!(r.path, PathBuf::from("/cfg/audit"));
787    }
788}