//! The persistent grant store — `access.json` in the user's config directory. //! //! Grants are keyed by the script's canonical path, then by canonical //! directory, each carrying a tri-state per direction: `null` (not decided), //! `true` (approved), `false` (denied). The file is hand-editable; [`persist`] //! keeps concurrent Luna processes from losing each other's updates by holding //! an exclusive `flock` on a sidecar lock file across its read-merge-write //! cycle, and replaces the store with `rename()` so readers never see a //! half-written file. use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; /// Config directory name under `$XDG_CONFIG_HOME`. A literal, not /// `CARGO_PKG_NAME`: renaming the binary silently abandoning every recorded /// grant should be an explicit decision made here. pub(crate) const APP_NAME: &str = "luna"; /// Tri-state access recorded for one directory. `None` = not decided yet, /// `Some(true)` = approved, `Some(false)` = denied — serialized as JSON /// `null` / `true` / `false`. #[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)] pub(crate) struct DirAccess { #[serde(default)] pub(crate) read: Option, #[serde(default)] pub(crate) write: Option, } impl DirAccess { pub(crate) fn get(&self, kind: super::policy::AccessKind) -> Option { match kind { super::policy::AccessKind::Read => self.read, super::policy::AccessKind::Write => self.write, } } } /// Grants recorded for one script: canonical directory → tri-state pair. /// `BTreeMap` for deterministic serialization (stable diffs of access.json). pub(crate) type DirGrants = BTreeMap; #[derive(Serialize, Deserialize)] pub(crate) struct AccessStore { #[serde(default = "version_default")] pub(crate) version: u32, #[serde(default)] pub(crate) scripts: BTreeMap, } fn version_default() -> u32 { 1 } impl Default for AccessStore { fn default() -> Self { AccessStore { version: 1, scripts: BTreeMap::new() } } } /// `$XDG_CONFIG_HOME/luna/access.json`, falling back to /// `~/.config/luna/access.json`. /// Per the XDG spec a non-absolute `$XDG_CONFIG_HOME` is ignored. `None` when /// no absolute base can be found — permissions then live for the session only. pub(crate) fn default_store_path() -> Option { let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) .or_else(|| { std::env::var_os("HOME") .map(|h| PathBuf::from(h).join(".config")) .filter(|p| p.is_absolute()) })?; Some(base.join(APP_NAME).join("access.json")) } /// Read the whole store. Missing file → empty store; unreadable or corrupt → /// warn and treat as empty (a later [`persist`] moves a corrupt file aside /// before overwriting, preserving whatever the user had). fn read_store(path: &Path) -> AccessStore { let bytes = match std::fs::read(path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AccessStore::default(), Err(e) => { log::warn!("cannot read {}: {e}", path.display()); return AccessStore::default(); } }; match serde_json::from_slice(&bytes) { Ok(store) => store, Err(e) => { log::warn!("{} is corrupt ({e}); ignoring it", path.display()); AccessStore::default() } } } /// The grants recorded for one script (empty if none). Startup snapshot; takes /// no lock — `persist`'s atomic rename guarantees a complete file. pub(crate) fn load_for_script(store_path: &Path, script_key: &str) -> DirGrants { read_store(store_path).scripts.remove(script_key).unwrap_or_default() } /// Record a decision: merge `update`'s decided flags (only the `Some` ones) /// into the store entry for (`script_key`, `dir_key`) and rewrite the file. /// /// Holds an exclusive `flock` on `access.json.lock` across the whole /// read-merge-write so a concurrently running script can't lose this update. /// The lock file is separate from the store on purpose: the final `rename` /// replaces the store's inode, so a lock on the store itself would not exclude /// a process that opened it just before the rename. pub(crate) fn persist( store_path: &Path, script_key: &str, dir_key: &str, update: DirAccess, ) -> std::io::Result<()> { let config_dir = store_path.parent().unwrap_or(Path::new("/")); std::fs::create_dir_all(config_dir)?; let lock_file = std::fs::File::options() .create(true) .truncate(false) .read(true) .write(true) .open(store_path.with_extension("json.lock"))?; rustix::fs::flock(&lock_file, rustix::fs::FlockOperation::LockExclusive)?; // Re-read fresh under the lock: the startup snapshot (or a previous // in-process copy) may be stale by now. let mut store = match std::fs::read(store_path) { Ok(bytes) => match serde_json::from_slice(&bytes) { Ok(store) => store, Err(e) => { // Corrupt: move it aside instead of silently overwriting // whatever the user had in there. let aside = store_path.with_extension("json.corrupt"); log::warn!( "{} is corrupt ({e}); moving it to {}", store_path.display(), aside.display() ); std::fs::rename(store_path, &aside)?; AccessStore::default() } }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => AccessStore::default(), Err(e) => return Err(e), }; let grants = store.scripts.entry(script_key.to_string()).or_default(); let entry = grants.entry(dir_key.to_string()).or_default(); if update.read.is_some() { entry.read = update.read; } if update.write.is_some() { entry.write = update.write; } // Keep the file tidy: drop entries that no longer record anything. grants.retain(|_, a| a.read.is_some() || a.write.is_some()); store.scripts.retain(|_, g| !g.is_empty()); // Write-to-temp + rename: readers see the old or the new file, never a // partial one, even across a crash mid-write. let tmp = store_path.with_extension(format!("json.tmp.{}", std::process::id())); let mut file = std::fs::File::create(&tmp)?; file.write_all(&serde_json::to_vec_pretty(&store)?)?; file.write_all(b"\n")?; file.sync_all()?; std::fs::rename(&tmp, store_path)?; Ok(()) // lock_file drops here, releasing the flock. } #[cfg(test)] mod tests { use super::super::policy::AccessKind; use super::*; fn store_path(dir: &tempfile::TempDir) -> PathBuf { dir.path().join("cfg").join("access.json") } #[test] fn tri_state_round_trip() { let json = r#"{ "version": 1, "scripts": { "/s/deploy.lua": { "/data": { "read": true, "write": true }, "/etc": { "read": true, "write": false }, "/secrets": { "read": null, "write": false } } } }"#; let store: AccessStore = serde_json::from_str(json).unwrap(); let grants = &store.scripts["/s/deploy.lua"]; assert_eq!(grants["/data"], DirAccess { read: Some(true), write: Some(true) }); assert_eq!(grants["/etc"], DirAccess { read: Some(true), write: Some(false) }); assert_eq!(grants["/secrets"], DirAccess { read: None, write: Some(false) }); // null and absent both mean "unknown" let sparse: DirAccess = serde_json::from_str(r#"{ "write": true }"#).unwrap(); assert_eq!(sparse, DirAccess { read: None, write: Some(true) }); // and null survives serialization (the file must distinguish it) let out = serde_json::to_string(&grants["/secrets"]).unwrap(); assert!(out.contains("\"read\":null"), "{out}"); // round trip let again: AccessStore = serde_json::from_str(&serde_json::to_string(&store).unwrap()).unwrap(); assert_eq!(again.scripts, store.scripts); } #[test] fn dir_access_get() { let a = DirAccess { read: Some(true), write: Some(false) }; assert_eq!(a.get(AccessKind::Read), Some(true)); assert_eq!(a.get(AccessKind::Write), Some(false)); } #[test] fn persist_creates_dir_and_merges_without_clobbering() { let tmp = tempfile::tempdir().unwrap(); let path = store_path(&tmp); // parent "cfg" does not exist yet persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); persist(&path, "/s/a.lua", "/data", DirAccess { read: None, write: Some(false) }).unwrap(); let grants = load_for_script(&path, "/s/a.lua"); assert_eq!(grants["/data"], DirAccess { read: Some(true), write: Some(false) }); // a second script's grants are separate assert!(load_for_script(&path, "/s/b.lua").is_empty()); } #[test] fn persist_prunes_undecided_entries() { let tmp = tempfile::tempdir().unwrap(); let path = store_path(&tmp); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); // Hand-written file with a null-null entry (as a user might leave it). std::fs::write( &path, r#"{ "version": 1, "scripts": { "/s/a.lua": { "/dead": { "read": null, "write": null } }, "/s/gone.lua": {} } }"#, ) .unwrap(); persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); let store = read_store(&path); assert!(!store.scripts["/s/a.lua"].contains_key("/dead")); assert!(!store.scripts.contains_key("/s/gone.lua")); assert_eq!(store.scripts["/s/a.lua"]["/data"].read, Some(true)); } #[test] fn persist_moves_corrupt_file_aside() { let tmp = tempfile::tempdir().unwrap(); let path = store_path(&tmp); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "{ not json").unwrap(); persist(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }).unwrap(); assert_eq!( std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(), "{ not json" ); assert_eq!(load_for_script(&path, "/s/a.lua")["/data"].read, Some(true)); } #[test] fn missing_or_corrupt_reads_as_empty() { let tmp = tempfile::tempdir().unwrap(); let path = store_path(&tmp); assert!(load_for_script(&path, "/s/a.lua").is_empty()); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "]]]").unwrap(); assert!(load_for_script(&path, "/s/a.lua").is_empty()); } #[test] fn concurrent_persists_do_not_lose_updates() { let tmp = tempfile::tempdir().unwrap(); let path = store_path(&tmp); let threads: Vec<_> = (0..8) .map(|i| { let path = path.clone(); std::thread::spawn(move || { for j in 0..10 { persist( &path, "/s/a.lua", &format!("/dir-{i}-{j}"), DirAccess { read: Some(true), write: None }, ) .unwrap(); } }) }) .collect(); for t in threads { t.join().unwrap(); } // every one of the 80 grants survived the concurrent read-merge-writes assert_eq!(load_for_script(&path, "/s/a.lua").len(), 80); } #[test] fn default_store_path_respects_xdg_rules() { // Can't mutate the process environment safely in a threaded test // runner; assert only on the shape of whatever the real env yields. if let Some(p) = default_store_path() { assert!(p.is_absolute()); assert!(p.ends_with(format!("{APP_NAME}/access.json"))); } } }