1use std::ffi::OsString;
47use std::path::{Path, PathBuf};
48
49use anyhow::{Context, Result, anyhow};
50
51pub const LOG_DIR_ENV: &str = "AI_MEMORY_LOG_DIR";
55
56pub const AUDIT_DIR_ENV: &str = "AI_MEMORY_AUDIT_DIR";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PathSource {
64 CliFlag,
66 EnvVar,
68 ConfigToml,
70 PlatformDefault,
72 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#[derive(Debug, Clone)]
92pub struct ResolvedDir {
93 pub path: PathBuf,
94 pub source: PathSource,
95}
96
97#[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
114pub 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
129pub 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 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#[must_use]
190pub fn platform_default(kind: DirKind) -> ResolvedDir {
191 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_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 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 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
283pub 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 let _ = rd;
320 }
321 Ok(())
322}
323
324pub 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#[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#[cfg(test)]
363mod tests {
364 use super::*;
365
366 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 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 unsafe {
392 std::env::set_var(self.key, v);
393 }
394 }
395 fn unset(&self) {
396 unsafe {
398 std::env::remove_var(self.key);
399 }
400 }
401 }
402 impl Drop for EnvGuard {
403 fn drop(&mut self) {
404 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 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 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 assert!(is_writable_dir(tmp.path()));
596 assert!(!is_writable_dir(&tmp.path().join("does-not-exist")));
598
599 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 #[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 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 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 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 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 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 ensure_dir_secure(&target).unwrap();
738 ensure_dir_secure(&target).unwrap();
739 }
740
741 #[test]
742 fn fall_through_uses_config_when_set() {
743 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 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 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}