diff options
Diffstat (limited to 'src/store.rs')
| -rw-r--r-- | src/store.rs | 262 |
1 files changed, 262 insertions, 0 deletions
diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..18a7641 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,262 @@ +//! Filesystem-based paste storage. +//! +//! Simple directory layout: +//! <root>/pastes/<hash>.bin +//! <root>/pins/<hash> +//! <root>/blocked/<hash> +//! <root>/contacts.bin + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::base58; +use crate::paste::Paste; + +/// Persistent paste store backed by the filesystem. +#[derive(Clone)] +pub struct PasteStore { + root: PathBuf, +} + +impl PasteStore { + /// Open or create a store rooted at the given directory. + /// Creates `pastes/`, `pins/`, and `blocked/` subdirectories. + pub fn open(root: &Path) -> std::io::Result<Self> { + fs::create_dir_all(root.join("pastes"))?; + fs::create_dir_all(root.join("pins"))?; + fs::create_dir_all(root.join("blocked"))?; + Ok(PasteStore { + root: root.to_path_buf(), + }) + } + + fn paste_path(&self, key: &[u8]) -> PathBuf { + self.root.join("pastes").join(base58::encode(key)) + } + + fn pin_path(&self, key: &[u8]) -> PathBuf { + self.root.join("pins").join(base58::encode(key)) + } + + fn block_path(&self, key: &[u8]) -> PathBuf { + self.root.join("blocked").join(base58::encode(key)) + } + + // ── Paste CRUD ────────────────────────────────── + + /// Write a paste to disk. The key (32 bytes) is prepended + /// to the file so [`original_keys`] can reconstruct it. + pub fn put_paste(&self, key: &[u8], value: &[u8]) -> std::io::Result<()> { + let path = self.paste_path(key); + let mut f = fs::File::create(path)?; + f.write_all(key)?; + f.write_all(value)?; + Ok(()) + } + + /// Read a paste from disk. Returns `None` if the paste + /// is blocked, expired (and not pinned), or does not exist. + pub fn get_paste(&self, key: &[u8]) -> Option<Vec<u8>> { + if self.is_blocked(key) { + return None; + } + let path = self.paste_path(key); + let data = fs::read(&path).ok()?; + // Strip key prefix (32 bytes) + if data.len() < 32 { + return None; + } + let value = data[32..].to_vec(); + + // Check expiry (pinned never expire) + if let Some(paste) = Paste::from_bytes(&value) + && paste.is_expired() + && !self.is_pinned(key) + { + return None; + } + Some(value) + } + + /// Delete a paste file from disk (no-op if absent). + pub fn remove_paste(&self, key: &[u8]) { + let _ = fs::remove_file(self.paste_path(key)); + } + + /// List all non-expired, non-blocked paste keys. + pub fn original_keys(&self) -> Vec<Vec<u8>> { + let dir = self.root.join("pastes"); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut keys = Vec::new(); + for entry in entries.flatten() { + let data = match fs::read(entry.path()) { + Ok(d) => d, + Err(_) => continue, + }; + if data.len() < 32 { + continue; + } + let key = &data[..32]; + let value = &data[32..]; + + if self.is_blocked(key) { + continue; + } + if let Some(paste) = Paste::from_bytes(value) + && paste.is_expired() + && !self.is_pinned(key) + { + continue; + } + keys.push(key.to_vec()); + } + keys + } + + // ── Pin / Block ───────────────────────────────── + + /// Mark a paste as pinned (never expires). + pub fn pin(&self, key: &[u8]) -> std::io::Result<()> { + fs::File::create(self.pin_path(key))?; + Ok(()) + } + + /// Remove the pin from a paste (re-enables expiry). + pub fn unpin(&self, key: &[u8]) -> std::io::Result<()> { + let _ = fs::remove_file(self.pin_path(key)); + Ok(()) + } + + pub fn is_pinned(&self, key: &[u8]) -> bool { + self.pin_path(key).exists() + } + + pub fn is_blocked(&self, key: &[u8]) -> bool { + self.block_path(key).exists() + } + + // ── GC ────────────────────────────────────────── + + /// Remove expired pastes from disk. Pinned pastes are kept. + pub fn gc(&self) -> std::io::Result<usize> { + let dir = self.root.join("pastes"); + let entries = fs::read_dir(&dir)?; + let mut removed = 0; + + for entry in entries.flatten() { + let data = match fs::read(entry.path()) { + Ok(d) => d, + Err(_) => continue, + }; + if data.len() < 32 { + continue; + } + let key = &data[..32]; + let value = &data[32..]; + + if self.is_pinned(key) { + continue; + } + if let Some(paste) = Paste::from_bytes(value) + && paste.is_expired() + { + let _ = fs::remove_file(entry.path()); + removed += 1; + } + } + Ok(removed) + } + + /// Count stored pastes. + pub fn paste_count(&self) -> usize { + let dir = self.root.join("pastes"); + fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0) + } +} + +// ── tesseras-dht persistence traits ───────────────── + +impl tesseras_dht::persist::RoutingPersistence for PasteStore { + fn save_contacts( + &self, + contacts: &[tesseras_dht::persist::ContactRecord], + ) -> Result<(), tesseras_dht::Error> { + let path = self.root.join("contacts.bin"); + let mut buf = Vec::new(); + for c in contacts { + let id = c.id.as_bytes(); + let addr = c.addr.to_string(); + let addr_bytes = addr.as_bytes(); + // length-prefixed: addr_len(u16) + id(32) + addr + let len = addr_bytes.len() as u16; + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(id); + buf.extend_from_slice(addr_bytes); + } + fs::write(&path, &buf).map_err(tesseras_dht::Error::Io)?; + log::info!("store: persisted {} routing contacts", contacts.len()); + Ok(()) + } + + fn load_contacts( + &self, + ) -> Result<Vec<tesseras_dht::persist::ContactRecord>, tesseras_dht::Error> + { + let path = self.root.join("contacts.bin"); + let data = match fs::read(&path) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(e) => return Err(tesseras_dht::Error::Io(e)), + }; + + let mut out = Vec::new(); + let mut pos = 0; + while pos + 2 + 32 <= data.len() { + let addr_len = + u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + 32 + addr_len > data.len() { + break; + } + let mut id_bytes = [0u8; 32]; + id_bytes.copy_from_slice(&data[pos..pos + 32]); + pos += 32; + let addr_str = + std::str::from_utf8(&data[pos..pos + addr_len]).unwrap_or(""); + pos += addr_len; + if let Ok(addr) = addr_str.parse() { + out.push(tesseras_dht::persist::ContactRecord { + id: tesseras_dht::NodeId::from_bytes(id_bytes), + addr, + }); + } + } + if !out.is_empty() { + log::info!("store: loaded {} routing contacts", out.len()); + } + Ok(out) + } +} + +impl tesseras_dht::persist::DataPersistence for PasteStore { + fn save( + &self, + _records: &[tesseras_dht::persist::StoredRecord], + ) -> Result<(), tesseras_dht::Error> { + Ok(()) // app-level storage handles this + } + + fn load( + &self, + ) -> Result<Vec<tesseras_dht::persist::StoredRecord>, tesseras_dht::Error> + { + Ok(Vec::new()) // republish timer re-populates DHT + } +} |