//! Filesystem-based paste storage. //! //! Simple directory layout: //! /pastes/.bin //! /chunks/.bin //! /pins/ //! /blocked/ //! /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/`, `chunks/`, `pins/`, and `blocked/` subdirectories. pub fn open(root: &Path) -> std::io::Result { fs::create_dir_all(root.join("pastes"))?; fs::create_dir_all(root.join("chunks"))?; 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 chunk_path(&self, key: &[u8]) -> PathBuf { self.root.join("chunks").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 atomically (write-to-temp + rename). /// 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); atomic_write(&path, &[key, value]) } /// 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> { 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)); } // ── Chunk CRUD ───────────────────────────────── /// Write a chunk to the chunks directory. pub fn put_chunk(&self, key: &[u8], value: &[u8]) -> std::io::Result<()> { let path = self.chunk_path(key); atomic_write(&path, &[key, value]) } /// Read a chunk from the chunks directory. pub fn get_chunk(&self, key: &[u8]) -> Option> { if self.is_blocked(key) { return None; } let path = self.chunk_path(key); let data = fs::read(&path).ok()?; if data.len() < 32 { return None; } Some(data[32..].to_vec()) } /// Delete a chunk file from disk (no-op if absent). pub fn remove_chunk(&self, key: &[u8]) { let _ = fs::remove_file(self.chunk_path(key)); } /// List all non-blocked chunk keys. pub fn chunk_keys(&self) -> Vec> { let dir = self.root.join("chunks"); 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]; if self.is_blocked(key) { continue; } // Check expiry on the chunk paste let value = &data[32..]; if let Some(paste) = Paste::from_bytes(value) && paste.is_expired() { continue; } keys.push(key.to_vec()); } keys } /// List all non-expired, non-blocked paste keys. pub fn original_keys(&self) -> Vec> { 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() } /// Mark a paste as blocked (prevents re-import from DHT). pub fn block(&self, key: &[u8]) { let _ = fs::File::create(self.block_path(key)); } pub fn is_blocked(&self, key: &[u8]) -> bool { self.block_path(key).exists() } // ── GC ────────────────────────────────────────── /// Remove expired pastes and chunks from disk. Pinned pastes are kept. pub fn gc(&self) -> std::io::Result { let mut removed = 0; for subdir in &["pastes", "chunks"] { let dir = self.root.join(subdir); let entries = match fs::read_dir(&dir) { Ok(e) => e, Err(_) => continue, }; 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 (excludes chunks). pub fn paste_count(&self) -> usize { let dir = self.root.join("pastes"); fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0) } /// Count stored chunks. pub fn chunk_count(&self) -> usize { let dir = self.root.join("chunks"); fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0) } } /// Write data to `path` atomically: write to a temporary file in /// the same directory, then rename over the target. This prevents /// corruption if the process is killed mid-write. fn atomic_write(path: &Path, chunks: &[&[u8]]) -> std::io::Result<()> { let parent = path.parent().unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp"); let tmp = parent.join(format!(".tmp.{}.{}", std::process::id(), name)); let mut f = fs::File::create(&tmp)?; for chunk in chunks { f.write_all(chunk)?; } f.sync_all()?; fs::rename(&tmp, path) } // ── 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); } atomic_write(&path, &[&buf]).map_err(tesseras_dht::Error::Io)?; log::info!("store: persisted {} routing contacts", contacts.len()); Ok(()) } fn load_contacts( &self, ) -> Result, 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, tesseras_dht::Error> { Ok(Vec::new()) // republish timer re-populates DHT } }