summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 21:43:24 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 21:43:24 +0100
commit6ddebec575aa8c9c5a357c673cbd8e8a5cc5769d (patch)
tree08974eee67646435fd2c54030002b2b2d8d351a2
parentv2.3.1: settle orphaned reservations on server restart (diff)
downloadzagan-bet-6ddebec575aa8c9c5a357c673cbd8e8a5cc5769d.tar.gz
zagan-bet-6ddebec575aa8c9c5a357c673cbd8e8a5cc5769d.zip
v2.3.2: group hosts by certificate fingerprint
-rw-r--r--Cargo.toml2
-rw-r--r--docs/TESTING.md5
-rw-r--r--src/client/ui.rs28
-rw-r--r--tests/client_ui.rs42
4 files changed, 73 insertions, 4 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 43b696b..c064cc3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.3.1" 3version = "2.3.2"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 60bc4ac..15b060c 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -64,7 +64,10 @@ Use an 80-column terminal because several output lines are width-sensitive.
648. `q` clears the board. Ctrl+C in a game clears the board and exits. 648. `q` clears the board. Ctrl+C in a game clears the board and exits.
659. `info`, `ping`, `send hello`, and `hosts` print readable output; the host 659. `info`, `ping`, `send hello`, and `hosts` print readable output; the host
66 fingerprint fits 80 columns. 66 fingerprint fits 80 columns.
6710. Windows: repeat 2-9 and confirm keys are not duplicated or printed twice. 6710. Connect to the same server through two addresses (for example `127.0.0.1`
68 and the machine's LAN IP). `hosts` must list both addresses on one line
69 under a single fingerprint, because the certificate key is the same.
7011. Windows: repeat 2-10 and confirm keys are not duplicated or printed twice.
68 71
69## Manual roulette checklist 72## Manual roulette checklist
70 73
diff --git a/src/client/ui.rs b/src/client/ui.rs
index 1a35db1..0fe78ae 100644
--- a/src/client/ui.rs
+++ b/src/client/ui.rs
@@ -247,14 +247,38 @@ pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec<String> {
247 247
248/// Format remembered hosts so a 64-character fingerprint does not wrap on an 248/// Format remembered hosts so a 64-character fingerprint does not wrap on an
249/// 80-column terminal. 249/// 80-column terminal.
250/// Render remembered hosts.
251///
252/// Two addresses that present the same certificate key are the same server, so
253/// they are grouped under one fingerprint: `127.0.0.1:11525` and
254/// `10.0.0.100:11525` reached through different networks still show as one
255/// entry. Hosts without a fingerprint are never merged, since nothing proves
256/// they are related.
250pub fn host_lines(hosts: &[(String, Option<String>)]) -> Vec<String> { 257pub fn host_lines(hosts: &[(String, Option<String>)]) -> Vec<String> {
251 if hosts.is_empty() { 258 if hosts.is_empty() {
252 return vec!["No remembered hosts.".to_string()]; 259 return vec!["No remembered hosts.".to_string()];
253 } 260 }
254 261
255 let mut lines = vec!["Remembered hosts:".to_string()]; 262 let mut groups: Vec<(Option<&str>, Vec<&str>)> = Vec::new();
256 for (host, fingerprint) in hosts { 263 for (host, fingerprint) in hosts {
257 lines.push(format!(" {host}")); 264 match fingerprint {
265 Some(fingerprint) => {
266 match groups
267 .iter_mut()
268 .find(|(key, _)| *key == Some(fingerprint.as_str()))
269 {
270 Some((_, addresses)) => addresses.push(host),
271 None => groups.push((Some(fingerprint), vec![host])),
272 }
273 }
274 // An unknown key proves nothing about identity.
275 None => groups.push((None, vec![host])),
276 }
277 }
278
279 let mut lines = vec!["Remembered hosts:".to_string()];
280 for (fingerprint, addresses) in groups {
281 lines.push(format!(" {}", addresses.join(", ")));
258 match fingerprint { 282 match fingerprint {
259 Some(fingerprint) => lines.push(format!(" fingerprint: {fingerprint}")), 283 Some(fingerprint) => lines.push(format!(" fingerprint: {fingerprint}")),
260 None => lines.push(" (no fingerprint)".to_string()), 284 None => lines.push(" (no fingerprint)".to_string()),
diff --git a/tests/client_ui.rs b/tests/client_ui.rs
index f5e3c0e..7a1b675 100644
--- a/tests/client_ui.rs
+++ b/tests/client_ui.rs
@@ -226,3 +226,45 @@ fn host_lines_fit_an_80_column_terminal() {
226 ); 226 );
227 } 227 }
228} 228}
229
230#[test]
231fn hosts_sharing_a_fingerprint_are_shown_as_one_server() {
232 let key = "5a15c712230831cb7270a36f5841bc5a52325db683073c845654a956d074f8ef";
233 let lines = host_lines(&[
234 ("10.0.0.100:11525".to_string(), Some(key.to_string())),
235 ("127.0.0.1:11525".to_string(), Some(key.to_string())),
236 (
237 "other.example:11525".to_string(),
238 Some("ff".repeat(32)),
239 ),
240 ]);
241
242 assert_eq!(
243 lines,
244 vec![
245 "Remembered hosts:",
246 " 10.0.0.100:11525, 127.0.0.1:11525",
247 &format!(" fingerprint: {key}"),
248 " other.example:11525",
249 &format!(" fingerprint: {}", "ff".repeat(32)),
250 ]
251 );
252
253 // The shared key is printed once, not once per address.
254 assert_eq!(lines.iter().filter(|line| line.contains(key)).count(), 1);
255}
256
257#[test]
258fn hosts_without_a_fingerprint_are_never_merged() {
259 let lines = host_lines(&[
260 ("a.example:443".to_string(), None),
261 ("b.example:443".to_string(), None),
262 ]);
263
264 assert!(lines.contains(&" a.example:443".to_string()));
265 assert!(lines.contains(&" b.example:443".to_string()));
266 assert_eq!(
267 lines.iter().filter(|line| line.contains("no fingerprint")).count(),
268 2
269 );
270}