1pub fn mint_topic_id(file_hash: &str, sender_hex: &str) -> String {
21 use sha2::{Digest, Sha256};
22 use std::sync::atomic::{AtomicU64, Ordering};
23 static MINTED: AtomicU64 = AtomicU64::new(0);
24 let nanos = std::time::SystemTime::now()
25 .duration_since(std::time::UNIX_EPOCH)
26 .map(|d| d.as_nanos())
27 .unwrap_or(0);
28 let seq = MINTED.fetch_add(1, Ordering::Relaxed);
29 let mut hasher = Sha256::new();
30 hasher.update(b"webxdc-realtime-v1:");
31 hasher.update(file_hash.as_bytes());
32 hasher.update(b":");
33 hasher.update(sender_hex.as_bytes());
34 hasher.update(b":");
35 hasher.update(nanos.to_le_bytes());
36 hasher.update(b":");
37 hasher.update(seq.to_le_bytes());
38 base32_nopad_encode(&hasher.finalize())
39}
40
41pub fn derive_url_topic_id(url: &str, msg_id: &str) -> String {
51 use sha2::{Digest, Sha256};
52 let mut hasher = Sha256::new();
53 hasher.update(b"webxdc-url-realtime-v1:");
54 hasher.update(url.as_bytes());
55 hasher.update(b":");
56 hasher.update(msg_id.as_bytes());
57 base32_nopad_encode(&hasher.finalize())
58}
59
60pub fn base32_nopad_encode(bytes: &[u8]) -> String {
63 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
64 let mut out = String::with_capacity((bytes.len() * 8 + 4) / 5);
65 let mut buf: u64 = 0;
66 let mut bits: u32 = 0;
67 for &b in bytes {
68 buf = (buf << 8) | b as u64;
69 bits += 8;
70 while bits >= 5 {
71 bits -= 5;
72 out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
73 }
74 }
75 if bits > 0 {
76 out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
77 }
78 out
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn minted_topic_is_32_bytes_base32() {
87 let t = mint_topic_id("abc123", "deadbeef");
88 assert_eq!(t.len(), 52);
90 assert!(t.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
91 }
92
93 #[test]
94 fn resends_mint_distinct_topics() {
95 let a = mint_topic_id("abc123", "deadbeef");
96 let b = mint_topic_id("abc123", "deadbeef");
97 assert_ne!(a, b, "same file re-sent must start a fresh session topic");
98 }
99
100 #[test]
101 fn base32_matches_rfc4648_vectors() {
102 assert_eq!(base32_nopad_encode(b""), "");
104 assert_eq!(base32_nopad_encode(b"f"), "MY");
105 assert_eq!(base32_nopad_encode(b"fo"), "MZXQ");
106 assert_eq!(base32_nopad_encode(b"foo"), "MZXW6");
107 assert_eq!(base32_nopad_encode(b"foob"), "MZXW6YQ");
108 assert_eq!(base32_nopad_encode(b"fooba"), "MZXW6YTB");
109 assert_eq!(base32_nopad_encode(b"foobar"), "MZXW6YTBOI");
110 }
111}
112
113pub fn peer_signal_content(topic_id: &str, node_addr: Option<&str>) -> String {
118 match node_addr {
119 Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
120 None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
121 }
122}
123
124pub fn parse_peer_signal(content: &str) -> Option<(String, Option<String>)> {
129 let v: serde_json::Value = serde_json::from_str(content).ok()?;
130 let topic_id = v
131 .get("topic")
132 .and_then(|t| t.as_str())
133 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
134 .to_string();
135 let node_addr = match v.get("op").and_then(|o| o.as_str())? {
136 "ad" => Some(v.get("addr").and_then(|a| a.as_str()).filter(|a| !a.is_empty() && a.len() <= 2048)?.to_string()),
137 "left" => None,
138 _ => return None,
139 };
140 Some((topic_id, node_addr))
141}
142
143#[cfg(test)]
144mod url_topic_tests {
145 use super::*;
146
147 #[test]
148 fn a_url_topic_is_deterministic_per_message_and_survives_server_rebuilds() {
149 let a = derive_url_topic_id("https://x.org/app.xdc", "msg1");
150 assert_eq!(a, derive_url_topic_id("https://x.org/app.xdc", "msg1"));
151 assert_eq!(a.len(), 52, "must be a valid 52-char base32 TopicId");
152 assert!(parse_peer_signal(&peer_signal_content(&a, Some("iroh:x"))).is_some());
153 assert_ne!(a, derive_url_topic_id("https://x.org/other.xdc", "msg1"), "different URL = disjoint topics");
154 assert_ne!(a, derive_url_topic_id("https://x.org/app.xdc", "msg2"), "re-share = fresh session");
155 }
156}
157
158#[cfg(test)]
159mod peer_signal_tests {
160 use super::*;
161
162 #[test]
163 fn peer_signal_round_trips_and_bounds() {
164 let topic = "A".repeat(52);
165 let ad = peer_signal_content(&topic, Some("iroh:node/abc"));
166 assert_eq!(parse_peer_signal(&ad), Some((topic.clone(), Some("iroh:node/abc".into()))));
167 let left = peer_signal_content(&topic, None);
168 assert_eq!(parse_peer_signal(&left), Some((topic.clone(), None)));
169
170 assert_eq!(parse_peer_signal(&peer_signal_content("short", Some("a"))), None);
172 let oversized = "a".repeat(2049);
173 assert_eq!(parse_peer_signal(&peer_signal_content(&topic, Some(&oversized))), None);
174 assert_eq!(parse_peer_signal(&format!("{{\"op\":\"warp\",\"topic\":\"{topic}\"}}")), None);
175 assert_eq!(parse_peer_signal("not json"), None);
176 }
177}