You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
322 lines
12 KiB
322 lines
12 KiB
//! 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<bool>,
|
|
#[serde(default)]
|
|
pub(crate) write: Option<bool>,
|
|
}
|
|
|
|
impl DirAccess {
|
|
pub(crate) fn get(&self, kind: super::policy::AccessKind) -> Option<bool> {
|
|
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<String, DirAccess>;
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub(crate) struct AccessStore {
|
|
#[serde(default = "version_default")]
|
|
pub(crate) version: u32,
|
|
#[serde(default)]
|
|
pub(crate) scripts: BTreeMap<String, DirGrants>,
|
|
}
|
|
|
|
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<PathBuf> {
|
|
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")));
|
|
}
|
|
}
|
|
}
|
|
|