Skip to content

Commit e8293b3

Browse files
authored
feat(cli): move runtime state to XDG state dirs (#1152)
* feat(cli): move runtime state to XDG state dirs Store audit, session registry, and rollback data under /nono/ (default ~/.local/state/nono/) on all platforms. Keep read fallback and one-time deprecation warnings for ~/.nono/ until v1.0.0. Signed-off-by: Aleksy Siek <aleksy@alwaysfurther.ai> * fix: review changes Signed-off-by: Aleksy Siek <aleksy@alwaysfurther.ai> --------- Signed-off-by: Aleksy Siek <aleksy@alwaysfurther.ai>
1 parent 7c9abd3 commit e8293b3

21 files changed

Lines changed: 876 additions & 263 deletions

crates/nono-cli/src/audit_ledger.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::audit_session::audit_root;
2+
use crate::state_paths;
23
use nix::fcntl::{Flock, FlockArg};
34
use nono::audit::{
45
LedgerRecord, LedgerVerificationResult, append_session_to_ledger_file,
@@ -9,7 +10,7 @@ use nono::undo::SessionMetadata;
910
use nono::{NonoError, Result};
1011
use std::fs::OpenOptions;
1112
use std::io::BufReader;
12-
use std::path::PathBuf;
13+
use std::path::{Path, PathBuf};
1314

1415
const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson";
1516
const AUDIT_LEDGER_LOCK_FILENAME: &str = "ledger.lock";
@@ -27,6 +28,7 @@ pub(crate) fn append_session(metadata: &SessionMetadata) -> Result<LedgerRecord>
2728

2829
let path = root.join(AUDIT_LEDGER_FILENAME);
2930
let _lock = LedgerLock::acquire(root.join(AUDIT_LEDGER_LOCK_FILENAME))?;
31+
state_paths::maybe_migrate_legacy_audit_ledger()?;
3032
let mut file = OpenOptions::new()
3133
.create(true)
3234
.read(true)
@@ -45,7 +47,29 @@ pub(crate) fn append_session(metadata: &SessionMetadata) -> Result<LedgerRecord>
4547
pub(crate) fn verify_session_in_ledger(
4648
metadata: &SessionMetadata,
4749
) -> Result<LedgerVerificationResult> {
48-
let root = audit_root()?;
50+
let primary = audit_root()?;
51+
let result = verify_session_in_ledger_at_root(&primary, metadata)?;
52+
if result.session_found {
53+
return Ok(result);
54+
}
55+
56+
if let Ok(legacy) = state_paths::legacy_audit_root()
57+
&& legacy != primary
58+
{
59+
let legacy_ledger = legacy.join(AUDIT_LEDGER_FILENAME);
60+
if legacy_ledger.exists() {
61+
state_paths::warn_legacy_audit_path(&legacy);
62+
return verify_session_in_ledger_at_root(&legacy, metadata);
63+
}
64+
}
65+
66+
Ok(result)
67+
}
68+
69+
fn verify_session_in_ledger_at_root(
70+
root: &Path,
71+
metadata: &SessionMetadata,
72+
) -> Result<LedgerVerificationResult> {
4973
let path = root.join(AUDIT_LEDGER_FILENAME);
5074
if !path.exists() {
5175
return missing_ledger_verification_result(metadata);
@@ -125,8 +149,11 @@ mod tests {
125149
fn ledger_appends_and_verifies_session_digest() {
126150
let _env_lock = ENV_LOCK.lock().unwrap();
127151
let tmp = tempfile::tempdir().unwrap();
152+
let state = tmp.path().join("state");
153+
std::fs::create_dir_all(&state).unwrap();
128154
let home = tmp.path().to_string_lossy().to_string();
129-
let _env = EnvVarGuard::set_all(&[("HOME", &home)]);
155+
let state_str = state.to_string_lossy().to_string();
156+
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);
130157

131158
let meta = sample_metadata("20260421-200000-11111");
132159
append_session(&meta).unwrap();
@@ -142,8 +169,11 @@ mod tests {
142169
fn ledger_rejects_malformed_session_id() {
143170
let _env_lock = ENV_LOCK.lock().unwrap();
144171
let tmp = tempfile::tempdir().unwrap();
172+
let state = tmp.path().join("state");
173+
std::fs::create_dir_all(&state).unwrap();
145174
let home = tmp.path().to_string_lossy().to_string();
146-
let _env = EnvVarGuard::set_all(&[("HOME", &home)]);
175+
let state_str = state.to_string_lossy().to_string();
176+
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);
147177

148178
let meta = sample_metadata("real-token\\|real-key");
149179
let err = match append_session(&meta) {

crates/nono-cli/src/audit_session.rs

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
//! Session discovery and management for the audit system.
22
//!
3-
//! Audit sessions are stored in `~/.nono/audit/`. For backwards
4-
//! compatibility, the audit commands also read legacy audit metadata from
5-
//! `~/.nono/rollbacks/` when no migrated audit entry exists yet.
3+
//! Audit sessions are stored under `$XDG_STATE_HOME/nono/audit/` (default
4+
//! `~/.local/state/nono/audit/`). For backwards compatibility, reads also
5+
//! check `~/.nono/audit/` until v1.0.0, and legacy audit metadata under
6+
//! `~/.nono/rollbacks/` (or the canonical rollback root) when no migrated
7+
//! audit entry exists yet.
68
7-
use crate::rollback_session;
9+
use crate::state_paths;
810
use nono::undo::{SessionMetadata, SnapshotManager};
911
use nono::{NonoError, Result};
1012
use std::collections::BTreeSet;
@@ -27,10 +29,9 @@ pub struct SessionInfo {
2729
pub is_stale: bool,
2830
}
2931

30-
/// Get the audit root directory (`~/.nono/audit/`)
32+
/// Get the canonical audit root directory (`$XDG_STATE_HOME/nono/audit/`).
3133
pub fn audit_root() -> Result<PathBuf> {
32-
let home = dirs::home_dir().ok_or(NonoError::HomeNotFound)?;
33-
Ok(home.join(".nono").join("audit"))
34+
state_paths::audit_root()
3435
}
3536

3637
/// Discover all audit sessions.
@@ -42,14 +43,12 @@ pub fn discover_sessions() -> Result<Vec<SessionInfo>> {
4243
let mut sessions = Vec::new();
4344
let mut seen_ids = BTreeSet::new();
4445
let primary_root = audit_root()?;
46+
let legacy_roots = state_paths::LegacyRootSet::resolve()?;
4547

46-
for root in [
47-
Some(primary_root.clone()),
48-
rollback_session::rollback_root().ok(),
49-
] {
50-
let Some(root) = root else {
51-
continue;
52-
};
48+
let mut roots: Vec<PathBuf> = state_paths::audit_discovery_roots()?;
49+
roots.extend(state_paths::rollback_discovery_roots()?);
50+
51+
for root in roots {
5352
if !root.exists() {
5453
continue;
5554
}
@@ -86,6 +85,7 @@ pub fn discover_sessions() -> Result<Vec<SessionInfo>> {
8685
continue;
8786
}
8887

88+
legacy_roots.warn_if_legacy_audit_data_read(&dir);
8989
sessions.push(build_session_info(dir, metadata));
9090
}
9191
}
@@ -98,12 +98,11 @@ pub fn discover_sessions() -> Result<Vec<SessionInfo>> {
9898
pub fn load_session(session_id: &str) -> Result<SessionInfo> {
9999
validate_session_id(session_id)?;
100100
let primary_root = audit_root()?;
101-
let roots = [
102-
Some(primary_root.clone()),
103-
rollback_session::rollback_root().ok(),
104-
];
101+
let legacy_roots = state_paths::LegacyRootSet::resolve()?;
102+
let mut roots: Vec<PathBuf> = state_paths::audit_discovery_roots()?;
103+
roots.extend(state_paths::rollback_discovery_roots()?);
105104

106-
for root in roots.into_iter().flatten() {
105+
for root in roots {
107106
let dir = root.join(session_id);
108107
if !dir.exists() {
109108
continue;
@@ -129,6 +128,7 @@ pub fn load_session(session_id: &str) -> Result<SessionInfo> {
129128
continue;
130129
}
131130

131+
legacy_roots.warn_if_legacy_audit_data_read(&dir);
132132
return Ok(build_session_info(dir, metadata));
133133
}
134134

@@ -242,8 +242,11 @@ mod tests {
242242
fn discover_sessions_excludes_rollback_backed_entries() {
243243
let _env_lock = ENV_LOCK.lock().unwrap();
244244
let tmp = tempfile::tempdir().unwrap();
245+
let state = tmp.path().join("state");
246+
fs::create_dir_all(&state).unwrap();
245247
let home = tmp.path().to_string_lossy().to_string();
246-
let _env = EnvVarGuard::set_all(&[("HOME", &home)]);
248+
let state_str = state.to_string_lossy().to_string();
249+
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);
247250

248251
let audit_dir = audit_root().unwrap().join("20260421-111111-10001");
249252
fs::create_dir_all(&audit_dir).unwrap();
@@ -267,7 +270,7 @@ mod tests {
267270
)
268271
.unwrap();
269272

270-
let legacy_audit_dir = rollback_session::rollback_root()
273+
let legacy_audit_dir = state_paths::legacy_rollback_root()
271274
.unwrap()
272275
.join("20260421-111111-10002");
273276
fs::create_dir_all(&legacy_audit_dir).unwrap();
@@ -291,7 +294,7 @@ mod tests {
291294
)
292295
.unwrap();
293296

294-
let rollback_dir = rollback_session::rollback_root()
297+
let rollback_dir = state_paths::legacy_rollback_root()
295298
.unwrap()
296299
.join("20260421-111111-10003");
297300
fs::create_dir_all(&rollback_dir).unwrap();
@@ -325,4 +328,95 @@ mod tests {
325328
assert!(ids.contains(&"20260421-111111-10002"));
326329
assert!(!ids.contains(&"20260421-111111-10003"));
327330
}
331+
332+
#[test]
333+
fn discover_sessions_reads_legacy_audit_root() {
334+
let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
335+
let tmp = tempfile::tempdir().unwrap();
336+
let state = tmp.path().join("state");
337+
fs::create_dir_all(&state).unwrap();
338+
let home = tmp.path().to_string_lossy().to_string();
339+
let state_str = state.to_string_lossy().to_string();
340+
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);
341+
342+
let legacy_audit_dir = state_paths::legacy_audit_root()
343+
.unwrap()
344+
.join("20260421-111111-20001");
345+
fs::create_dir_all(&legacy_audit_dir).unwrap();
346+
SnapshotManager::write_session_metadata(
347+
&legacy_audit_dir,
348+
&SessionMetadata {
349+
session_id: "20260421-111111-20001".to_string(),
350+
started: "2026-04-21T11:11:11+01:00".to_string(),
351+
ended: Some("2026-04-21T11:11:12+01:00".to_string()),
352+
command: vec!["/bin/echo".to_string()],
353+
executable_identity: None,
354+
tracked_paths: vec![PathBuf::from("/tmp/work")],
355+
snapshot_count: 0,
356+
exit_code: Some(0),
357+
merkle_roots: Vec::new(),
358+
network_events: Vec::new(),
359+
audit_event_count: 1,
360+
audit_integrity: None,
361+
audit_attestation: None,
362+
},
363+
)
364+
.unwrap();
365+
366+
let sessions = discover_sessions().unwrap();
367+
let ids: Vec<_> = sessions
368+
.iter()
369+
.map(|s| s.metadata.session_id.as_str())
370+
.collect();
371+
assert!(ids.contains(&"20260421-111111-20001"));
372+
assert!(is_legacy_audit_only_session(
373+
sessions
374+
.iter()
375+
.find(|s| s.metadata.session_id == "20260421-111111-20001")
376+
.expect("legacy session")
377+
));
378+
}
379+
380+
#[test]
381+
fn discover_sessions_does_not_warn_when_legacy_audit_root_is_empty() {
382+
let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
383+
let tmp = tempfile::tempdir().unwrap();
384+
let state = tmp.path().join("state");
385+
fs::create_dir_all(&state).unwrap();
386+
let home = tmp.path().to_string_lossy().to_string();
387+
let state_str = state.to_string_lossy().to_string();
388+
let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);
389+
390+
let legacy_root = state_paths::legacy_audit_root().unwrap();
391+
fs::create_dir_all(&legacy_root).unwrap();
392+
fs::write(legacy_root.join("ledger.ndjson"), b"{}\n").unwrap();
393+
394+
let canonical_dir = state_paths::audit_root()
395+
.unwrap()
396+
.join("20260421-111111-30001");
397+
fs::create_dir_all(&canonical_dir).unwrap();
398+
SnapshotManager::write_session_metadata(
399+
&canonical_dir,
400+
&SessionMetadata {
401+
session_id: "20260421-111111-30001".to_string(),
402+
started: "2026-04-21T11:11:11+01:00".to_string(),
403+
ended: Some("2026-04-21T11:11:12+01:00".to_string()),
404+
command: vec!["/bin/echo".to_string()],
405+
executable_identity: None,
406+
tracked_paths: vec![PathBuf::from("/tmp/work")],
407+
snapshot_count: 0,
408+
exit_code: Some(0),
409+
merkle_roots: Vec::new(),
410+
network_events: Vec::new(),
411+
audit_event_count: 1,
412+
audit_integrity: None,
413+
audit_attestation: None,
414+
},
415+
)
416+
.unwrap();
417+
418+
let sessions = discover_sessions().unwrap();
419+
assert_eq!(sessions.len(), 1);
420+
assert!(is_primary_audit_session(&sessions[0].dir));
421+
}
328422
}

crates/nono-cli/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1699,7 +1699,7 @@ pub struct RunArgs {
16991699
pub skip_dir: Vec<String>,
17001700

17011701
/// Override the rollback snapshot destination directory.
1702-
/// By default, snapshots are stored in ~/.nono/rollbacks/.
1702+
/// By default, snapshots are stored in $XDG_STATE_HOME/nono/rollbacks/.
17031703
/// The destination must be within a path already granted write access
17041704
/// by --allow (or profile); nono will fail with a clear error if not.
17051705
/// Useful for Docker volume mounts or shared storage paths.

crates/nono-cli/src/config/mod.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,9 @@ pub fn user_config_dir() -> Option<PathBuf> {
6363
dirs::config_dir().map(|p| p.join("nono"))
6464
}
6565

66-
/// Get the user state directory path (for version tracking)
67-
#[allow(dead_code)]
66+
/// Get the user state directory path (for version tracking and runtime state)
6867
pub fn user_state_dir() -> Option<PathBuf> {
69-
dirs::state_dir()
70-
.or_else(dirs::data_local_dir)
71-
.map(|p| p.join("nono"))
68+
crate::state_paths::user_state_dir().ok()
7269
}
7370

7471
// ============================================================================

crates/nono-cli/src/launch_runtime.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ fn validate_rollback_destination(
432432

433433
Err(NonoError::ConfigParse(format!(
434434
"--rollback-dest '{}' is not covered by sandbox write permissions. \
435-
Add --allow {} to grant access, or omit --rollback-dest to use the default path (~/.nono/rollbacks/).",
435+
Add --allow {} to grant access, or omit --rollback-dest to use the default path ($XDG_STATE_HOME/nono/rollbacks/).",
436436
dest.display(),
437437
dest.display()
438438
)))

crates/nono-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ mod session_commands;
6565
mod setup;
6666
mod startup_prompt;
6767
mod startup_runtime;
68+
mod state_paths;
6869
mod supervised_runtime;
6970
mod terminal_approval;
7071
mod theme;

crates/nono-cli/src/protected_paths.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Protection for nono's own state paths.
22
//!
33
//! These checks enforce a hard fail if initial sandbox capabilities overlap
4-
//! with internal CLI state roots (currently `~/.nono`).
4+
//! with internal CLI state roots (`~/.nono` and `$XDG_STATE_HOME/nono`).
55
66
use nono::{CapabilitySet, NonoError, Result, try_canonicalize};
77
use std::path::{Path, PathBuf};
@@ -17,12 +17,11 @@ pub struct ProtectedRoots {
1717
impl ProtectedRoots {
1818
/// Build protected roots from current defaults.
1919
///
20-
/// Today this protects the full `~/.nono` subtree.
20+
/// Protects both the legacy `~/.nono` tree and the canonical `$XDG_STATE_HOME/nono`
21+
/// runtime state directory (audit, sessions, rollbacks).
2122
pub fn from_defaults() -> Result<Self> {
22-
let home = dirs::home_dir().ok_or(NonoError::HomeNotFound)?;
23-
let state_root = try_canonicalize(&home.join(".nono"));
2423
Ok(Self {
25-
roots: vec![state_root],
24+
roots: crate::state_paths::protected_state_roots()?,
2625
})
2726
}
2827

crates/nono-cli/src/proxy_runtime.rs

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -369,20 +369,17 @@ pub(crate) fn start_proxy_runtime(
369369
}
370370

371371
/// Choose the directory the proxy will write the TLS-intercept trust bundle
372-
/// into. Conventionally `~/.nono/sessions/<random>/`, kept owner-only.
372+
/// into. Conventionally `$XDG_STATE_HOME/nono/sessions/<random>/`, kept owner-only.
373373
///
374374
/// Returns `Ok(None)` if no `HOME` is set (rare edge cases like CI). We log
375375
/// a warning rather than failing because TLS interception is opt-in: a
376376
/// missing directory just means CONNECTs to L7-bearing routes will get the
377377
/// usual 403, which is a coherent fallback rather than a hard error.
378378
fn prepare_intercept_ca_dir() -> Result<Option<PathBuf>> {
379-
let home = match dirs::home_dir() {
380-
Some(h) => h,
381-
None => {
382-
warn!(
383-
"no $HOME found; skipping TLS-intercept setup (CONNECTs to L7-bearing routes \
384-
will be denied with 403)"
385-
);
379+
let dir = match crate::session::ensure_sessions_dir() {
380+
Ok(base) => base,
381+
Err(e) => {
382+
warn!("cannot resolve session registry for TLS-intercept setup: {e}; skipping");
386383
return Ok(None);
387384
}
388385
};
@@ -396,10 +393,7 @@ fn prepare_intercept_ca_dir() -> Result<Option<PathBuf>> {
396393
.map(|d| d.subsec_nanos())
397394
.unwrap_or(0);
398395
let suffix = format!("{}-{:09}", pid, nanos);
399-
let dir = home
400-
.join(".nono")
401-
.join("sessions")
402-
.join(format!("intercept-{}", suffix));
396+
let dir = dir.join(format!("intercept-{suffix}"));
403397
if let Err(e) = std::fs::create_dir_all(&dir) {
404398
warn!(
405399
"failed to create TLS-intercept dir '{}': {}; skipping interception",

0 commit comments

Comments
 (0)