1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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());
}
}
|