diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 21:43:24 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 21:43:24 +0100 |
| commit | 6ddebec575aa8c9c5a357c673cbd8e8a5cc5769d (patch) | |
| tree | 08974eee67646435fd2c54030002b2b2d8d351a2 | |
| parent | v2.3.1: settle orphaned reservations on server restart (diff) | |
| download | zagan-bet-6ddebec575aa8c9c5a357c673cbd8e8a5cc5769d.tar.gz zagan-bet-6ddebec575aa8c9c5a357c673cbd8e8a5cc5769d.zip | |
v2.3.2: group hosts by certificate fingerprint
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/TESTING.md | 5 | ||||
| -rw-r--r-- | src/client/ui.rs | 28 | ||||
| -rw-r--r-- | tests/client_ui.rs | 42 |
4 files changed, 73 insertions, 4 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "2.3.1" | 3 | version = "2.3.2" |
| 4 | edition = "2024" | 4 | edition = "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. | |||
| 64 | 8. `q` clears the board. Ctrl+C in a game clears the board and exits. | 64 | 8. `q` clears the board. Ctrl+C in a game clears the board and exits. |
| 65 | 9. `info`, `ping`, `send hello`, and `hosts` print readable output; the host | 65 | 9. `info`, `ping`, `send hello`, and `hosts` print readable output; the host |
| 66 | fingerprint fits 80 columns. | 66 | fingerprint fits 80 columns. |
| 67 | 10. Windows: repeat 2-9 and confirm keys are not duplicated or printed twice. | 67 | 10. 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. | ||
| 70 | 11. 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. | ||
| 250 | pub fn host_lines(hosts: &[(String, Option<String>)]) -> Vec<String> { | 257 | pub 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] | ||
| 231 | fn 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] | ||
| 258 | fn 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 | } | ||
