//! 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, } /// 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) } /// 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() } pub fn is_trusted(&self, node_id: &str) -> bool { self.state .lock() .expect("meta lock poisoned") .trusted_peers .contains(node_id) } } #[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")); } }