1pub fn mint_topic_id(file_hash: &str, sender_hex: &str) -> String {
17 use sha2::{Digest, Sha256};
18 let nanos = std::time::SystemTime::now()
19 .duration_since(std::time::UNIX_EPOCH)
20 .map(|d| d.as_nanos())
21 .unwrap_or(0);
22 let mut hasher = Sha256::new();
23 hasher.update(b"webxdc-realtime-v1:");
24 hasher.update(file_hash.as_bytes());
25 hasher.update(b":");
26 hasher.update(sender_hex.as_bytes());
27 hasher.update(b":");
28 hasher.update(nanos.to_le_bytes());
29 base32_nopad_encode(&hasher.finalize())
30}
31
32pub fn base32_nopad_encode(bytes: &[u8]) -> String {
35 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
36 let mut out = String::with_capacity((bytes.len() * 8 + 4) / 5);
37 let mut buf: u64 = 0;
38 let mut bits: u32 = 0;
39 for &b in bytes {
40 buf = (buf << 8) | b as u64;
41 bits += 8;
42 while bits >= 5 {
43 bits -= 5;
44 out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
45 }
46 }
47 if bits > 0 {
48 out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
49 }
50 out
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56
57 #[test]
58 fn minted_topic_is_32_bytes_base32() {
59 let t = mint_topic_id("abc123", "deadbeef");
60 assert_eq!(t.len(), 52);
62 assert!(t.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
63 }
64
65 #[test]
66 fn resends_mint_distinct_topics() {
67 let a = mint_topic_id("abc123", "deadbeef");
68 let b = mint_topic_id("abc123", "deadbeef");
69 assert_ne!(a, b, "same file re-sent must start a fresh session topic");
70 }
71
72 #[test]
73 fn base32_matches_rfc4648_vectors() {
74 assert_eq!(base32_nopad_encode(b""), "");
76 assert_eq!(base32_nopad_encode(b"f"), "MY");
77 assert_eq!(base32_nopad_encode(b"fo"), "MZXQ");
78 assert_eq!(base32_nopad_encode(b"foo"), "MZXW6");
79 assert_eq!(base32_nopad_encode(b"foob"), "MZXW6YQ");
80 assert_eq!(base32_nopad_encode(b"fooba"), "MZXW6YTB");
81 assert_eq!(base32_nopad_encode(b"foobar"), "MZXW6YTBOI");
82 }
83}
84
85pub fn peer_signal_content(topic_id: &str, node_addr: Option<&str>) -> String {
90 match node_addr {
91 Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
92 None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
93 }
94}
95
96pub fn parse_peer_signal(content: &str) -> Option<(String, Option<String>)> {
101 let v: serde_json::Value = serde_json::from_str(content).ok()?;
102 let topic_id = v
103 .get("topic")
104 .and_then(|t| t.as_str())
105 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
106 .to_string();
107 let node_addr = match v.get("op").and_then(|o| o.as_str())? {
108 "ad" => Some(v.get("addr").and_then(|a| a.as_str()).filter(|a| !a.is_empty() && a.len() <= 2048)?.to_string()),
109 "left" => None,
110 _ => return None,
111 };
112 Some((topic_id, node_addr))
113}
114
115#[cfg(test)]
116mod peer_signal_tests {
117 use super::*;
118
119 #[test]
120 fn peer_signal_round_trips_and_bounds() {
121 let topic = "A".repeat(52);
122 let ad = peer_signal_content(&topic, Some("iroh:node/abc"));
123 assert_eq!(parse_peer_signal(&ad), Some((topic.clone(), Some("iroh:node/abc".into()))));
124 let left = peer_signal_content(&topic, None);
125 assert_eq!(parse_peer_signal(&left), Some((topic.clone(), None)));
126
127 assert_eq!(parse_peer_signal(&peer_signal_content("short", Some("a"))), None);
129 let oversized = "a".repeat(2049);
130 assert_eq!(parse_peer_signal(&peer_signal_content(&topic, Some(&oversized))), None);
131 assert_eq!(parse_peer_signal(&format!("{{\"op\":\"warp\",\"topic\":\"{topic}\"}}")), None);
132 assert_eq!(parse_peer_signal("not json"), None);
133 }
134}