PeerTrust syncs a peer's pins and serves it everything - too much to grant a stranger. An app that wants two peers sharing something (two phones at the same place) to hand each other new content allows pushes for ttl_secs (at most a day): both sides may push, the receiver pins, and the allowance lapses on its own. Asking again extends it; PeerUntrust ends it. varde-ctl peer allow-push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
9.0 KiB
Rust
265 lines
9.0 KiB
Rust
//! 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<String, PinRecord>,
|
|
/// Known formats of hashes we imported or fetched (hex).
|
|
#[serde(default)]
|
|
formats: BTreeMap<String, StoredFormat>,
|
|
/// Trusted peer NodeIds (z-base-32).
|
|
#[serde(default)]
|
|
trusted_peers: BTreeSet<String>,
|
|
/// 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<String, u64>,
|
|
/// Hashes (hex) the user shared via ticket export: standing consent
|
|
/// to serve them to anyone, like an `open_lan` pin.
|
|
#[serde(default)]
|
|
exported: BTreeSet<String>,
|
|
}
|
|
|
|
/// Handle to the metadata file. Cheap to share behind an `Arc`.
|
|
#[derive(Debug)]
|
|
pub struct Meta {
|
|
path: PathBuf,
|
|
state: Mutex<MetaState>,
|
|
}
|
|
|
|
impl Meta {
|
|
/// Load metadata from `store_dir/meta.json`, starting empty if absent.
|
|
pub fn load(store_dir: &Path) -> Result<Meta> {
|
|
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<R>(&self, f: impl FnOnce(&mut MetaState) -> R) -> Result<R> {
|
|
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<bool> {
|
|
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<String> {
|
|
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<bool> {
|
|
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<bool> {
|
|
self.mutate(|s| s.trusted_peers.remove(node_id))
|
|
}
|
|
|
|
pub fn trusted_peers(&self) -> BTreeSet<String> {
|
|
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<bool> {
|
|
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"));
|
|
}
|
|
}
|