use std::ffi::OsString;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
pub const LOG_DIR_ENV: &str = "AI_MEMORY_LOG_DIR";
pub const AUDIT_DIR_ENV: &str = "AI_MEMORY_AUDIT_DIR";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathSource {
CliFlag,
EnvVar,
ConfigToml,
PlatformDefault,
SystemdLogsDir,
}
impl PathSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::CliFlag => "CLI flag (--log-dir / --audit-dir)",
Self::EnvVar => "environment variable (AI_MEMORY_LOG_DIR / AI_MEMORY_AUDIT_DIR)",
Self::ConfigToml => "[logging]/[audit] path in config.toml",
Self::PlatformDefault => "platform default",
Self::SystemdLogsDir => "systemd LogsDirectory (/var/log/ai-memory/)",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedDir {
pub path: PathBuf,
pub source: PathSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirKind {
Log,
Audit,
}
impl DirKind {
fn suffix(self) -> &'static str {
match self {
Self::Log => "logs",
Self::Audit => "audit",
}
}
}
pub fn resolve_log_dir(
cli_override: Option<&Path>,
config_path: Option<&str>,
) -> Result<ResolvedDir> {
resolve_dir(DirKind::Log, cli_override, LOG_DIR_ENV, config_path)
}
pub fn resolve_audit_dir(
cli_override: Option<&Path>,
config_path: Option<&str>,
) -> Result<ResolvedDir> {
resolve_dir(DirKind::Audit, cli_override, AUDIT_DIR_ENV, config_path)
}
fn resolve_dir(
kind: DirKind,
cli_override: Option<&Path>,
env_var: &str,
config_path: Option<&str>,
) -> Result<ResolvedDir> {
let resolved = if let Some(p) = cli_override {
ResolvedDir {
path: PathBuf::from(p),
source: PathSource::CliFlag,
}
} else if let Some(env_val) = std::env::var_os(env_var) {
if env_val.is_empty() {
fall_through_to_config_or_default(kind, config_path)?
} else {
ResolvedDir {
path: PathBuf::from(env_val),
source: PathSource::EnvVar,
}
}
} else {
fall_through_to_config_or_default(kind, config_path)?
};
enforce_not_world_writable(&resolved)?;
Ok(resolved)
}
fn fall_through_to_config_or_default(
kind: DirKind,
config_path: Option<&str>,
) -> Result<ResolvedDir> {
if let Some(raw) = config_path
&& !raw.is_empty()
{
return Ok(ResolvedDir {
path: PathBuf::from(expand_tilde(raw)),
source: PathSource::ConfigToml,
});
}
Ok(platform_default(kind))
}
#[must_use]
pub fn platform_default(kind: DirKind) -> ResolvedDir {
if std::env::var_os("INVOCATION_ID").is_some() {
let p = PathBuf::from("/var/log/ai-memory").join(kind.suffix());
if is_writable_dir(&p.parent().unwrap_or(&p)) {
return ResolvedDir {
path: p,
source: PathSource::SystemdLogsDir,
};
}
}
let p = if cfg!(target_os = "macos") {
macos_default(kind)
} else if cfg!(target_os = "windows") {
windows_default(kind)
} else {
linux_xdg_default(kind)
};
ResolvedDir {
path: p,
source: PathSource::PlatformDefault,
}
}
fn linux_xdg_default(kind: DirKind) -> PathBuf {
let base = std::env::var_os("XDG_STATE_HOME")
.filter(|s| !s.is_empty())
.map_or_else(
|| {
let home = home_dir_or_dot();
home.join(".local").join("state")
},
PathBuf::from,
);
base.join("ai-memory").join(kind.suffix())
}
fn macos_default(kind: DirKind) -> PathBuf {
let home = home_dir_or_dot();
let base = home.join("Library").join("Logs").join("ai-memory");
match kind {
DirKind::Log => base,
DirKind::Audit => base.join("audit"),
}
}
fn windows_default(kind: DirKind) -> PathBuf {
let base = std::env::var_os("LOCALAPPDATA")
.filter(|s| !s.is_empty())
.map_or_else(
|| {
home_dir_or_dot()
.join("AppData")
.join("Local")
.join("ai-memory")
},
|s| PathBuf::from(s).join("ai-memory"),
);
base.join(kind.suffix())
}
fn home_dir_or_dot() -> PathBuf {
if let Some(h) = std::env::var_os("HOME").filter(|s| !s.is_empty()) {
return PathBuf::from(h);
}
if let Some(h) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
return PathBuf::from(h);
}
PathBuf::from(".")
}
fn is_writable_dir(p: &Path) -> bool {
if !p.exists() || !p.is_dir() {
return false;
}
let probe = p.join(format!(".ai-memory-write-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
pub fn enforce_not_world_writable(rd: &ResolvedDir) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if !rd.path.exists() {
return Ok(());
}
let md = std::fs::metadata(&rd.path).with_context(|| {
format!(
"stat {} (resolved via {})",
rd.path.display(),
rd.source.as_str()
)
})?;
let mode = md.permissions().mode();
if mode & 0o002 != 0 {
return Err(anyhow!(
"log directory {} is world-writable (mode {:#o}); refusing for security. \
Resolved via: {}. Pick a non-world-writable directory and re-run.",
rd.path.display(),
mode & 0o7777,
rd.source.as_str()
));
}
}
#[cfg(not(unix))]
{
let _ = rd;
}
Ok(())
}
pub fn ensure_dir_secure(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating log directory {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(dir, perms)
.with_context(|| format!("setting mode 0700 on log directory {}", dir.display()))?;
}
Ok(())
}
#[must_use]
pub fn expand_tilde(raw: &str) -> String {
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
let mut buf = OsString::from(home);
buf.push("/");
buf.push(rest);
return buf.to_string_lossy().into_owned();
}
raw.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
struct EnvGuard {
key: &'static str,
prev: Option<OsString>,
}
impl EnvGuard {
fn capture(key: &'static str) -> Self {
Self {
key,
prev: std::env::var_os(key),
}
}
fn set(&self, v: &str) {
unsafe {
std::env::set_var(self.key, v);
}
}
fn unset(&self) {
unsafe {
std::env::remove_var(self.key);
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(v) = &self.prev {
std::env::set_var(self.key, v);
} else {
std::env::remove_var(self.key);
}
}
}
}
#[test]
fn log_dir_cli_flag_overrides_env_var() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.set("/should/not/win");
let cli = PathBuf::from("/cli/wins");
let resolved = resolve_log_dir(Some(&cli), Some("/config/loses")).unwrap();
assert_eq!(resolved.path, cli);
assert_eq!(resolved.source, PathSource::CliFlag);
}
#[test]
fn log_dir_env_var_overrides_config_toml() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.set("/env/wins");
let resolved = resolve_log_dir(None, Some("/config/loses")).unwrap();
assert_eq!(resolved.path, PathBuf::from("/env/wins"));
assert_eq!(resolved.source, PathSource::EnvVar);
}
#[test]
fn log_dir_config_toml_overrides_platform_default() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.unset();
let _inv = EnvGuard::capture("INVOCATION_ID");
_inv.unset();
let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
assert_eq!(resolved.path, PathBuf::from("/config/wins"));
assert_eq!(resolved.source, PathSource::ConfigToml);
}
#[test]
fn log_dir_platform_default_resolves_per_os() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.unset();
let _inv = EnvGuard::capture("INVOCATION_ID");
_inv.unset();
let resolved = resolve_log_dir(None, None).unwrap();
assert_eq!(resolved.source, PathSource::PlatformDefault);
let s = resolved.path.to_string_lossy().to_string();
if cfg!(target_os = "macos") {
assert!(
s.contains("Library/Logs/ai-memory"),
"macOS default should be under Library/Logs/ai-memory, got {s}"
);
} else if cfg!(target_os = "windows") {
assert!(
s.to_lowercase().contains("ai-memory"),
"Windows default should contain ai-memory, got {s}"
);
} else {
assert!(
s.contains("ai-memory") && s.contains("logs"),
"Linux/Unix XDG default should contain ai-memory/logs, got {s}"
);
}
}
#[test]
fn audit_dir_cli_flag_overrides_env_var() {
let _g = env_lock();
let env = EnvGuard::capture(AUDIT_DIR_ENV);
env.set("/should/not/win");
let cli = PathBuf::from("/cli/audit/wins");
let resolved = resolve_audit_dir(Some(&cli), Some("/config/loses")).unwrap();
assert_eq!(resolved.path, cli);
assert_eq!(resolved.source, PathSource::CliFlag);
}
#[test]
fn audit_dir_env_var_overrides_config_toml() {
let _g = env_lock();
let env = EnvGuard::capture(AUDIT_DIR_ENV);
env.set("/env/audit/wins");
let resolved = resolve_audit_dir(None, Some("/config/loses")).unwrap();
assert_eq!(resolved.path, PathBuf::from("/env/audit/wins"));
assert_eq!(resolved.source, PathSource::EnvVar);
}
#[test]
fn audit_dir_config_toml_overrides_platform_default() {
let _g = env_lock();
let env = EnvGuard::capture(AUDIT_DIR_ENV);
env.unset();
let _inv = EnvGuard::capture("INVOCATION_ID");
_inv.unset();
let resolved = resolve_audit_dir(None, Some("/config/audit/wins")).unwrap();
assert_eq!(resolved.path, PathBuf::from("/config/audit/wins"));
assert_eq!(resolved.source, PathSource::ConfigToml);
}
#[test]
fn audit_dir_platform_default_resolves_per_os() {
let _g = env_lock();
let env = EnvGuard::capture(AUDIT_DIR_ENV);
env.unset();
let _inv = EnvGuard::capture("INVOCATION_ID");
_inv.unset();
let resolved = resolve_audit_dir(None, None).unwrap();
assert_eq!(resolved.source, PathSource::PlatformDefault);
let s = resolved.path.to_string_lossy().to_string();
assert!(
s.contains("ai-memory") && s.contains("audit"),
"audit platform default should mention ai-memory and audit, got {s}"
);
}
#[test]
#[cfg(unix)]
fn log_dir_creates_directory_with_secure_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("nested").join("logs");
ensure_dir_secure(&target).unwrap();
let md = std::fs::metadata(&target).unwrap();
let mode = md.permissions().mode() & 0o7777;
assert_eq!(
mode, 0o700,
"ensure_dir_secure must apply mode 0700 (got {mode:#o})"
);
}
#[test]
#[cfg(unix)]
fn log_dir_refuses_world_writable_destination() {
use std::os::unix::fs::PermissionsExt;
let _g = env_lock();
let tmp = tempfile::tempdir().unwrap();
let bad = tmp.path().join("worldwrite");
std::fs::create_dir(&bad).unwrap();
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.unset();
let err = resolve_log_dir(Some(&bad), None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("world-writable"),
"error should mention world-writable, got: {msg}"
);
assert!(
msg.contains("CLI flag"),
"error should name resolution layer (CLI flag), got: {msg}"
);
}
#[test]
#[cfg(unix)]
fn audit_dir_refuses_world_writable_destination() {
use std::os::unix::fs::PermissionsExt;
let _g = env_lock();
let tmp = tempfile::tempdir().unwrap();
let bad = tmp.path().join("audit-worldwrite");
std::fs::create_dir(&bad).unwrap();
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
let env = EnvGuard::capture(AUDIT_DIR_ENV);
env.unset();
let err = resolve_audit_dir(Some(&bad), None).unwrap_err();
assert!(format!("{err}").contains("world-writable"));
}
#[test]
fn log_dir_systemd_mode_uses_var_log_when_writable() {
let _g = env_lock();
let _inv = EnvGuard::capture("INVOCATION_ID");
_inv.set("test-invocation-id");
let tmp = tempfile::tempdir().unwrap();
assert!(is_writable_dir(tmp.path()));
assert!(!is_writable_dir(&tmp.path().join("does-not-exist")));
let resolved = platform_default(DirKind::Log);
assert!(matches!(
resolved.source,
PathSource::SystemdLogsDir | PathSource::PlatformDefault
));
_inv.unset();
let resolved2 = platform_default(DirKind::Log);
assert_eq!(
resolved2.source,
PathSource::PlatformDefault,
"without INVOCATION_ID, must never pick SystemdLogsDir"
);
}
#[test]
fn log_dir_empty_env_var_falls_through_to_config() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.set("");
let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
assert_eq!(resolved.source, PathSource::ConfigToml);
}
#[test]
fn expand_tilde_keeps_non_tilde_paths_unchanged() {
assert_eq!(expand_tilde("/abs/path"), "/abs/path");
assert_eq!(expand_tilde("relative/path"), "relative/path");
}
#[test]
fn path_source_strings_are_human_readable() {
for s in [
PathSource::CliFlag,
PathSource::EnvVar,
PathSource::ConfigToml,
PathSource::PlatformDefault,
PathSource::SystemdLogsDir,
] {
assert!(!s.as_str().is_empty());
}
}
#[test]
fn expand_tilde_expands_home_dir() {
let _g = env_lock();
let env = EnvGuard::capture("HOME");
env.set("/test-home");
assert_eq!(expand_tilde("~/state/log"), "/test-home/state/log");
assert_eq!(expand_tilde("~root"), "~root");
}
#[test]
fn expand_tilde_no_home_keeps_input_unchanged() {
let _g = env_lock();
let env = EnvGuard::capture("HOME");
env.unset();
assert_eq!(expand_tilde("~/state"), "~/state");
}
#[cfg(unix)]
#[test]
fn enforce_not_world_writable_passes_through_on_nonexistent_path() {
let tmp = tempfile::tempdir().unwrap();
let r = ResolvedDir {
path: tmp.path().join("does-not-exist"),
source: PathSource::ConfigToml,
};
assert!(enforce_not_world_writable(&r).is_ok());
}
#[cfg(unix)]
#[test]
fn enforce_not_world_writable_passes_safe_dir() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let safe = tmp.path().join("safe");
std::fs::create_dir(&safe).unwrap();
std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o755)).unwrap();
let r = ResolvedDir {
path: safe,
source: PathSource::ConfigToml,
};
assert!(enforce_not_world_writable(&r).is_ok());
}
#[test]
fn is_writable_dir_returns_false_for_a_file_path() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("regular.txt");
std::fs::write(&f, b"hello").unwrap();
assert!(!is_writable_dir(&f));
}
#[test]
fn is_writable_dir_returns_false_for_nonexistent_path() {
let tmp = tempfile::tempdir().unwrap();
assert!(!is_writable_dir(&tmp.path().join("nope")));
}
#[test]
fn dirkind_suffix_returns_logs_or_audit() {
assert_eq!(DirKind::Log.suffix(), "logs");
assert_eq!(DirKind::Audit.suffix(), "audit");
}
#[test]
fn ensure_dir_secure_creates_nested_path() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("a").join("b").join("c");
ensure_dir_secure(&target).unwrap();
assert!(target.is_dir());
}
#[test]
fn ensure_dir_secure_idempotent_on_existing_dir() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("present");
std::fs::create_dir(&target).unwrap();
ensure_dir_secure(&target).unwrap();
ensure_dir_secure(&target).unwrap();
}
#[test]
fn fall_through_uses_config_when_set() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.unset();
let r = resolve_log_dir(None, Some("/tmp/explicit-config")).unwrap();
assert_eq!(r.source, PathSource::ConfigToml);
assert_eq!(r.path, PathBuf::from("/tmp/explicit-config"));
}
#[test]
fn fall_through_expands_tilde_in_config_path() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.unset();
let home = EnvGuard::capture("HOME");
home.set("/test-tilde-home");
let r = resolve_log_dir(None, Some("~/state/logs")).unwrap();
assert_eq!(r.path, PathBuf::from("/test-tilde-home/state/logs"));
assert_eq!(r.source, PathSource::ConfigToml);
}
#[test]
fn fall_through_empty_config_path_uses_platform_default() {
let _g = env_lock();
let env = EnvGuard::capture(LOG_DIR_ENV);
env.unset();
let _inv = EnvGuard::capture("INVOCATION_ID");
_inv.unset();
let r = resolve_log_dir(None, Some("")).unwrap();
assert_eq!(r.source, PathSource::PlatformDefault);
}
#[test]
fn empty_audit_env_var_falls_through_to_config() {
let _g = env_lock();
let env = EnvGuard::capture(AUDIT_DIR_ENV);
env.set("");
let r = resolve_audit_dir(None, Some("/cfg/audit")).unwrap();
assert_eq!(r.source, PathSource::ConfigToml);
assert_eq!(r.path, PathBuf::from("/cfg/audit"));
}
#[cfg(unix)]
#[test]
fn is_writable_dir_returns_false_when_parent_is_readonly() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let ro = tmp.path().join("readonly");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
assert!(!is_writable_dir(&ro));
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn home_dir_or_dot_falls_back_to_dot_when_no_home() {
let _g = env_lock();
let home = EnvGuard::capture("HOME");
home.unset();
let user = EnvGuard::capture("USERPROFILE");
user.unset();
let p = super::home_dir_or_dot();
assert_eq!(p, PathBuf::from("."));
}
#[test]
fn home_dir_or_dot_prefers_home_over_userprofile() {
let _g = env_lock();
let home = EnvGuard::capture("HOME");
home.set("/test-home-precedence");
let user = EnvGuard::capture("USERPROFILE");
user.set("/test-userprofile");
let p = super::home_dir_or_dot();
assert_eq!(p, PathBuf::from("/test-home-precedence"));
}
#[test]
fn home_dir_or_dot_uses_userprofile_when_home_unset() {
let _g = env_lock();
let home = EnvGuard::capture("HOME");
home.unset();
let user = EnvGuard::capture("USERPROFILE");
user.set("/test-userprofile-only");
let p = super::home_dir_or_dot();
assert_eq!(p, PathBuf::from("/test-userprofile-only"));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_default_returns_library_logs_path() {
let _g = env_lock();
let home = EnvGuard::capture("HOME");
home.set("/test-home");
let log = super::macos_default(DirKind::Log);
assert_eq!(log, PathBuf::from("/test-home/Library/Logs/ai-memory"));
let audit = super::macos_default(DirKind::Audit);
assert_eq!(
audit,
PathBuf::from("/test-home/Library/Logs/ai-memory/audit")
);
}
#[test]
fn linux_xdg_default_uses_xdg_state_home_when_set() {
let _g = env_lock();
let xdg = EnvGuard::capture("XDG_STATE_HOME");
xdg.set("/custom-xdg");
let p = super::linux_xdg_default(DirKind::Log);
assert_eq!(p, PathBuf::from("/custom-xdg/ai-memory/logs"));
let pa = super::linux_xdg_default(DirKind::Audit);
assert_eq!(pa, PathBuf::from("/custom-xdg/ai-memory/audit"));
}
#[test]
fn linux_xdg_default_falls_back_to_home_local_state() {
let _g = env_lock();
let xdg = EnvGuard::capture("XDG_STATE_HOME");
xdg.unset();
let home = EnvGuard::capture("HOME");
home.set("/test-home-xdg");
let p = super::linux_xdg_default(DirKind::Log);
assert_eq!(
p,
PathBuf::from("/test-home-xdg/.local/state/ai-memory/logs")
);
}
#[test]
fn linux_xdg_default_empty_xdg_falls_back_to_local_state() {
let _g = env_lock();
let xdg = EnvGuard::capture("XDG_STATE_HOME");
xdg.set("");
let home = EnvGuard::capture("HOME");
home.set("/test-home-empty-xdg");
let p = super::linux_xdg_default(DirKind::Log);
assert_eq!(
p,
PathBuf::from("/test-home-empty-xdg/.local/state/ai-memory/logs")
);
}
#[test]
fn windows_default_uses_localappdata_when_set() {
let _g = env_lock();
let app = EnvGuard::capture("LOCALAPPDATA");
app.set("/winapp");
let p = super::windows_default(DirKind::Log);
assert_eq!(p, PathBuf::from("/winapp/ai-memory/logs"));
let pa = super::windows_default(DirKind::Audit);
assert_eq!(pa, PathBuf::from("/winapp/ai-memory/audit"));
}
#[test]
fn windows_default_falls_back_to_home_appdata_when_localappdata_unset() {
let _g = env_lock();
let app = EnvGuard::capture("LOCALAPPDATA");
app.unset();
let home = EnvGuard::capture("HOME");
home.set("/test-win-home");
let user = EnvGuard::capture("USERPROFILE");
user.unset();
let p = super::windows_default(DirKind::Log);
assert_eq!(
p,
PathBuf::from("/test-win-home/AppData/Local/ai-memory/logs")
);
}
#[test]
fn windows_default_empty_localappdata_falls_back_to_home_appdata() {
let _g = env_lock();
let app = EnvGuard::capture("LOCALAPPDATA");
app.set("");
let home = EnvGuard::capture("HOME");
home.set("/test-win-home-empty");
let p = super::windows_default(DirKind::Log);
assert_eq!(
p,
PathBuf::from("/test-win-home-empty/AppData/Local/ai-memory/logs")
);
}
}