use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub const DATA_DIR_OVERRIDE_ENV: &str = "TRUSTY_DATA_DIR_OVERRIDE";
pub fn sanitize_data_root(candidate: PathBuf, app_name: &str) -> PathBuf {
let safe_fallback = || std::env::temp_dir().join(format!("trusty-{app_name}"));
if !candidate.is_absolute() {
tracing::error!(
path = %candidate.display(),
app = app_name,
"resolved data root is not absolute; \
falling back to temp dir to prevent CWD-relative palace creation. \
Check HOME and TRUSTY_DATA_DIR_OVERRIDE in the daemon environment."
);
return safe_fallback();
}
if candidate == Path::new("/") {
tracing::error!(
app = app_name,
"resolved data root is the filesystem root (/); \
falling back to temp dir. \
Check HOME and TRUSTY_DATA_DIR_OVERRIDE in the daemon environment."
);
return safe_fallback();
}
if candidate.parent() == Some(Path::new("/")) {
tracing::error!(
path = %candidate.display(),
app = app_name,
"resolved data root is a direct child of the filesystem root; \
this usually means HOME or XDG_DATA_HOME is set to '/'. \
Falling back to temp dir to prevent data scatter under /."
);
return safe_fallback();
}
candidate
}
pub fn resolve_data_dir(app_name: &str) -> Result<PathBuf> {
let base = match std::env::var(DATA_DIR_OVERRIDE_ENV) {
Ok(raw) if raw.trim().is_empty() => {
tracing::warn!(
env = DATA_DIR_OVERRIDE_ENV,
"TRUSTY_DATA_DIR_OVERRIDE is set but empty; ignoring and using \
the platform data directory instead. An empty override would \
produce a relative path that resolves against the daemon's \
working directory (/ under launchd), which is never correct."
);
dirs::data_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
.context("could not resolve data directory or home directory")?
}
Ok(raw) => {
let p = PathBuf::from(&raw);
if !p.is_absolute() {
anyhow::bail!(
"TRUSTY_DATA_DIR_OVERRIDE={raw:?} is a relative path; only \
absolute paths are accepted to prevent the data directory \
from depending on the daemon's working directory"
);
}
if p == Path::new("/") {
anyhow::bail!(
"TRUSTY_DATA_DIR_OVERRIDE={raw:?} resolves to the filesystem \
root (/); refusing to create palace directories directly \
under / as that would scatter data across the root filesystem"
);
}
p
}
Err(_) => dirs::data_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
.context("could not resolve data directory or home directory")?,
};
let dir = if base.ends_with(format!(".{app_name}")) {
base
} else {
base.join(app_name)
};
let dir = sanitize_data_root(dir, app_name);
std::fs::create_dir_all(&dir)
.with_context(|| format!("create data directory {}", dir.display()))?;
Ok(dir)
}
pub fn is_dir(path: &Path) -> bool {
path.metadata().map(|m| m.is_dir()).unwrap_or(false)
}
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use super::ENV_LOCK;
fn tempfile_like_dir() -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!("trusty-common-test-{pid}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn resolve_data_dir_creates_directory() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile_like_dir();
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
}
let dir = resolve_data_dir("trusty-test-xyz").unwrap();
assert!(
dir.exists(),
"data dir should be created at {}",
dir.display()
);
assert!(dir.is_dir());
assert!(
dir.starts_with(&tmp),
"data dir {} should live under override {}",
dir.display(),
tmp.display()
);
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
}
#[test]
fn resolve_data_dir_empty_override_uses_platform_dir() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, "");
}
let result = resolve_data_dir("trusty-test-empty-override");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
let dir = result.expect("empty override should fall back to platform dir");
assert!(
dir.is_absolute(),
"resolved dir should be absolute, got {}",
dir.display()
);
assert_ne!(
dir,
std::path::PathBuf::from("/"),
"resolved dir must not be filesystem root"
);
}
#[test]
fn resolve_data_dir_whitespace_override_uses_platform_dir() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, " ");
}
let result = resolve_data_dir("trusty-test-ws-override");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
let dir = result.expect("whitespace override should fall back to platform dir");
assert!(dir.is_absolute(), "resolved dir should be absolute");
}
#[test]
fn resolve_data_dir_relative_override_errors() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, "relative/path");
}
let result = resolve_data_dir("trusty-test-relative");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
assert!(
result.is_err(),
"relative override should be rejected, but got Ok({})",
result.unwrap().display()
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("relative"),
"error should mention 'relative', got: {msg}"
);
}
#[test]
fn resolve_data_dir_root_override_errors() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, "/");
}
let result = resolve_data_dir("trusty-test-root");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
assert!(
result.is_err(),
"root '/' override should be rejected, but got Ok({})",
result.unwrap().display()
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains('/'),
"error should mention the path, got: {msg}"
);
}
#[test]
fn resolve_data_dir_valid_absolute_override_is_honoured() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile_like_dir();
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
}
let result = resolve_data_dir("trusty-test-abs-override");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
let dir = result.expect("valid absolute override should succeed");
assert!(
dir.starts_with(&tmp),
"resolved dir {} should be under override {}",
dir.display(),
tmp.display()
);
assert!(dir.is_absolute(), "resolved dir must be absolute");
}
#[test]
fn sanitize_data_root_rejects_relative() {
let result = sanitize_data_root(PathBuf::from("relative/path"), "myapp");
assert!(result.is_absolute(), "fallback must be absolute");
let name = result.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("trusty-"),
"fallback dir name should start with trusty-, got {name}"
);
}
#[test]
fn sanitize_data_root_rejects_root() {
let result = sanitize_data_root(PathBuf::from("/"), "myapp");
assert!(result.is_absolute(), "fallback must be absolute");
assert_ne!(result, PathBuf::from("/"), "must not still be /");
let name = result.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("trusty-"),
"fallback should start with trusty-"
);
}
#[test]
fn sanitize_data_root_rejects_bare_root_child() {
let result = sanitize_data_root(PathBuf::from("/bare-child"), "myapp");
assert!(result.is_absolute(), "fallback must be absolute");
assert_ne!(
result,
PathBuf::from("/bare-child"),
"bare root-child must be replaced"
);
let name = result.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("trusty-"),
"fallback should start with trusty-"
);
}
#[test]
fn sanitize_data_root_passes_valid_path() {
let tmp = tempfile_like_dir();
let candidate = tmp.join("trusty-myapp");
let result = sanitize_data_root(candidate.clone(), "myapp");
assert_eq!(
result, candidate,
"valid absolute path should be returned unchanged"
);
}
#[test]
fn is_dir_recognises_directories() {
let tmp = tempfile_like_dir();
assert!(is_dir(&tmp));
assert!(!is_dir(&tmp.join("nope")));
}
}