summaryrefslogtreecommitdiffstats
path: root/src/store.rs
diff options
context:
space:
mode:
authormurilo ijanc2026-03-25 23:23:10 -0300
committermurilo ijanc2026-03-25 23:23:10 -0300
commita96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 (patch)
treef98bb20411dbc6a49e8c286b054a88d89eea795f /src/store.rs
downloadtesseras-url-a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600.tar.gz
Initial implementation of tesseras-urlHEADmain
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.rs250
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())
+ }
+}