From a96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 Mon Sep 17 00:00:00 2001 From: murilo ijanc Date: Wed, 25 Mar 2026 23:23:10 -0300 Subject: Initial implementation of tesseras-url 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 --- src/url_entry.rs | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/url_entry.rs (limited to 'src/url_entry.rs') diff --git a/src/url_entry.rs b/src/url_entry.rs new file mode 100644 index 0000000..f52b4a1 --- /dev/null +++ b/src/url_entry.rs @@ -0,0 +1,172 @@ +//! URL entry record format. +//! +//! Binary format (no external serialization deps): +//! version: u8 +//! created_at: u64 (big-endian) +//! ttl_secs: u64 (big-endian) 0 = no expiry +//! slug_len: u16 (big-endian) +//! slug: [u8; slug_len] +//! target_url: [u8] (remaining) + +use tesseras_dht::sha2::{Digest, Sha256}; + +/// Maximum URL size: 8 KiB. +pub const MAX_URL_SIZE: usize = 8 * 1024; + +/// Current format version. +const FORMAT_VERSION: u8 = 1; + +/// Fixed header: version(1) + created_at(8) + ttl(8) + slug_len(2) = 19. +const HEADER_SIZE: usize = 19; + +/// A URL entry stored locally and replicated via the DHT. +#[derive(Debug, Clone)] +pub struct UrlEntry { + pub version: u8, + pub created_at: u64, + pub ttl_secs: u64, + pub slug: String, + pub target_url: String, +} + +impl UrlEntry { + /// Create a new entry with the current timestamp. + pub fn new(slug: String, target_url: String, ttl_secs: u64) -> Self { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + UrlEntry { + version: FORMAT_VERSION, + created_at, + ttl_secs, + slug, + target_url, + } + } + + /// Compute the 32-byte DHT key from a slug: SHA256(slug). + pub fn dht_key(slug: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(slug.as_bytes()); + h.finalize().into() + } + + /// Generate a short slug from a URL: first 8 bytes of + /// SHA256(url), base58-encoded (~11 chars). + pub fn auto_slug(url: &str) -> String { + let mut h = Sha256::new(); + h.update(url.as_bytes()); + let hash: [u8; 32] = h.finalize().into(); + crate::base58::encode(&hash[..8]) + } + + /// Serialize to bytes. + pub fn to_bytes(&self) -> Vec { + let slug_bytes = self.slug.as_bytes(); + let url_bytes = self.target_url.as_bytes(); + let mut buf = + Vec::with_capacity(HEADER_SIZE + slug_bytes.len() + url_bytes.len()); + buf.push(self.version); + buf.extend_from_slice(&self.created_at.to_be_bytes()); + buf.extend_from_slice(&self.ttl_secs.to_be_bytes()); + buf.extend_from_slice(&(slug_bytes.len() as u16).to_be_bytes()); + buf.extend_from_slice(slug_bytes); + buf.extend_from_slice(url_bytes); + buf + } + + /// Deserialize from bytes. + pub fn from_bytes(data: &[u8]) -> Option { + if data.len() < HEADER_SIZE { + return None; + } + let version = data[0]; + let created_at = u64::from_be_bytes(data[1..9].try_into().ok()?); + let ttl_secs = u64::from_be_bytes(data[9..17].try_into().ok()?); + let slug_len = + u16::from_be_bytes(data[17..19].try_into().ok()?) as usize; + if data.len() < HEADER_SIZE + slug_len { + return None; + } + let slug = + std::str::from_utf8(&data[HEADER_SIZE..HEADER_SIZE + slug_len]) + .ok()? + .to_string(); + let target_url = + std::str::from_utf8(&data[HEADER_SIZE + slug_len..]) + .ok()? + .to_string(); + Some(UrlEntry { + version, + created_at, + ttl_secs, + slug, + target_url, + }) + } + + /// Whether this entry has expired. ttl_secs == 0 means never. + pub fn is_expired(&self) -> bool { + if self.ttl_secs == 0 { + return false; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now > self.created_at.saturating_add(self.ttl_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let entry = UrlEntry::new( + "abc123".into(), + "https://example.com".into(), + 3600, + ); + let bytes = entry.to_bytes(); + let decoded = UrlEntry::from_bytes(&bytes).unwrap(); + assert_eq!(decoded.slug, "abc123"); + assert_eq!(decoded.target_url, "https://example.com"); + assert_eq!(decoded.ttl_secs, 3600); + assert_eq!(decoded.version, FORMAT_VERSION); + } + + #[test] + fn auto_slug_deterministic() { + let s1 = UrlEntry::auto_slug("https://example.com"); + let s2 = UrlEntry::auto_slug("https://example.com"); + assert_eq!(s1, s2); + } + + #[test] + fn auto_slug_differs() { + let s1 = UrlEntry::auto_slug("https://a.com"); + let s2 = UrlEntry::auto_slug("https://b.com"); + assert_ne!(s1, s2); + } + + #[test] + fn dht_key_deterministic() { + let k1 = UrlEntry::dht_key("test"); + let k2 = UrlEntry::dht_key("test"); + assert_eq!(k1, k2); + } + + #[test] + fn zero_ttl_never_expires() { + let entry = UrlEntry::new("s".into(), "https://x.com".into(), 0); + assert!(!entry.is_expired()); + } + + #[test] + fn too_short_fails() { + assert!(UrlEntry::from_bytes(&[0u8; 5]).is_none()); + } +} -- cgit v1.2.3