netget/protocol/dual.rs
1//! Dual-protocol detection: which server protocols have a client counterpart.
2//!
3//! The server and client registries use divergent canonical names ("DoH" vs
4//! "DNS-over-HTTPS", "Tor Relay" vs "Tor", …), and their keyword matchers are
5//! not suitable for joining them (`ClientRegistry::parse_from_str` does greedy
6//! substring matching over an unordered map, so an ambiguous input is
7//! nondeterministic). This module joins the two canonical name tables
8//! deterministically: an explicit alias table first, then normalized-name
9//! equality (lowercase, all non-alphanumerics stripped), which absorbs the
10//! case- and punctuation-only differences (IGMP/igmp, ISIS/IS-IS,
11//! SOCKET_FILE/SocketFile, BLUETOOTH_BLE/"Bluetooth (BLE)", …).
12//!
13//! `tests/dual_protocol_test.rs` enforces alias-table completeness by diffing
14//! both tables through [`alias_table_problems`].
15
16use super::client_registry::{ALL_KNOWN_CLIENT_PROTOCOLS, CLIENT_REGISTRY};
17use super::server_registry::ALL_KNOWN_PROTOCOLS;
18
19/// Server-canonical → client-canonical, for names that differ beyond
20/// case/punctuation. Both sides must exist in their respective `ALL_KNOWN_*`
21/// tables; `alias_table_problems` (and the test built on it) fails otherwise.
22///
23/// The USB-* and BLUETOOTH_BLE_* profile servers are deliberately absent: the
24/// generic "USB" / "Bluetooth (BLE)" clients speak the base transport, not a
25/// specific profile, so pairing a profile server with them would promise an
26/// exchange the client cannot hold up. Bitcoin P2P is likewise absent: the Bitcoin
27/// client speaks Bitcoin Core JSON-RPC over HTTP, not the peer-to-peer wire protocol.
28const SERVER_TO_CLIENT_ALIASES: &[(&str, &str)] = &[
29 ("DoH", "DNS-over-HTTPS"),
30 ("Proxy", "HTTP Proxy"),
31 ("Tor Relay", "Tor"),
32 ("OpenID", "OpenIDConnect"),
33 ("SamlIdp", "SAML"),
34 ("SamlSp", "SAML"),
35 ("WebRTC Signaling", "WebRTC"),
36 ("Torrent-Tracker", "BitTorrent Tracker"),
37 ("Torrent-DHT", "BitTorrent DHT"),
38 ("Torrent-Peer", "BitTorrent Peer Wire"),
39];
40
41/// Lowercase with every non-alphanumeric character stripped, so that
42/// "SSH Agent" == "ssh-agent" == "SshAgent".
43fn normalize(name: &str) -> String {
44 name.chars()
45 .filter(|c| c.is_ascii_alphanumeric())
46 .collect::<String>()
47 .to_ascii_lowercase()
48}
49
50/// The client-registry canonical name for a server protocol, if any client
51/// implementation exists in the codebase (regardless of whether it is compiled
52/// into this build). Deterministic; never consults keyword matching.
53pub fn client_protocol_for_server(server_name: &str) -> Option<&'static str> {
54 if let Some((_, client)) = SERVER_TO_CLIENT_ALIASES
55 .iter()
56 .find(|(server, _)| server.eq_ignore_ascii_case(server_name))
57 {
58 return Some(client);
59 }
60 let wanted = normalize(server_name);
61 ALL_KNOWN_CLIENT_PROTOCOLS
62 .iter()
63 .map(|(name, _feature)| *name)
64 .find(|name| normalize(name) == wanted)
65}
66
67/// Like [`client_protocol_for_server`], but `Some` only when the client
68/// protocol is actually compiled into this build (present in the runtime
69/// client registry), so the UI can offer "open a client against this server"
70/// truthfully.
71pub fn compiled_client_protocol_for_server(server_name: &str) -> Option<String> {
72 // A known canonical name keeps its identity even when its server is not compiled.
73 // The keyword resolver deliberately falls back from an unavailable FTP to TCP;
74 // that CLI convenience must not change which client a protocol is paired with.
75 let wanted = normalize(server_name);
76 let known_canonical = ALL_KNOWN_PROTOCOLS
77 .iter()
78 .map(|(name, _)| *name)
79 .find(|name| normalize(name) == wanted);
80 // Instances preserve the operator's spelling (e.g. `bitcoin`), which may be a
81 // registry keyword rather than the canonical server name (`Bitcoin P2P`). Resolve
82 // those aliases before joining: the Bitcoin keyword would match the RPC client.
83 let canonical = known_canonical.or_else(|| {
84 super::server_registry::registry()
85 .resolve(server_name)
86 .ok()
87 .map(|server| server.protocol_name())
88 });
89 let client_name = client_protocol_for_server(canonical.unwrap_or(server_name))?;
90 // The join already returned the client's canonical name. Keyword matching here
91 // could substitute another compiled client for an unavailable implementation.
92 CLIENT_REGISTRY
93 .get(client_name)
94 .map(|_| client_name.to_string())
95}
96
97/// Every (server canonical name, client canonical name) pair derivable from
98/// the two registry tables. Codebase-wide, not restricted to this build.
99pub fn all_dual_protocols() -> Vec<(&'static str, &'static str)> {
100 ALL_KNOWN_PROTOCOLS
101 .iter()
102 .filter_map(|(server, _feature)| {
103 client_protocol_for_server(server).map(|client| (*server, client))
104 })
105 .collect()
106}
107
108/// Consistency problems in the alias table, for the completeness test:
109/// an alias whose server side is not a known server protocol, or whose client
110/// side is not a known client protocol, or that duplicates what normalization
111/// already achieves (dead weight that will rot).
112pub fn alias_table_problems() -> Vec<String> {
113 let mut problems = Vec::new();
114 for (server, client) in SERVER_TO_CLIENT_ALIASES {
115 if !ALL_KNOWN_PROTOCOLS.iter().any(|(name, _)| name == server) {
116 problems.push(format!(
117 "alias server side {server:?} is not in ALL_KNOWN_PROTOCOLS"
118 ));
119 }
120 if !ALL_KNOWN_CLIENT_PROTOCOLS
121 .iter()
122 .any(|(name, _)| name == client)
123 {
124 problems.push(format!(
125 "alias client side {client:?} is not in ALL_KNOWN_CLIENT_PROTOCOLS"
126 ));
127 }
128 if normalize(server) == normalize(client) {
129 problems.push(format!(
130 "alias {server:?} -> {client:?} is redundant: normalization already matches them"
131 ));
132 }
133 }
134 problems
135}