use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::exit::Exit;
#[derive(Debug, Clone)]
pub struct DataLayout {
pub data_dir: PathBuf,
pub db_path: PathBuf,
pub event_log_path: PathBuf,
}
impl DataLayout {
pub fn resolve(db: Option<PathBuf>, event_log: Option<PathBuf>) -> Result<Self, Exit> {
let data_dir = match (&db, &event_log) {
(Some(db_path), _) => parent_or_default(db_path)?,
(None, Some(log_path)) => parent_or_default(log_path)?,
(None, None) => default_data_dir().ok_or(Exit::PreconditionUnmet)?,
};
let db_path = db.unwrap_or_else(|| data_dir.join("cortex.db"));
let event_log_path = event_log.unwrap_or_else(|| data_dir.join("events.jsonl"));
Ok(Self {
data_dir,
db_path,
event_log_path,
})
}
}
#[must_use]
pub fn default_data_dir() -> Option<PathBuf> {
if let Some(data_dir) = std::env::var_os("CORTEX_DATA_DIR").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(data_dir));
}
if let Some(data_home) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(data_home).join("cortex"));
}
dirs::data_dir().map(|d| d.join("cortex"))
}
pub fn assert_secure_data_dir(dir: &Path) -> Result<(), Exit> {
let meta = std::fs::metadata(dir).map_err(|_| Exit::PreconditionUnmet)?;
if !meta.is_dir() {
return Err(Exit::PreconditionUnmet);
}
#[cfg(unix)]
{
let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 {
return Err(Exit::PreconditionUnmet);
}
}
Ok(())
}
fn parent_or_default(path: &Path) -> Result<PathBuf, Exit> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
Ok(parent.to_path_buf())
} else {
default_data_dir().ok_or(Exit::PreconditionUnmet)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn resolve_with_explicit_db_uses_db_parent() {
let tmp = tempdir().unwrap();
let db = tmp.path().join("custom.db");
let layout = DataLayout::resolve(Some(db.clone()), None).unwrap();
assert_eq!(layout.data_dir, tmp.path());
assert_eq!(layout.db_path, db);
assert_eq!(layout.event_log_path, tmp.path().join("events.jsonl"));
}
#[test]
fn resolve_with_both_overrides_honors_both() {
let tmp = tempdir().unwrap();
let db = tmp.path().join("a/db.sqlite");
let log = tmp.path().join("b/log.jsonl");
let layout = DataLayout::resolve(Some(db.clone()), Some(log.clone())).unwrap();
assert_eq!(layout.db_path, db);
assert_eq!(layout.event_log_path, log);
}
#[test]
fn default_data_dir_resolves_under_xdg_or_platform_root() {
let dir = default_data_dir();
assert!(
dir.is_some(),
"default_data_dir() should resolve on test host"
);
assert!(dir.unwrap().ends_with("cortex"));
}
#[cfg(unix)]
#[test]
fn assert_secure_data_dir_rejects_0755() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let d = tmp.path().join("loose");
fs::create_dir(&d).unwrap();
fs::set_permissions(&d, fs::Permissions::from_mode(0o755)).unwrap();
let err = assert_secure_data_dir(&d).unwrap_err();
assert_eq!(err, Exit::PreconditionUnmet);
}
#[cfg(unix)]
#[test]
fn assert_secure_data_dir_accepts_0700() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let d = tmp.path().join("tight");
fs::create_dir(&d).unwrap();
fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).unwrap();
assert_secure_data_dir(&d).expect("0700 should pass");
}
}