use nono::{NonoError, Result, try_canonicalize};
use std::cell::Cell;
use std::path::{Path, PathBuf};
const LEGACY_HOME_SUBDIR: &str = ".nono";
const LEGACY_REMOVE_BY: &str = "v1.0.0";
const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson";
thread_local! {
static LEGACY_AUDIT_WARNED: Cell<bool> = const { Cell::new(false) };
static LEGACY_SESSIONS_WARNED: Cell<bool> = const { Cell::new(false) };
static LEGACY_ROLLBACK_WARNED: Cell<bool> = const { Cell::new(false) };
}
fn resolve_xdg_state_base() -> Result<PathBuf> {
if let Ok(raw) = std::env::var("XDG_STATE_HOME") {
let path = PathBuf::from(&raw);
if path.is_absolute() {
return Ok(path);
}
tracing::warn!(
"Ignoring invalid XDG_STATE_HOME='{}' (must be absolute), falling back to default state dir",
raw
);
}
let home = PathBuf::from(crate::config::validated_home()?);
Ok(home.join(".local").join("state"))
}
pub fn user_state_dir() -> Result<PathBuf> {
Ok(resolve_xdg_state_base()?.join("nono"))
}
pub fn legacy_home_state_root() -> Result<PathBuf> {
let home = PathBuf::from(crate::config::validated_home()?);
Ok(home.join(LEGACY_HOME_SUBDIR))
}
pub fn audit_root() -> Result<PathBuf> {
Ok(user_state_dir()?.join("audit"))
}
pub fn legacy_audit_root() -> Result<PathBuf> {
Ok(legacy_home_state_root()?.join("audit"))
}
pub fn sessions_dir() -> Result<PathBuf> {
Ok(user_state_dir()?.join("sessions"))
}
pub fn legacy_sessions_dir() -> Result<PathBuf> {
Ok(legacy_home_state_root()?.join("sessions"))
}
pub fn rollback_root() -> Result<PathBuf> {
Ok(user_state_dir()?.join("rollbacks"))
}
pub fn legacy_rollback_root() -> Result<PathBuf> {
Ok(legacy_home_state_root()?.join("rollbacks"))
}
pub fn audit_discovery_roots() -> Result<Vec<PathBuf>> {
let primary = audit_root()?;
let mut roots = vec![primary.clone()];
if let Ok(legacy) = legacy_audit_root()
&& legacy != primary
{
roots.push(legacy);
}
Ok(roots)
}
pub fn session_registry_dirs_for_read() -> Result<Vec<PathBuf>> {
let primary = sessions_dir()?;
let mut dirs = vec![primary.clone()];
if let Ok(legacy) = legacy_sessions_dir()
&& legacy != primary
{
dirs.push(legacy);
}
Ok(dirs)
}
pub fn rollback_discovery_roots() -> Result<Vec<PathBuf>> {
let primary = rollback_root()?;
let mut roots = vec![primary.clone()];
if let Ok(legacy) = legacy_rollback_root()
&& legacy != primary
{
roots.push(legacy);
}
Ok(roots)
}
pub fn any_rollback_root_exists() -> Result<bool> {
Ok(rollback_discovery_roots()?.iter().any(|root| root.exists()))
}
pub fn protected_state_roots() -> Result<Vec<PathBuf>> {
let mut roots = vec![
try_canonicalize(&legacy_home_state_root()?),
try_canonicalize(&user_state_dir()?),
];
roots.sort();
roots.dedup();
Ok(roots)
}
pub(crate) fn warn_legacy_audit_path(path: &Path) {
LEGACY_AUDIT_WARNED.with(|warned| {
if warned.get() {
return;
}
warned.set(true);
eprintln!(
"warning: reading audit data from deprecated path {} (will be removed in {LEGACY_REMOVE_BY}); \
new audit data is stored under $XDG_STATE_HOME/nono/audit/ (default ~/.local/state/nono/audit/)",
path.display(),
);
});
}
fn warn_legacy_sessions_path(path: &Path) {
LEGACY_SESSIONS_WARNED.with(|warned| {
if warned.get() {
return;
}
warned.set(true);
eprintln!(
"warning: reading session registry from deprecated path {} (will be removed in {LEGACY_REMOVE_BY}); \
new session files are stored under $XDG_STATE_HOME/nono/sessions/ (default ~/.local/state/nono/sessions/)",
path.display(),
);
});
}
fn warn_legacy_rollback_path(path: &Path) {
LEGACY_ROLLBACK_WARNED.with(|warned| {
if warned.get() {
return;
}
warned.set(true);
eprintln!(
"warning: reading rollback data from deprecated path {} (will be removed in {LEGACY_REMOVE_BY}); \
new rollback data is stored under $XDG_STATE_HOME/nono/rollbacks/ (default ~/.local/state/nono/rollbacks/)",
path.display(),
);
});
}
pub struct LegacyRootSet {
primary_audit: PathBuf,
legacy_audit: PathBuf,
primary_rollback: PathBuf,
legacy_rollback: PathBuf,
primary_sessions: PathBuf,
legacy_sessions: PathBuf,
}
impl LegacyRootSet {
pub fn resolve() -> Result<Self> {
Ok(Self {
primary_audit: try_canonicalize(&audit_root()?),
legacy_audit: try_canonicalize(&legacy_audit_root()?),
primary_rollback: try_canonicalize(&rollback_root()?),
legacy_rollback: try_canonicalize(&legacy_rollback_root()?),
primary_sessions: try_canonicalize(&sessions_dir()?),
legacy_sessions: try_canonicalize(&legacy_sessions_dir()?),
})
}
fn is_under_legacy(path: &Path, legacy: &Path, primary: &Path) -> bool {
if legacy == primary {
return false;
}
let path = try_canonicalize(path);
path.starts_with(legacy) && !path.starts_with(primary)
}
pub fn warn_if_legacy_audit_data_read(&self, session_dir: &Path) {
if Self::is_under_legacy(session_dir, &self.legacy_audit, &self.primary_audit) {
warn_legacy_audit_path(&self.legacy_audit);
return;
}
if Self::is_under_legacy(session_dir, &self.legacy_rollback, &self.primary_rollback) {
warn_legacy_rollback_path(&self.legacy_rollback);
}
}
pub fn warn_if_legacy_rollback_data_read(&self, session_dir: &Path) {
if Self::is_under_legacy(session_dir, &self.legacy_rollback, &self.primary_rollback) {
warn_legacy_rollback_path(&self.legacy_rollback);
}
}
pub fn warn_if_legacy_session_file_read(&self, session_file: &Path) {
if Self::is_under_legacy(session_file, &self.legacy_sessions, &self.primary_sessions) {
warn_legacy_sessions_path(&self.legacy_sessions);
}
}
}
pub fn maybe_migrate_legacy_audit_ledger() -> Result<()> {
let primary = audit_root()?;
let legacy = legacy_audit_root()?;
if primary == legacy {
return Ok(());
}
let new_ledger = primary.join(AUDIT_LEDGER_FILENAME);
if new_ledger.exists() {
return Ok(());
}
let legacy_ledger = legacy.join(AUDIT_LEDGER_FILENAME);
if !legacy_ledger.exists() {
return Ok(());
}
std::fs::create_dir_all(&primary).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to create audit root {}: {e}",
primary.display()
))
})?;
let tmp_ledger = primary.join(format!("{AUDIT_LEDGER_FILENAME}.tmp"));
if tmp_ledger.exists() {
std::fs::remove_file(&tmp_ledger).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to remove stale audit ledger migration temp file {}: {e}",
tmp_ledger.display()
))
})?;
}
std::fs::copy(&legacy_ledger, &tmp_ledger).map_err(|e| {
let _ = std::fs::remove_file(&tmp_ledger);
NonoError::Snapshot(format!(
"Failed to copy legacy audit ledger to temporary file {}: {e}",
tmp_ledger.display()
))
})?;
std::fs::rename(&tmp_ledger, &new_ledger).map_err(|e| {
let _ = std::fs::remove_file(&tmp_ledger);
NonoError::Snapshot(format!(
"Failed to rename temporary audit ledger to {}: {e}",
new_ledger.display()
))
})?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::test_env::{ENV_LOCK, EnvVarGuard};
use std::fs;
fn isolated_env(base: &Path) -> (EnvVarGuard, PathBuf) {
let home = base.join("home");
let state = base.join("state");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&state).unwrap();
let home_str = home.to_string_lossy().to_string();
let state_str = state.to_string_lossy().to_string();
let guard = EnvVarGuard::set_all(&[("HOME", &home_str), ("XDG_STATE_HOME", &state_str)]);
(guard, home)
}
#[test]
fn default_state_base_uses_local_state_without_xdg_env() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
fs::create_dir_all(&home).unwrap();
let home_str = home.to_string_lossy().to_string();
let _env = EnvVarGuard::set_all(&[("HOME", &home_str)]);
assert_eq!(
user_state_dir().unwrap(),
home.join(".local").join("state").join("nono")
);
}
#[test]
fn canonical_paths_use_xdg_state_home() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let (_env, home) = isolated_env(tmp.path());
assert_eq!(
audit_root().unwrap(),
tmp.path().join("state").join("nono").join("audit")
);
assert_eq!(
sessions_dir().unwrap(),
tmp.path().join("state").join("nono").join("sessions")
);
assert_eq!(
legacy_audit_root().unwrap(),
home.join(".nono").join("audit")
);
assert_eq!(
legacy_sessions_dir().unwrap(),
home.join(".nono").join("sessions")
);
assert_eq!(
rollback_root().unwrap(),
tmp.path().join("state").join("nono").join("rollbacks")
);
assert_eq!(
legacy_rollback_root().unwrap(),
home.join(".nono").join("rollbacks")
);
}
#[test]
fn protected_roots_include_both_legacy_and_xdg() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let (_env, home) = isolated_env(tmp.path());
let roots = protected_state_roots().unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|p| p.ends_with(".nono")));
assert!(
roots
.iter()
.any(|p| p.ends_with("nono") && !p.ends_with(".nono"))
);
let _ = home;
}
#[test]
fn audit_discovery_roots_lists_primary_before_legacy() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let (_env, _home) = isolated_env(tmp.path());
let roots = audit_discovery_roots().unwrap();
assert_eq!(roots.len(), 2);
assert!(roots[0].ends_with("nono/audit"));
assert!(roots[1].ends_with(".nono/audit"));
}
#[test]
fn rollback_discovery_roots_lists_primary_before_legacy() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let (_env, _home) = isolated_env(tmp.path());
let roots = rollback_discovery_roots().unwrap();
assert_eq!(roots.len(), 2);
assert!(roots[0].ends_with("nono/rollbacks"));
assert!(roots[1].ends_with(".nono/rollbacks"));
}
#[test]
fn maybe_migrate_legacy_audit_ledger_copies_via_temp_rename() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let (_env, home) = isolated_env(tmp.path());
let legacy_ledger = legacy_audit_root().unwrap().join(AUDIT_LEDGER_FILENAME);
fs::create_dir_all(legacy_ledger.parent().unwrap()).unwrap();
fs::write(&legacy_ledger, b"{\"sequence\":0}\n").unwrap();
maybe_migrate_legacy_audit_ledger().unwrap();
let migrated = audit_root().unwrap().join(AUDIT_LEDGER_FILENAME);
assert!(migrated.exists());
assert!(
!audit_root()
.unwrap()
.join(format!("{AUDIT_LEDGER_FILENAME}.tmp"))
.exists()
);
let content = fs::read_to_string(&migrated).unwrap();
assert_eq!(content, "{\"sequence\":0}\n");
let _ = home;
}
}