Lua runner with rich builtin stdlib ("lunascript")
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.
 
 
luna/src/stdlib/fs/store.rs

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")));
}
}
}