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    // COVERAGE: SystemdLogsDir branch unreachable on macOS dev host
194    //           (/var/log/ai-memory parent not writable to the test
195    //           user); exercised by Linux systemd integration tests
196    //           in the production CI matrix.
197    if std::env::var_os("INVOCATION_ID").is_some() {
198        let p = PathBuf::from("/var/log/ai-memory").join(kind.suffix());
199        if is_writable_dir(&p.parent().unwrap_or(&p)) {
200            return ResolvedDir {
201                path: p,
202                source: PathSource::SystemdLogsDir,
203            };
204        }
205    }
206
207    // COVERAGE: target_os="windows" branch unreachable on macOS dev
208    //           host; exercised by GitHub Actions matrix CI.
209    // COVERAGE: target_os="linux" (non-macOS, non-windows) branch
210    //           unreachable on macOS dev host; exercised by GitHub
211    //           Actions matrix CI.
212    let p = if cfg!(target_os = "macos") {
213        macos_default(kind)
214    } else if cfg!(target_os = "windows") {
215        windows_default(kind)
216    } else {
217        // Linux + every other Unix (BSD, illumos, etc.) — XDG.
218        linux_xdg_default(kind)
219    };
220    ResolvedDir {
221        path: p,
222        source: PathSource::PlatformDefault,
223    }
224}
225
226fn linux_xdg_default(kind: DirKind) -> PathBuf {
227    let base = std::env::var_os("XDG_STATE_HOME")
228        .filter(|s| !s.is_empty())
229        .map_or_else(
230            || {
231                let home = home_dir_or_dot();
232                home.join(".local").join("state")
233            },
234            PathBuf::from,
235        );
236    base.join("ai-memory").join(kind.suffix())
237}
238
239fn macos_default(kind: DirKind) -> PathBuf {
240    let home = home_dir_or_dot();
241    let base = home.join("Library").join("Logs").join("ai-memory");
242    match kind {
243        DirKind::Log => base,
244        DirKind::Audit => base.join("audit"),
245    }
246}
247
248fn windows_default(kind: DirKind) -> PathBuf {
249    let base = std::env::var_os("LOCALAPPDATA")
250        .filter(|s| !s.is_empty())
251        .map_or_else(
252            || {
253                // Fallback if LOCALAPPDATA is unset (mostly tests / WSL).
254                home_dir_or_dot()
255                    .join("AppData")
256                    .join("Local")
257                    .join("ai-memory")
258            },
259            |s| PathBuf::from(s).join("ai-memory"),
260        );
261    base.join(kind.suffix())
262}
263
264fn home_dir_or_dot() -> PathBuf {
265    if let Some(h) = std::env::var_os("HOME").filter(|s| !s.is_empty()) {
266        return PathBuf::from(h);
267    }
268    if let Some(h) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
269        return PathBuf::from(h);
270    }
271    PathBuf::from(".")
272}
273
274fn is_writable_dir(p: &Path) -> bool {
275    if !p.exists() || !p.is_dir() {
276        return false;
277    }
278    // Probe: try to create a unique temp file in the directory; if it
279    // fails, treat the dir as not-writable. We keep this best-effort —
280    // the kernel is the source of truth and this is a hint for the
281    // resolution decision.
282    let probe = p.join(format!(".ai-memory-write-probe-{}", std::process::id()));
283    match std::fs::File::create(&probe) {
284        Ok(_) => {
285            let _ = std::fs::remove_file(&probe);
286            true
287        }
288        Err(_) => false,
289    }
290}
291
292/// Reject world-writable directories. Returns `Ok(())` if the path
293/// doesn't exist yet (we'll create it secure) or if it's safely
294/// permissioned.
295///
296/// # Errors
297/// - The path exists and `mode & 0o002 != 0` on Unix.
298pub fn enforce_not_world_writable(rd: &ResolvedDir) -> Result<()> {
299    #[cfg(unix)]
300    {
301        use std::os::unix::fs::PermissionsExt;
302        if !rd.path.exists() {
303            return Ok(());
304        }
305        // COVERAGE: fs::metadata error closure (lines 296-302) is
306        //           reachable only under a TOCTOU race between
307        //           `exists()` returning true and `metadata()` failing
308        //           (e.g. the dir is rm-rf'd between the two syscalls).
309        //           Not deterministically triggerable from a single-
310        //           process test.
311        let md = std::fs::metadata(&rd.path).with_context(|| {
312            format!(
313                "stat {} (resolved via {})",
314                rd.path.display(),
315                rd.source.as_str()
316            )
317        })?;
318        let mode = md.permissions().mode();
319        if mode & 0o002 != 0 {
320            return Err(anyhow!(
321                "log directory {} is world-writable (mode {:#o}); refusing for security. \
322                 Resolved via: {}. Pick a non-world-writable directory and re-run.",
323                rd.path.display(),
324                mode & 0o7777,
325                rd.source.as_str()
326            ));
327        }
328    }
329    #[cfg(not(unix))]
330    {
331        // Windows: default ACL on `LOCALAPPDATA` and user-created dirs
332        // is "Authenticated Users" only — no world-writable concept
333        // mapping, so this is a no-op.
334        let _ = rd;
335    }
336    Ok(())
337}
338
339/// Create `dir` (and missing parents) with mode `0700` on Unix. On
340/// Windows defers to `std::fs::create_dir_all` and the default ACL.
341///
342/// # Errors
343/// - The directory cannot be created.
344/// - On Unix: the resulting permissions cannot be applied.
345pub fn ensure_dir_secure(dir: &Path) -> Result<()> {
346    std::fs::create_dir_all(dir)
347        .with_context(|| format!("creating log directory {}", dir.display()))?;
348    #[cfg(unix)]
349    {
350        use std::os::unix::fs::PermissionsExt;
351        let perms = std::fs::Permissions::from_mode(0o700);
352        // COVERAGE: set_permissions error closure (line 338) reachable
353        //           only when the dir was just created (so create_dir_all
354        //           succeeded) but the test user lacks chmod permission
355        //           on it — e.g. SELinux MAC override. Not portable to
356        //           tests on macOS/Linux dev hosts.
357        std::fs::set_permissions(dir, perms)
358            .with_context(|| format!("setting mode 0700 on log directory {}", dir.display()))?;
359    }
360    Ok(())
361}
362
363/// Tilde-expand a config string. Mirrors [`crate::audit::expand_tilde`]
364/// so this module stays self-contained for resolver-level tests.
365#[must_use]
366pub fn expand_tilde(raw: &str) -> String {
367    if let Some(rest) = raw.strip_prefix("~/")
368        && let Some(home) = std::env::var_os("HOME")
369    {
370        let mut buf = OsString::from(home);
371        buf.push("/");
372        buf.push(rest);
373        return buf.to_string_lossy().into_owned();
374    }
375    raw.to_string()
376}
377
378// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    /// Process-wide lock so tests that mutate env vars (LOG_DIR_ENV /
387    /// AUDIT_DIR_ENV / INVOCATION_ID / HOME / etc.) don't race.
388    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
389        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
390        LOCK.get_or_init(|| std::sync::Mutex::new(()))
391            .lock()
392            .unwrap_or_else(|p| p.into_inner())
393    }
394
395    /// Snapshot+restore an env var across a test body so we don't leak
396    /// state into sibling tests.
397    struct EnvGuard {
398        key: &'static str,
399        prev: Option<OsString>,
400    }
401    impl EnvGuard {
402        fn capture(key: &'static str) -> Self {
403            Self {
404                key,
405                prev: std::env::var_os(key),
406            }
407        }
408        fn set(&self, v: &str) {
409            // SAFETY: serialised via env_lock() at the test entry; no
410            // other thread is reading the env concurrently.
411            unsafe {
412                std::env::set_var(self.key, v);
413            }
414        }
415        fn unset(&self) {
416            // SAFETY: same as `set`.
417            unsafe {
418                std::env::remove_var(self.key);
419            }
420        }
421    }
422    impl Drop for EnvGuard {
423        fn drop(&mut self) {
424            // SAFETY: same as `set`.
425            unsafe {
426                if let Some(v) = &self.prev {
427                    std::env::set_var(self.key, v);
428                } else {
429                    std::env::remove_var(self.key);
430                }
431            }
432        }
433    }
434
435    #[test]
436    fn log_dir_cli_flag_overrides_env_var() {
437        let _g = env_lock();
438        let env = EnvGuard::capture(LOG_DIR_ENV);
439        env.set("/should/not/win");
440        let cli = PathBuf::from("/cli/wins");
441        let resolved = resolve_log_dir(Some(&cli), Some("/config/loses")).unwrap();
442        assert_eq!(resolved.path, cli);
443        assert_eq!(resolved.source, PathSource::CliFlag);
444    }
445
446    #[test]
447    fn log_dir_env_var_overrides_config_toml() {
448        let _g = env_lock();
449        let env = EnvGuard::capture(LOG_DIR_ENV);
450        env.set("/env/wins");
451        let resolved = resolve_log_dir(None, Some("/config/loses")).unwrap();
452        assert_eq!(resolved.path, PathBuf::from("/env/wins"));
453        assert_eq!(resolved.source, PathSource::EnvVar);
454    }
455
456    #[test]
457    fn log_dir_config_toml_overrides_platform_default() {
458        let _g = env_lock();
459        let env = EnvGuard::capture(LOG_DIR_ENV);
460        env.unset();
461        let _inv = EnvGuard::capture("INVOCATION_ID");
462        _inv.unset();
463        let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
464        assert_eq!(resolved.path, PathBuf::from("/config/wins"));
465        assert_eq!(resolved.source, PathSource::ConfigToml);
466    }
467
468    #[test]
469    fn log_dir_platform_default_resolves_per_os() {
470        let _g = env_lock();
471        let env = EnvGuard::capture(LOG_DIR_ENV);
472        env.unset();
473        let _inv = EnvGuard::capture("INVOCATION_ID");
474        _inv.unset();
475        let resolved = resolve_log_dir(None, None).unwrap();
476        assert_eq!(resolved.source, PathSource::PlatformDefault);
477        let s = resolved.path.to_string_lossy().to_string();
478        if cfg!(target_os = "macos") {
479            assert!(
480                s.contains("Library/Logs/ai-memory"),
481                "macOS default should be under Library/Logs/ai-memory, got {s}"
482            );
483        } else if cfg!(target_os = "windows") {
484            assert!(
485                s.to_lowercase().contains("ai-memory"),
486                "Windows default should contain ai-memory, got {s}"
487            );
488        } else {
489            // Linux + BSD + others fall through to XDG.
490            assert!(
491                s.contains("ai-memory") && s.contains("logs"),
492                "Linux/Unix XDG default should contain ai-memory/logs, got {s}"
493            );
494        }
495    }
496
497    #[test]
498    fn audit_dir_cli_flag_overrides_env_var() {
499        let _g = env_lock();
500        let env = EnvGuard::capture(AUDIT_DIR_ENV);
501        env.set("/should/not/win");
502        let cli = PathBuf::from("/cli/audit/wins");
503        let resolved = resolve_audit_dir(Some(&cli), Some("/config/loses")).unwrap();
504        assert_eq!(resolved.path, cli);
505        assert_eq!(resolved.source, PathSource::CliFlag);
506    }
507
508    #[test]
509    fn audit_dir_env_var_overrides_config_toml() {
510        let _g = env_lock();
511        let env = EnvGuard::capture(AUDIT_DIR_ENV);
512        env.set("/env/audit/wins");
513        let resolved = resolve_audit_dir(None, Some("/config/loses")).unwrap();
514        assert_eq!(resolved.path, PathBuf::from("/env/audit/wins"));
515        assert_eq!(resolved.source, PathSource::EnvVar);
516    }
517
518    #[test]
519    fn audit_dir_config_toml_overrides_platform_default() {
520        let _g = env_lock();
521        let env = EnvGuard::capture(AUDIT_DIR_ENV);
522        env.unset();
523        let _inv = EnvGuard::capture("INVOCATION_ID");
524        _inv.unset();
525        let resolved = resolve_audit_dir(None, Some("/config/audit/wins")).unwrap();
526        assert_eq!(resolved.path, PathBuf::from("/config/audit/wins"));
527        assert_eq!(resolved.source, PathSource::ConfigToml);
528    }
529
530    #[test]
531    fn audit_dir_platform_default_resolves_per_os() {
532        let _g = env_lock();
533        let env = EnvGuard::capture(AUDIT_DIR_ENV);
534        env.unset();
535        let _inv = EnvGuard::capture("INVOCATION_ID");
536        _inv.unset();
537        let resolved = resolve_audit_dir(None, None).unwrap();
538        assert_eq!(resolved.source, PathSource::PlatformDefault);
539        let s = resolved.path.to_string_lossy().to_string();
540        assert!(
541            s.contains("ai-memory") && s.contains("audit"),
542            "audit platform default should mention ai-memory and audit, got {s}"
543        );
544    }
545
546    #[test]
547    #[cfg(unix)]
548    fn log_dir_creates_directory_with_secure_permissions() {
549        use std::os::unix::fs::PermissionsExt;
550        let tmp = tempfile::tempdir().unwrap();
551        let target = tmp.path().join("nested").join("logs");
552        ensure_dir_secure(&target).unwrap();
553        let md = std::fs::metadata(&target).unwrap();
554        let mode = md.permissions().mode() & 0o7777;
555        assert_eq!(
556            mode, 0o700,
557            "ensure_dir_secure must apply mode 0700 (got {mode:#o})"
558        );
559    }
560
561    #[test]
562    #[cfg(unix)]
563    fn log_dir_refuses_world_writable_destination() {
564        use std::os::unix::fs::PermissionsExt;
565        let _g = env_lock();
566        let tmp = tempfile::tempdir().unwrap();
567        let bad = tmp.path().join("worldwrite");
568        std::fs::create_dir(&bad).unwrap();
569        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
570        let env = EnvGuard::capture(LOG_DIR_ENV);
571        env.unset();
572        let err = resolve_log_dir(Some(&bad), None).unwrap_err();
573        let msg = format!("{err}");
574        assert!(
575            msg.contains("world-writable"),
576            "error should mention world-writable, got: {msg}"
577        );
578        assert!(
579            msg.contains("CLI flag"),
580            "error should name resolution layer (CLI flag), got: {msg}"
581        );
582    }
583
584    #[test]
585    #[cfg(unix)]
586    fn audit_dir_refuses_world_writable_destination() {
587        use std::os::unix::fs::PermissionsExt;
588        let _g = env_lock();
589        let tmp = tempfile::tempdir().unwrap();
590        let bad = tmp.path().join("audit-worldwrite");
591        std::fs::create_dir(&bad).unwrap();
592        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
593        let env = EnvGuard::capture(AUDIT_DIR_ENV);
594        env.unset();
595        let err = resolve_audit_dir(Some(&bad), None).unwrap_err();
596        assert!(format!("{err}").contains("world-writable"));
597    }
598
599    #[test]
600    fn log_dir_systemd_mode_uses_var_log_when_writable() {
601        // Pure-logic test — we don't actually write to /var/log. We
602        // assert the resolver's INVOCATION_ID branch picks SystemdLogsDir
603        // when the writability probe succeeds. We use a tempdir
604        // symlinked into a custom platform_default_for_systemd helper
605        // tested via an env-var override path: setting INVOCATION_ID
606        // and pointing /var/log via cfg-gated test harness is not
607        // portable, so we instead test the underlying `is_writable_dir`
608        // helper plus the env-var detection independently.
609        let _g = env_lock();
610        let _inv = EnvGuard::capture("INVOCATION_ID");
611        _inv.set("test-invocation-id");
612
613        let tmp = tempfile::tempdir().unwrap();
614        // Confirm is_writable_dir matches reality on a fresh tempdir.
615        assert!(is_writable_dir(tmp.path()));
616        // Confirm a non-existent path is not "writable".
617        assert!(!is_writable_dir(&tmp.path().join("does-not-exist")));
618
619        // Exercise the platform_default branch with INVOCATION_ID set.
620        // We can't force /var/log writable in unit tests, so we accept
621        // either SystemdLogsDir (CI runners w/ /var/log root-writable
622        // still won't pass the probe) or PlatformDefault. The contract
623        // is: when INVOCATION_ID is unset, we never pick SystemdLogsDir.
624        let resolved = platform_default(DirKind::Log);
625        assert!(matches!(
626            resolved.source,
627            PathSource::SystemdLogsDir | PathSource::PlatformDefault
628        ));
629
630        _inv.unset();
631        let resolved2 = platform_default(DirKind::Log);
632        assert_eq!(
633            resolved2.source,
634            PathSource::PlatformDefault,
635            "without INVOCATION_ID, must never pick SystemdLogsDir"
636        );
637    }
638
639    #[test]
640    fn log_dir_empty_env_var_falls_through_to_config() {
641        let _g = env_lock();
642        let env = EnvGuard::capture(LOG_DIR_ENV);
643        env.set("");
644        let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
645        assert_eq!(resolved.source, PathSource::ConfigToml);
646    }
647
648    #[test]
649    fn expand_tilde_keeps_non_tilde_paths_unchanged() {
650        assert_eq!(expand_tilde("/abs/path"), "/abs/path");
651        assert_eq!(expand_tilde("relative/path"), "relative/path");
652    }
653
654    #[test]
655    fn path_source_strings_are_human_readable() {
656        for s in [
657            PathSource::CliFlag,
658            PathSource::EnvVar,
659            PathSource::ConfigToml,
660            PathSource::PlatformDefault,
661            PathSource::SystemdLogsDir,
662        ] {
663            assert!(!s.as_str().is_empty());
664        }
665    }
666
667    // ------------------------------------------------------------------
668    // PR-9e coverage uplift (issue #487): close the security-guard and
669    // tilde-expansion gaps flagged in the audit report.
670    // ------------------------------------------------------------------
671
672    #[test]
673    fn expand_tilde_expands_home_dir() {
674        let _g = env_lock();
675        let env = EnvGuard::capture("HOME");
676        env.set("/test-home");
677        assert_eq!(expand_tilde("~/state/log"), "/test-home/state/log");
678        // Bare `~` (no slash) is not expanded — matches the audit
679        // module's expand_tilde which strictly looks for the `~/` prefix.
680        assert_eq!(expand_tilde("~root"), "~root");
681    }
682
683    #[test]
684    fn expand_tilde_no_home_keeps_input_unchanged() {
685        let _g = env_lock();
686        let env = EnvGuard::capture("HOME");
687        env.unset();
688        // Without HOME set, expansion must be a no-op.
689        assert_eq!(expand_tilde("~/state"), "~/state");
690    }
691
692    #[cfg(unix)]
693    #[test]
694    fn enforce_not_world_writable_passes_through_on_nonexistent_path() {
695        let tmp = tempfile::tempdir().unwrap();
696        // Path under tempdir that does not exist — must succeed.
697        let r = ResolvedDir {
698            path: tmp.path().join("does-not-exist"),
699            source: PathSource::ConfigToml,
700        };
701        assert!(enforce_not_world_writable(&r).is_ok());
702    }
703
704    #[cfg(unix)]
705    #[test]
706    fn enforce_not_world_writable_passes_safe_dir() {
707        use std::os::unix::fs::PermissionsExt;
708        let tmp = tempfile::tempdir().unwrap();
709        let safe = tmp.path().join("safe");
710        std::fs::create_dir(&safe).unwrap();
711        std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o755)).unwrap();
712        let r = ResolvedDir {
713            path: safe,
714            source: PathSource::ConfigToml,
715        };
716        assert!(enforce_not_world_writable(&r).is_ok());
717    }
718
719    #[test]
720    fn is_writable_dir_returns_false_for_a_file_path() {
721        let tmp = tempfile::tempdir().unwrap();
722        let f = tmp.path().join("regular.txt");
723        std::fs::write(&f, b"hello").unwrap();
724        // A path that exists but is a file (not a dir) must fail
725        // is_writable_dir's "is_dir" guard.
726        assert!(!is_writable_dir(&f));
727    }
728
729    #[test]
730    fn is_writable_dir_returns_false_for_nonexistent_path() {
731        let tmp = tempfile::tempdir().unwrap();
732        assert!(!is_writable_dir(&tmp.path().join("nope")));
733    }
734
735    #[test]
736    fn dirkind_suffix_returns_logs_or_audit() {
737        // Pure-logic helper exposed via DirKind — covers both arms of
738        // the suffix() match.
739        assert_eq!(DirKind::Log.suffix(), "logs");
740        assert_eq!(DirKind::Audit.suffix(), "audit");
741    }
742
743    #[test]
744    fn ensure_dir_secure_creates_nested_path() {
745        let tmp = tempfile::tempdir().unwrap();
746        let target = tmp.path().join("a").join("b").join("c");
747        ensure_dir_secure(&target).unwrap();
748        assert!(target.is_dir());
749    }
750
751    #[test]
752    fn ensure_dir_secure_idempotent_on_existing_dir() {
753        let tmp = tempfile::tempdir().unwrap();
754        let target = tmp.path().join("present");
755        std::fs::create_dir(&target).unwrap();
756        // Second call must not error even though the dir already exists.
757        ensure_dir_secure(&target).unwrap();
758        ensure_dir_secure(&target).unwrap();
759    }
760
761    #[test]
762    fn fall_through_uses_config_when_set() {
763        // Indirect test: with no env override and a config path,
764        // resolve_log_dir must pick ConfigToml.
765        let _g = env_lock();
766        let env = EnvGuard::capture(LOG_DIR_ENV);
767        env.unset();
768        let r = resolve_log_dir(None, Some("/tmp/explicit-config")).unwrap();
769        assert_eq!(r.source, PathSource::ConfigToml);
770        assert_eq!(r.path, PathBuf::from("/tmp/explicit-config"));
771    }
772
773    #[test]
774    fn fall_through_expands_tilde_in_config_path() {
775        let _g = env_lock();
776        let env = EnvGuard::capture(LOG_DIR_ENV);
777        env.unset();
778        let home = EnvGuard::capture("HOME");
779        home.set("/test-tilde-home");
780        let r = resolve_log_dir(None, Some("~/state/logs")).unwrap();
781        // The tilde must be expanded to the test HOME.
782        assert_eq!(r.path, PathBuf::from("/test-tilde-home/state/logs"));
783        assert_eq!(r.source, PathSource::ConfigToml);
784    }
785
786    #[test]
787    fn fall_through_empty_config_path_uses_platform_default() {
788        let _g = env_lock();
789        let env = EnvGuard::capture(LOG_DIR_ENV);
790        env.unset();
791        let _inv = EnvGuard::capture("INVOCATION_ID");
792        _inv.unset();
793        // Empty config string must NOT short-circuit ConfigToml — it
794        // falls through to platform default.
795        let r = resolve_log_dir(None, Some("")).unwrap();
796        assert_eq!(r.source, PathSource::PlatformDefault);
797    }
798
799    #[test]
800    fn empty_audit_env_var_falls_through_to_config() {
801        let _g = env_lock();
802        let env = EnvGuard::capture(AUDIT_DIR_ENV);
803        env.set("");
804        let r = resolve_audit_dir(None, Some("/cfg/audit")).unwrap();
805        assert_eq!(r.source, PathSource::ConfigToml);
806        assert_eq!(r.path, PathBuf::from("/cfg/audit"));
807    }
808
809    // -----------------------------------------------------------------
810    // L0.7-2 Tier A — strategic gap closures on the always-compiled paths.
811    // Note: lines 217-228 (linux_xdg_default), 239-262 (windows_default),
812    // 259-262 (Windows HOME fallback), and 463-471 (test cfg arms for
813    // linux/windows assertions) are platform-cfg branches that the
814    // host OS (macOS in this session) does not execute. They are
815    // reachable on Linux/Windows CI but not on darwin runners.
816    // COVERAGE: platform-specific via cfg!() runtime branch — covered
817    //           on Linux/Windows CI but unreachable on macOS.
818    // -----------------------------------------------------------------
819
820    #[cfg(unix)]
821    #[test]
822    fn is_writable_dir_returns_false_when_parent_is_readonly() {
823        // Line 279: Err(_) => false arm of is_writable_dir. Make the
824        // tempdir read+exec but not writable, then probe a path
825        // beneath it — File::create fails with EACCES.
826        use std::os::unix::fs::PermissionsExt;
827        let tmp = tempfile::tempdir().unwrap();
828        let ro = tmp.path().join("readonly");
829        std::fs::create_dir(&ro).unwrap();
830        std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
831        assert!(!is_writable_dir(&ro));
832        // Restore writable mode so tempdir cleanup can rmdir.
833        std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
834    }
835
836    #[test]
837    fn home_dir_or_dot_falls_back_to_dot_when_no_home() {
838        // Lines 256-262 cover home_dir_or_dot fallbacks. We can hit
839        // the macOS / linux path by ensuring HOME is set, and the
840        // final dot-fallback by unsetting both HOME and USERPROFILE.
841        let _g = env_lock();
842        let home = EnvGuard::capture("HOME");
843        home.unset();
844        let user = EnvGuard::capture("USERPROFILE");
845        user.unset();
846        let p = super::home_dir_or_dot();
847        // Without HOME / USERPROFILE we fall through to ".".
848        assert_eq!(p, PathBuf::from("."));
849    }
850
851    #[test]
852    fn home_dir_or_dot_prefers_home_over_userprofile() {
853        let _g = env_lock();
854        let home = EnvGuard::capture("HOME");
855        home.set("/test-home-precedence");
856        let user = EnvGuard::capture("USERPROFILE");
857        user.set("/test-userprofile");
858        let p = super::home_dir_or_dot();
859        assert_eq!(p, PathBuf::from("/test-home-precedence"));
860    }
861
862    #[test]
863    fn home_dir_or_dot_uses_userprofile_when_home_unset() {
864        let _g = env_lock();
865        let home = EnvGuard::capture("HOME");
866        home.unset();
867        let user = EnvGuard::capture("USERPROFILE");
868        user.set("/test-userprofile-only");
869        let p = super::home_dir_or_dot();
870        assert_eq!(p, PathBuf::from("/test-userprofile-only"));
871    }
872
873    #[cfg(target_os = "macos")]
874    #[test]
875    fn macos_default_returns_library_logs_path() {
876        // Lines 230-237: macos_default body.
877        let _g = env_lock();
878        let home = EnvGuard::capture("HOME");
879        home.set("/test-home");
880        let log = super::macos_default(DirKind::Log);
881        assert_eq!(log, PathBuf::from("/test-home/Library/Logs/ai-memory"));
882        let audit = super::macos_default(DirKind::Audit);
883        assert_eq!(
884            audit,
885            PathBuf::from("/test-home/Library/Logs/ai-memory/audit")
886        );
887    }
888
889    #[test]
890    fn linux_xdg_default_uses_xdg_state_home_when_set() {
891        // Lines 217-228: linux_xdg_default — always compiled, so we can
892        // unit-test it on any host. XDG_STATE_HOME wins when set
893        // non-empty.
894        let _g = env_lock();
895        let xdg = EnvGuard::capture("XDG_STATE_HOME");
896        xdg.set("/custom-xdg");
897        let p = super::linux_xdg_default(DirKind::Log);
898        assert_eq!(p, PathBuf::from("/custom-xdg/ai-memory/logs"));
899        let pa = super::linux_xdg_default(DirKind::Audit);
900        assert_eq!(pa, PathBuf::from("/custom-xdg/ai-memory/audit"));
901    }
902
903    #[test]
904    fn linux_xdg_default_falls_back_to_home_local_state() {
905        let _g = env_lock();
906        let xdg = EnvGuard::capture("XDG_STATE_HOME");
907        xdg.unset();
908        let home = EnvGuard::capture("HOME");
909        home.set("/test-home-xdg");
910        let p = super::linux_xdg_default(DirKind::Log);
911        assert_eq!(
912            p,
913            PathBuf::from("/test-home-xdg/.local/state/ai-memory/logs")
914        );
915    }
916
917    #[test]
918    fn linux_xdg_default_empty_xdg_falls_back_to_local_state() {
919        let _g = env_lock();
920        let xdg = EnvGuard::capture("XDG_STATE_HOME");
921        xdg.set("");
922        let home = EnvGuard::capture("HOME");
923        home.set("/test-home-empty-xdg");
924        let p = super::linux_xdg_default(DirKind::Log);
925        assert_eq!(
926            p,
927            PathBuf::from("/test-home-empty-xdg/.local/state/ai-memory/logs")
928        );
929    }
930
931    #[test]
932    fn windows_default_uses_localappdata_when_set() {
933        // Lines 239-253: windows_default body. Always compiled, so we
934        // unit-test the function directly on any host. The
935        // LOCALAPPDATA value, when present, is joined with `ai-memory`
936        // and then `kind.suffix()`.
937        let _g = env_lock();
938        let app = EnvGuard::capture("LOCALAPPDATA");
939        app.set("/winapp");
940        let p = super::windows_default(DirKind::Log);
941        assert_eq!(p, PathBuf::from("/winapp/ai-memory/logs"));
942        let pa = super::windows_default(DirKind::Audit);
943        assert_eq!(pa, PathBuf::from("/winapp/ai-memory/audit"));
944    }
945
946    #[test]
947    fn windows_default_falls_back_to_home_appdata_when_localappdata_unset() {
948        let _g = env_lock();
949        let app = EnvGuard::capture("LOCALAPPDATA");
950        app.unset();
951        let home = EnvGuard::capture("HOME");
952        home.set("/test-win-home");
953        let user = EnvGuard::capture("USERPROFILE");
954        user.unset();
955        let p = super::windows_default(DirKind::Log);
956        assert_eq!(
957            p,
958            PathBuf::from("/test-win-home/AppData/Local/ai-memory/logs")
959        );
960    }
961
962    #[test]
963    fn windows_default_empty_localappdata_falls_back_to_home_appdata() {
964        let _g = env_lock();
965        let app = EnvGuard::capture("LOCALAPPDATA");
966        app.set("");
967        let home = EnvGuard::capture("HOME");
968        home.set("/test-win-home-empty");
969        let p = super::windows_default(DirKind::Log);
970        assert_eq!(
971            p,
972            PathBuf::from("/test-win-home-empty/AppData/Local/ai-memory/logs")
973        );
974    }
975}