diff options
| author | murilo ijanc | 2026-03-25 23:23:10 -0300 |
|---|---|---|
| committer | murilo ijanc | 2026-03-25 23:23:10 -0300 |
| commit | a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 (patch) | |
| tree | f98bb20411dbc6a49e8c286b054a88d89eea795f /src/store.rs | |
| download | tesseras-url-a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600.tar.gz | |
Decentralized URL shortener built on tesseras-dht. Includes:
- tud daemon: DHT node, Unix socket API, HTTP 302 redirect server
- tu CLI: shorten, resolve, del, list, status commands
- Auto-generated slugs (8-byte SHA256, base58) or custom slugs
- TTL support (default: forever)
- Automatic re-join of bootstrap nodes when routing table is empty
- OpenBSD pledge(2) and unveil(2) sandboxing
- DNS SRV bootstrap discovery
- Verbose mode (-v) for both binaries
Diffstat (limited to 'src/store.rs')
| -rw-r--r-- | src/store.rs | 250 |
1 files changed, 250 insertions, 0 deletions
diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..d15bf1c --- /dev/null +++ b/src/store.rs @@ -0,0 +1,250 @@ +//! Filesystem-based URL entry storage. +//! +//! Simple directory layout: +//! <root>/urls/<hash> +//! <root>/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<Self> { + 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<Vec<u8>> { + 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<Vec<u8>> { + 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<usize> { + 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<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 UrlStore { + fn save( + &self, + _records: &[tesseras_dht::persist::StoredRecord], + ) -> Result<(), tesseras_dht::Error> { + Ok(()) + } + + fn load( + &self, + ) -> Result<Vec<tesseras_dht::persist::StoredRecord>, tesseras_dht::Error> + { + Ok(Vec::new()) + } +} |