summaryrefslogtreecommitdiffstats
path: root/src/url_entry.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/url_entry.rs')
-rw-r--r--src/url_entry.rs172
1 files changed, 172 insertions, 0 deletions
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<u8> {
+ 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<Self> {
+ 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());
+ }
+}