//! Persistent daemon metadata: pins, known formats, trusted peers. //! //! Stored as a single JSON file (`meta.json`) in the store directory — //! the spec allows JSON for the MVP and the data is tiny and human //! auditable. Writes go through a temp file + rename so a crash never //! leaves a torn file. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use varde_proto::PinPolicy; /// Whether a hash names a single blob or a HashSeq (directory root). /// /// Recorded at import/pin time because the bytes alone don't say; a /// materialize of an unknown hash defaults to `Raw`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StoredFormat { Raw, HashSeq, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PinRecord { pub policy: PinPolicy, } #[derive(Debug, Default, Serialize, Deserialize)] struct MetaState { /// Pinned root hashes (hex) and their policies. #[serde(default)] pins: BTreeMap, /// Known formats of hashes we imported or fetched (hex). #[serde(default)] formats: BTreeMap, /// Trusted peer NodeIds (z-base-32). #[serde(default)] trusted_peers: BTreeSet, /// Peers allowed to push to us and be pushed to, until a unix time /// (`PeerAllowPush`): consent for that one thing, lapsing on its own. #[serde(default)] push_peers: BTreeMap, /// Hashes (hex) the user shared via ticket export: standing consent /// to serve them to anyone, like an `open_lan` pin. #[serde(default)] exported: BTreeSet, } /// Handle to the metadata file. Cheap to share behind an `Arc`. #[derive(Debug)] pub struct Meta { path: PathBuf, state: Mutex, } impl Meta { /// Load metadata from `store_dir/meta.json`, starting empty if absent. pub fn load(store_dir: &Path) -> Result { let path = store_dir.join("meta.json"); let state = match std::fs::read_to_string(&path) { Ok(text) => serde_json::from_str(&text) .with_context(|| format!("parsing {}", path.display()))?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => MetaState::default(), Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), }; Ok(Meta { path, state: Mutex::new(state), }) } fn mutate(&self, f: impl FnOnce(&mut MetaState) -> R) -> Result { let mut state = self.state.lock().expect("meta lock poisoned"); let result = f(&mut state); // Persist inside the lock so concurrent mutations can't write // stale snapshots over each other. The file is tiny. let json = serde_json::to_string_pretty(&*state)?; let tmp = self.path.with_extension("json.tmp"); std::fs::write(&tmp, json).with_context(|| format!("writing {}", tmp.display()))?; std::fs::rename(&tmp, &self.path) .with_context(|| format!("renaming into {}", self.path.display()))?; Ok(result) } /// Create or update a pin. pub fn pin(&self, hash: &str, policy: PinPolicy) -> Result<()> { self.mutate(|s| { s.pins.insert(hash.to_string(), PinRecord { policy }); }) } /// Remove a pin. Returns false if there was none. pub fn unpin(&self, hash: &str) -> Result { self.mutate(|s| s.pins.remove(hash).is_some()) } pub fn is_pinned(&self, hash: &str) -> bool { self.state .lock() .expect("meta lock poisoned") .pins .contains_key(hash) } /// Snapshot of all pins as (hash, record). pub fn pins(&self) -> Vec<(String, PinRecord)> { let state = self.state.lock().expect("meta lock poisoned"); state .pins .iter() .map(|(h, r)| (h.clone(), r.clone())) .collect() } /// Record the format of a hash we learned about. pub fn record_format(&self, hash: &str, format: StoredFormat) -> Result<()> { self.mutate(|s| { s.formats.insert(hash.to_string(), format); }) } /// Best knowledge of the format of `hash`; defaults to `Raw`. pub fn format_of(&self, hash: &str) -> StoredFormat { let state = self.state.lock().expect("meta lock poisoned"); state .formats .get(hash) .copied() .unwrap_or(StoredFormat::Raw) } /// Record standing consent to serve `hash` to anyone (set by ticket /// export — sharing a ticket is deliberate publication). pub fn record_exported(&self, hash: &str) -> Result<()> { self.mutate(|s| { s.exported.insert(hash.to_string()); }) } /// Hashes with standing serve-to-anyone consent. pub fn exported(&self) -> BTreeSet { self.state .lock() .expect("meta lock poisoned") .exported .clone() } /// Add a trusted peer. Returns false if it was already trusted. pub fn trust_peer(&self, node_id: &str) -> Result { self.mutate(|s| s.trusted_peers.insert(node_id.to_string())) } /// Remove a trusted peer. Returns false if it was not trusted. pub fn untrust_peer(&self, node_id: &str) -> Result { self.mutate(|s| s.trusted_peers.remove(node_id)) } pub fn trusted_peers(&self) -> BTreeSet { self.state .lock() .expect("meta lock poisoned") .trusted_peers .clone() } /// Allow pushes with `node_id` until `until` (unix seconds). Extends, /// never shortens, an allowance already held; expired ones are /// dropped on the way. pub fn allow_push(&self, node_id: &str, until: u64) -> Result<()> { let now = unix_now(); self.mutate(|s| { s.push_peers.retain(|_, u| *u > now); let entry = s.push_peers.entry(node_id.to_string()).or_insert(0); *entry = (*entry).max(until); }) } /// Whether pushes with `node_id` are allowed now: a trusted peer, or /// one holding an unexpired push allowance. pub fn may_push(&self, node_id: &str) -> bool { let state = self.state.lock().expect("meta lock poisoned"); state.trusted_peers.contains(node_id) || state.push_peers.get(node_id).is_some_and(|u| *u > unix_now()) } /// End a push allowance. Returns whether there was one. pub fn disallow_push(&self, node_id: &str) -> Result { self.mutate(|s| s.push_peers.remove(node_id).is_some()) } pub fn is_trusted(&self, node_id: &str) -> bool { self.state .lock() .expect("meta lock poisoned") .trusted_peers .contains(node_id) } } /// Seconds since the unix epoch. pub fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn survives_reload() { let dir = tempfile::tempdir().unwrap(); let meta = Meta::load(dir.path()).unwrap(); meta.pin("abc", PinPolicy { open_lan: true }).unwrap(); meta.record_format("abc", StoredFormat::HashSeq).unwrap(); meta.trust_peer("node1").unwrap(); let meta = Meta::load(dir.path()).unwrap(); assert!(meta.is_pinned("abc")); assert_eq!(meta.format_of("abc"), StoredFormat::HashSeq); assert!(meta.is_trusted("node1")); assert_eq!(meta.format_of("unknown"), StoredFormat::Raw); } #[test] fn unpin_reports_absence() { let dir = tempfile::tempdir().unwrap(); let meta = Meta::load(dir.path()).unwrap(); assert!(!meta.unpin("nope").unwrap()); meta.pin("h", PinPolicy::default()).unwrap(); assert!(meta.unpin("h").unwrap()); assert!(!meta.is_pinned("h")); } #[test] fn a_push_allowance_lapses_and_never_shortens() { let dir = tempfile::tempdir().unwrap(); let meta = Meta::load(dir.path()).unwrap(); let now = unix_now(); assert!(!meta.may_push("p")); meta.allow_push("p", now + 600).unwrap(); assert!(meta.may_push("p")); assert!(!meta.is_trusted("p"), "a push allowance is not trust"); meta.allow_push("p", now + 10).unwrap(); assert!(meta.may_push("p"), "a shorter ask does not cut it short"); meta.allow_push("q", now.saturating_sub(1)).unwrap(); assert!(!meta.may_push("q"), "expired"); // Survives a reload. let again = Meta::load(dir.path()).unwrap(); assert!(again.may_push("p")); assert!(again.disallow_push("p").unwrap()); assert!(!again.may_push("p")); // Full trust allows pushes too. again.trust_peer("t").unwrap(); assert!(again.may_push("t")); } }