//! Filesystem-based URL entry storage. //! //! Simple directory layout: //! /urls/ //! /contacts.bin use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use crate::base58; use crate::url_entry::UrlEntry; /// Persistent URL store backed by the filesystem. #[derive(Clone)] pub struct UrlStore { root: PathBuf, } impl UrlStore { /// Open or create a store rooted at the given directory. pub fn open(root: &Path) -> std::io::Result { fs::create_dir_all(root.join("urls"))?; Ok(UrlStore { root: root.to_path_buf(), }) } fn entry_path(&self, key: &[u8]) -> PathBuf { self.root.join("urls").join(base58::encode(key)) } /// Write a URL entry to disk atomically. /// The key (32 bytes) is prepended to the file. pub fn put_entry( &self, key: &[u8], value: &[u8], ) -> std::io::Result<()> { let path = self.entry_path(key); atomic_write(&path, &[key, value]) } /// Read a URL entry from disk. Returns `None` if expired or absent. pub fn get_entry(&self, key: &[u8]) -> Option> { let path = self.entry_path(key); let data = fs::read(&path).ok()?; if data.len() < 32 { return None; } let value = data[32..].to_vec(); if let Some(entry) = UrlEntry::from_bytes(&value) && entry.is_expired() { return None; } Some(value) } /// Delete a URL entry from disk. pub fn remove_entry(&self, key: &[u8]) { let _ = fs::remove_file(self.entry_path(key)); } /// List all non-expired entry keys. pub fn original_keys(&self) -> Vec> { let dir = self.root.join("urls"); 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 let Some(e) = UrlEntry::from_bytes(value) && e.is_expired() { continue; } keys.push(key.to_vec()); } keys } /// List all non-expired entries as (slug, target_url) pairs. pub fn list_entries(&self) -> Vec<(String, String)> { let dir = self.root.join("urls"); let entries = match fs::read_dir(&dir) { Ok(e) => e, Err(_) => return Vec::new(), }; let mut out = 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 value = &data[32..]; if let Some(e) = UrlEntry::from_bytes(value) { if !e.is_expired() { out.push((e.slug, e.target_url)); } } } out } /// Remove expired entries from disk. pub fn gc(&self) -> std::io::Result { let dir = self.root.join("urls"); 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 value = &data[32..]; if let Some(e) = UrlEntry::from_bytes(value) && e.is_expired() { let _ = fs::remove_file(entry.path()); removed += 1; } } Ok(removed) } /// Count stored entries. pub fn entry_count(&self) -> usize { let dir = self.root.join("urls"); fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0) } } /// Write data to path atomically (temp + rename). 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 UrlStore { 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(); 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 UrlStore { fn save( &self, _records: &[tesseras_dht::persist::StoredRecord], ) -> Result<(), tesseras_dht::Error> { Ok(()) } fn load( &self, ) -> Result, tesseras_dht::Error> { Ok(Vec::new()) } }