//! 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()); } }