Skip to main content

logicaffeine_system/
addr.rs

1//! Address normalization — libp2p multiaddr → WebSocket URL.
2//!
3//! The compiled path dials peers with libp2p multiaddrs
4//! (`/ip4/127.0.0.1/tcp/9944`); the interpreter rides the thin WS relay, which
5//! speaks `ws://`. To keep one surface across both runtimes a program can write
6//! `Connect to "/ip4/127.0.0.1/tcp/9944"` and have the interpreter accept it —
7//! this maps the multiaddr to the relay's `ws://host:port`. Raw `ws://`/`wss://`
8//! URLs pass through unchanged, so either form works.
9//!
10//! Pure string logic, no libp2p, no target gating — it compiles into the native
11//! binary and the wasm bundle alike, so the interpreter normalizes addresses the
12//! same way on both.
13
14/// Normalize a peer address to a WebSocket URL the relay can dial.
15///
16/// Accepts:
17/// - a libp2p multiaddr: `/ip4/H/tcp/P`, `/ip6/H/tcp/P`, `/dns4/H/tcp/P`
18///   (also `/dns`, `/dns6`, `/dnsaddr`), with an optional `/ws` or `/wss`
19///   transport suffix (default `ws`) and an optional trailing `/p2p/<id>`
20///   (ignored — the relay addresses by host:port, not peer id);
21/// - a raw `ws://…` or `wss://…` URL, returned unchanged.
22///
23/// IPv6 literals are bracketed (`/ip6/::1/tcp/9944` → `ws://[::1]:9944`).
24///
25/// Returns `Err` with a human message on anything it cannot map.
26pub fn multiaddr_to_ws_url(addr: &str) -> Result<String, String> {
27    let trimmed = addr.trim();
28    if trimmed.is_empty() {
29        return Err("address is empty".to_string());
30    }
31
32    let lower = trimmed.to_ascii_lowercase();
33    if lower.starts_with("ws://") || lower.starts_with("wss://") {
34        return Ok(trimmed.to_string());
35    }
36
37    if !trimmed.starts_with('/') {
38        return Err(format!("not a ws:// URL or multiaddr: {trimmed}"));
39    }
40
41    let segs: Vec<&str> = trimmed.split('/').skip(1).collect();
42    let mut host: Option<(String, bool)> = None; // (host, is_ipv6_literal)
43    let mut port: Option<String> = None;
44    let mut scheme = "ws";
45
46    let mut i = 0;
47    while i < segs.len() {
48        match segs[i] {
49            "ip4" | "dns" | "dns4" | "dns6" | "dnsaddr" => {
50                let v = segs.get(i + 1).filter(|s| !s.is_empty());
51                let v = v.ok_or_else(|| format!("multiaddr '{trimmed}' has no host after /{}/", segs[i]))?;
52                host = Some((v.to_string(), false));
53                i += 2;
54            }
55            "ip6" => {
56                let v = segs
57                    .get(i + 1)
58                    .filter(|s| !s.is_empty())
59                    .ok_or_else(|| format!("multiaddr '{trimmed}' has no host after /ip6/"))?;
60                host = Some((v.to_string(), true));
61                i += 2;
62            }
63            "tcp" => {
64                let v = segs
65                    .get(i + 1)
66                    .filter(|s| !s.is_empty())
67                    .ok_or_else(|| format!("multiaddr '{trimmed}' has no port after /tcp/"))?;
68                if v.parse::<u16>().is_err() {
69                    return Err(format!("multiaddr '{trimmed}' has a non-numeric port '{v}'"));
70                }
71                port = Some(v.to_string());
72                i += 2;
73            }
74            "ws" => {
75                scheme = "ws";
76                i += 1;
77            }
78            "wss" | "tls" => {
79                scheme = "wss";
80                i += 1;
81            }
82            "p2p" | "p2p-circuit" => {
83                // The peer-id (and circuit-relay marker) carry no host:port; the
84                // relay does not use them. Skip the marker and any value.
85                i += if segs[i] == "p2p" { 2 } else { 1 };
86            }
87            other => {
88                return Err(format!("multiaddr '{trimmed}' has unsupported protocol '/{other}'"));
89            }
90        }
91    }
92
93    let (host, is_v6) = host.ok_or_else(|| format!("multiaddr '{trimmed}' has no host component"))?;
94    let port = port.ok_or_else(|| format!("multiaddr '{trimmed}' has no /tcp/<port>"))?;
95    let host = if is_v6 { format!("[{host}]") } else { host };
96    Ok(format!("{scheme}://{host}:{port}"))
97}
98
99/// The canonical relay topic for a peer address — its stable identity on the
100/// relay, computed identically by both endpoints.
101///
102/// A multiaddr or `ws://` URL normalizes to its `ws://` form (so
103/// `/ip4/127.0.0.1/tcp/8000` and `ws://127.0.0.1:8000` name the same peer); any
104/// other string (a bare name like `"alice"`) is used verbatim (trimmed). This is
105/// what `Send … to <peer>` publishes on and what `Listen at "<me>"` subscribes
106/// to, so a sender and a receiver that write the same address agree on the topic.
107pub fn canonical_topic(addr: &str) -> String {
108    multiaddr_to_ws_url(addr).unwrap_or_else(|_| addr.trim().to_string())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn canonical_topic_normalizes_multiaddr_and_url_to_same() {
117        assert_eq!(canonical_topic("/ip4/127.0.0.1/tcp/8000"), "ws://127.0.0.1:8000");
118        assert_eq!(canonical_topic("ws://127.0.0.1:8000"), "ws://127.0.0.1:8000");
119        // The two surfaces name the same peer.
120        assert_eq!(canonical_topic("/ip4/127.0.0.1/tcp/8000"), canonical_topic("ws://127.0.0.1:8000"));
121    }
122
123    #[test]
124    fn canonical_topic_passes_bare_names_through() {
125        assert_eq!(canonical_topic("alice"), "alice");
126        assert_eq!(canonical_topic("  bob  "), "bob");
127        assert_eq!(canonical_topic("game-room-42"), "game-room-42");
128    }
129
130    #[test]
131    fn ip4_tcp_defaults_to_ws() {
132        assert_eq!(multiaddr_to_ws_url("/ip4/127.0.0.1/tcp/9944").unwrap(), "ws://127.0.0.1:9944");
133    }
134
135    #[test]
136    fn ip4_tcp_ws_suffix() {
137        assert_eq!(multiaddr_to_ws_url("/ip4/127.0.0.1/tcp/9944/ws").unwrap(), "ws://127.0.0.1:9944");
138    }
139
140    #[test]
141    fn ip4_tcp_wss_suffix_is_secure() {
142        assert_eq!(multiaddr_to_ws_url("/ip4/10.0.0.5/tcp/443/wss").unwrap(), "wss://10.0.0.5:443");
143    }
144
145    #[test]
146    fn dns4_hostname() {
147        assert_eq!(multiaddr_to_ws_url("/dns4/relay.example.com/tcp/443/wss").unwrap(), "wss://relay.example.com:443");
148    }
149
150    #[test]
151    fn dns_and_dns6_and_dnsaddr_are_hostnames() {
152        assert_eq!(multiaddr_to_ws_url("/dns/example.com/tcp/80/ws").unwrap(), "ws://example.com:80");
153        assert_eq!(multiaddr_to_ws_url("/dns6/example.com/tcp/80").unwrap(), "ws://example.com:80");
154        assert_eq!(multiaddr_to_ws_url("/dnsaddr/example.com/tcp/8000").unwrap(), "ws://example.com:8000");
155    }
156
157    #[test]
158    fn ip6_literal_is_bracketed() {
159        assert_eq!(multiaddr_to_ws_url("/ip6/::1/tcp/9944").unwrap(), "ws://[::1]:9944");
160        assert_eq!(
161            multiaddr_to_ws_url("/ip6/2001:db8::1/tcp/443/wss").unwrap(),
162            "wss://[2001:db8::1]:443"
163        );
164    }
165
166    #[test]
167    fn trailing_p2p_peer_id_is_ignored() {
168        assert_eq!(
169            multiaddr_to_ws_url("/ip4/127.0.0.1/tcp/9944/ws/p2p/12D3KooWABCDEF").unwrap(),
170            "ws://127.0.0.1:9944"
171        );
172        assert_eq!(
173            multiaddr_to_ws_url("/ip4/127.0.0.1/tcp/9944/p2p/12D3KooWABCDEF").unwrap(),
174            "ws://127.0.0.1:9944"
175        );
176    }
177
178    #[test]
179    fn ws_and_wss_urls_pass_through() {
180        assert_eq!(multiaddr_to_ws_url("ws://127.0.0.1:9944").unwrap(), "ws://127.0.0.1:9944");
181        assert_eq!(multiaddr_to_ws_url("wss://relay.example.com/path").unwrap(), "wss://relay.example.com/path");
182        // case-insensitive scheme detection, value preserved verbatim
183        assert_eq!(multiaddr_to_ws_url("WS://Host:1").unwrap(), "WS://Host:1");
184    }
185
186    #[test]
187    fn whitespace_is_trimmed() {
188        assert_eq!(multiaddr_to_ws_url("  /ip4/127.0.0.1/tcp/9944  ").unwrap(), "ws://127.0.0.1:9944");
189    }
190
191    #[test]
192    fn empty_is_error() {
193        assert!(multiaddr_to_ws_url("").is_err());
194        assert!(multiaddr_to_ws_url("   ").is_err());
195    }
196
197    #[test]
198    fn garbage_is_error() {
199        assert!(multiaddr_to_ws_url("not-an-address").is_err());
200        assert!(multiaddr_to_ws_url("/not/a/valid/addr").is_err());
201    }
202
203    #[test]
204    fn missing_port_is_error() {
205        assert!(multiaddr_to_ws_url("/ip4/127.0.0.1").is_err());
206        assert!(multiaddr_to_ws_url("/ip4/127.0.0.1/tcp").is_err());
207    }
208
209    #[test]
210    fn non_numeric_port_is_error() {
211        assert!(multiaddr_to_ws_url("/ip4/127.0.0.1/tcp/notaport").is_err());
212    }
213
214    #[test]
215    fn missing_host_is_error() {
216        assert!(multiaddr_to_ws_url("/tcp/9944").is_err());
217    }
218
219    #[test]
220    fn non_tcp_transport_is_error() {
221        // The relay is TCP/WS only; a /udp/ multiaddr cannot map to a ws URL.
222        assert!(multiaddr_to_ws_url("/ip4/127.0.0.1/udp/9944").is_err());
223    }
224}