From eecd0d9d486de03b26eca89a1df8ac2440ffb032 Mon Sep 17 00:00:00 2001 From: Simon Keimer Date: Mon, 21 Sep 2026 17:05:24 +0200 Subject: [PATCH 1/3] test(spot): pin the POTA and polled-source bounds as numbers, not constants The boundary inputs of the POTA parser tests were built from the constants they test (MAX_BODY + 1, MAX_SPOTS + 1), so changing a constant moved the test with it and nothing noticed. Mutating the constants themselves showed MAX_BODY, MAX_SPOTS, the frequency ceiling and the default poll interval all unpinned. The limits are now written as numbers (512 KiB, 2000 spots, 64-character text, 300 GHz plus one hertz, 30 s / 60 s / 1 h). MAX_TOKEN stays an equivalent mutation: the same 64 is applied again downstream by sanitise_text, and a string there is a borrowed slice; documented in the code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ma4bQKqGNFTYyVSEKPcXRk --- crates/k4-spot/src/polled.rs | 5 ++++ crates/k4-spot/src/pota.rs | 45 +++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/k4-spot/src/polled.rs b/crates/k4-spot/src/polled.rs index f50e553..317c44d 100644 --- a/crates/k4-spot/src/polled.rs +++ b/crates/k4-spot/src/polled.rs @@ -490,6 +490,11 @@ mod tests { clamp_interval(u64::MAX), Duration::from_secs(MAX_INTERVAL_SECS) ); + // The bounds and the default, as numbers: 30 s to 1 h, once a minute by default. + assert_eq!( + (MIN_INTERVAL_SECS, DEFAULT_INTERVAL_SECS, MAX_INTERVAL_SECS), + (30, 60, 3600) + ); const { assert!(MIN_INTERVAL_SECS <= DEFAULT_INTERVAL_SECS) }; const { assert!(DEFAULT_INTERVAL_SECS <= MAX_INTERVAL_SECS) }; } diff --git a/crates/k4-spot/src/pota.rs b/crates/k4-spot/src/pota.rs index 80b6ee6..b95e063 100644 --- a/crates/k4-spot/src/pota.rs +++ b/crates/k4-spot/src/pota.rs @@ -24,7 +24,10 @@ pub const MAX_BODY: usize = 512 * 1024; pub const MAX_SPOTS: usize = 2000; /// Longest string read as a value. A longer one in a field that is used drops that field; the -/// strings that are only skipped are bounded by [`MAX_BODY`]. +/// strings that are only skipped are bounded by [`MAX_BODY`]. The same 64 is applied again by +/// `sanitise_text` to what is kept, so this limit is also enforced downstream: changing it alone +/// changes nothing a caller can see, and a string here is a borrowed slice, so it is not what +/// bounds memory. const MAX_TOKEN: usize = 64; /// Lowest and highest frequency accepted, Hz. The floor also catches a reply that has switched to @@ -464,8 +467,10 @@ mod tests { (" 10136", None), ("10136 ", None), ("0x2f", None), - ("1234567890", None), // too many digits - ("300000001.0", None), // above 300 GHz + ("1234567890", None), // too many digits + ("300000000.0", Some(300_000_000_000)), // exactly 300 GHz + ("300000000.001", None), // one hertz above 300 GHz + ("300000001.0", None), // above 300 GHz ] { assert_eq!(khz_to_hz(text), want, "{text:?}"); } @@ -586,20 +591,38 @@ mod tests { parse_spots(&huge, NOW).is_ok(), "the unpadded list is valid" ); - huge.resize(MAX_BODY + 1, b' '); + // The limits are written as numbers, not taken from the constants: a test built from a + // constant moves with it and never notices a change. 512 KiB is read, one byte more is not. + huge.resize(524_289, b' '); assert!(parse_spots(&huge, NOW).is_err()); - huge.truncate(MAX_BODY); + huge.truncate(524_288); assert!( parse_spots(&huge, NOW).is_ok(), "exactly at the cap is read" ); - let many = format!("[{}]", vec!["{}"; MAX_SPOTS + 1].join(",")); + let many = format!("[{}]", vec!["{}"; 2001].join(",")); assert!(parse_spots(many.as_bytes(), NOW).is_err()); - let ok_many = format!("[{}]", vec!["{}"; MAX_SPOTS].join(",")); - assert_eq!( - parse_spots(ok_many.as_bytes(), NOW).unwrap().rejected, - MAX_SPOTS as u64 - ); + let ok_many = format!("[{}]", vec!["{}"; 2000].join(",")); + assert_eq!(parse_spots(ok_many.as_bytes(), NOW).unwrap().rejected, 2000); + } + + /// FR-SPOT-08: a text field is used up to 64 characters and skipped beyond — the limit written as + /// a number, and checked on a field that is otherwise valid so only its length can matter. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_pota_text_field_limit() { + let with_mode = |mode: &str| { + let rec = record("aa1aaa", "10136.0", "2026-09-21T05:07:00") + .replace(r#""mode":"FT8""#, &format!(r#""mode":"{mode}""#)); + parse_spots(format!("[{rec}]").as_bytes(), NOW) + .unwrap() + .spots[0] + .mode + .clone() + }; + assert_eq!(with_mode(&"m".repeat(64)), Some("m".repeat(64))); + assert_eq!(with_mode(&"m".repeat(65)), None); + assert_eq!(with_mode("FT8"), Some("FT8".to_string())); } /// FR-SPOT-08: text with escapes, over-long text, or non-ASCII loses only that field; a From 91e427dda12166dc109f5b40101bfbb894c56946 Mon Sep 17 00:00:00 2001 From: Simon Keimer Date: Mon, 21 Sep 2026 17:05:45 +0200 Subject: [PATCH 2/3] feat(spot): FreeDV Reporter as a live WebSocket source (FR-SPOT-08); drop WSPRnet Stations on the air right now on FreeDV Reporter (qso.freedv.org) appear as coral nameplates. The source joins in the read-only `view` role: the operator is not listed as a station and nothing identifying is sent beyond the program's name and version. New in k4-spot, hand-written, dependency-free and bounded like the MQTT client: - json: a strict parser (8 levels, 20 000 values, 4096-byte strings; no trailing comma, duplicate key or lone surrogate). - ws: a WebSocket client (RFC 6455) with SHA-1 and base64 for the accept key, checked against FIPS 180-4, RFC 4648 and the RFC's own examples; the upgrade reply is checked in full, and masked, reserved, oversize or fragmented-control frames are refused. - sio: Engine.IO 4 / Socket.IO 4 packets. - freedv: the station roster (a presence list; capped at 4096 stations). - freedv_source: upgrade, open, view connect, liveness from the ping timing the server announces, reconnect with backoff, periodic re-stamping of stations that stay on the roster. App: a fifth worker slot, FreeDvPrefs, a Networks section, demo spots. The format is from another client's public source and was then checked against the real service, four sessions of at most ten seconds in the view role. That showed the first parser rejected 10-12 events per session as malformed: a freq of 0 (meaning no frequency) and non-ASCII message text, both normal. A wrong field type still rejects an event; a well-typed value that cannot be shown now degrades only that field. The same probe then reported 0 rejected. Mutation testing also found that nearly every bound in the new modules was unpinned (tests built their boundary inputs from the constants they tested); they are now numbers. The same fix for the POTA parser is the previous commit. WSPRnet is dropped by decision; SOTA stays blocked on API-consumers membership. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ma4bQKqGNFTYyVSEKPcXRk --- CHANGELOG.md | 1 + app/src/main.rs | 183 +++- app/src/spot_sources.rs | 205 +++- app/src/spots.rs | 2 + crates/k4-config/src/lib.rs | 38 + crates/k4-config/tests/config.rs | 36 + crates/k4-spot/src/freedv.rs | 1113 ++++++++++++++++++++++ crates/k4-spot/src/freedv_source.rs | 451 +++++++++ crates/k4-spot/src/json.rs | 503 ++++++++++ crates/k4-spot/src/lib.rs | 5 + crates/k4-spot/src/model.rs | 1 + crates/k4-spot/src/sio.rs | 355 +++++++ crates/k4-spot/src/style.rs | 5 +- crates/k4-spot/src/tooltip.rs | 2 + crates/k4-spot/src/ws.rs | 907 ++++++++++++++++++ crates/k4-spot/tests/freedv_live.rs | 64 ++ crates/k4-spot/tests/freedv_source.rs | 968 +++++++++++++++++++ docs/references/external-references.md | 45 +- docs/requirements/system-requirements.md | 7 +- docs/test/coverage.generated.md | 2 +- docs/test/test-strategy.md | 3 +- docs/user-manual.md | 6 +- 22 files changed, 4874 insertions(+), 28 deletions(-) create mode 100644 crates/k4-spot/src/freedv.rs create mode 100644 crates/k4-spot/src/freedv_source.rs create mode 100644 crates/k4-spot/src/json.rs create mode 100644 crates/k4-spot/src/sio.rs create mode 100644 crates/k4-spot/src/ws.rs create mode 100644 crates/k4-spot/tests/freedv_live.rs create mode 100644 crates/k4-spot/tests/freedv_source.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 24aee5b..e6c2bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ and [`docs/requirements/system-requirements.md`](docs/requirements/system-requir nothing identifying; only the bands you are on are requested, and nothing is requested when no radio is connected. (This replaces the poll interval the setting used to have.) +- **FreeDV Reporter spots.** Turn on **FreeDV Reporter** in the Networks window and the stations that are on the air right now on FreeDV Reporter appear as **coral** nameplates at the frequency they report. You join **read-only**: you are not listed as a station and nothing that identifies you is sent (only the program's name and version). It is a presence list, so a plate stays while the station is connected and fades after it leaves. It was built from another program's source and then checked against the live service for a few seconds at a time, which found two ordinary cases the first version was too strict about; please report anything that does not draw. (WSPRnet was dropped.) - **Spectrum afterglow.** A new **Spectrum afterglow** setting (Settings, in milliseconds; **0 = off, the default**) makes a peak linger on the spectrum trace and fade, so a brief signal — a CW dit, an FT8 tone — can still be seen after it has passed. The trail is drawn under the live trace as a faint fill and a dimmer outline. A peak falls 4.3 dB every time-constant you set, 50 to 5000 ms; a few hundred milliseconds is a gentle trail, several seconds holds a signal for a while. It follows each row as it arrives, so it looks the same however fast the window redraws, and it restarts when the pan moves or changes span. The waterfall is unchanged. - **Lower GPU load while a pan is streaming.** The window used to redraw at the display's refresh rate whenever spectrum rows were arriving, although rows come at only about 30 a second and a frame between two of them shows nothing new. It now redraws at the rate rows arrive (never faster than about 125 a second, never slower than 10). A new row can appear up to one row interval later than before. `K4_FPS=1` in the environment prints the frames drawn per second, for checking. - **TLS for PSK Reporter, with approval of an untrusted certificate.** A new **TLS** switch under PSK Reporter in the Networks window (off by default; the port follows it, 1883 to 1884) encrypts the connection. PSK Reporter's own certificate is trusted by the public authorities, so it just works. If you point it at a server whose certificate is *not* — a self-hosted broker, say — nothing is sent and you are shown why and the certificate's SHA-256 fingerprint; **Trust this certificate** approves exactly that one for that host and port. If the certificate later changes you are asked again, with a warning, never silently. Approvals are listed and can be withdrawn. diff --git a/app/src/main.rs b/app/src/main.rs index 236fbca..73b77ff 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -114,6 +114,18 @@ enum SpotNet { Rbn, DxCluster, Pota, + FreeDv, +} + +/// What the spot worker was last told to run (so a tick sends only a change): one entry per network, +/// `None` while it is off. +#[derive(Debug, Clone, Default, PartialEq)] +struct SpotSent { + rbn: Option, + dx_cluster: Option, + psk_reporter: Option, + pota: Option, + freedv: Option, } /// The certificate a click approves (FR-SPOT-13): the one that is on screen, and only if the click @@ -205,12 +217,7 @@ struct App { /// The sources' status as of the last tick, for the Networks window. spot_status_ui: spot_sources::Statuses, /// What the worker was last told to run and keep, so a tick sends only a change. - spot_sent: ( - Option, - Option, - Option, - Option, - ), + spot_sent: SpotSent, spot_window_sent: Option<(u64, u64)>, /// Draw the waterfall on the GPU (false = the CPU rasteriser, when there is no wgpu adapter). gpu_waterfall: bool, @@ -338,6 +345,7 @@ struct App { spot_rbn_port: String, spot_dx_port: String, spot_pota_secs: String, + spot_freedv_port: String, /// The certificates approved for encrypted connections, shared with the worker. spot_pins: tls::Pins, // KPA1500 client worker (FR-AMP-03): a shared snapshot the worker writes @@ -1088,6 +1096,7 @@ impl App { let spot_rbn_port = spot_networks.rbn.port.to_string(); let spot_dx_port = spot_networks.dx_cluster.port.to_string(); let spot_pota_secs = spot_networks.pota.poll_secs().to_string(); + let spot_freedv_port = spot_networks.freedv.port.to_string(); // The amplifier worker starts idle (disconnected); the tick reconciler // connects it once the K4 is up and support is enabled. let kpa_shared = Arc::new(Mutex::new(kpa::Shared::default())); @@ -1156,7 +1165,7 @@ impl App { spot_tx, spot_status, spot_status_ui: spot_sources::Statuses::default(), - spot_sent: (None, None, None, None), + spot_sent: SpotSent::default(), spot_window_sent: None, gpu_waterfall: waterfall_gpu::gpu_available(), ui: initial, @@ -1224,6 +1233,7 @@ impl App { spot_rbn_port, spot_dx_port, spot_pota_secs, + spot_freedv_port, spot_pins, kpa1500_enabled, kpa1500_host, @@ -1691,6 +1701,8 @@ impl App { nets.rbn.port = k4_config::parse_spot_port(&self.spot_rbn_port, 7000); nets.dx_cluster.port = k4_config::parse_spot_port(&self.spot_dx_port, 7300); nets.pota.poll_secs = k4_config::parse_spot_poll_secs(&self.spot_pota_secs); + nets.freedv.port = + k4_config::parse_spot_port(&self.spot_freedv_port, k4_config::SPOT_FREEDV_DEFAULT_PORT); nets } @@ -2922,6 +2934,10 @@ impl App { let n = &mut self.spot_networks.pota; n.enabled = !n.enabled; } + SpotNet::FreeDv => { + let n = &mut self.spot_networks.freedv; + n.enabled = !n.enabled; + } } self.save_config(); } @@ -2931,6 +2947,7 @@ impl App { SpotNet::Rbn => self.spot_networks.rbn.host = host, SpotNet::DxCluster => self.spot_networks.dx_cluster.host = host, SpotNet::PskReporter => self.spot_networks.psk_reporter.host = host, + SpotNet::FreeDv => self.spot_networks.freedv.host = host, SpotNet::Pota => {} } } @@ -2940,6 +2957,7 @@ impl App { SpotNet::Rbn => self.spot_rbn_port = digits, SpotNet::DxCluster => self.spot_dx_port = digits, SpotNet::PskReporter => self.spot_psk_port = digits, + SpotNet::FreeDv => self.spot_freedv_port = digits, SpotNet::Pota => {} } } @@ -2979,7 +2997,7 @@ impl App { match net { SpotNet::Rbn => self.spot_networks.rbn.login = login, SpotNet::DxCluster => self.spot_networks.dx_cluster.login = login, - SpotNet::PskReporter | SpotNet::Pota => {} + SpotNet::PskReporter | SpotNet::Pota | SpotNet::FreeDv => {} } } Message::KpaSetMode(operate) => self.kpa_send(k4_kpa::cat::set_mode(operate)), @@ -3284,14 +3302,30 @@ impl App { interval: k4_spot::polled::clamp_interval(nets.pota.poll_secs()), max_body: k4_spot::pota::MAX_BODY, }); - if (rbn.clone(), dx.clone(), psk.clone(), pota.clone()) != self.spot_sent { - self.spot_sent = (rbn.clone(), dx.clone(), psk.clone(), pota.clone()); + let freedv = + nets.freedv + .enabled + .then(|| k4_spot::freedv_source::FreeDvConfig { + host: nets.freedv.host.clone(), + port: nets.freedv.port, + user_agent: concat!("K4remote/", env!("CARGO_PKG_VERSION")).into(), + }); + let sent = SpotSent { + rbn, + dx_cluster: dx, + psk_reporter: psk, + pota, + freedv, + }; + if sent != self.spot_sent { let _ = self.spot_tx.send(spot_sources::Cmd::Configure { - rbn, - dx_cluster: dx, - psk_reporter: psk, - pota, + rbn: sent.rbn.clone(), + dx_cluster: sent.dx_cluster.clone(), + psk_reporter: sent.psk_reporter.clone(), + pota: sent.pota.clone(), + freedv: sent.freedv.clone(), }); + self.spot_sent = sent; } let win = spots::spot_window(self.ui.vfo_a_hz, self.ui.vfo_b_hz); if spots::window_needs_update(self.spot_window_sent, win) { @@ -6690,6 +6724,7 @@ impl App { nets.rbn.enabled, nets.dx_cluster.enabled, nets.pota.enabled, + nets.freedv.enabled, ] .iter() .filter(|e| **e) @@ -6722,7 +6757,7 @@ impl App { Row::new() .spacing(8) .align_y(Alignment::Center) - .push(Text::new(format!("Spotting networks: {on} of 4 on")).size(12)) + .push(Text::new(format!("Spotting networks: {on} of 5 on")).size(12)) .push(small_btn("Networks…", Message::ToggleSpotWindow)), ) .into() @@ -6952,6 +6987,41 @@ impl App { &self.spot_status_ui.pota, nets.pota.enabled, )) + .push(Text::new("FreeDV Reporter").size(13)) + .push( + Text::new( + "Stations on the air right now on FreeDV Reporter, read-only: you join as a \ + viewer, nothing identifying is sent and you are not listed as a station. \ + It is a presence list, so a station's plate stays while it is connected \ + and fades after it leaves.", + ) + .size(11) + .color(dim), + ) + .push(small_btn_pair( + nets.freedv.enabled, + "FreeDV Reporter: ON", + "FreeDV Reporter: OFF", + Message::ToggleSpotNetwork(SpotNet::FreeDv), + )) + .push(Self::spot_field( + "Host", + "qso.freedv.org", + &nets.freedv.host, + 230.0, + |v| Message::SpotHostChanged(SpotNet::FreeDv, v), + )) + .push(Self::spot_field( + "Port", + "80", + &self.spot_freedv_port, + 90.0, + |v| Message::SpotPortChanged(SpotNet::FreeDv, v), + )) + .push(Self::spot_status_line( + &self.spot_status_ui.freedv, + nets.freedv.enabled, + )) .push( Container::new( Row::new() @@ -10781,3 +10851,86 @@ mod afterglow_wiring_tests { ); } } + +#[cfg(test)] +mod freedv_wiring_tests { + /// FR-SPOT-08: FreeDV Reporter's settings are carried through every hand-off — the switch, the + /// host and port fields, the saved port (the save ends in `..Default::default()`-style + /// construction of the networks, so a forgotten field would silently reset), the tick that + /// tells the worker, and the window that shows it. Structural, reading only the code above this + /// module so the needles cannot match this test's own text. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_settings_are_wired_end_to_end() { + let whole = include_str!("main.rs"); + let code = &whole[..whole + .find(concat!("mod freedv_wiring", "_tests {")) + .expect("the test module")]; + // Compared with all whitespace removed, so rustfmt's line breaks cannot make it pass or fail. + let squash = |t: &str| t.split_whitespace().collect::(); + let code = squash(code); + for (what, needle) in [ + ( + "the switch toggles it", + "SpotNet::FreeDv => {\n let n = &mut self.spot_networks.freedv;\n n.enabled = !n.enabled;", + ), + ( + "the host field is stored", + "SpotNet::FreeDv => self.spot_networks.freedv.host = host,", + ), + ( + "the port field is kept", + "SpotNet::FreeDv => self.spot_freedv_port = digits,", + ), + ( + "the saved port comes from the field", + "nets.freedv.port = k4_config::parse_spot_port(\n &self.spot_freedv_port,", + ), + ( + "the port field starts from the saved port", + "let spot_freedv_port = spot_networks.freedv.port.to_string();", + ), + ( + "the tick builds the worker's config from the settings", + "nets\n .freedv\n .enabled\n .then(|| k4_spot::freedv_source::FreeDvConfig {\n host: nets.freedv.host.clone(),\n port: nets.freedv.port,", + ), + ( + "the request names the program, not the operator", + "user_agent: concat!(\"K4remote/\", env!(\"CARGO_PKG_VERSION\")).into(),", + ), + ( + "the worker is told", + "freedv: sent.freedv.clone(),", + ), + ( + "the sent state includes it (so a change is sent)", + " pota,\n freedv,\n };", + ), + ( + "the summary counts it", + "nets.pota.enabled,\n nets.freedv.enabled,", + ), + ( + "the window has its switch", + "Message::ToggleSpotNetwork(SpotNet::FreeDv),", + ), + ( + "the window has its host field", + "Message::SpotHostChanged(SpotNet::FreeDv, v)", + ), + ( + "the window has its port field", + "Message::SpotPortChanged(SpotNet::FreeDv, v)", + ), + ( + "the window shows its status", + "&self.spot_status_ui.freedv,", + ), + ] { + assert!( + code.contains(&squash(needle)), + "FreeDV wiring missing — {what}" + ); + } + } +} diff --git a/app/src/spot_sources.rs b/app/src/spot_sources.rs index 066d4d3..4cc2f2b 100644 --- a/app/src/spot_sources.rs +++ b/app/src/spot_sources.rs @@ -1,5 +1,6 @@ //! The worker thread that runs the spot sources — the Reverse Beacon Network, a DX cluster (both -//! telnet), PSK Reporter (MQTT) and POTA (a polled HTTP list) — keeps the spot store fed, and +//! telnet), PSK Reporter (MQTT), POTA (a polled HTTP list) and FreeDV Reporter (a WebSocket) — +//! keeps the spot store fed, and //! reports what each is doing (FR-SPOT-05/07/08/09). //! //! Each source is polled independently on this thread, off the UI and the radio-control paths, so @@ -16,6 +17,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use k4_spot::freedv_source::{FreeDvConfig, FreeDvSource}; use k4_spot::mqtt_source::{CertInfo, MqttConfig, MqttSource}; use k4_spot::polled::{PolledConfig, PolledSource}; use k4_spot::pota; @@ -48,11 +50,16 @@ pub struct Statuses { pub dx_cluster: Option, pub psk_reporter: Option, pub pota: Option, + pub freedv: Option, } pub type StatusHandle = Arc>; /// Instructions from the UI. +// `Configure` carries one config per network and the other commands carry almost nothing. A command is +// sent only when a setting changes — a few times a session — so the size gap costs nothing, and +// boxing it would only add a deref to every place a configuration is built. +#[allow(clippy::large_enum_variant)] pub enum Cmd { /// Run these sources (`None` = off). A source is restarted only when its settings change. Configure { @@ -60,6 +67,7 @@ pub enum Cmd { dx_cluster: Option, psk_reporter: Option, pota: Option, + freedv: Option, }, /// Keep only spots inside `[lo, hi]` Hz, and (for PSK Reporter) subscribe to the bands that /// overlap it. An empty range (`lo > hi`) keeps none and subscribes to none; `None` keeps @@ -75,12 +83,14 @@ enum FeedConfig { Telnet(TelnetConfig), Mqtt(MqttConfig), Polled(PolledConfig), + FreeDv(FreeDvConfig), } enum Feed { Telnet(TelnetSource), Mqtt(MqttSource), Polled(PolledSource), + FreeDv(FreeDvSource), } impl Feed { @@ -105,6 +115,7 @@ impl Feed { parse, )) } + FeedConfig::FreeDv(c) => Feed::FreeDv(FreeDvSource::new(c.clone())), }) } @@ -113,6 +124,7 @@ impl Feed { Feed::Telnet(s) => s.poll(sink), Feed::Mqtt(s) => s.poll(sink), Feed::Polled(s) => s.poll(sink), + Feed::FreeDv(s) => s.poll(sink), } } @@ -121,6 +133,7 @@ impl Feed { Feed::Telnet(s) => s.set_window(w), Feed::Mqtt(s) => s.set_window(w), Feed::Polled(s) => s.set_window(w), + Feed::FreeDv(s) => s.set_window(w), } } @@ -136,6 +149,7 @@ impl Feed { Feed::Telnet(s) => s.state(), Feed::Mqtt(s) => s.state(), Feed::Polled(s) => s.state(), + Feed::FreeDv(s) => s.state(), } } @@ -158,6 +172,7 @@ impl Feed { Feed::Telnet(s) => s.stats(), Feed::Mqtt(s) => s.stats(), Feed::Polled(s) => s.stats(), + Feed::FreeDv(s) => s.stats(), } } } @@ -207,6 +222,7 @@ fn run(rx: &Receiver, store: &SpotHandle, status: &StatusHandle, pins: &tls Slot::default(), Slot::default(), Slot::default(), + Slot::default(), ]; // Until told otherwise, keep nothing and subscribe to nothing: no radio, no view, nothing to // label. @@ -220,12 +236,14 @@ fn run(rx: &Receiver, store: &SpotHandle, status: &StatusHandle, pins: &tls dx_cluster, psk_reporter, pota, + freedv, }) => { let wanted = [ rbn.map(FeedConfig::Telnet), dx_cluster.map(FeedConfig::Telnet), psk_reporter.map(FeedConfig::Mqtt), pota.map(FeedConfig::Polled), + freedv.map(FeedConfig::FreeDv), ]; for (slot, want) in slots.iter_mut().zip(wanted) { if slot.cfg != want { @@ -285,6 +303,7 @@ fn run(rx: &Receiver, store: &SpotHandle, status: &StatusHandle, pins: &tls dx_cluster: slots[1].status(), psk_reporter: slots[2].status(), pota: slots[3].status(), + freedv: slots[4].status(), }; if now != published { if let Ok(mut g) = status.lock() { @@ -454,6 +473,7 @@ mod tests { dx_cluster: Some(cfg(Network::DxCluster, dead)), psk_reporter: None, pota: None, + freedv: None, }) .unwrap(); @@ -492,6 +512,7 @@ mod tests { dx_cluster: None, psk_reporter: None, pota: None, + freedv: None, }) .unwrap(); wait("all sources are stopped", || { @@ -557,6 +578,7 @@ mod tests { dx_cluster: None, psk_reporter: Some(mqtt_cfg(port)), pota: None, + freedv: None, }) .unwrap(); assert_eq!( @@ -741,6 +763,7 @@ mod tests { dx_cluster: None, psk_reporter: None, pota: Some(pota_cfg(good)), + freedv: None, }) .unwrap(); wait("the POTA spot reaches the store", || { @@ -780,6 +803,7 @@ mod tests { dx_cluster: None, psk_reporter: None, pota: Some(pota_cfg(bad)), + freedv: None, }) .unwrap(); wait("POTA's failure is reported", || { @@ -811,6 +835,7 @@ mod tests { dx_cluster: None, psk_reporter: None, pota: None, + freedv: None, }) .unwrap(); wait("every source is stopped", || { @@ -927,6 +952,7 @@ mod tests { ..mqtt_cfg(port) }), pota: None, + freedv: None, }) .unwrap(); @@ -989,4 +1015,181 @@ mod tests { assert_eq!(p.error, None); assert_eq!(connects.load(Ordering::SeqCst), 1); } + + /// A mock FreeDV Reporter: upgrade, Engine.IO open, read the client's connect frame, Socket.IO + /// acknowledgement, then the given events, then hold the line. Frames are built by hand. + fn mock_freedv(events: Vec) -> u16 { + fn frame(payload: &[u8]) -> Vec { + let mut f = vec![0x81]; + match payload.len() { + n if n < 126 => f.push(n as u8), + n => { + f.push(126); + f.extend_from_slice(&(n as u16).to_be_bytes()); + } + } + f.extend_from_slice(payload); + f + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + let Ok((mut s, _)) = listener.accept() else { + return; + }; + let mut req = Vec::new(); + let mut b = [0u8; 1]; + while !req.ends_with(b"\r\n\r\n") { + if s.read_exact(&mut b).is_err() { + return; + } + req.push(b[0]); + } + let req = String::from_utf8_lossy(&req).into_owned(); + let key = req + .lines() + .find_map(|l| l.strip_prefix("Sec-WebSocket-Key: ")) + .unwrap_or("") + .trim() + .to_string(); + let _ = write!( + s, + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n", + k4_spot::ws::accept_key(&key) + ); + let _ = s.write_all(&frame( + br#"0{"sid":"s","pingInterval":25000,"pingTimeout":20000}"#, + )); + // The client's connect frame: 2 header bytes, 4 mask bytes, the payload. + let mut h = [0u8; 2]; + if s.read_exact(&mut h).is_err() { + return; + } + let mut rest = vec![0u8; 4 + usize::from(h[1] & 0x7f)]; + if s.read_exact(&mut rest).is_err() { + return; + } + let _ = s.write_all(&frame(br#"40{"sid":"me"}"#)); + for e in events { + let _ = s.write_all(&frame(e.as_bytes())); + } + thread::sleep(Duration::from_secs(4)); + }); + port + } + + fn freedv_cfg(port: u16) -> FreeDvConfig { + FreeDvConfig { + host: "127.0.0.1".into(), + port, + user_agent: "K4remote/test".into(), + } + } + + /// FR-SPOT-08/09: the worker runs FreeDV Reporter into the store (window-filtered), a failing + /// FreeDV Reporter is reported against its own network while RBN keeps delivering, and + /// switching it off removes its status. + /// trace: FR-SPOT-08, FR-SPOT-09 + #[test] + fn fr_spot_08_worker_runs_freedv_and_isolates_its_failure() { + let store: SpotHandle = Arc::default(); + let status: StatusHandle = Arc::default(); + let (tx, rx) = mpsc::channel(); + spawn(rx, Arc::clone(&store), Arc::clone(&status), Arc::default()); + tx.send(Cmd::Window(Some((14_000_000, 14_100_000)))) + .unwrap(); + + let port = mock_freedv(vec![ + r#"42["freq_change",{"sid":"a","freq":14074000,"callsign":"aa1aaa"}]"#.into(), + r#"42["freq_change",{"sid":"b","freq":7177000,"callsign":"bb2bbb"}]"#.into(), + ]); + tx.send(Cmd::Configure { + rbn: None, + dx_cluster: None, + psk_reporter: None, + pota: None, + freedv: Some(freedv_cfg(port)), + }) + .unwrap(); + wait("the FreeDV spot reaches the store", || { + store.lock().unwrap().spots().len() == 1 + }); + { + let st = store.lock().unwrap(); + let spot = &st.spots()[0]; + assert_eq!((spot.call.as_str(), spot.freq_hz), ("AA1AAA", 14_074_000)); + assert_eq!(spot.network, Network::FreeDvReporter); + } + wait("FreeDV reports it is connected", || { + status + .lock() + .unwrap() + .freedv + .as_ref() + .is_some_and(|f| f.state == ConnState::Connected && f.stats.outside_window == 1) + }); + let f = status.lock().unwrap().freedv.clone().unwrap(); + assert!(!f.polled, "a live feed, not a polled one"); + assert_eq!(f.error, None); + assert_eq!(f.stats.spots, 1); + assert_eq!(describe(&f), ("connected — 1 spots".into(), false)); + + // FreeDV fails (nothing listening) while RBN is healthy: the failure is FreeDV's alone. + let closed = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let cluster = mock_cluster("DX de K1TTT-#: 14074.0 W1AW CW 30 dB 20 WPM CQ 1200Z\r\n"); + tx.send(Cmd::Configure { + rbn: Some(cfg(Network::Rbn, cluster)), + dx_cluster: None, + psk_reporter: None, + pota: None, + freedv: Some(freedv_cfg(closed)), + }) + .unwrap(); + wait("FreeDV's failure is reported", || { + status + .lock() + .unwrap() + .freedv + .as_ref() + .is_some_and(|f| f.error.is_some()) + }); + wait("RBN delivers meanwhile", || { + status + .lock() + .unwrap() + .rbn + .as_ref() + .is_some_and(|r| r.state == ConnState::Connected && r.stats.spots >= 1) + }); + let s = status.lock().unwrap().clone(); + let f = s.freedv.unwrap(); + assert_eq!(f.state, ConnState::Disconnected); + assert!( + f.error + .as_deref() + .is_some_and(|e| e.contains("connect to 127.0.0.1")), + "{:?}", + f.error + ); + assert!(describe(&f).1, "shown as a problem"); + assert_eq!(s.rbn.unwrap().error, None, "RBN is unaffected"); + + // Off means gone. + tx.send(Cmd::Configure { + rbn: None, + dx_cluster: None, + psk_reporter: None, + pota: None, + freedv: None, + }) + .unwrap(); + wait("every source is stopped", || { + let s = status.lock().unwrap(); + s.freedv.is_none() && s.rbn.is_none() + }); + } } diff --git a/app/src/spots.rs b/app/src/spots.rs index f244b33..2470005 100644 --- a/app/src/spots.rs +++ b/app/src/spots.rs @@ -39,6 +39,8 @@ const DEMO: &[(&str, i64, Network, u64)] = &[ ("SM5DDD", 9_500, Network::DxCluster, 400), ("W4POT", 6_000, Network::Pota, 40), ("K9ACT", -6_500, Network::Pota, 250), + ("F5FDV", -9_000, Network::FreeDvReporter, 30), + ("VK3RAD", 12_500, Network::FreeDvReporter, 120), ("PY2EEE", 15_000, Network::PskReporter, 600), ("ZL1FFF", 23_900, Network::Rbn, 800), // just inside the right edge ("VK9GGG", 40_000, Network::Rbn, 15), // outside a 48 kHz view diff --git a/crates/k4-config/src/lib.rs b/crates/k4-config/src/lib.rs index b993dde..9d4d746 100644 --- a/crates/k4-config/src/lib.rs +++ b/crates/k4-config/src/lib.rs @@ -586,6 +586,40 @@ impl PotaPrefs { } } +/// FreeDV Reporter (R-EXT-05): the service's host and its plain-WebSocket port. +pub const SPOT_FREEDV_DEFAULT_HOST: &str = "qso.freedv.org"; +pub const SPOT_FREEDV_DEFAULT_PORT: u16 = 80; + +fn default_freedv_host() -> String { + SPOT_FREEDV_DEFAULT_HOST.to_string() +} + +fn default_freedv_port() -> u16 { + SPOT_FREEDV_DEFAULT_PORT +} + +/// FreeDV Reporter as a spot source (FR-SPOT-08): a live WebSocket feed of the stations on the air, +/// joined read-only. Off until the operator turns it on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FreeDvPrefs { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_freedv_host")] + pub host: String, + #[serde(default = "default_freedv_port")] + pub port: u16, +} + +impl Default for FreeDvPrefs { + fn default() -> Self { + Self { + enabled: false, + host: default_freedv_host(), + port: default_freedv_port(), + } + } +} + /// A telnet spot source — the Reverse Beacon Network or a DX cluster /// (FR-SPOT-04, connected by FR-SPOT-07). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -641,6 +675,8 @@ pub struct SpotNetworks { pub dx_cluster: ClusterPrefs, #[serde(default)] pub pota: PotaPrefs, + #[serde(default)] + pub freedv: FreeDvPrefs, /// Certificates approved by hand for encrypted connections. Read through /// [`SpotNetworks::trusted`], which drops anything malformed. #[serde(default)] @@ -654,6 +690,7 @@ impl Default for SpotNetworks { rbn: ClusterPrefs::rbn(), dx_cluster: ClusterPrefs::dx_cluster(), pota: PotaPrefs::default(), + freedv: FreeDvPrefs::default(), trusted_certs: Vec::new(), } } @@ -701,6 +738,7 @@ impl SpotNetworks { || self.rbn.enabled || self.dx_cluster.enabled || self.pota.enabled + || self.freedv.enabled } } diff --git a/crates/k4-config/tests/config.rs b/crates/k4-config/tests/config.rs index 52786c2..42969b3 100644 --- a/crates/k4-config/tests/config.rs +++ b/crates/k4-config/tests/config.rs @@ -433,6 +433,42 @@ fn fr_spot_04_networks_default_off_and_persist() { assert_eq!(parse_spot_poll_secs(bad), 60, "interval {bad:?}"); } + // FreeDV Reporter: off, at the service's own host and plain-WebSocket port; its settings + // round-trip, it counts as a network being on, and a config without it loads with the defaults. + assert!(!def.freedv.enabled); + assert_eq!( + (def.freedv.host.as_str(), def.freedv.port), + ("qso.freedv.org", 80) + ); + let mut with_freedv = def.clone(); + with_freedv.freedv.enabled = true; + with_freedv.freedv.host = "reporter.example.org".into(); + with_freedv.freedv.port = 8080; + let prefs = Prefs { + spot_networks: with_freedv.clone(), + ..Default::default() + }; + let back: Prefs = toml::from_str(&toml::to_string(&prefs).expect("serialize")).expect("parse"); + assert_eq!(back.spot_networks, with_freedv); + assert!( + back.spot_networks.any_enabled(), + "FreeDV alone counts as on" + ); + assert!(!back.spot_networks.pota.enabled, "toggles are independent"); + let partial: Prefs = + toml::from_str("tune_step_hz = 100\n[spot_networks.freedv]\nenabled = true\n") + .expect("a config naming only the switch"); + assert!(partial.spot_networks.freedv.enabled); + assert_eq!(partial.spot_networks.freedv.host, "qso.freedv.org"); + assert_eq!(partial.spot_networks.freedv.port, 80); // A section that names the network but not the switch leaves it off: nothing is ever turned on + // by a file that does not say so. + let silent: Prefs = + toml::from_str("tune_step_hz = 100\n[spot_networks.freedv]\nhost = \"h.example\"\n") + .expect("a config that omits the switch"); + assert!(!silent.spot_networks.freedv.enabled); + assert!(!silent.spot_networks.any_enabled()); + assert_eq!(silent.spot_networks.freedv.host, "h.example"); + // TLS is off by default and persists; approved certificates start empty. assert!(!def.psk_reporter.tls); assert!(def.trusted().is_empty()); diff --git a/crates/k4-spot/src/freedv.rs b/crates/k4-spot/src/freedv.rs new file mode 100644 index 0000000..6d925c5 --- /dev/null +++ b/crates/k4-spot/src/freedv.rs @@ -0,0 +1,1113 @@ +//! FreeDV Reporter's station table (FR-SPOT-08): the events of `qso.freedv.org` become a roster of +//! who is on which frequency, and a roster row becomes a [`Spot`]. Pure and offline. +//! +//! The format is from another client's public source, read for interface facts only +//! (`docs/references/external-references.md`, R-EXT-05) — FreeDV Reporter publishes no +//! specification. The events used, and their fields: `new_connection` (`sid`, `callsign`, +//! `grid_square`), `remove_connection` (`sid`), `freq_change` (`sid`, `freq` in **hertz**), +//! `tx_report` (`sid`, `transmitting`, `mode`), `rx_report` (`callsign` heard, `receiver_callsign`, +//! `snr`), `message_update` (`sid`, `message`) and `bulk_update` (a list of `[name, args]` pairs, +//! the roster as it stands when the session begins). +//! +//! **What a row is.** A station that is connected *now* and has said what frequency it is on — a +//! presence list, not a stream of timestamped reports. The caller stamps the spot with the time it +//! last confirmed the row (when its event arrived, or on its periodic refresh), so a station that +//! has left stops being refreshed and fades with age like any other spot. +//! +//! Everything here is **untrusted**: a field of the wrong type drops that event whole (counted in +//! [`Roster::rejected`]) and leaves the roster as it was; ids, text and numbers are bounded; and +//! the roster itself is capped at [`MAX_STATIONS`]. + +use std::collections::{BTreeMap, HashMap}; + +use crate::json::Value; +use crate::{sanitise_text, Network, Spot}; + +/// Most stations kept. A busy day has a few hundred; the cap is what stops a flood of invented +/// session ids from growing the table without end. +pub const MAX_STATIONS: usize = 4096; + +/// Most items of one `bulk_update` read. +pub const MAX_BULK: usize = 10_000; + +/// Lowest and highest frequency accepted, Hz (the same guard as the other networks: a value in +/// kilohertz by mistake is below the floor). +const MIN_FREQ_HZ: u64 = 100_000; +const MAX_FREQ_HZ: u64 = 300_000_000_000; + +/// Longest session id, callsign, grid, mode and message kept. +const MAX_SID: usize = 64; +const MAX_CALL: usize = 32; +const MAX_GRID: usize = 8; +const MAX_MODE: usize = 16; +const MAX_MESSAGE: usize = 128; + +#[derive(Debug, Default, Clone, PartialEq)] +struct Station { + call: String, + grid: String, + freq_hz: u64, + tx: bool, + mode: String, + message: String, + /// The last receiver to report hearing this station, and the signal report. + heard_by: Option<(String, i16)>, +} + +/// The stations on the air, by the server's session id. +#[derive(Debug, Default)] +pub struct Roster { + by_sid: HashMap, + /// Events whose fields were the wrong type or out of range, and dropped whole. + pub rejected: u64, + /// Stations not added because the roster was full. + pub dropped: u64, + /// For diagnosis: per event name, how many were rejected and the *shape* of the last one (its + /// field names and the kinds of their values — never a value, so nothing an operator or a + /// station typed can end up in a log). + rejected_shapes: BTreeMap, +} + +/// What kind of value this is, for a shape — its kind and a coarse class, never the value. A string +/// gives its length and whether it is printable ASCII; a whole number says whether it is zero, +/// below the frequency floor, in range or above the ceiling (which is what decides an event). +fn kind(v: &Value) -> String { + match v { + Value::Null => "null".into(), + Value::Bool(_) => "bool".into(), + Value::Num(n) if n.fract() == 0.0 => { + let class = match n { + n if *n < 0.0 => "negative", + n if *n == 0.0 => "zero", + n if *n < MIN_FREQ_HZ as f64 => "below-floor", + n if *n <= MAX_FREQ_HZ as f64 => "in-range", + _ => "above-ceiling", + }; + format!("int({class})") + } + Value::Num(_) => "float".into(), + Value::Str(t) => { + let ascii = t.bytes().all(|b| (b' '..=b'~').contains(&b)); + format!( + "str(len={},{})", + t.len(), + if ascii { "ascii" } else { "non-ascii" } + ) + } + Value::Arr(_) => "array".into(), + Value::Obj(_) => "object".into(), + } +} + +/// `key:kind,key:kind` for an object, the kind for anything else. +fn shape(args: &Value) -> String { + match args { + Value::Obj(members) => { + let mut parts: Vec = members + .iter() + .map(|(k, v)| format!("{k}:{}", kind(v))) + .collect(); + parts.sort(); + parts.join(",") + } + other => kind(other).to_string(), + } +} + +fn sid_of(args: &Value) -> Option { + args.get("sid") + .and_then(Value::as_str) + .filter(|s| { + !s.is_empty() && s.len() <= MAX_SID && s.bytes().all(|b| (b'!'..=b'~').contains(&b)) + }) + .map(str::to_string) +} + +/// A text field. `Err` only if it is present with the **wrong type** — that is a malformed event. +/// Absent, `null`, or a string that cannot be kept (longer than `max`, or not printable ASCII — +/// operators write free text with accents and emoji, which a nameplate cannot show) is `Ok(None)`: +/// the field degrades, the rest of the event still applies. (Found by looking at the real service: +/// rejecting the whole event for a non-ASCII message lost the station's other data.) +fn text_of(args: &Value, key: &str, max: usize) -> Result, ()> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Str(s)) => { + let t = s.trim(); + Ok( + (t.len() <= max && t.bytes().all(|b| (b' '..=b'~').contains(&b))) + .then(|| t.to_string()), + ) + } + Some(_) => Err(()), + } +} + +impl Roster { + /// How many stations are known. + pub fn len(&self) -> usize { + self.by_sid.len() + } + + pub fn is_empty(&self) -> bool { + self.by_sid.is_empty() + } + + /// Forget everyone (a new session starts from the server's `bulk_update`). + pub fn clear(&mut self) { + self.by_sid.clear(); + } + + /// The session ids known. + pub fn sids(&self) -> impl Iterator { + self.by_sid.keys() + } + + fn entry(&mut self, sid: String) -> Option<&mut Station> { + if !self.by_sid.contains_key(&sid) && self.by_sid.len() >= MAX_STATIONS { + self.dropped += 1; + return None; + } + Some(self.by_sid.entry(sid).or_default()) + } + + /// Apply one server event and return the session ids whose row **changed** (so the caller can + /// refresh just those spots). Unknown events change nothing; a known one with a bad field is + /// counted in [`Roster::rejected`] and changes nothing. + pub fn on_event(&mut self, name: &str, args: &Value) -> Vec { + if name == "bulk_update" { + return self.bulk(args); + } + self.one(name, args) + } + + fn bulk(&mut self, args: &Value) -> Vec { + let Some(items) = args.as_array() else { + self.rejected += 1; + return Vec::new(); + }; + let mut changed = Vec::new(); + for item in items.iter().take(MAX_BULK) { + let (Some(name), args) = ( + item.at(0).and_then(Value::as_str), + item.at(1).unwrap_or(&Value::Null), + ) else { + self.rejected += 1; + continue; + }; + // A `bulk_update` inside a `bulk_update` is not a thing the server sends, and letting + // it nest would be an unbounded-recursion door. + if name == "bulk_update" { + self.rejected += 1; + continue; + } + changed.extend(self.one(name, args)); + } + changed.sort(); + changed.dedup(); + changed + } + + /// Per event name, how many were rejected and the shape of the last one (diagnosis only). + pub fn rejected_shapes(&self) -> &BTreeMap { + &self.rejected_shapes + } + + fn one(&mut self, name: &str, args: &Value) -> Vec { + let before = self.rejected; + let changed = self.apply(name, args); + // Only the six known events can be rejected, so this holds at most six names. + if self.rejected > before { + let entry = self + .rejected_shapes + .entry(name.to_string()) + .or_insert((0, String::new())); + entry.0 += self.rejected - before; + entry.1 = shape(args).chars().take(200).collect(); + } + changed + } + + fn apply(&mut self, name: &str, args: &Value) -> Vec { + match name { + "new_connection" => self.new_connection(args), + "remove_connection" => match sid_of(args) { + Some(sid) => { + self.by_sid.remove(&sid); + // Nothing to refresh: the station is gone and its spot will age out. + Vec::new() + } + None => self.reject(), + }, + "freq_change" => self.freq_change(args), + "tx_report" => self.tx_report(args), + "rx_report" => self.rx_report(args), + "message_update" => self.message_update(args), + _ => Vec::new(), + } + } + + fn reject(&mut self) -> Vec { + self.rejected += 1; + Vec::new() + } + + fn new_connection(&mut self, args: &Value) -> Vec { + let (Some(sid), Ok(call), Ok(grid)) = ( + sid_of(args), + text_of(args, "callsign", MAX_CALL), + text_of(args, "grid_square", MAX_GRID), + ) else { + return self.reject(); + }; + // A reconnecting station keeps the frequency already known; the server re-sends its + // `freq_change` straight after. + let Some(s) = self.entry(sid.clone()) else { + return Vec::new(); + }; + s.call = call.unwrap_or_default().to_ascii_uppercase(); + s.grid = grid.unwrap_or_default(); + vec![sid] + } + + fn freq_change(&mut self, args: &Value) -> Vec { + let (Some(sid), Some(freq), Ok(call), Ok(grid)) = ( + sid_of(args), + args.get("freq").and_then(Value::as_u64), + text_of(args, "callsign", MAX_CALL), + text_of(args, "grid_square", MAX_GRID), + ) else { + return self.reject(); + }; + // Zero is how a station says it has no frequency (not set yet, or cleared): a normal event + // that removes it from the map, not a malformed one (seen on the real service). Any other + // value outside the plausible range is refused — a unit mix-up must not become a spot. + if freq != 0 && !(MIN_FREQ_HZ..=MAX_FREQ_HZ).contains(&freq) { + return self.reject(); + } + let Some(s) = self.entry(sid.clone()) else { + return Vec::new(); + }; + // A station first seen through `freq_change` still gets an identity, when it is given. + if let Some(c) = call { + s.call = c.to_ascii_uppercase(); + } + if let Some(g) = grid { + s.grid = g; + } + s.freq_hz = freq; + vec![sid] + } + + fn tx_report(&mut self, args: &Value) -> Vec { + let (Some(sid), Some(tx), Ok(mode), Ok(call), Ok(grid)) = ( + sid_of(args), + args.get("transmitting").and_then(Value::as_bool), + text_of(args, "mode", MAX_MODE), + text_of(args, "callsign", MAX_CALL), + text_of(args, "grid_square", MAX_GRID), + ) else { + return self.reject(); + }; + let Some(s) = self.entry(sid.clone()) else { + return Vec::new(); + }; + if let Some(c) = call { + s.call = c.to_ascii_uppercase(); + } + if let Some(g) = grid { + s.grid = g; + } + s.tx = tx; + if let Some(m) = mode { + s.mode = m; + } + vec![sid] + } + + fn rx_report(&mut self, args: &Value) -> Vec { + // Keyed by the *heard* station's callsign, not by session: `sid` here is the receiver. + let (Ok(Some(heard)), Ok(Some(receiver)), Some(snr)) = ( + text_of(args, "callsign", MAX_CALL), + text_of(args, "receiver_callsign", MAX_CALL), + args.get("snr").and_then(Value::as_f64), + ) else { + return self.reject(); + }; + if !(-60.0..=200.0).contains(&snr) { + return self.reject(); + } + // An empty callsign is a routine part of the event (a receiver saying it hears *something*, + // or the server clearing a station's receive data when it retunes): no sighting to record, + // and not a malformed event either. + if heard.is_empty() { + return Vec::new(); + } + let (heard, receiver) = (heard.to_ascii_uppercase(), receiver.to_ascii_uppercase()); + let mut changed = Vec::new(); + for (sid, s) in &mut self.by_sid { + if s.call == heard { + s.heard_by = Some((receiver.clone(), snr.round() as i16)); + changed.push(sid.clone()); + } + } + changed + } + + fn message_update(&mut self, args: &Value) -> Vec { + // The key must be there (an event without it is malformed); its value may be empty, `null` + // or text a nameplate cannot show, all of which clear the message. + let (Some(sid), Some(_), Ok(message)) = ( + sid_of(args), + args.get("message"), + text_of(args, "message", MAX_MESSAGE), + ) else { + return self.reject(); + }; + let Some(s) = self.entry(sid.clone()) else { + return Vec::new(); + }; + s.message = message.unwrap_or_default(); + vec![sid] + } + + /// The spot for a station, stamped `now` (Unix seconds), or `None` if it cannot be placed: no + /// usable callsign, or no frequency yet. + pub fn spot(&self, sid: &str, now: u64) -> Option { + let s = self.by_sid.get(sid)?; + // No frequency yet is a zero one, which `Spot::new` refuses. + let mut spot = Spot::new(&s.call, s.freq_hz, now, Network::FreeDvReporter)?; + if !s.mode.is_empty() { + spot = spot.with_mode(&s.mode); + } + // What the overlay has no column for goes in the comment: the operator's own status line, + // and that the station is transmitting. + let mut parts: Vec<&str> = Vec::new(); + if !s.message.is_empty() { + parts.push(&s.message); + } + if s.tx { + parts.push("TX"); + } + if !parts.is_empty() { + let joined = parts.join(" - "); + // A message too long to keep is dropped rather than cut, and the TX marker stays. + let comment = sanitise_text(&joined).or_else(|| s.tx.then(|| "TX".to_string())); + if let Some(c) = comment { + spot = spot.with_comment(&c); + } + } + if let Some((by, snr)) = &s.heard_by { + spot = spot.with_spotter(by).with_snr(*snr); + } + Some(spot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::json::parse; + + fn ev(r: &mut Roster, name: &str, args: &str) -> Vec { + r.on_event(name, &parse(args).unwrap()) + } + + /// FR-SPOT-08: events build the roster the way the server describes it, and a row becomes a + /// spot with the frequency, mode, message, TX marker and who heard it. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_events_build_the_roster_and_spots() { + let mut r = Roster::default(); + assert_eq!( + ev( + &mut r, + "new_connection", + r#"{"sid":"s1","callsign":"aa1aaa","grid_square":"FN31"}"# + ), + ["s1"] + ); + // No frequency yet: no spot. + assert_eq!(r.spot("s1", 1000), None); + assert_eq!( + ev(&mut r, "freq_change", r#"{"sid":"s1","freq":14236000}"#), + ["s1"] + ); + let s = r.spot("s1", 1000).unwrap(); + assert_eq!( + (s.call.as_str(), s.freq_hz, s.time, s.network), + ("AA1AAA", 14_236_000, 1000, Network::FreeDvReporter) + ); + assert_eq!( + ( + s.mode.as_deref(), + s.comment.as_deref(), + s.spotter.as_deref(), + s.snr_db + ), + (None, None, None, None) + ); + // Mode and TX from a tx_report; the message from a message_update. + ev( + &mut r, + "tx_report", + r#"{"sid":"s1","transmitting":true,"mode":"RADEV1"}"#, + ); + ev( + &mut r, + "message_update", + r#"{"sid":"s1","message":"CQ FreeDV"}"#, + ); + let s = r.spot("s1", 2000).unwrap(); + assert_eq!(s.mode.as_deref(), Some("RADEV1")); + assert_eq!(s.comment.as_deref(), Some("CQ FreeDV - TX")); + ev(&mut r, "tx_report", r#"{"sid":"s1","transmitting":false}"#); + assert_eq!( + r.spot("s1", 2000).unwrap().comment.as_deref(), + Some("CQ FreeDV") + ); + assert_eq!( + r.spot("s1", 2000).unwrap().mode.as_deref(), + Some("RADEV1"), + "the mode is kept" + ); + // Who heard it, and how well — matched by the *heard* callsign. + assert_eq!( + ev( + &mut r, + "rx_report", + r#"{"sid":"s9","callsign":"aa1aaa","receiver_callsign":"bb2bbb","snr":-7.4}"# + ), + ["s1"] + ); + let s = r.spot("s1", 2000).unwrap(); + assert_eq!((s.spotter.as_deref(), s.snr_db), (Some("BB2BBB"), Some(-7))); + // A retune changes the frequency; the station stays one row. + ev(&mut r, "freq_change", r#"{"sid":"s1","freq":7177000}"#); + assert_eq!(r.len(), 1); + assert_eq!(r.spot("s1", 2000).unwrap().freq_hz, 7_177_000); + // Leaving removes the row. + assert_eq!( + ev(&mut r, "remove_connection", r#"{"sid":"s1"}"#), + Vec::::new() + ); + assert!(r.is_empty()); + assert_eq!(r.spot("s1", 2000), None); + assert_eq!(r.rejected, 0); + // A frequency event may carry the identity too (a station first seen that way). + assert_eq!( + ev( + &mut r, + "freq_change", + r#"{"sid":"s2","freq":14236000,"callsign":"cc3ccc","grid_square":"JO50"}"# + ), + ["s2"] + ); + assert_eq!(r.spot("s2", 1).unwrap().call, "CC3CCC"); + // A message that cannot be kept (too long for a spot) is dropped, the TX marker stays. + ev( + &mut r, + "message_update", + &format!(r#"{{"sid":"s2","message":"{}"}}"#, "x".repeat(100)), + ); + ev(&mut r, "tx_report", r#"{"sid":"s2","transmitting":true}"#); + assert_eq!(r.spot("s2", 1).unwrap().comment.as_deref(), Some("TX")); + // Unknown events change nothing and are not errors. + assert_eq!( + ev(&mut r, "something_new", r#"{"sid":"s2"}"#), + Vec::::new() + ); + assert_eq!(r.rejected, 0); + } + + /// FR-SPOT-08: `bulk_update` is a list of events applied in order; a `bulk_update` inside one + /// is refused; and an item that is malformed costs only itself. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_bulk_update() { + let mut r = Roster::default(); + let changed = ev( + &mut r, + "bulk_update", + r#"[["new_connection",{"sid":"a","callsign":"AA1AAA"}],["freq_change",{"sid":"a","freq":14236000}], + ["new_connection",{"sid":"b","callsign":"BB2BBB"}],["freq_change",{"sid":"b","freq":7177000}], + ["freq_change",{"sid":"a","freq":14236000}]]"#, + ); + assert_eq!(changed, ["a", "b"], "each changed row once, in order"); + assert_eq!(r.len(), 2); + assert_eq!(r.spot("b", 5).unwrap().freq_hz, 7_177_000); + // Malformed items are counted and skipped; the rest still applies. + let before = r.rejected; + let changed = ev( + &mut r, + "bulk_update", + r#"[5, ["freq_change"], [1,2], ["freq_change",{"sid":"a","freq":"x"}], ["freq_change",{"sid":"a","freq":14074000}], null]"#, + ); + assert_eq!(changed, ["a"]); + assert_eq!(r.spot("a", 5).unwrap().freq_hz, 14_074_000); + assert_eq!(r.rejected - before, 5, "five bad items counted"); + // Nested bulk_update is refused. + let before = r.rejected; + assert!(ev( + &mut r, + "bulk_update", + r#"[["bulk_update",[["new_connection",{"sid":"z","callsign":"ZZ9ZZZ"}]]]]"# + ) + .is_empty()); + assert_eq!(r.rejected - before, 1); + assert_eq!(r.len(), 2, "the nested one was not applied"); + // Not a list. + let before = r.rejected; + assert!(ev(&mut r, "bulk_update", r#"{"a":1}"#).is_empty()); + assert_eq!(r.rejected - before, 1); + // Only MAX_BULK items are read. Every item retunes the *same* station, so the roster cannot + // hide the count (it is capped lower than MAX_BULK): the last frequency it holds says how + // many items were applied. + let mut r = Roster::default(); + let items: Vec = (0..10_005) + .map(|i| { + parse(&format!( + r#"["freq_change",{{"sid":"one","freq":{},"callsign":"AA1AAA"}}]"#, + 1_000_000 + i + )) + .unwrap() + }) + .collect(); + // (The JSON node cap stops a list this long at parse time, so the value is built by hand.) + r.on_event("bulk_update", &Value::Arr(items)); + assert_eq!( + r.spot("one", 1).unwrap().freq_hz, + 1_000_000 + 10_000 - 1, + "exactly 10 000 items are applied" + ); + } + + /// FR-SPOT-08: the frequency bounds are exact, and an `rx_report` finds every session of the + /// heard station, rounds the report rather than cutting it, and never matches a station that + /// has no callsign. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_boundaries_and_reports() { + let mut r = Roster::default(); + // The floor and ceiling themselves are accepted, one either side is not. + for (freq, ok) in [ + (99_999u64, false), + (100_000, true), + (300_000_000_000, true), + (300_000_000_001, false), + ] { + let before = r.rejected; + let changed = ev( + &mut r, + "freq_change", + &format!(r#"{{"sid":"f","freq":{freq},"callsign":"AA1AAA"}}"#), + ); + assert_eq!(!changed.is_empty(), ok, "{freq}"); + assert_eq!(r.rejected - before, u64::from(!ok), "{freq}"); + } + // A session id is kept up to 64 characters and refused beyond. + let mut r = Roster::default(); + for (len, ok) in [(64usize, true), (65, false)] { + let sid = "s".repeat(len); + let changed = ev( + &mut r, + "freq_change", + &format!(r#"{{"sid":"{sid}","freq":14236000}}"#), + ); + assert_eq!(!changed.is_empty(), ok, "{len}"); + } + // Two sessions of one operator, and a station with no callsign at all. + let mut r = Roster::default(); + ev( + &mut r, + "freq_change", + r#"{"sid":"one","freq":14236000,"callsign":"aa1aaa"}"#, + ); + ev( + &mut r, + "freq_change", + r#"{"sid":"two","freq":7177000,"callsign":"AA1AAA"}"#, + ); + ev(&mut r, "freq_change", r#"{"sid":"anon","freq":14236000}"#); + ev( + &mut r, + "freq_change", + r#"{"sid":"other","freq":14236000,"callsign":"BB2BBB"}"#, + ); + let changed = ev( + &mut r, + "rx_report", + r#"{"callsign":"AA1AAA","receiver_callsign":"dd4ddd","snr":-7.6}"#, + ); + let mut changed_sorted = changed.clone(); + changed_sorted.sort(); + assert_eq!( + changed_sorted, + ["one", "two"], + "both sessions of the heard station" + ); + for sid in ["one", "two"] { + let s = r.spot(sid, 1).unwrap(); + assert_eq!( + (s.spotter.as_deref(), s.snr_db), + (Some("DD4DDD"), Some(-8)), + "-7.6 rounds to -8" + ); + } + assert_eq!( + r.spot("other", 1).unwrap().spotter, + None, + "another station is untouched" + ); + // Half rounds away from zero, as `f64::round` does; positive too. + ev( + &mut r, + "rx_report", + r#"{"callsign":"BB2BBB","receiver_callsign":"dd4ddd","snr":3.5}"#, + ); + assert_eq!(r.spot("other", 1).unwrap().snr_db, Some(4)); + // An empty heard callsign is not a sighting, and does not match the station with none. + let before = r.rejected; + assert!(ev( + &mut r, + "rx_report", + r#"{"callsign":"","receiver_callsign":"ee5eee","snr":1}"# + ) + .is_empty()); + assert_eq!(r.rejected, before, "routine, not malformed"); + assert_eq!(r.spot("one", 1).unwrap().spotter.as_deref(), Some("DD4DDD")); + // (`anon` has no usable callsign so has no spot to inspect; the empty report must simply + // not have changed anything, which the empty result above shows.) + assert_eq!(r.spot("anon", 1), None); + } + + /// FR-SPOT-08: a field of the wrong type, a bad id, an out-of-range number or a hostile string + /// drops the event whole and leaves what was known alone. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_hostile_events_are_dropped_whole() { + let mut r = Roster::default(); + ev( + &mut r, + "freq_change", + r#"{"sid":"good","freq":14236000,"callsign":"AA1AAA"}"#, + ); + let snapshot = r.spot("good", 1).unwrap(); + let long = "x".repeat(200); + let bad = vec![ + ("new_connection", r#"{"callsign":"AA1AAA"}"#.to_string()), + ( + "new_connection", + r#"{"sid":"","callsign":"AA1AAA"}"#.to_string(), + ), + ( + "new_connection", + r#"{"sid":"has space","callsign":"AA1AAA"}"#.to_string(), + ), + ( + "new_connection", + format!(r#"{{"sid":"{long}","callsign":"AA1AAA"}}"#), + ), + ( + "new_connection", + r#"{"sid":5,"callsign":"AA1AAA"}"#.to_string(), + ), + ("new_connection", r#"{"sid":"a","callsign":5}"#.to_string()), + ("new_connection", "[]".to_string()), + ("new_connection", "null".to_string()), + ("remove_connection", r#"{"sid":7}"#.to_string()), + ("freq_change", r#"{"sid":"a"}"#.to_string()), + ( + "freq_change", + r#"{"sid":"a","freq":"14236000"}"#.to_string(), + ), + ("freq_change", r#"{"sid":"a","freq":-1}"#.to_string()), + ("freq_change", r#"{"sid":"a","freq":14236.5}"#.to_string()), + ("freq_change", r#"{"sid":"a","freq":99999}"#.to_string()), + ( + "freq_change", + r#"{"sid":"a","freq":300000000001}"#.to_string(), + ), + ("freq_change", r#"{"sid":"a","freq":1e30}"#.to_string()), + ("tx_report", r#"{"sid":"a"}"#.to_string()), + ( + "tx_report", + r#"{"sid":"a","transmitting":"yes"}"#.to_string(), + ), + ("tx_report", r#"{"sid":"a","transmitting":1}"#.to_string()), + ( + "tx_report", + r#"{"sid":"a","transmitting":true,"mode":5}"#.to_string(), + ), + ("rx_report", r#"{"callsign":"AA1AAA"}"#.to_string()), + ( + "rx_report", + r#"{"callsign":"AA1AAA","receiver_callsign":"BB2BBB"}"#.to_string(), + ), + ( + "rx_report", + r#"{"callsign":"AA1AAA","receiver_callsign":"BB2BBB","snr":"9"}"#.to_string(), + ), + ( + "rx_report", + r#"{"callsign":"AA1AAA","receiver_callsign":"BB2BBB","snr":-61}"#.to_string(), + ), + ( + "rx_report", + r#"{"callsign":"AA1AAA","receiver_callsign":"BB2BBB","snr":201}"#.to_string(), + ), + ( + "rx_report", + r#"{"callsign":"AA1AAA","receiver_callsign":5,"snr":1}"#.to_string(), + ), + ("message_update", r#"{"sid":"a"}"#.to_string()), + ("message_update", r#"{"sid":"a","message":5}"#.to_string()), + ]; + for (name, args) in &bad { + let before = r.rejected; + let changed = ev(&mut r, name, args); + assert!(changed.is_empty(), "{name} {args}"); + assert_eq!(r.rejected, before + 1, "{name} {args} must be counted"); + assert_eq!(r.len(), 1, "{name} {args} added a station"); + } + assert_eq!( + r.spot("good", 1).unwrap(), + snapshot, + "the known station is untouched" + ); + // Empty callsign in an rx_report is routine: not a sighting, and not counted as bad. + let before = r.rejected; + assert!(ev( + &mut r, + "rx_report", + r#"{"callsign":"","receiver_callsign":"BB2BBB","snr":3}"# + ) + .is_empty()); + assert_eq!(r.rejected, before); + // A callsign that is not a callsign is stored but never becomes a spot. + ev( + &mut r, + "freq_change", + r#"{"sid":"odd","freq":14236000,"callsign":"CQ"}"#, + ); + assert_eq!(r.spot("odd", 1), None); + ev(&mut r, "freq_change", r#"{"sid":"none","freq":14236000}"#); + assert_eq!(r.spot("none", 1), None, "no callsign, no spot"); + assert_eq!(r.spot("missing", 1), None); + } + + /// FR-SPOT-08: shapes seen on the **real service** (`freedv_live`, 2026-09-21): a `freq_change` + /// with `freq` 0 clears the frequency and is not malformed; a `message_update` with non-ASCII + /// text clears the message and is not malformed; an unusable well-typed field degrades that + /// field only while the rest of the event applies; a wrong *type* still rejects the event; and + /// the fields this client does not use (`last_update`) are ignored. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_real_service_shapes() { + let mut r = Roster::default(); + ev( + &mut r, + "freq_change", + r#"{"sid":"s","freq":14236000,"callsign":"aa1aaa","grid_square":"FN31pr","last_update":"2026-09-21T05:07:00.000000000Z"}"#, + ); + assert_eq!(r.spot("s", 1).unwrap().freq_hz, 14_236_000); + // Zero clears the frequency: the row changed (so the caller is told), it has no spot any + // more, and nothing was counted as malformed. + assert_eq!( + ev( + &mut r, + "freq_change", + r#"{"sid":"s","freq":0,"callsign":"AA1AAA","grid_square":"FN31pr","last_update":"x"}"# + ), + ["s"] + ); + assert_eq!( + r.spot("s", 2), + None, + "a station with no frequency has no plate" + ); + assert_eq!(r.rejected, 0); + // A frequency again puts it back. + ev(&mut r, "freq_change", r#"{"sid":"s","freq":7177000}"#); + assert_eq!(r.spot("s", 3).unwrap().freq_hz, 7_177_000); + // A non-ASCII message clears the message; the event is accepted. + ev(&mut r, "message_update", r#"{"sid":"s","message":"CQ"}"#); + assert_eq!(r.spot("s", 3).unwrap().comment.as_deref(), Some("CQ")); + let accent = "73 de F5ABC \u{e9}t\u{e9} \u{1f600}"; + let changed = ev( + &mut r, + "message_update", + &format!(r#"{{"sid":"s","message":"{accent}","last_update":"x"}}"#), + ); + assert_eq!(changed, ["s"]); + assert_eq!( + r.spot("s", 3).unwrap().comment, + None, + "the message that cannot be shown is gone" + ); + assert_eq!(r.rejected, 0); + // An empty and a null message also clear it. + for msg in ["\"\"", "null"] { + ev(&mut r, "message_update", r#"{"sid":"s","message":"CQ"}"#); + ev( + &mut r, + "message_update", + &format!(r#"{{"sid":"s","message":{msg}}}"#), + ); + assert_eq!(r.spot("s", 3).unwrap().comment, None, "{msg}"); + } + assert_eq!(r.rejected, 0); + // An unusable well-typed field degrades that field only: the frequency still applies. + let long = "x".repeat(200); + ev( + &mut r, + "freq_change", + &format!(r#"{{"sid":"t","freq":14074000,"callsign":"F5é","grid_square":"{long}"}}"#), + ); + assert_eq!(r.len(), 2); + assert_eq!( + r.spot("t", 1), + None, + "no usable callsign, so no plate — but the row is there" + ); + ev( + &mut r, + "freq_change", + r#"{"sid":"t","freq":14074000,"callsign":"BB2BBB"}"#, + ); + assert_eq!(r.spot("t", 1).unwrap().call, "BB2BBB"); + ev( + &mut r, + "tx_report", + &format!(r#"{{"sid":"t","transmitting":true,"mode":"{long}"}}"#), + ); + let t = r.spot("t", 1).unwrap(); + assert_eq!( + (t.mode.as_deref(), t.comment.as_deref()), + (None, Some("TX")), + "the mode is dropped, TX kept" + ); + assert_eq!(r.rejected, 0); + // A wrong *type* is still a malformed event, and changes nothing. + for (name, args) in [ + ("message_update", r#"{"sid":"s","message":5}"#), + ("message_update", r#"{"sid":"s"}"#), + ("freq_change", r#"{"sid":"s","freq":14236000,"callsign":5}"#), + ("tx_report", r#"{"sid":"s","transmitting":true,"mode":5}"#), + ( + "new_connection", + r#"{"sid":"s","callsign":"AA1AAA","grid_square":[]}"#, + ), + ] { + let before = r.rejected; + assert!(ev(&mut r, name, args).is_empty(), "{name} {args}"); + assert_eq!(r.rejected, before + 1, "{name} {args}"); + } + // Nonzero and outside the plausible range is refused, not turned into a spot. + let before = r.rejected; + assert!(ev(&mut r, "freq_change", r#"{"sid":"s","freq":14236}"#).is_empty()); + assert_eq!(r.rejected, before + 1); + assert_eq!( + r.spot("s", 3).unwrap().freq_hz, + 7_177_000, + "the frequency it had is kept" + ); + // The diagnostic says which events were refused and their shape, never a value. + let shapes = r.rejected_shapes(); + assert_eq!(shapes["freq_change"].0, 2); + assert!( + shapes["freq_change"].1.contains("freq:int(below-floor)"), + "{shapes:?}" + ); + // Two message_updates were refused (a number, and no message at all); the shape kept is the + // last one's: a session id and nothing else. + assert_eq!(shapes["message_update"].0, 2); + assert_eq!(shapes["message_update"].1, "sid:str(len=1,ascii)"); + assert!( + !format!("{shapes:?}").contains("F5ABC"), + "no value leaks into the diagnostic" + ); + } + + /// FR-SPOT-08: the diagnostic shape names each field and the *kind* of its value — a coarse + /// class for numbers, a length and character class for text — in a fixed order, and contains + /// no value. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_diagnostic_shapes() { + let sh = |json: &str| shape(&parse(json).unwrap()); + // Every kind of value, and every class of whole number. + assert_eq!(sh(r#"{"a":null}"#), "a:null"); + assert_eq!(sh(r#"{"a":true}"#), "a:bool"); + assert_eq!(sh(r#"{"a":1.5}"#), "a:float"); + assert_eq!(sh(r#"{"a":[1]}"#), "a:array"); + assert_eq!(sh(r#"{"a":{"b":1}}"#), "a:object"); + assert_eq!(sh(r#"{"a":-1}"#), "a:int(negative)"); + assert_eq!(sh(r#"{"a":0}"#), "a:int(zero)"); + assert_eq!(sh(r#"{"a":99999}"#), "a:int(below-floor)"); + assert_eq!(sh(r#"{"a":100000}"#), "a:int(in-range)"); + assert_eq!(sh(r#"{"a":300000000000}"#), "a:int(in-range)"); + assert_eq!(sh(r#"{"a":300000000001}"#), "a:int(above-ceiling)"); + assert_eq!(sh(r#"{"a":"abc"}"#), "a:str(len=3,ascii)"); + assert_eq!(sh(r#"{"a":""}"#), "a:str(len=0,ascii)"); + assert_eq!(sh("{\"a\":\"\u{e9}\"}"), "a:str(len=2,non-ascii)"); + // Keys are sorted, so the shape does not depend on the order the server wrote them in. + assert_eq!( + sh(r#"{"z":1,"a":"x","m":null}"#), + "a:str(len=1,ascii),m:null,z:int(below-floor)" + ); + // Not an object: just the kind. + assert_eq!(sh("[1,2]"), "array"); + assert_eq!(sh("\"secret text\""), "str(len=11,ascii)"); + assert_eq!(sh("null"), "null"); + // No value ever appears. + for json in [r#"{"callsign":"AA1AAA","message":"hello"}"#, r#""AA1AAA""#] { + let out = sh(json); + assert!(!out.contains("AA1AAA") && !out.contains("hello"), "{out}"); + } + } + + /// FR-SPOT-08: what the roster *keeps* of a text field is bounded and clean — the exact limits, + /// no surrounding whitespace, nothing that is not printable ASCII — checked on the stored rows + /// (the public spot would hide a difference, since building a spot sanitises again). + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_stored_text_is_bounded_and_clean() { + let mut r = Roster::default(); + let s = |n: usize| "x".repeat(n); + let feed = |r: &mut Roster, sid: &str, call: &str, grid: &str, mode: &str, msg: &str| { + ev( + r, + "freq_change", + &format!( + r#"{{"sid":"{sid}","freq":14236000,"callsign":"{call}","grid_square":"{grid}"}}"# + ), + ); + ev( + r, + "tx_report", + &format!(r#"{{"sid":"{sid}","transmitting":false,"mode":"{mode}"}}"#), + ); + ev( + r, + "message_update", + &format!(r#"{{"sid":"{sid}","message":"{msg}"}}"#), + ); + }; + // At every limit: kept (and the callsign upper-cased). + feed(&mut r, "at", &s(32), &s(8), &s(16), &s(128)); + let st = &r.by_sid["at"]; + assert_eq!(st.call, s(32).to_ascii_uppercase()); + assert_eq!( + (st.grid.len(), st.mode.len(), st.message.len()), + (8, 16, 128) + ); + // One over: dropped, not cut. + feed( + &mut r, + "over", + &s(32 + 1), + &s(8 + 1), + &s(16 + 1), + &s(128 + 1), + ); + let st = &r.by_sid["over"]; + assert_eq!( + ( + st.call.as_str(), + st.grid.as_str(), + st.mode.as_str(), + st.message.as_str() + ), + ("", "", "", "") + ); + assert_eq!( + st.freq_hz, 14_236_000, + "the rest of the events still applied" + ); + // Not printable ASCII: dropped. Surrounding whitespace: trimmed. + feed( + &mut r, + "odd", + "F5\u{e9}", + "FN\u{e9}31", + "RA\u{e9}", + "caf\u{e9}", + ); + let st = &r.by_sid["odd"]; + assert_eq!( + ( + st.call.as_str(), + st.grid.as_str(), + st.mode.as_str(), + st.message.as_str() + ), + ("", "", "", "") + ); + feed( + &mut r, + "pad", + " aa1aaa ", + " FN31 ", + " RADEV1 ", + " hi there ", + ); + let st = &r.by_sid["pad"]; + assert_eq!( + ( + st.call.as_str(), + st.grid.as_str(), + st.mode.as_str(), + st.message.as_str() + ), + ("AA1AAA", "FN31", "RADEV1", "hi there") + ); + // Whitespace only is empty, not a message of spaces; and a limit counts the trimmed text. + feed(&mut r, "blank", " ", " ", " ", " "); + let st = &r.by_sid["blank"]; + assert_eq!( + (st.grid.as_str(), st.mode.as_str(), st.message.as_str()), + ("", "", "") + ); + feed(&mut r, "trim", "a", "a", "a", &format!(" {} ", s(128))); + assert_eq!(r.by_sid["trim"].message.len(), 128); + assert_eq!(r.rejected, 0); + } + + /// FR-SPOT-08: the roster is capped; a flood of invented session ids cannot grow it, and the + /// stations already there keep working. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_roster_is_capped() { + let mut r = Roster::default(); + for i in 0..4146 { + ev( + &mut r, + "new_connection", + &format!(r#"{{"sid":"s{i}","callsign":"AA1AAA"}}"#), + ); + } + assert_eq!(r.len(), 4096); + assert_eq!(r.dropped, 50); + // Every entry point is capped, not just the first. + ev(&mut r, "freq_change", r#"{"sid":"new1","freq":14236000}"#); + ev(&mut r, "tx_report", r#"{"sid":"new2","transmitting":true}"#); + ev(&mut r, "message_update", r#"{"sid":"new3","message":"hi"}"#); + assert_eq!(r.len(), 4096); + assert_eq!(r.dropped, 53); + // An existing station is still updated at the cap, and one leaving makes room. + assert_eq!( + ev(&mut r, "freq_change", r#"{"sid":"s0","freq":14236000}"#), + ["s0"] + ); + ev(&mut r, "remove_connection", r#"{"sid":"s1"}"#); + ev( + &mut r, + "new_connection", + r#"{"sid":"fresh","callsign":"BB2BBB"}"#, + ); + assert_eq!(r.len(), 4096); + assert!(r.sids().any(|s| s == "fresh")); + r.clear(); + assert!(r.is_empty()); + } +} diff --git a/crates/k4-spot/src/freedv_source.rs b/crates/k4-spot/src/freedv_source.rs new file mode 100644 index 0000000..db394a0 --- /dev/null +++ b/crates/k4-spot/src/freedv_source.rs @@ -0,0 +1,451 @@ +//! A FreeDV Reporter spot source (FR-SPOT-08): connects to `qso.freedv.org` over a WebSocket, +//! joins in the **read-only `view` role**, keeps a roster of who is on which frequency and delivers +//! them as spots. +//! +//! Built on [`crate::ws`] (the WebSocket), [`crate::sio`] (Engine.IO / Socket.IO packets), +//! [`crate::freedv`] (the roster) and the [`SpotSource`] interface, and shaped like +//! [`crate::mqtt_source::MqttSource`]: each [`poll`] does a bounded amount of work, a failure is +//! *returned* so it can be shown against its network (FR-SPOT-09), and the source reconnects by +//! itself with backoff. +//! +//! **Receive-only, and anonymous.** The only thing sent beyond the upgrade request is the `view` +//! connect (see [`crate::sio::connect_view`]) and the replies the protocol requires — a pong to each +//! ping. Nothing identifies the operator, and the `report` role that would make a station publicly +//! visible is not reachable from here (`FR-SPOT-12`). +//! +//! **Presence.** The reporter says who is on the air *now*, not when they last did something. A +//! station's spot is stamped when its event arrives and again on a periodic refresh while it stays +//! on the roster, so a station that has left stops being refreshed and fades with age like any +//! other spot, instead of every idle station disappearing after the age limit. +//! +//! [`poll`]: SpotSource::poll + +use std::io::{ErrorKind, Read, Write}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::cluster::RateGate; +use crate::freedv::Roster; +use crate::mqtt_source::{plain_connector, ConnectError, Connector, Wire}; +use crate::sio::{self, Packet}; +use crate::telnet::{ConnState, Stats, Timing}; +use crate::ws::{self, FrameReader, Message, Rng}; +use crate::{Network, SourceError, Spot, SpotSource}; + +/// The path of the Socket.IO endpoint on a WebSocket (Engine.IO revision 4). +pub const PATH: &str = "/socket.io/?EIO=4&transport=websocket"; + +/// How often the spots of stations still on the roster are re-stamped. +pub const DEFAULT_REFRESH: Duration = Duration::from_secs(30); + +/// What to connect to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreeDvConfig { + pub host: String, + pub port: u16, + /// Sent as the `User-Agent`: the program and its version, nothing about the operator. + pub user_agent: String, +} + +enum Phase { + /// The upgrade request is sent; its reply is being read. + Upgrade { + key: String, + buf: Vec, + }, + /// Upgraded; waiting for Engine.IO's `open`. + Open, + /// `view` connect sent; waiting for Socket.IO's `connect`. + Connect, + Live, +} + +struct Conn { + stream: Box, + phase: Phase, + reader: FrameReader, + gate: RateGate, + rng: Rng, + since: Instant, + last_rx: Instant, + /// The server's ping interval plus its timeout, once it has said (`open`). + watchdog: Option, +} + +impl Conn { + fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), SourceError> { + let mask = self.rng.mask(); + self.stream + .write_all(&ws::encode(opcode, payload, mask)) + .map_err(|e| SourceError(format!("could not send to the server: {e}"))) + } +} + +/// A FreeDV Reporter source. +pub struct FreeDvSource { + cfg: FreeDvConfig, + timing: Timing, + refresh: Duration, + connector: Connector, + conn: Option, + next_attempt: Instant, + backoff: Duration, + /// Only spots inside `[lo, hi]` Hz are kept; `None` keeps everything. + window: Option<(u64, u64)>, + roster: Roster, + stats: Stats, + /// Packets that were not valid once the session was running (a bad event costs only itself). + packets_rejected: u64, + attempts: u64, + epoch: Instant, + last_refresh: Instant, +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +impl FreeDvSource { + pub fn new(cfg: FreeDvConfig) -> Self { + Self::with_timing(cfg, Timing::default()) + } + + pub fn with_timing(cfg: FreeDvConfig, timing: Timing) -> Self { + let now = Instant::now(); + Self { + backoff: timing.initial_backoff, + cfg, + timing, + refresh: DEFAULT_REFRESH, + connector: plain_connector(), + conn: None, + next_attempt: now, + window: None, + roster: Roster::default(), + stats: Stats::default(), + packets_rejected: 0, + attempts: 0, + epoch: now, + last_refresh: now, + } + } + + /// Change how often stations still on the roster are re-stamped (tests). + pub fn set_refresh(&mut self, refresh: Duration) { + self.refresh = refresh; + } + + /// Replace the connector (tests). + pub fn set_connector(&mut self, connector: Connector) { + self.connector = connector; + } + + /// Keep only spots between `lo` and `hi` Hz (`None` = all). + pub fn set_window(&mut self, window: Option<(u64, u64)>) { + self.window = window; + } + + pub fn state(&self) -> ConnState { + match self.conn.as_ref().map(|c| &c.phase) { + None => ConnState::Disconnected, + Some(Phase::Live) => ConnState::Connected, + Some(_) => ConnState::AwaitingLogin, + } + } + + pub fn stats(&self) -> Stats { + Stats { + rejected: self.stats.rejected + self.packets_rejected + self.roster.rejected, + ..self.stats + } + } + + /// Per event name, how many were rejected and the shape of the last (field names and value + /// kinds only), for diagnosing a format the source does not understand. + pub fn rejected_shapes(&self) -> &std::collections::BTreeMap { + self.roster.rejected_shapes() + } + + /// Stations on the roster now. + pub fn stations(&self) -> usize { + self.roster.len() + } + + /// Connection attempts made so far (including failed ones). + pub fn attempts(&self) -> u64 { + self.attempts + } + + pub fn config(&self) -> &FreeDvConfig { + &self.cfg + } + + fn fail(&mut self, now: Instant, msg: String) -> SourceError { + self.conn = None; + // A new session starts from the server's `bulk_update`; stations from this one that have + // left meanwhile must not be refreshed into the next. + self.roster.clear(); + self.next_attempt = now + self.backoff; + self.backoff = (self.backoff * 2).min(self.timing.max_backoff); + SourceError(msg) + } + + fn try_connect(&mut self, now: Instant) -> Result<(), SourceError> { + let host = self.cfg.host.trim().to_string(); + if host.is_empty() { + self.next_attempt = now + self.timing.max_backoff; + return Err(SourceError("no host is set".into())); + } + self.attempts += 1; + let stream = match (self.connector)(&host, self.cfg.port, self.timing.connect_timeout) { + Ok(s) => s, + Err(ConnectError::Failed(why)) => return Err(self.fail(now, why)), + Err(ConnectError::Untrusted(_)) => { + return Err(self.fail( + now, + "an unexpected certificate on a plain connection".into(), + )) + } + }; + let mut rng = Rng::seeded(); + let key = rng.key(); + let request = match ws::request(&host, self.cfg.port, PATH, &key, &self.cfg.user_agent) { + Ok(r) => r, + // A setting that cannot make a request: no point retrying quickly. + Err(why) => { + self.next_attempt = now + self.timing.max_backoff; + return Err(SourceError(format!("cannot connect: {why}"))); + } + }; + let mut conn = Conn { + stream, + phase: Phase::Upgrade { + key, + buf: Vec::new(), + }, + reader: FrameReader::new(), + gate: RateGate::new(self.timing.rate_per_sec), + rng, + since: now, + last_rx: now, + watchdog: None, + }; + if let Err(e) = conn.stream.write_all(&request) { + return Err(self.fail(now, format!("could not send to {host}: {e}"))); + } + self.conn = Some(conn); + Ok(()) + } + + /// Hand a spot to the sink if it is inside the window and the rate allows. + fn emit(&mut self, spot: Spot, now_ms: u64, sink: &mut dyn FnMut(Spot)) { + if let Some((lo, hi)) = self.window { + if spot.freq_hz < lo || spot.freq_hz > hi { + self.stats.outside_window += 1; + return; + } + } + let allowed = self.conn.as_mut().is_none_or(|c| c.gate.allow(now_ms)); + if !allowed { + self.stats.shed += 1; + return; + } + self.stats.spots += 1; + sink(spot); + } + + fn handle( + &mut self, + msg: Message, + now_ms: u64, + unix: u64, + connected: &mut bool, + sink: &mut dyn FnMut(Spot), + ) -> Result<(), SourceError> { + let conn = self.conn.as_mut().expect("handled only while connected"); + match msg { + Message::Close(_) => { + return Err(SourceError("the server closed the connection".into())) + } + Message::Ping(p) => conn.send(ws::OP_PONG, &p)?, + Message::Pong(_) | Message::Binary(_) => {} + Message::Text(text) => { + let live = matches!(conn.phase, Phase::Live); + let packet = match sio::parse(&text) { + Ok(p) => p, + // Before the session is running, a packet we cannot read means it is not the + // service we expect; once it is running, a bad packet costs only itself. + Err(e) if !live => { + return Err(SourceError(format!("unexpected data from the server: {e}"))) + } + Err(_) => { + self.packets_rejected += 1; + return Ok(()); + } + }; + match packet { + Packet::Open { + ping_interval_ms, + ping_timeout_ms, + .. + } if matches!(conn.phase, Phase::Open) => { + conn.watchdog = + Some(Duration::from_millis(ping_interval_ms + ping_timeout_ms)); + let connect = sio::connect_view(); + conn.send(ws::OP_TEXT, connect.as_bytes())?; + conn.phase = Phase::Connect; + } + Packet::Connect { .. } if matches!(conn.phase, Phase::Connect) => { + conn.phase = Phase::Live; + *connected = true; + } + Packet::Ping => conn.send(ws::OP_TEXT, sio::PONG.as_bytes())?, + Packet::Close | Packet::Disconnect => { + return Err(SourceError("the server ended the session".into())) + } + Packet::ConnectError => { + return Err(SourceError("the server refused the connection".into())) + } + Packet::Event { name, args } if live => { + let changed = self.roster.on_event(&name, &args); + for sid in changed { + if let Some(spot) = self.roster.spot(&sid, unix) { + self.emit(spot, now_ms, sink); + } + } + } + // Anything else — an event before the session is running, a second `open`, a + // pong, something this client does not use — is ignored. + _ => {} + } + } + } + Ok(()) + } + + fn service(&mut self, now: Instant, sink: &mut dyn FnMut(Spot)) -> Result<(), SourceError> { + let unix = unix_now(); + let now_ms = self.epoch.elapsed().as_millis() as u64; + let timing = self.timing.clone(); + let mut messages: Vec = Vec::new(); + + { + let conn = self + .conn + .as_mut() + .expect("service runs only when connected"); + let deadline = Instant::now() + timing.read_budget; + let mut buf = [0u8; 8192]; + let mut total = 0usize; + loop { + match conn.stream.read(&mut buf) { + Ok(0) => return Err(SourceError("the server closed the connection".into())), + Ok(n) => { + total += n; + conn.last_rx = Instant::now(); + let bytes = &buf[..n]; + match &mut conn.phase { + Phase::Upgrade { key, buf: head } => { + head.extend_from_slice(bytes); + match ws::check_response(head, key) { + Ok(None) => {} + Ok(Some(end)) => { + let rest = head.split_off(end); + conn.phase = Phase::Open; + messages.extend(conn.reader.push(&rest).map_err(|e| { + SourceError(format!("protocol error: {e}")) + })?); + } + Err(e) => return Err(SourceError(e)), + } + } + _ => messages.extend( + conn.reader + .push(bytes) + .map_err(|e| SourceError(format!("protocol error: {e}")))?, + ), + } + } + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { + break + } + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(SourceError(format!("read failed: {e}"))), + } + if total >= timing.max_bytes_per_poll || Instant::now() >= deadline { + break; + } + } + + // Liveness: before the session runs, the connection must complete in time; after, the + // server's own ping timing says how long silence may last. + if let Some(watchdog) = conn.watchdog { + if now.saturating_duration_since(conn.last_rx) > watchdog { + return Err(SourceError(format!( + "no ping from the server for {} s", + watchdog.as_secs() + ))); + } + } + if !matches!(conn.phase, Phase::Live) + && now.saturating_duration_since(conn.since) > timing.prompt_timeout + { + let what = match conn.phase { + Phase::Upgrade { .. } => "the upgrade to a WebSocket", + Phase::Open => "the session to open", + _ => "the connection to be accepted", + }; + return Err(SourceError(format!( + "no answer within {} s waiting for {what}", + timing.prompt_timeout.as_secs_f32().round() + ))); + } + } + + let mut connected_now = false; + for msg in messages { + self.handle(msg, now_ms, unix, &mut connected_now, sink)?; + } + if connected_now { + self.backoff = timing.initial_backoff; + self.stats.connects += 1; + self.last_refresh = now; + } + + // Re-stamp the stations that are still there, so a station that stays connected stays + // on the overlay and one that has left does not. + // (Nothing needs checking that the session is live: the roster is empty until it is, because + // events before the acknowledgement are ignored and a lost connection clears it.) + if now.saturating_duration_since(self.last_refresh) >= self.refresh { + self.last_refresh = now; + let sids: Vec = self.roster.sids().cloned().collect(); + for sid in sids { + if let Some(spot) = self.roster.spot(&sid, unix) { + self.emit(spot, now_ms, sink); + } + } + } + Ok(()) + } +} + +impl SpotSource for FreeDvSource { + fn network(&self) -> Network { + Network::FreeDvReporter + } + + fn poll(&mut self, sink: &mut dyn FnMut(Spot)) -> Result<(), SourceError> { + let now = Instant::now(); + if self.conn.is_none() { + if now < self.next_attempt { + return Ok(()); + } + return self.try_connect(now); + } + match self.service(now, sink) { + Ok(()) => Ok(()), + Err(e) => Err(self.fail(now, e.0)), + } + } +} diff --git a/crates/k4-spot/src/json.rs b/crates/k4-spot/src/json.rs new file mode 100644 index 0000000..bff335d --- /dev/null +++ b/crates/k4-spot/src/json.rs @@ -0,0 +1,503 @@ +//! A small, strict, bounded JSON parser for the networks whose messages nest (FreeDV Reporter's +//! `bulk_update` is a list of `[name, {..}]` pairs), where the flat scanners of [`crate::psk`] and +//! [`crate::pota`] do not reach. +//! +//! The text is **untrusted**, so this parser refuses what a lenient one would repair: a trailing +//! comma, a duplicate key, a lone surrogate, a control character in a string, a number with a +//! leading zero, anything after the value. It is bounded three ways so no input can make it +//! allocate or recurse without limit: [`MAX_DEPTH`] levels of nesting, [`MAX_NODES`] values in +//! all, and [`MAX_STRING`] bytes in any one string or key. + +/// Deepest nesting read. +pub const MAX_DEPTH: usize = 8; +/// Most values (of any kind) read from one text. +pub const MAX_NODES: usize = 20_000; +/// Longest string or key read, bytes after decoding. +pub const MAX_STRING: usize = 4096; + +/// A JSON value. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec), + /// Members in order; keys are unique (a duplicate is a parse error). + Obj(Vec<(String, Value)>), +} + +impl Value { + /// The member `key` of an object. + pub fn get(&self, key: &str) -> Option<&Value> { + match self { + Value::Obj(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + /// Element `i` of an array. + pub fn at(&self, i: usize) -> Option<&Value> { + match self { + Value::Arr(a) => a.get(i), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Value::Num(n) if n.is_finite() => Some(*n), + _ => None, + } + } + + /// A non-negative whole number, exactly representable (at most 2^53). + pub fn as_u64(&self) -> Option { + let n = self.as_f64()?; + (n >= 0.0 && n.fract() == 0.0 && n <= 9_007_199_254_740_992.0).then_some(n as u64) + } + + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Value::Arr(a) => Some(a), + _ => None, + } + } +} + +struct Parser<'a> { + /// The text, kept as a `&str` so slicing it to read a character costs nothing and cannot + /// split one (the input is valid UTF-8 by construction). + text: &'a str, + s: &'a [u8], + i: usize, + nodes: usize, +} + +type R = Result; + +impl<'a> Parser<'a> { + fn ws(&mut self) { + while self + .s + .get(self.i) + .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n')) + { + self.i += 1; + } + } + + fn peek(&self) -> Option { + self.s.get(self.i).copied() + } + + fn eat(&mut self, b: u8) -> bool { + let hit = self.peek() == Some(b); + if hit { + self.i += 1; + } + hit + } + + fn node(&mut self) -> R<()> { + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(format!("more than {MAX_NODES} values")); + } + Ok(()) + } + + fn value(&mut self, depth: usize) -> R { + self.node()?; + self.ws(); + match self.peek().ok_or("unexpected end")? { + b'{' => self.object(depth), + b'[' => self.array(depth), + b'"' => self.string().map(Value::Str), + b'-' | b'0'..=b'9' => self.number(), + b't' => self.literal("true", Value::Bool(true)), + b'f' => self.literal("false", Value::Bool(false)), + b'n' => self.literal("null", Value::Null), + other => Err(format!("unexpected byte 0x{other:02x}")), + } + } + + fn literal(&mut self, word: &str, v: Value) -> R { + if self.s[self.i..].starts_with(word.as_bytes()) { + self.i += word.len(); + Ok(v) + } else { + Err(format!("expected {word}")) + } + } + + fn object(&mut self, depth: usize) -> R { + if depth >= MAX_DEPTH { + return Err(format!("nested deeper than {MAX_DEPTH}")); + } + self.i += 1; // { + let mut members: Vec<(String, Value)> = Vec::new(); + self.ws(); + if self.eat(b'}') { + return Ok(Value::Obj(members)); + } + loop { + self.ws(); + if self.peek() != Some(b'"') { + return Err("expected a string key".into()); + } + let key = self.string()?; + if members.iter().any(|(k, _)| *k == key) { + return Err(format!("duplicate key {key:?}")); + } + self.ws(); + if !self.eat(b':') { + return Err("expected ':'".into()); + } + let v = self.value(depth + 1)?; + members.push((key, v)); + self.ws(); + if self.eat(b',') { + continue; + } + if self.eat(b'}') { + return Ok(Value::Obj(members)); + } + return Err("expected ',' or '}'".into()); + } + } + + fn array(&mut self, depth: usize) -> R { + if depth >= MAX_DEPTH { + return Err(format!("nested deeper than {MAX_DEPTH}")); + } + self.i += 1; // [ + let mut items = Vec::new(); + self.ws(); + if self.eat(b']') { + return Ok(Value::Arr(items)); + } + loop { + items.push(self.value(depth + 1)?); + self.ws(); + if self.eat(b',') { + continue; + } + if self.eat(b']') { + return Ok(Value::Arr(items)); + } + return Err("expected ',' or ']'".into()); + } + } + + fn hex4(&mut self) -> R { + let mut v = 0u32; + for _ in 0..4 { + let d = self + .peek() + .and_then(|b| (b as char).to_digit(16)) + .ok_or("bad \\u escape")?; + v = v * 16 + d; + self.i += 1; + } + Ok(v) + } + + fn string(&mut self) -> R { + self.i += 1; // opening quote + let mut out = String::new(); + loop { + let b = self.peek().ok_or("unterminated string")?; + match b { + b'"' => { + self.i += 1; + return Ok(out); + } + b'\\' => { + self.i += 1; + let e = self.peek().ok_or("unterminated escape")?; + self.i += 1; + match e { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hi = self.hex4()?; + let cp = if (0xD800..0xDC00).contains(&hi) { + // A high surrogate must be followed by a low one. + if !(self.eat(b'\\') && self.eat(b'u')) { + return Err("lone surrogate".into()); + } + let lo = self.hex4()?; + if !(0xDC00..0xE000).contains(&lo) { + return Err("lone surrogate".into()); + } + 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00) + } else if (0xDC00..0xE000).contains(&hi) { + return Err("lone surrogate".into()); + } else { + hi + }; + out.push(char::from_u32(cp).ok_or("bad code point")?); + } + _ => return Err("bad escape".into()), + } + } + 0..=0x1f => return Err("control character in a string".into()), + _ => { + // Copy one whole UTF-8 character. `self.i` is always on a character boundary: + // it only ever advances by whole characters or over ASCII. + let c = self.text[self.i..] + .chars() + .next() + .ok_or("unterminated string")?; + out.push(c); + self.i += c.len_utf8(); + } + } + if out.len() > MAX_STRING { + return Err(format!("a string longer than {MAX_STRING} bytes")); + } + } + } + + fn number(&mut self) -> R { + let start = self.i; + self.eat(b'-'); + match self.peek() { + // A leading zero needs no check of its own: `01` reads as `0` and leaves a `1` that + // nothing accepts (pinned by the tests), so a second guard would only mask the first. + Some(b'0') => self.i += 1, + Some(b'1'..=b'9') => { + while self.peek().is_some_and(|b| b.is_ascii_digit()) { + self.i += 1; + } + } + _ => return Err("bad number".into()), + } + if self.eat(b'.') { + let d0 = self.i; + while self.peek().is_some_and(|b| b.is_ascii_digit()) { + self.i += 1; + } + if self.i == d0 { + return Err("bad number".into()); + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.i += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.i += 1; + } + let d0 = self.i; + while self.peek().is_some_and(|b| b.is_ascii_digit()) { + self.i += 1; + } + if self.i == d0 { + return Err("bad number".into()); + } + } + if self.i - start > 32 { + return Err("a number of more than 32 characters".into()); + } + let text = std::str::from_utf8(&self.s[start..self.i]).map_err(|_| "bad number")?; + let n: f64 = text.parse().map_err(|_| "bad number".to_string())?; + if n.is_finite() { + Ok(Value::Num(n)) + } else { + Err("a number out of range".into()) + } + } +} + +/// Parse `text` as exactly one JSON value. +pub fn parse(text: &str) -> Result { + let mut p = Parser { + text, + s: text.as_bytes(), + i: 0, + nodes: 0, + }; + let v = p.value(0)?; + p.ws(); + if p.i != p.s.len() { + return Err("text after the value".into()); + } + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FR-SPOT-08: real-shaped messages parse to the right tree, and the accessors read what they + /// should — exactly, and no more. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_json_parses_and_reads() { + let v = parse(r#" {"sid":"a1","freq":14236000,"transmitting":false,"snr":-7.5,"x":null,"l":[1,"b",[true]]} "#) + .unwrap(); + assert_eq!(v.get("sid").and_then(Value::as_str), Some("a1")); + assert_eq!(v.get("freq").and_then(Value::as_u64), Some(14_236_000)); + assert_eq!(v.get("transmitting").and_then(Value::as_bool), Some(false)); + assert_eq!(v.get("snr").and_then(Value::as_f64), Some(-7.5)); + assert_eq!(v.get("x"), Some(&Value::Null)); + assert_eq!(v.get("missing"), None); + let l = v.get("l").unwrap(); + assert_eq!(l.at(0).and_then(Value::as_u64), Some(1)); + assert_eq!(l.at(1).and_then(Value::as_str), Some("b")); + assert_eq!( + l.at(2).and_then(|a| a.at(0)).and_then(Value::as_bool), + Some(true) + ); + assert_eq!(l.at(3), None); + assert_eq!(l.as_array().map(<[Value]>::len), Some(3)); + // Accessors do not coerce: a string is not a number, a float is not a whole number. + assert_eq!(v.get("sid").and_then(Value::as_f64), None); + assert_eq!( + v.get("snr").and_then(Value::as_u64), + None, + "-7.5 is not a u64" + ); + assert_eq!(parse("-1").unwrap().as_u64(), None); + assert_eq!(parse("1.0").unwrap().as_u64(), Some(1)); + // A fraction is not a whole number, positive or not (the sign must not be what stops it). + assert_eq!(parse("1.5").unwrap().as_u64(), None); + assert_eq!(parse("14236000.5").unwrap().as_u64(), None); + assert_eq!(parse("0.5").unwrap().as_u64(), None); + // A hand-built value can hold what the parser never produces; the accessors still refuse it. + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(Value::Num(bad).as_f64(), None); + assert_eq!(Value::Num(bad).as_u64(), None); + } + assert_eq!(parse("1e3").unwrap().as_u64(), Some(1000)); + assert_eq!( + parse("9007199254740992").unwrap().as_u64(), + Some(9_007_199_254_740_992) + ); + assert_eq!( + parse("9007199254740994").unwrap().as_u64(), + None, + "beyond 2^53" + ); + assert_eq!(parse("\"1\"").unwrap().as_u64(), None); + assert_eq!(parse("true").unwrap().as_str(), None); + assert_eq!(parse("{}").unwrap(), Value::Obj(Vec::new())); + assert_eq!(parse("[]").unwrap(), Value::Arr(Vec::new())); + // Escapes decode, including a surrogate pair and multi-byte text. + let bs = '\\'; + let text = + format!("\"a{bs}n{bs}\"{bs}{bs}{bs}/{bs}u00e9{bs}ud83d{bs}ude00 \u{e9}\u{4e2d}\""); + assert_eq!( + parse(&text).unwrap().as_str(), + Some("a\n\"\\/\u{e9}\u{1f600} \u{e9}\u{4e2d}") + ); + } + + /// FR-SPOT-08: what a lenient parser would repair is refused, and every bound holds. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_json_refuses_what_it_would_repair_and_is_bounded() { + let bs = '\\'; + let bad: Vec<(&str, String)> = vec![ + ("empty", String::new()), + ("whitespace only", " ".into()), + ("trailing comma array", "[1,]".into()), + ("trailing comma object", r#"{"a":1,}"#.into()), + ("leading comma", "[,1]".into()), + ("double comma", "[1,,2]".into()), + ("duplicate key", r#"{"a":1,"a":2}"#.into()), + ("text after", "1 2".into()), + ("two values", "[] []".into()), + ("unquoted key", "{a:1}".into()), + ("single quotes", "{'a':1}".into()), + ("missing colon", r#"{"a" 1}"#.into()), + ("missing value", r#"{"a":}"#.into()), + ("unterminated string", "\"abc".into()), + ("unterminated array", "[1,2".into()), + ("unterminated object", r#"{"a":1"#.into()), + ("control char", "\"a\tb\"".into()), + ("newline in string", "\"a\nb\"".into()), + ("bad escape", format!("\"{bs}q\"")), + ("short unicode", format!("\"{bs}u12\"")), + ("non-hex unicode", format!("\"{bs}u12g4\"")), + ("lone high surrogate", format!("\"{bs}ud83d\"")), + ("lone low surrogate", format!("\"{bs}ude00\"")), + ("high then non-low", format!("\"{bs}ud83d{bs}u0041\"")), + ("leading zero", "01".into()), + ("negative leading zero", "-01".into()), + ("plus sign", "+1".into()), + ("bare minus", "-".into()), + ("dot without digits", "1.".into()), + ("leading dot", ".5".into()), + ("exponent without digits", "1e".into()), + ("hex number", "0x10".into()), + ("NaN", "NaN".into()), + ("Infinity", "Infinity".into()), + ("overflowing number", "1e999".into()), + ("too-long number", format!("1{}", "0".repeat(40))), + ("bad literal", "tru".into()), + ("capital literal", "True".into()), + ("comment", "[1] // x".into()), + ]; + for (name, text) in &bad { + assert!(parse(text).is_err(), "{name} must be refused: {text:?}"); + } + // The limits are written as numbers, not taken from the constants: a test built from a constant + // moves with it and never notices a change. Depth: 8 levels are read, a ninth is not. + let nest = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n)); + assert!(parse(&nest(8)).is_ok()); + assert!(parse(&nest(9)).is_err()); + let objects = |n: usize| format!("{}1{}", r#"{"a":"#.repeat(n), "}".repeat(n)); + assert!(parse(&objects(8)).is_ok()); + assert!(parse(&objects(9)).is_err()); + assert!( + parse(&nest(100_000)).is_err(), + "a deep bomb must not recurse without limit" + ); + // Nodes: exactly MAX_NODES values are read; one more is not. + let flat = |n: usize| format!("[{}]", vec!["0"; n - 1].join(",")); + assert!( + parse(&flat(20_000)).is_ok(), + "the array and its elements make MAX_NODES" + ); + assert!(parse(&flat(20_001)).is_err()); + // Strings: MAX_STRING bytes are read, one more is not — keys as well as values. + let s = |n: usize| format!("\"{}\"", "a".repeat(n)); + assert!(parse(&s(4096)).is_ok()); + assert!(parse(&s(4097)).is_err()); + assert!(parse(&format!("{{{}:1}}", s(4097))).is_err()); + assert!(parse(&format!("{{{}:1}}", s(4096))).is_ok()); + // Time is linear in the text: 256 KB of short strings is read at once, not in seconds. + let big = format!("[{}]", vec!["\"abcdefghij\u{4e2d}\""; 15_000].join(",")); + let t = std::time::Instant::now(); + assert!(parse(&big).is_ok()); + assert!( + t.elapsed() < std::time::Duration::from_secs(2), + "parsing took {:?}", + t.elapsed() + ); + // Multi-byte characters count as their bytes. + let wide = format!("\"{}\"", "\u{4e2d}".repeat(1366)); + assert!(parse(&wide).is_err()); + } +} diff --git a/crates/k4-spot/src/lib.rs b/crates/k4-spot/src/lib.rs index f96360f..d48a1bd 100644 --- a/crates/k4-spot/src/lib.rs +++ b/crates/k4-spot/src/lib.rs @@ -7,6 +7,9 @@ //! clock and every result is deterministic. pub mod cluster; +pub mod freedv; +pub mod freedv_source; +pub mod json; pub mod layout; mod model; pub mod mqtt; @@ -14,10 +17,12 @@ pub mod mqtt_source; pub mod polled; pub mod pota; pub mod psk; +pub mod sio; mod store; pub mod style; pub mod telnet; pub mod tooltip; +pub mod ws; pub use model::{ normalise_callsign, sanitise_text, Network, Parsed, SourceError, Spot, SpotSource, MAX_TEXT_LEN, diff --git a/crates/k4-spot/src/model.rs b/crates/k4-spot/src/model.rs index e16925b..2a0e275 100644 --- a/crates/k4-spot/src/model.rs +++ b/crates/k4-spot/src/model.rs @@ -14,6 +14,7 @@ pub enum Network { Rbn, DxCluster, Pota, + FreeDvReporter, } /// One report of a station on a frequency. diff --git a/crates/k4-spot/src/sio.rs b/crates/k4-spot/src/sio.rs new file mode 100644 index 0000000..520fc2b --- /dev/null +++ b/crates/k4-spot/src/sio.rs @@ -0,0 +1,355 @@ +//! Engine.IO 4 / Socket.IO 4 text packets, as FreeDV Reporter speaks them over a WebSocket. Pure +//! and offline: a text message becomes a [`Packet`], and the few packets this client sends are built +//! here. +//! +//! Only what the reporter uses is understood: the Engine.IO `open`, `ping`/`pong` and `close`, and +//! on the default namespace the Socket.IO `connect`, `disconnect`, `connect_error` and `event`. +//! Anything else — binary attachments, acknowledgements, another namespace, an upgrade probe — is +//! [`Packet::Ignored`], never an error: a server that grows a feature must not be able to make this +//! client fall over. What *is* malformed is an error. + +use crate::json::{self, Value}; + +/// The reply to a server ping. +pub const PONG: &str = "3"; + +/// The Socket.IO protocol revision the reporter's clients announce. +pub const PROTOCOL_VERSION: u32 = 2; + +/// Longest event name accepted. +const MAX_EVENT_NAME: usize = 64; +/// Longest session id accepted. +const MAX_SID: usize = 128; +/// Bounds for the server's ping timing, milliseconds: what it says is clamped into this range, so a +/// hostile `pingInterval` of years cannot switch the liveness check off, nor one of zero spin it. +pub const MIN_PING_MS: u64 = 1_000; +pub const MAX_PING_MS: u64 = 120_000; +/// Used when the open packet does not say (Engine.IO's own defaults). +const DEFAULT_PING_INTERVAL_MS: u64 = 25_000; +const DEFAULT_PING_TIMEOUT_MS: u64 = 20_000; + +/// One packet from the server. +#[derive(Debug, Clone, PartialEq)] +pub enum Packet { + /// Engine.IO `open`: the session's ping timing. + Open { + sid: String, + ping_interval_ms: u64, + ping_timeout_ms: u64, + }, + Ping, + Pong, + /// Engine.IO `close`. + Close, + /// Socket.IO `connect`: the namespace accepted us. + Connect { + sid: Option, + }, + /// Socket.IO `disconnect`. + Disconnect, + /// Socket.IO `connect_error`: the server refused us. + ConnectError, + /// Socket.IO `event`: its name and first argument (`Null` if it has none). + Event { + name: String, + args: Value, + }, + /// Valid, and nothing this client uses. + Ignored, +} + +fn bounded_id(v: Option<&Value>) -> Option { + v.and_then(Value::as_str) + .filter(|s| { + !s.is_empty() && s.len() <= MAX_SID && s.bytes().all(|b| (b'!'..=b'~').contains(&b)) + }) + .map(str::to_string) +} + +fn ping_ms(v: Option<&Value>, default: u64) -> u64 { + v.and_then(Value::as_u64) + .unwrap_or(default) + .clamp(MIN_PING_MS, MAX_PING_MS) +} + +/// Read one text message from the server. +pub fn parse(text: &str) -> Result { + let mut chars = text.chars(); + let kind = chars.next().ok_or("an empty packet")?; + let rest = chars.as_str(); + match kind { + '0' => { + let v = json::parse(rest).map_err(|e| format!("the open packet: {e}"))?; + if !matches!(v, Value::Obj(_)) { + return Err("the open packet is not an object".into()); + } + Ok(Packet::Open { + sid: bounded_id(v.get("sid")).unwrap_or_default(), + ping_interval_ms: ping_ms(v.get("pingInterval"), DEFAULT_PING_INTERVAL_MS), + ping_timeout_ms: ping_ms(v.get("pingTimeout"), DEFAULT_PING_TIMEOUT_MS), + }) + } + '1' if rest.is_empty() => Ok(Packet::Close), + '2' if rest.is_empty() => Ok(Packet::Ping), + '3' if rest.is_empty() => Ok(Packet::Pong), + // A ping or pong with a body is the upgrade probe, which is not used on a WebSocket + // opened directly; a `noop` (6) and an `upgrade` (5) likewise. + '1'..='3' | '5' | '6' => Ok(Packet::Ignored), + '4' => parse_message(rest), + c if c.is_ascii_digit() => Ok(Packet::Ignored), + _ => Err("a packet does not begin with a digit".into()), + } +} + +fn parse_message(rest: &str) -> Result { + let mut chars = rest.chars(); + let kind = chars.next().ok_or("an empty Socket.IO packet")?; + let mut body = chars.as_str(); + // A namespace other than the default (`/`) is not ours: `/name,` before the data. + if body.starts_with('/') { + return Ok(Packet::Ignored); + } + match kind { + '0' => { + if body.is_empty() { + return Ok(Packet::Connect { sid: None }); + } + let v = json::parse(body).map_err(|e| format!("the connect packet: {e}"))?; + Ok(Packet::Connect { + sid: bounded_id(v.get("sid")), + }) + } + '1' => Ok(Packet::Disconnect), + '4' => Ok(Packet::ConnectError), + '2' => { + // An acknowledgement id may precede the data; this client never acknowledges, so it is + // read and dropped. + let ack = body.bytes().take_while(u8::is_ascii_digit).count(); + if ack > 16 { + return Err("an acknowledgement id of more than 16 digits".into()); + } + body = &body[ack..]; + let v = json::parse(body).map_err(|e| format!("an event: {e}"))?; + let name = v + .at(0) + .and_then(Value::as_str) + .ok_or("an event has no name")?; + if name.is_empty() + || name.len() > MAX_EVENT_NAME + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.')) + { + return Err("an event name is empty, too long, or has odd characters".into()); + } + Ok(Packet::Event { + name: name.to_string(), + args: v.at(1).cloned().unwrap_or(Value::Null), + }) + } + // Acknowledgements and binary events: not used, not an error. + _ => Ok(Packet::Ignored), + } +} + +/// The Socket.IO `connect` this client sends: the **read-only `view` role** and the protocol +/// revision, and **nothing else** — no callsign, no grid, no software or system description. In +/// the `report` role those are what make a station publicly visible; K4 Remote is receive-only +/// (`FR-SPOT-12`) and never asks for it. +pub fn connect_view() -> String { + format!("40{{\"role\":\"view\",\"protocol_version\":{PROTOCOL_VERSION}}}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(name: &str, args: Value) -> Packet { + Packet::Event { + name: name.into(), + args, + } + } + + /// FR-SPOT-08: the packets a real session carries parse to the right thing, including the + /// ones worded to catch a careless reader (an id before the event data, a namespace, a body + /// on a ping). + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_sio_packets_parse() { + assert_eq!( + parse( + r#"0{"sid":"AbC_123","upgrades":[],"pingInterval":5000,"pingTimeout":5000,"maxPayload":1000000}"# + ), + Ok(Packet::Open { + sid: "AbC_123".into(), + ping_interval_ms: 5000, + ping_timeout_ms: 5000 + }) + ); + assert_eq!(parse("2"), Ok(Packet::Ping)); + assert_eq!(parse("3"), Ok(Packet::Pong)); + assert_eq!(parse("1"), Ok(Packet::Close)); + assert_eq!(parse("40"), Ok(Packet::Connect { sid: None })); + assert_eq!( + parse(r#"40{"sid":"xyz-1"}"#), + Ok(Packet::Connect { + sid: Some("xyz-1".into()) + }) + ); + assert_eq!(parse("41"), Ok(Packet::Disconnect)); + assert_eq!(parse(r#"44{"message":"nope"}"#), Ok(Packet::ConnectError)); + assert_eq!( + parse(r#"42["freq_change",{"sid":"a","freq":14236000}]"#), + Ok(event( + "freq_change", + json::parse(r#"{"sid":"a","freq":14236000}"#).unwrap() + )) + ); + assert_eq!( + parse(r#"42["connection_successful"]"#), + Ok(event("connection_successful", Value::Null)) + ); + // Only the first argument is kept. + assert_eq!(parse(r#"42["e",1,2,3]"#), Ok(event("e", Value::Num(1.0)))); + // An acknowledgement id in front of the data is read and dropped. + assert_eq!( + parse(r#"4212["e",{"a":1}]"#), + Ok(event("e", json::parse(r#"{"a":1}"#).unwrap())) + ); + // The ping timing is clamped into range; absent means Engine.IO's defaults. + for (json_text, want) in [ + (r#"{"pingInterval":1,"pingTimeout":1}"#, (1_000, 1_000)), + ( + r#"{"pingInterval":99999999999,"pingTimeout":99999999999}"#, + (120_000, 120_000), + ), + (r#"{"pingInterval":"x","pingTimeout":-5}"#, (25_000, 20_000)), + (r#"{}"#, (25_000, 20_000)), + // Engine.IO's own defaults, written as literals (25 s and 20 s), so the constants are + // checked and not merely compared with themselves. + (r#"{"pingInterval":2500}"#, (2_500, 20_000)), + (r#"{"pingTimeout":2500}"#, (25_000, 2_500)), + (r#"{"pingInterval":2500.5}"#, (25_000, 20_000)), + ] { + let Ok(Packet::Open { + ping_interval_ms, + ping_timeout_ms, + .. + }) = parse(&format!("0{json_text}")) + else { + panic!("{json_text}"); + }; + assert_eq!((ping_interval_ms, ping_timeout_ms), want, "{json_text}"); + } + // The ping limits are exact: 999 is raised to 1000, 1000 is kept, 120000 is kept, 120001 is lowered. + for (ms, want) in [ + (999u64, 1_000u64), + (1_000, 1_000), + (120_000, 120_000), + (120_001, 120_000), + ] { + let Ok(Packet::Open { + ping_interval_ms, + ping_timeout_ms, + .. + }) = parse(&format!(r#"0{{"pingInterval":{ms},"pingTimeout":{ms}}}"#)) + else { + panic!("{ms}"); + }; + assert_eq!((ping_interval_ms, ping_timeout_ms), (want, want), "{ms}"); + } + // A session id is kept up to 128 characters and dropped beyond. + for (len, kept) in [(128usize, true), (129, false)] { + let sid = "s".repeat(len); + let Ok(Packet::Connect { sid: got }) = parse(&format!(r#"40{{"sid":"{sid}"}}"#)) else { + panic!("{len}"); + }; + assert_eq!(got.is_some(), kept, "{len}"); + } + // Valid but not ours: never an error. + for ignored in [ + "5", + "6", + "2probe", + "3probe", + "10", + "40/admin,", + "42/admin,[\"e\"]", + "43[]", + "45-[\"x\",{\"_placeholder\":true,\"num\":0}]", + "46-1[]", + "47", + "9", + "7abc", + ] { + assert_eq!(parse(ignored), Ok(Packet::Ignored), "{ignored:?}"); + } + // A session id with odd characters is dropped, not kept. + assert_eq!( + parse(r#"0{"sid":"a b"}"#) + .map(|p| matches!(p, Packet::Open { ref sid, .. } if sid.is_empty())), + Ok(true) + ); + } + + /// FR-SPOT-08: malformed packets are errors, not guesses. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_sio_malformed_packets_are_errors() { + let long_name = format!("42[\"{}\"]", "a".repeat(65)); + let long_ack = format!("42{}[\"e\"]", "1".repeat(17)); + for (name, text) in [ + ("empty", ""), + ("not a digit", "x"), + ("letter first", "a[]"), + ("open without json", "0"), + ("open with junk", "0not json"), + ("open not an object", "0[1]"), + ("open with trailing text", r#"0{} x"#), + ("empty socket.io packet", "4"), + ("connect with junk", "40junk"), + ("event without data", "42"), + ("event not json", "42nope"), + ("event not an array", r#"42{"a":1}"#), + ("empty array", "42[]"), + ("name not a string", "42[1,2]"), + ("empty name", r#"42[""]"#), + ("name with a space", r#"42["a b"]"#), + ("name with a slash", r#"42["a/b"]"#), + ("name too long", long_name.as_str()), + ("ack id too long", long_ack.as_str()), + ("trailing comma", r#"42["e",]"#), + ("duplicate key in args", r#"42["e",{"a":1,"a":2}]"#), + ] { + assert!(parse(text).is_err(), "{name} must be an error: {text:?}"); + } + // A name of exactly the maximum length is fine. + assert!(parse(&format!("42[\"{}\"]", "a".repeat(64))).is_ok()); + // Names may use the punctuation real ones do. + assert!(parse(r#"42["a_b-c:d.e"]"#).is_ok()); + } + + /// FR-SPOT-08 / FR-SPOT-12: what this client sends is the read-only `view` connect, exactly, + /// and the pong — and nothing that identifies the operator. + /// trace: FR-SPOT-08, FR-SPOT-12 + #[test] + fn fr_spot_12_the_connect_is_view_only_and_anonymous() { + assert_eq!(connect_view(), r#"40{"role":"view","protocol_version":2}"#); + assert_eq!(PONG, "3"); + let sent = connect_view().to_ascii_lowercase(); + for identifying in [ + "callsign", + "grid", + "version\":\"", + "os", + "rx_only", + "report", + ] { + // "os" appears inside "protocol": check as the JSON keys they would be. + let key = format!("\"{identifying}\""); + assert!(!sent.contains(&key), "the connect carries {identifying}"); + } + assert!(!sent.contains("\"role\":\"report\"")); + } +} diff --git a/crates/k4-spot/src/style.rs b/crates/k4-spot/src/style.rs index 71affd4..605a766 100644 --- a/crates/k4-spot/src/style.rs +++ b/crates/k4-spot/src/style.rs @@ -30,6 +30,8 @@ pub fn source_rgb(network: Network) -> (u8, u8, u8) { Network::DxCluster => (206, 166, 255), // Activators on a park (POTA): green. Network::Pota => (140, 230, 150), + // Stations on the air on FreeDV Reporter: coral. + Network::FreeDvReporter => (255, 150, 165), } } @@ -73,11 +75,12 @@ pub fn contrast(a: (u8, u8, u8), b: (u8, u8, u8)) -> f64 { mod tests { use super::*; - const SOURCES: [Network; 4] = [ + const SOURCES: [Network; 5] = [ Network::Rbn, Network::PskReporter, Network::DxCluster, Network::Pota, + Network::FreeDvReporter, ]; /// FR-SPOT-11: the fade starts at fully opaque, never rises as a spot ages, reaches its floor at diff --git a/crates/k4-spot/src/tooltip.rs b/crates/k4-spot/src/tooltip.rs index b42110d..16c2f22 100644 --- a/crates/k4-spot/src/tooltip.rs +++ b/crates/k4-spot/src/tooltip.rs @@ -29,6 +29,7 @@ pub fn network_name(network: Network) -> &'static str { Network::DxCluster => "DX cluster", Network::PskReporter => "PSK Reporter", Network::Pota => "POTA", + Network::FreeDvReporter => "FreeDV Reporter", } } @@ -152,6 +153,7 @@ mod tests { assert_eq!(network_name(Network::DxCluster), "DX cluster"); assert_eq!(network_name(Network::PskReporter), "PSK Reporter"); assert_eq!(network_name(Network::Pota), "POTA"); + assert_eq!(network_name(Network::FreeDvReporter), "FreeDV Reporter"); } /// FR-SPOT-10: a tooltip sits beside the pointer and is always fully inside the pane, flipped to diff --git a/crates/k4-spot/src/ws.rs b/crates/k4-spot/src/ws.rs new file mode 100644 index 0000000..647f229 --- /dev/null +++ b/crates/k4-spot/src/ws.rs @@ -0,0 +1,907 @@ +//! A minimal WebSocket client (RFC 6455) for the networks reached that way (FreeDV Reporter). Pure +//! and offline: it builds the upgrade request, checks the reply, and turns bytes into messages and +//! messages into bytes; the socket is somebody else's. +//! +//! It is written for a peer that is **not trusted**. The reply to the upgrade is checked in full +//! (status, `Upgrade`, `Connection`, and the `Sec-WebSocket-Accept` value the key implies — which +//! needs SHA-1 and base64, both here and tested against their published vectors); no extension or +//! sub-protocol is ever requested, so one that comes back is refused; and every size is bounded +//! *before* anything is buffered: the header block, a frame's declared length, and a fragmented +//! message's total. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Largest reply header block read, bytes. +pub const MAX_HEADERS: usize = 8 * 1024; +/// Largest message (a frame, or a fragmented message's total) read, bytes. +pub const MAX_MESSAGE: usize = 256 * 1024; +/// Most header lines read. +const MAX_HEADER_LINES: usize = 64; + +/// The GUID RFC 6455 §1.3 appends to the client's key. +const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +// --------------------------------------------------------------------------------------------- +// SHA-1 and base64, only for the accept key. SHA-1 is broken as a hash; RFC 6455 uses it as a +// handshake check against confused HTTP peers, not for security, and asks for nothing else. + +/// SHA-1 (FIPS 180-4) of `data`. +pub fn sha1(data: &[u8]) -> [u8; 20] { + let mut h: [u32; 5] = [ + 0x6745_2301, + 0xEFCD_AB89, + 0x98BA_DCFE, + 0x1032_5476, + 0xC3D2_E1F0, + ]; + let mut msg = data.to_vec(); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&((data.len() as u64) * 8).to_be_bytes()); + let (blocks, _) = msg.as_chunks::<64>(); + for block in blocks { + let mut w = [0u32; 80]; + for (i, word) in block.as_chunks::<4>().0.iter().enumerate() { + w[i] = u32::from_be_bytes(*word); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let [mut a, mut b, mut c, mut d, mut e] = h; + for (i, wi) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | (!b & d), 0x5A82_7999), + 20..=39 => (b ^ c ^ d, 0x6ED9_EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC), + _ => (b ^ c ^ d, 0xCA62_C1D6), + }; + let t = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(*wi); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = t; + } + for (hv, v) in h.iter_mut().zip([a, b, c, d, e]) { + *hv = hv.wrapping_add(v); + } + } + let mut out = [0u8; 20]; + for (i, v) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&v.to_be_bytes()); + } + out +} + +/// Standard base64 (RFC 4648 §4) with padding. +pub fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let n = (u32::from(chunk[0]) << 16) + | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) + | u32::from(*chunk.get(2).unwrap_or(&0)); + out.push(T[(n >> 18) as usize & 63] as char); + out.push(T[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + T[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + T[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +/// The `Sec-WebSocket-Accept` value a server must answer to `key`. +pub fn accept_key(key: &str) -> String { + base64(&sha1(format!("{key}{GUID}").as_bytes())) +} + +// --------------------------------------------------------------------------------------------- +// Randomness for the client key and the frame masks: a small generator seeded from the clock, the +// process id and a counter. Masking exists to stop a confused intermediary reading client data as +// HTTP, not to hide anything, so this does not need to be a cryptographic source. + +/// A tiny xorshift generator. +#[derive(Debug, Clone)] +pub struct Rng(u64); + +impl Rng { + pub fn seeded() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0x9E37_79B9_7F4A_7C15); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(1, |d| d.as_nanos() as u64); + let c = COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed); + Self::from_seed(nanos ^ c ^ (u64::from(std::process::id()) << 32)) + } + + pub fn from_seed(seed: u64) -> Self { + Self(if seed == 0 { + 0x2545_F491_4F6C_DD1D + } else { + seed + }) + } + + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + /// A fresh `Sec-WebSocket-Key`: 16 random bytes, base64. + pub fn key(&mut self) -> String { + let mut b = [0u8; 16]; + b[..8].copy_from_slice(&self.next_u64().to_le_bytes()); + b[8..].copy_from_slice(&self.next_u64().to_le_bytes()); + base64(&b) + } + + pub fn mask(&mut self) -> [u8; 4] { + (self.next_u64() as u32).to_le_bytes() + } +} + +// --------------------------------------------------------------------------------------------- +// The upgrade. + +fn header_safe(what: &str, v: &str) -> Result<(), String> { + if v.is_empty() || v.bytes().any(|b| b < 0x20 || b == 0x7f) { + return Err(format!("{what} is empty or has a control character")); + } + Ok(()) +} + +/// The client's upgrade request. The host, path and user agent are checked for control characters +/// (a CR or LF in one would let it inject headers). +pub fn request( + host: &str, + port: u16, + path: &str, + key: &str, + user_agent: &str, +) -> Result, String> { + header_safe("the host", host)?; + header_safe("the path", path)?; + header_safe("the user agent", user_agent)?; + if host.contains(' ') || path.contains(' ') || !path.starts_with('/') { + return Err("the host or path is not valid in a request".into()); + } + let host_header = if port == 80 { + host.to_string() + } else { + format!("{host}:{port}") + }; + Ok(format!( + "GET {path} HTTP/1.1\r\nHost: {host_header}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nUser-Agent: {user_agent}\r\n\r\n" + ) + .into_bytes()) +} + +/// Check the server's reply to the upgrade. `Ok(None)` means the header block is not complete yet; +/// `Ok(Some(n))` means it is valid and ends at byte `n` (what follows is the first frames); `Err` +/// is a refusal worded for the operator. +pub fn check_response(buf: &[u8], key: &str) -> Result, String> { + let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { + if buf.len() > MAX_HEADERS { + return Err("the server's reply has no end to its headers".into()); + } + return Ok(None); + }; + let end = end + 4; + if end > MAX_HEADERS { + return Err("the server's reply headers are too long".into()); + } + let head = std::str::from_utf8(&buf[..end]).map_err(|_| "the server's reply is not text")?; + let mut lines = head.split("\r\n"); + let status = lines.next().unwrap_or(""); + let mut parts = status.splitn(3, ' '); + if parts.next() != Some("HTTP/1.1") { + return Err("the server did not answer with HTTP/1.1".into()); + } + let code: u16 = parts + .next() + .and_then(|c| c.parse().ok()) + .ok_or("the server's status line is malformed")?; + if code != 101 { + return Err(format!( + "the server answered HTTP {code}, not a WebSocket upgrade" + )); + } + let (mut upgrade, mut connection, mut accept) = (false, false, false); + for (n, line) in lines.filter(|l| !l.is_empty()).enumerate() { + if n >= MAX_HEADER_LINES { + return Err("the server's reply has too many headers".into()); + } + let (name, value) = line.split_once(':').ok_or("a header line has no colon")?; + let (name, value) = (name.trim().to_ascii_lowercase(), value.trim()); + match name.as_str() { + "upgrade" => upgrade = value.eq_ignore_ascii_case("websocket"), + "connection" => { + connection = value + .split(',') + .any(|t| t.trim().eq_ignore_ascii_case("upgrade")) + } + "sec-websocket-accept" => accept = value == accept_key(key), + // None was asked for, so one that comes back is a server doing what it was not told to. + "sec-websocket-extensions" | "sec-websocket-protocol" => { + return Err("the server chose an extension or sub-protocol nobody asked for".into()) + } + _ => {} + } + } + if !upgrade { + return Err("the reply lacks `Upgrade: websocket`".into()); + } + if !connection { + return Err("the reply lacks `Connection: Upgrade`".into()); + } + if !accept { + return Err("the server's Sec-WebSocket-Accept does not match the key sent".into()); + } + Ok(Some(end)) +} + +// --------------------------------------------------------------------------------------------- +// Frames. + +/// A message as it arrives from the server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + Text(String), + Binary(Vec), + Ping(Vec), + Pong(Vec), + /// The close code, if the server gave one. + Close(Option), +} + +pub const OP_TEXT: u8 = 0x1; +pub const OP_BINARY: u8 = 0x2; +pub const OP_CLOSE: u8 = 0x8; +pub const OP_PING: u8 = 0x9; +pub const OP_PONG: u8 = 0xA; + +/// Encode one final, masked client frame. +pub fn encode(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + 14); + out.push(0x80 | (opcode & 0x0f)); + match payload.len() { + n if n < 126 => out.push(0x80 | n as u8), + n if n <= 0xffff => { + out.push(0x80 | 126); + out.extend_from_slice(&(n as u16).to_be_bytes()); + } + n => { + out.push(0x80 | 127); + out.extend_from_slice(&(n as u64).to_be_bytes()); + } + } + out.extend_from_slice(&mask); + out.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4])); + out +} + +/// Turns the server's bytes into messages, across reads split anywhere. +#[derive(Debug, Default)] +pub struct FrameReader { + buf: Vec, + /// A fragmented message in progress: its opcode and what has arrived. + partial: Option<(u8, Vec)>, +} + +impl FrameReader { + pub fn new() -> Self { + Self::default() + } + + /// Add bytes and return every message they complete. An error ends the connection: the peer + /// broke the protocol, and nothing after it can be trusted. + pub fn push(&mut self, bytes: &[u8]) -> Result, String> { + self.buf.extend_from_slice(bytes); + let mut out = Vec::new(); + loop { + let b = &self.buf; + if b.len() < 2 { + break; + } + let (fin, rsv, op) = (b[0] & 0x80 != 0, b[0] & 0x70, b[0] & 0x0f); + let (masked, len7) = (b[1] & 0x80 != 0, usize::from(b[1] & 0x7f)); + if rsv != 0 { + return Err("a frame uses a reserved bit (no extension was negotiated)".into()); + } + if masked { + return Err("the server sent a masked frame".into()); + } + if !matches!(op, 0x0 | 0x1 | 0x2 | 0x8 | 0x9 | 0xA) { + return Err(format!("a frame has the reserved opcode {op:#x}")); + } + let (header, len) = match len7 { + 126 => { + if b.len() < 4 { + break; + } + (4, usize::from(u16::from_be_bytes([b[2], b[3]]))) + } + 127 => { + if b.len() < 10 { + break; + } + let n = u64::from_be_bytes([b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9]]); + if n >> 63 != 0 { + return Err("a frame declares a length with the top bit set".into()); + } + if n > MAX_MESSAGE as u64 { + return Err(format!("a frame is larger than {} KB", MAX_MESSAGE / 1024)); + } + (10, n as usize) + } + n => (2, n), + }; + // (The 7- and 16-bit forms cannot exceed the cap; the 64-bit form was checked above, + // before its length was used for anything.) + let is_control = op & 0x8 != 0; + if is_control && (!fin || len > 125) { + return Err("a control frame is fragmented or longer than 125 bytes".into()); + } + if b.len() < header + len { + break; + } + let payload = b[header..header + len].to_vec(); + self.buf.drain(..header + len); + + match op { + OP_PING => out.push(Message::Ping(payload)), + OP_PONG => out.push(Message::Pong(payload)), + OP_CLOSE => { + if payload.len() == 1 { + return Err("a close frame has a one-byte body".into()); + } + let code = + (payload.len() >= 2).then(|| u16::from_be_bytes([payload[0], payload[1]])); + out.push(Message::Close(code)); + } + 0x0 => { + let Some((first, mut have)) = self.partial.take() else { + return Err("a continuation frame with nothing to continue".into()); + }; + if have.len() + payload.len() > MAX_MESSAGE { + return Err(format!( + "a fragmented message is larger than {} KB", + MAX_MESSAGE / 1024 + )); + } + have.extend_from_slice(&payload); + if fin { + out.push(finish(first, have)?); + } else { + self.partial = Some((first, have)); + } + } + _ => { + if self.partial.is_some() { + return Err("a new message began before the last one ended".into()); + } + if fin { + out.push(finish(op, payload)?); + } else { + self.partial = Some((op, payload)); + } + } + } + } + Ok(out) + } +} + +fn finish(op: u8, payload: Vec) -> Result { + if op == OP_TEXT { + String::from_utf8(payload) + .map(Message::Text) + .map_err(|_| "a text message is not valid UTF-8".to_string()) + } else { + Ok(Message::Binary(payload)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + /// FR-SPOT-08: SHA-1, base64 and the accept key against published vectors — FIPS 180 + /// ("abc", the empty string, the two-block message, a million `a`), RFC 4648 §10, and the worked + /// example in RFC 6455 §1.3. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_ws_hashes_match_published_vectors() { + assert_eq!( + hex(&sha1(b"abc")), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!(hex(&sha1(b"")), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + assert_eq!( + hex(&sha1( + b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" + )), + "84983e441c3bd26ebaae4aa1f95129e5e54670f1" + ); + assert_eq!( + hex(&sha1(&vec![b'a'; 1_000_000])), + "34aa973cd4c4daa4f61eeb2bdbad27316534016f" + ); + // Around the padding boundaries (55, 56, 63, 64, 65 bytes) — where implementations slip. + for (n, want) in [ + (55, "c1c8bbdc22796e28c0e15163d20899b65621d65a"), + (56, "c2db330f6083854c99d4b5bfb6e8f29f201be699"), + (63, "03f09f5b158a7a8cdad920bddc29b81c18a551f5"), + (64, "0098ba824b5c16427bd7a1122a5a442a25ec644d"), + (65, "11655326c708d70319be2610e8a57d9a5b959d3b"), + ] { + assert_eq!(hex(&sha1(&vec![b'a'; n])), want, "{n} bytes"); + } + for (raw, want) in [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("fooba", "Zm9vYmE="), + ("foobar", "Zm9vYmFy"), + ] { + assert_eq!(base64(raw.as_bytes()), want, "{raw:?}"); + } + assert_eq!( + base64(&[0xfb, 0xff, 0xbf]), + "+/+/", + "the two non-alphanumeric characters" + ); + // RFC 6455 §1.3: this key gives this accept value. + assert_eq!( + accept_key("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + /// FR-SPOT-08: the client key is 16 bytes of base64, differs from one connection to the next, + /// and the masks are not constant. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_ws_key_and_mask_vary() { + let mut r = Rng::seeded(); + let (a, b) = (r.key(), r.key()); + assert_eq!(a.len(), 24); + assert!( + a.ends_with("=="), + "16 bytes encode with two pad characters: {a}" + ); + assert_ne!(a, b); + assert_ne!( + Rng::seeded().key(), + Rng::seeded().key(), + "separate generators differ" + ); + let masks: std::collections::HashSet<[u8; 4]> = (0..50).map(|_| r.mask()).collect(); + assert!(masks.len() > 40); + // A zero seed does not stick at zero. + assert_ne!(Rng::from_seed(0).next_u64(), 0); + assert_eq!( + Rng::from_seed(7).next_u64(), + Rng::from_seed(7).next_u64(), + "deterministic" + ); + } + + /// FR-SPOT-08: the request is what a server expects, and a value that could inject a header + /// is refused. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_ws_request_is_well_formed_and_cannot_inject() { + let req = request( + "qso.freedv.org", + 80, + "/socket.io/?EIO=4&transport=websocket", + "KEY==", + "K4remote/1", + ) + .unwrap(); + let text = String::from_utf8(req).unwrap(); + assert_eq!( + text, + "GET /socket.io/?EIO=4&transport=websocket HTTP/1.1\r\nHost: qso.freedv.org\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: KEY==\r\nSec-WebSocket-Version: 13\r\nUser-Agent: K4remote/1\r\n\r\n" + ); + let other = request("h.example", 8080, "/p", "K", "u").unwrap(); + assert!(String::from_utf8(other) + .unwrap() + .contains("Host: h.example:8080\r\n")); + for (h, p, u) in [ + ("h\r\nX: y", "/p", "u"), + ("h", "/p\r\nX: y", "u"), + ("h", "/p", "u\r\nX: y"), + ("h", "/p", "u\nX: y"), + ("", "/p", "u"), + ("h", "", "u"), + ("h", "/p", ""), + ("h", "p", "u"), + ("h st", "/p", "u"), + ("h", "/p q", "u"), + ("h\0", "/p", "u"), + ] { + assert!(request(h, 80, p, "K", u).is_err(), "{h:?} {p:?} {u:?}"); + } + } + + fn reply(extra: &str, key: &str) -> Vec { + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n{extra}\r\n", + accept_key(key) + ) + .into_bytes() + } + + /// FR-SPOT-08: the upgrade reply is accepted only when every part is right, needs the whole + /// header block, leaves the bytes after it alone, and is refused when it is wrong or hostile. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_ws_response_is_checked_in_full() { + let key = "dGhlIHNhbXBsZSBub25jZQ=="; + let good = reply("", key); + assert_eq!(check_response(&good, key), Ok(Some(good.len()))); + // Bytes after the headers (the first frames) are not consumed. + let mut more = good.clone(); + more.extend_from_slice(b"\x81\x02hi"); + assert_eq!(check_response(&more, key), Ok(Some(good.len()))); + // Not complete yet: every prefix asks for more, never accepts early. + for n in 0..good.len() { + assert_eq!( + check_response(&good[..n], key), + Ok(None), + "prefix of {n} bytes" + ); + } + // Header names and the values that are case-insensitive, and a token list. + for token in [ + "keep-alive, Upgrade", + "keep-alive, upgrade", + "UPGRADE", + " upgrade ,keep-alive", + ] { + let loose = format!( + "HTTP/1.1 101 Switching Protocols\r\nupgrade: WebSocket\r\nCONNECTION: {token}\r\nsec-websocket-accept: {}\r\nServer: x\r\n\r\n", + accept_key(key) + ) + .into_bytes(); + assert_eq!( + check_response(&loose, key), + Ok(Some(loose.len())), + "{token:?}" + ); + } + + let wrong = |what: &str, bytes: Vec| { + let e = check_response(&bytes, key).expect_err(what); + assert!(!e.is_empty()); + e + }; + assert!(wrong("wrong accept", reply("", "another key")).contains("does not match")); + assert!(wrong("status 200", b"HTTP/1.1 200 OK\r\n\r\n".to_vec()).contains("HTTP 200")); + assert!(wrong( + "status 301", + b"HTTP/1.1 301 Moved\r\nLocation: http://x/\r\n\r\n".to_vec() + ) + .contains("HTTP 301")); + assert!( + wrong("status 403", b"HTTP/1.1 403 Forbidden\r\n\r\n".to_vec()).contains("HTTP 403") + ); + assert!(wrong("HTTP/1.0", b"HTTP/1.0 101 x\r\n\r\n".to_vec()).contains("HTTP/1.1")); + assert!(wrong("garbage status", b"HTTP/1.1 abc\r\n\r\n".to_vec()).contains("malformed")); + assert!(wrong("not http", b"SSH-2.0-x\r\n\r\n".to_vec()).contains("HTTP/1.1")); + assert!(wrong( + "no upgrade header", + format!( + "HTTP/1.1 101 x\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n", + accept_key(key) + ) + .into_bytes() + ) + .contains("Upgrade: websocket")); + assert!(wrong("upgrade is not websocket", format!("HTTP/1.1 101 x\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n", accept_key(key)).into_bytes()).contains("Upgrade: websocket")); + assert!(wrong( + "no connection header", + format!( + "HTTP/1.1 101 x\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: {}\r\n\r\n", + accept_key(key) + ) + .into_bytes() + ) + .contains("Connection: Upgrade")); + assert!(wrong( + "no accept", + b"HTTP/1.1 101 x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec() + ) + .contains("does not match")); + assert!(wrong( + "extension nobody asked for", + reply("Sec-WebSocket-Extensions: permessage-deflate\r\n", key) + ) + .contains("extension")); + assert!(wrong( + "sub-protocol nobody asked for", + reply("Sec-WebSocket-Protocol: chat\r\n", key) + ) + .contains("sub-protocol")); + assert!( + wrong("header without a colon", reply("no colon here\r\n", key)).contains("no colon") + ); + assert!(wrong( + "not text", + b"HTTP/1.1 101 x\r\nX: \xff\xfe\r\n\r\n".to_vec() + ) + .contains("not text")); + // Bounds: a header block that never ends, one that ends too late, and too many headers. + assert!(wrong("endless", vec![b'a'; 8193]).contains("no end")); + let mut late = b"HTTP/1.1 101 x\r\n".to_vec(); + late.extend(std::iter::repeat_n(b'a', 8192)); + late.extend_from_slice(b"\r\n\r\n"); + assert!(wrong("too long", late).contains("too long")); + // A block that ends exactly at the cap is read; one byte later is not. + let pad = |total: usize| { + let mut v = + b"HTTP/1.1 101 x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Pad: ".to_vec(); + let tail = format!("\r\nSec-WebSocket-Accept: {}\r\n\r\n", accept_key(key)); + let fill = total - v.len() - tail.len(); + v.extend(std::iter::repeat_n(b'a', fill)); + v.extend_from_slice(tail.as_bytes()); + assert_eq!(v.len(), total); + v + }; + assert_eq!(check_response(&pad(8192), key), Ok(Some(8192))); + assert!(wrong("one past the cap", pad(8193)).contains("too long")); + let many: String = (0..70).map(|i| format!("X-{i}: v\r\n")).collect(); + assert!(wrong("too many", reply(&many, key)).contains("too many")); + let sixty: String = (0..61).map(|i| format!("X-{i}: v\r\n")).collect(); + assert!( + check_response(&reply(&sixty, key), key).is_ok(), + "just under the limit is fine" + ); + // 64 header lines (3 + 61) are read; a 65th is one too many. + let sixty_five: String = (0..62).map(|i| format!("X-{i}: v\r\n")).collect(); + assert!( + check_response(&reply(&sixty_five, key), key) + .unwrap_err() + .contains("too many"), + "65 header lines must be refused" + ); + // Nothing to decide yet is not an error, up to the cap. + assert_eq!(check_response(&vec![b'a'; 8192], key), Ok(None)); + } + + fn feed(chunks: &[&[u8]]) -> Result, String> { + let mut r = FrameReader::new(); + let mut all = Vec::new(); + for c in chunks { + all.extend(r.push(c)?); + } + Ok(all) + } + + /// FR-SPOT-08: frames against the worked examples in RFC 6455 §5.7 — the encoder produces the + /// masked ones byte for byte, the reader takes the unmasked ones, however the bytes are split. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_ws_frames_match_the_rfc_examples() { + // §5.7: a single-frame masked text "Hello" (what a client sends) and unmasked (what a + // server sends), a masked pong, and a masked ping. + let key = [0x37, 0xfa, 0x21, 0x3d]; + assert_eq!( + encode(OP_TEXT, b"Hello", key), + [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58] + ); + assert_eq!( + encode(OP_PONG, b"Hello", key), + [0x8a, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58] + ); + assert_eq!( + feed(&[&[0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]]), + Ok(vec![Message::Text("Hello".into())]) + ); + assert_eq!( + feed(&[&[0x89, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]]), + Ok(vec![Message::Ping(b"Hello".to_vec())]) + ); + // A fragmented message: "Hel" then "lo". + assert_eq!( + feed(&[&[0x01, 0x03, 0x48, 0x65, 0x6c], &[0x80, 0x02, 0x6c, 0x6f]]), + Ok(vec![Message::Text("Hello".into())]) + ); + // A control frame in the middle of a fragmented message is delivered, and the message goes on. + assert_eq!( + feed(&[&[0x01, 0x03, 0x48, 0x65, 0x6c, 0x89, 0x00, 0x80, 0x02, 0x6c, 0x6f]]), + Ok(vec![ + Message::Ping(Vec::new()), + Message::Text("Hello".into()) + ]) + ); + // 256 bytes of binary uses the 16-bit length; 64 KiB the 64-bit one. + let mut f256 = vec![0x82, 0x7e, 0x01, 0x00]; + f256.extend(std::iter::repeat_n(7u8, 256)); + assert_eq!(feed(&[&f256]), Ok(vec![Message::Binary(vec![7; 256])])); + let mut f64k = vec![0x82, 0x7f, 0, 0, 0, 0, 0, 1, 0, 0]; + f64k.extend(std::iter::repeat_n(9u8, 65_536)); + assert_eq!(feed(&[&f64k]), Ok(vec![Message::Binary(vec![9; 65_536])])); + // The encoder's own length forms: 125, 126, 65535 and 65536 bytes. + for (n, second) in [ + (125usize, 0x80 | 125u8), + (126, 0x80 | 126), + (65_535, 0x80 | 126), + (65_536, 0x80 | 127), + ] { + let f = encode(OP_BINARY, &vec![1u8; n], [1, 2, 3, 4]); + assert_eq!(f[1], second, "{n}"); + let header = match n { + 0..=125 => 2, + 126..=65_535 => 4, + _ => 10, + }; + assert_eq!(f.len(), header + 4 + n); + // Masking is its own inverse: unmask by hand and the payload is what went in. + let body: Vec = f[header + 4..] + .iter() + .enumerate() + .map(|(i, b)| b ^ [1, 2, 3, 4][i % 4]) + .collect(); + assert!(body.iter().all(|b| *b == 1)); + } + // Split at every byte: the same messages come out. + let stream: Vec = [ + &[0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f][..], + &[0x89, 0x02, 0x01, 0x02], + &[0x82, 0x7e, 0x00, 0x80][..], + &[5u8; 128], + &[0x88, 0x02, 0x03, 0xe8], + ] + .concat(); + let whole = feed(&[&stream]).unwrap(); + assert_eq!(whole.len(), 4); + assert_eq!(whole[3], Message::Close(Some(1000))); + let bytewise: Vec<&[u8]> = stream.chunks(1).collect(); + assert_eq!(feed(&bytewise).unwrap(), whole); + // Close with no body. + assert_eq!(feed(&[&[0x88, 0x00]]), Ok(vec![Message::Close(None)])); + // Empty text and empty binary. + assert_eq!( + feed(&[&[0x81, 0x00, 0x82, 0x00]]), + Ok(vec![ + Message::Text(String::new()), + Message::Binary(Vec::new()) + ]) + ); + } + + /// FR-SPOT-08: a peer that breaks the protocol ends the connection, and every bound is + /// checked before anything large is buffered. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_ws_reader_refuses_hostile_frames() { + let bad: Vec<(&str, Vec, &str)> = vec![ + ( + "masked from the server", + vec![0x81, 0x85, 1, 2, 3, 4, 0, 0, 0, 0, 0], + "masked", + ), + ("RSV1", vec![0xc1, 0x00], "reserved bit"), + ("RSV2", vec![0xa1, 0x00], "reserved bit"), + ("RSV3", vec![0x91, 0x00], "reserved bit"), + ("reserved opcode 3", vec![0x83, 0x00], "reserved opcode"), + ("reserved opcode 7", vec![0x87, 0x00], "reserved opcode"), + ("reserved opcode B", vec![0x8b, 0x00], "reserved opcode"), + ("reserved opcode F", vec![0x8f, 0x00], "reserved opcode"), + ("fragmented ping", vec![0x09, 0x00], "control frame"), + ("fragmented close", vec![0x08, 0x00], "control frame"), + ( + "long ping", + { + let mut v = vec![0x89, 126, 0, 126]; + v.extend([0u8; 126]); + v + }, + "control frame", + ), + ("one-byte close body", vec![0x88, 0x01, 0x03], "one-byte"), + ( + "continuation with nothing", + vec![0x80, 0x01, 0x41], + "nothing to continue", + ), + ( + "new message inside a fragmented one", + vec![0x01, 0x01, 0x41, 0x81, 0x01, 0x42], + "began before", + ), + ("invalid utf-8", vec![0x81, 0x02, 0xff, 0xfe], "UTF-8"), + ( + "invalid utf-8 across fragments", + vec![0x01, 0x01, 0xc3, 0x80, 0x01, 0x41], + "UTF-8", + ), + ( + "64-bit length, top bit set", + vec![0x82, 0x7f, 0x80, 0, 0, 0, 0, 0, 0, 1], + "top bit", + ), + ( + "64-bit length beyond the cap", + vec![0x82, 0x7f, 0, 0, 0, 0, 0, 0x10, 0, 0], + "larger than", + ), + ]; + for (name, bytes, why) in bad { + let e = feed(&[&bytes]).expect_err(name); + assert!(e.contains(why), "{name}: {e}"); + } + // The size cap: a frame declaring MAX_MESSAGE is read, one more is refused *before* its + // payload has arrived (nothing is buffered for it). + let mut at_cap = vec![0x82, 0x7f, 0, 0, 0, 0, 0, 0x04, 0, 0]; + assert_eq!(MAX_MESSAGE, 0x40000); + at_cap.extend(std::iter::repeat_n(0u8, MAX_MESSAGE)); + assert_eq!(feed(&[&at_cap]).map(|m| m.len()), Ok(1)); + let over = [0x82, 0x7f, 0, 0, 0, 0, 0, 0x04, 0, 1]; + let mut r = FrameReader::new(); + assert!( + r.push(&over).unwrap_err().contains("larger than"), + "refused on its header alone" + ); + // A fragmented message's total is bounded too: two fragments of 200 KB. + let frag = |first: bool| { + let mut v = vec![ + if first { 0x02 } else { 0x80 }, + 0x7f, + 0, + 0, + 0, + 0, + 0, + 0x03, + 0x0d, + 0x40, + ]; + v.extend(std::iter::repeat_n(1u8, 200_000)); + v + }; + let mut r = FrameReader::new(); + assert_eq!(r.push(&frag(true)), Ok(vec![])); + assert!(r + .push(&frag(false)) + .unwrap_err() + .contains("fragmented message is larger")); + // Many small fragments that add up are bounded as well. + let mut r = FrameReader::new(); + assert_eq!(r.push(&[0x02, 0x7e, 0x80, 0x00]).map(|_| ()), Ok(())); + // (a partial frame declaring 32 KB then stalling holds at most that much) + assert!(r.buf.len() <= 4); + // Errors leave the reader unusable in spirit, but a further push must not panic. + let mut r = FrameReader::new(); + assert!(r.push(&[0xc1, 0x00]).is_err()); + let _ = r.push(&[0x81, 0x01, 0x41]); + } +} diff --git a/crates/k4-spot/tests/freedv_live.rs b/crates/k4-spot/tests/freedv_live.rs new file mode 100644 index 0000000..702aec5 --- /dev/null +++ b/crates/k4-spot/tests/freedv_live.rs @@ -0,0 +1,64 @@ +//! A manual probe of the real FreeDV Reporter, never run by the suite: +//! +//! `cargo test -p k4-spot --test freedv_live -- --ignored --nocapture` +//! +//! It joins `qso.freedv.org` in the **read-only `view` role** for at most ten seconds and hangs up. +//! What it sends is the WebSocket upgrade (with a `User-Agent`), the `view` connect and the pongs +//! the protocol requires — nothing identifying. It prints **counts only**, never a callsign: how +//! far the session got, how many stations the roster holds, how many events were malformed. That +//! is what decides whether the format built from another client's source matches the service. + +use std::time::{Duration, Instant}; + +use k4_spot::freedv_source::{FreeDvConfig, FreeDvSource}; +use k4_spot::telnet::ConnState; +use k4_spot::SpotSource; + +#[test] +#[ignore = "contacts the real FreeDV Reporter"] +fn live_freedv_reporter() { + let mut src = FreeDvSource::new(FreeDvConfig { + host: "qso.freedv.org".into(), + port: 80, + user_agent: "K4remote-live-probe".into(), + }); + let t0 = Instant::now(); + let (mut spots, mut errors, mut joined_at) = (0usize, Vec::new(), None); + let mut freqs: Vec = Vec::new(); + while t0.elapsed() < Duration::from_secs(10) { + if let Err(e) = src.poll(&mut |s| { + spots += 1; + freqs.push(s.freq_hz); + }) { + errors.push(e.to_string()); + break; + } + if joined_at.is_none() && src.state() == ConnState::Connected { + joined_at = Some(t0.elapsed()); + } + std::thread::sleep(Duration::from_millis(20)); + } + let st = src.stats(); + freqs.sort_unstable(); + freqs.dedup(); + println!( + "PROBE: state={:?} joined_after={:?}", + src.state(), + joined_at + ); + println!( + "PROBE: stations={} spots_delivered={} distinct_frequencies={} rejected={} connects={}", + src.stations(), + spots, + freqs.len(), + st.rejected, + st.connects + ); + if let (Some(lo), Some(hi)) = (freqs.first(), freqs.last()) { + println!("PROBE: frequency range {lo}..{hi} Hz"); + } + println!("PROBE: errors={errors:?}"); + for (event, (count, shape)) in src.rejected_shapes() { + println!("PROBE: rejected {count} x {event}: {shape}"); + } +} diff --git a/crates/k4-spot/tests/freedv_source.rs b/crates/k4-spot/tests/freedv_source.rs new file mode 100644 index 0000000..9aed4b9 --- /dev/null +++ b/crates/k4-spot/tests/freedv_source.rs @@ -0,0 +1,968 @@ +//! The FreeDV Reporter source against a scripted mock server on loopback (FR-SPOT-08, FR-SPOT-09, +//! FR-SPOT-12). +//! +//! The server side is written here by hand — its frames are built and the client's are read +//! byte by byte — independently of the crate's own encoder and decoder, so what the client puts on +//! the wire is checked against RFC 6455 and not against itself. (It borrows only `accept_key`, which +//! is verified against the RFC's worked example in the crate's own tests.) +//! trace: FR-SPOT-08, FR-SPOT-09, FR-SPOT-12 + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::thread; +use std::time::{Duration, Instant}; + +use k4_spot::freedv_source::{FreeDvConfig, FreeDvSource}; +use k4_spot::telnet::{ConnState, Timing}; +use k4_spot::ws::accept_key; +use k4_spot::{Network, Spot, SpotSource}; + +fn fast() -> Timing { + Timing { + connect_timeout: Duration::from_secs(1), + prompt_timeout: Duration::from_millis(500), + initial_backoff: Duration::from_millis(30), + max_backoff: Duration::from_millis(120), + read_budget: Duration::from_millis(50), + ..Timing::default() + } +} + +fn cfg(port: u16) -> FreeDvConfig { + FreeDvConfig { + host: "127.0.0.1".into(), + port, + user_agent: "K4remote/test".into(), + } +} + +/// An unmasked server frame (what a server sends), built by hand. +fn frame(op: u8, payload: &[u8]) -> Vec { + let mut f = vec![0x80 | op]; + match payload.len() { + n if n < 126 => f.push(n as u8), + n if n <= 0xffff => { + f.push(126); + f.extend_from_slice(&(n as u16).to_be_bytes()); + } + n => { + f.push(127); + f.extend_from_slice(&(n as u64).to_be_bytes()); + } + } + f.extend_from_slice(payload); + f +} + +fn text(s: &str) -> Vec { + frame(0x1, s.as_bytes()) +} + +/// Read the client's upgrade request; returns its text and its `Sec-WebSocket-Key`. +fn read_upgrade(s: &mut TcpStream) -> (String, String) { + s.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut req = Vec::new(); + let mut b = [0u8; 1]; + while !req.ends_with(b"\r\n\r\n") { + s.read_exact(&mut b).expect("the upgrade request"); + req.push(b[0]); + } + let req = String::from_utf8(req).unwrap(); + let key = req + .lines() + .find_map(|l| l.strip_prefix("Sec-WebSocket-Key: ")) + .expect("a key") + .trim() + .to_string(); + (req, key) +} + +fn accept(s: &mut TcpStream, key: &str) { + write!( + s, + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n", + accept_key(key) + ) + .unwrap(); +} + +/// Read one client frame, checking it is masked as RFC 6455 requires, and unmask it by hand. +fn read_frame(s: &mut TcpStream, wait: Duration) -> Option<(u8, Vec)> { + s.set_read_timeout(Some(wait)).ok()?; + let mut h = [0u8; 2]; + s.read_exact(&mut h).ok()?; + assert_eq!(h[0] & 0x80, 0x80, "the client's frames are final"); + assert_eq!(h[0] & 0x70, 0, "no reserved bits"); + assert_eq!(h[1] & 0x80, 0x80, "a client frame must be masked"); + let len = match h[1] & 0x7f { + 126 => { + let mut l = [0u8; 2]; + s.read_exact(&mut l).ok()?; + usize::from(u16::from_be_bytes(l)) + } + 127 => { + let mut l = [0u8; 8]; + s.read_exact(&mut l).ok()?; + u64::from_be_bytes(l) as usize + } + n => usize::from(n), + }; + let mut mask = [0u8; 4]; + s.read_exact(&mut mask).ok()?; + let mut body = vec![0u8; len]; + s.read_exact(&mut body).ok()?; + for (i, b) in body.iter_mut().enumerate() { + *b ^= mask[i % 4]; + } + Some((h[0] & 0x0f, body)) +} + +fn serve(scripts: Vec>) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + for script in scripts { + let Ok((stream, _)) = listener.accept() else { + return; + }; + script(stream); + } + }); + port +} + +const OPEN: &str = r#"0{"sid":"srv1","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}"#; +const VIEW: &str = r#"40{"role":"view","protocol_version":2}"#; + +/// The server side of a good session up to and including the client's `view` connect and the +/// server's acknowledgement. Returns what the client sent. +fn join(s: &mut TcpStream) -> (String, (u8, Vec)) { + let (req, key) = read_upgrade(s); + accept(s, &key); + s.write_all(&text(OPEN)).unwrap(); + let connect = read_frame(s, Duration::from_secs(3)).expect("the view connect"); + s.write_all(&text(r#"40{"sid":"me1"}"#)).unwrap(); + (req, connect) +} + +fn ev(name: &str, args: &str) -> Vec { + text(&format!("42[\"{name}\",{args}]")) +} + +struct Run { + spots: Vec, + errors: Vec, +} + +fn pump( + src: &mut FreeDvSource, + max: Duration, + mut done: impl FnMut(&mut FreeDvSource, &Run) -> bool, +) -> Run { + let mut run = Run { + spots: Vec::new(), + errors: Vec::new(), + }; + let t0 = Instant::now(); + while t0.elapsed() < max { + let mut got = Vec::new(); + if let Err(e) = src.poll(&mut |s| got.push(s)) { + run.errors.push(e.to_string()); + } + run.spots.extend(got); + if done(src, &run) { + break; + } + thread::sleep(Duration::from_millis(5)); + } + run +} + +const LONG: Duration = Duration::from_secs(5); + +/// The source upgrades, joins **only** as a read-only viewer, sends nothing that identifies the +/// operator, and delivers the in-window stations from the server's `bulk_update` and later events +/// as spots — counting the bad and out-of-window ones. +#[test] +fn fr_spot_08_source_joins_as_a_viewer_and_delivers_spots() { + let (tx, rx) = std::sync::mpsc::channel(); + let port = serve(vec![Box::new(move |mut s| { + let (req, connect) = join(&mut s); + // An event before anything else is fine to ignore; then the roster. + s.write_all(&ev( + "bulk_update", + r#"[["new_connection",{"sid":"a","callsign":"aa1aaa","grid_square":"FN31"}],["freq_change",{"sid":"a","freq":14236000}], + ["new_connection",{"sid":"b","callsign":"bb2bbb"}],["freq_change",{"sid":"b","freq":7177000}], + ["freq_change",{"sid":"c","freq":"x"}], + ["freq_change",{"sid":"lo","freq":14000000,"callsign":"lo1lo"}], + ["freq_change",{"sid":"hi","freq":14300000,"callsign":"hi1hi"}], + ["freq_change",{"sid":"below","freq":13999999,"callsign":"be1low"}], + ["freq_change",{"sid":"above","freq":14300001,"callsign":"ab1ove"}]]"#, + )) + .unwrap(); + s.write_all(&ev( + "tx_report", + r#"{"sid":"a","transmitting":true,"mode":"RADEV1"}"#, + )) + .unwrap(); + s.write_all(&ev( + "rx_report", + r#"{"sid":"z","callsign":"AA1AAA","receiver_callsign":"cc3ccc","snr":-9}"#, + )) + .unwrap(); + // What else does the client send in the next moment? (Nothing.) + let extra = read_frame(&mut s, Duration::from_millis(400)); + tx.send((req, connect, extra)).unwrap(); + thread::sleep(Duration::from_millis(300)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + src.set_window(Some((14_000_000, 14_300_000))); + let run = pump(&mut src, LONG, |_, r| { + r.spots.iter().any(|s| s.snr_db == Some(-9)) + }); + assert!(run.errors.is_empty(), "{:?}", run.errors); + assert_eq!(src.state(), ConnState::Connected); + assert_eq!( + src.stations(), + 6, + "a, b, lo, hi, below, above; the bad `c` was dropped" + ); + // The window's edges are inclusive, and one hertz outside either is not. + let calls: std::collections::HashSet<&str> = + run.spots.iter().map(|s| s.call.as_str()).collect(); + assert!( + calls.contains("LO1LO") && calls.contains("HI1HI"), + "{calls:?}" + ); + assert!( + !calls.contains("BE1LOW") && !calls.contains("AB1OVE"), + "{calls:?}" + ); + assert_eq!(src.network(), Network::FreeDvReporter); + // The station inside the window, with what the events said. + let last = run.spots.iter().rev().find(|s| s.call == "AA1AAA").unwrap(); + assert_eq!(last.network, Network::FreeDvReporter); + assert_eq!(last.freq_hz, 14_236_000); + assert_eq!(last.mode.as_deref(), Some("RADEV1")); + assert_eq!(last.comment.as_deref(), Some("TX")); + assert_eq!(last.spotter.as_deref(), Some("CC3CCC")); + assert_eq!(last.snr_db, Some(-9)); + assert!( + run.spots.iter().all(|s| s.call != "BB2BBB"), + "7.177 MHz is outside the window" + ); + let st = src.stats(); + assert!(st.spots >= 1 && st.outside_window >= 3, "{st:?}"); + assert_eq!(st.rejected, 1, "the bad freq_change is counted"); + assert_eq!(st.connects, 1); + + // What the client sent, on the wire. + let (req, (op, connect), extra) = rx.recv_timeout(Duration::from_secs(3)).unwrap(); + assert!( + req.starts_with("GET /socket.io/?EIO=4&transport=websocket HTTP/1.1\r\n"), + "{req}" + ); + for want in [ + "Upgrade: websocket\r\n", + "Connection: Upgrade\r\n", + "Sec-WebSocket-Version: 13\r\n", + "User-Agent: K4remote/test\r\n", + ] { + assert!(req.contains(want), "{req}"); + } + let lower = req.to_ascii_lowercase(); + for leak in ["cookie", "authorization", "callsign", "referer", "origin"] { + assert!(!lower.contains(leak), "the request carries {leak}:\n{req}"); + } + assert_eq!(op, 0x1, "the connect is a text frame"); + assert_eq!( + String::from_utf8(connect).unwrap(), + VIEW, + "exactly the read-only view connect" + ); + assert_eq!(extra, None, "the client sent nothing else, unprompted"); +} + +/// Engine.IO pings and WebSocket pings are each answered, with the right reply. +#[test] +fn fr_spot_08_source_answers_pings() { + let (tx, rx) = std::sync::mpsc::channel(); + let port = serve(vec![Box::new(move |mut s| { + join(&mut s); + s.write_all(&text("2")).unwrap(); + let eio = read_frame(&mut s, Duration::from_secs(2)); + s.write_all(&frame(0x9, b"xyz")).unwrap(); + let wsp = read_frame(&mut s, Duration::from_secs(2)); + s.write_all(&frame(0xA, b"unsolicited")).unwrap(); + s.write_all(&text("3")).unwrap(); + let after = read_frame(&mut s, Duration::from_millis(300)); + tx.send((eio, wsp, after)).unwrap(); + thread::sleep(Duration::from_millis(200)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, Duration::from_secs(3), |_, _| { + rx.try_recv().is_ok_and(|v| { + assert_eq!( + v.0, + Some((0x1, b"3".to_vec())), + "an Engine.IO ping is answered with a `3`" + ); + assert_eq!( + v.1, + Some((0xA, b"xyz".to_vec())), + "a WebSocket ping with a pong echoing it" + ); + assert_eq!(v.2, None, "pongs are not answered"); + true + }) + }); + assert!(run.errors.is_empty(), "{:?}", run.errors); +} + +/// A reply to the upgrade that is wrong or hostile is an error worded for the operator, and the +/// source goes on to try again. +#[test] +fn fr_spot_08_source_refuses_a_bad_upgrade() { + type Script = Box; + let cases: Vec<(&str, Script, &str)> = vec![ + ( + "wrong accept key", + Box::new(|mut s| { + let (_, _) = read_upgrade(&mut s); + accept(&mut s, "some other key"); + thread::sleep(Duration::from_millis(300)); + }), + "does not match", + ), + ( + "HTTP 403", + Box::new(|mut s| { + read_upgrade(&mut s); + s.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + thread::sleep(Duration::from_millis(300)); + }), + "HTTP 403", + ), + ( + "not HTTP at all", + Box::new(|mut s| { + read_upgrade(&mut s); + s.write_all(b"SSH-2.0-OpenSSH_9.9\r\n\r\n").unwrap(); + thread::sleep(Duration::from_millis(300)); + }), + "HTTP/1.1", + ), + ( + "an extension nobody asked for", + Box::new(|mut s| { + let (_, key) = read_upgrade(&mut s); + write!(s, "HTTP/1.1 101 x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\nSec-WebSocket-Extensions: permessage-deflate\r\n\r\n", accept_key(&key)).unwrap(); + thread::sleep(Duration::from_millis(300)); + }), + "extension", + ), + ( + "the server not speaking Engine.IO after the upgrade", + Box::new(|mut s| { + let (_, key) = read_upgrade(&mut s); + accept(&mut s, &key); + s.write_all(&text("hello there")).unwrap(); + thread::sleep(Duration::from_millis(300)); + }), + "unexpected data", + ), + ]; + for (name, script, want) in cases { + let port = serve(vec![script]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!( + run.errors.first().is_some_and(|e| e.contains(want)), + "{name}: {:?}", + run.errors + ); + assert_eq!(src.state(), ConnState::Disconnected, "{name}"); + assert!(run.spots.is_empty(), "{name}"); + } +} + +/// A server that accepts the connection and then says nothing is given up on, and one that goes +/// quiet after joining is caught by the ping timing it announced. +#[test] +fn fr_spot_08_source_gives_up_on_a_silent_server() { + // Silent from the start: no reply to the upgrade. + let port = serve(vec![Box::new(|mut s| { + read_upgrade(&mut s); + thread::sleep(Duration::from_secs(2)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!( + run.errors[0].contains("no answer within") && run.errors[0].contains("upgrade"), + "{:?}", + run.errors + ); + + // Upgraded but never sends `open`. + let port = serve(vec![Box::new(|mut s| { + let (_, key) = read_upgrade(&mut s); + accept(&mut s, &key); + thread::sleep(Duration::from_secs(2)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!( + run.errors[0].contains("session to open"), + "{:?}", + run.errors + ); + + // Never accepts the `view` connect. + let port = serve(vec![Box::new(|mut s| { + let (_, key) = read_upgrade(&mut s); + accept(&mut s, &key); + s.write_all(&text(OPEN)).unwrap(); + let _ = read_frame(&mut s, Duration::from_secs(1)); + thread::sleep(Duration::from_secs(2)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!(run.errors[0].contains("accepted"), "{:?}", run.errors); + + // Joined, then quiet for longer than the server said it would be (1 s + 1 s, the minimum). + let port = serve(vec![Box::new(|mut s| { + let (_, key) = read_upgrade(&mut s); + accept(&mut s, &key); + s.write_all(&text( + r#"0{"sid":"s","pingInterval":1000,"pingTimeout":1000}"#, + )) + .unwrap(); + let _ = read_frame(&mut s, Duration::from_secs(1)); + s.write_all(&text("40")).unwrap(); + thread::sleep(Duration::from_secs(4)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let t0 = Instant::now(); + let run = pump(&mut src, Duration::from_secs(4), |_, r| { + !r.errors.is_empty() + }); + assert!( + run.errors[0].contains("no ping from the server for 2 s"), + "{:?}", + run.errors + ); + assert!( + t0.elapsed() >= Duration::from_millis(1900), + "gave up early: {:?}", + t0.elapsed() + ); + assert_eq!(src.state(), ConnState::Disconnected); +} + +/// A server that ends the session, or refuses it, is reported as such. +#[test] +fn fr_spot_08_source_reports_a_refusal_and_an_end() { + for (packet, want) in [ + (r#"44{"message":"no"}"#, "refused"), + ("41", "ended the session"), + ("1", "ended the session"), + ] { + let port = serve(vec![Box::new(move |mut s| { + let (_, key) = read_upgrade(&mut s); + accept(&mut s, &key); + s.write_all(&text(OPEN)).unwrap(); + let _ = read_frame(&mut s, Duration::from_secs(1)); + s.write_all(&text(packet)).unwrap(); + thread::sleep(Duration::from_millis(300)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!(run.errors[0].contains(want), "{packet}: {:?}", run.errors); + } + // A WebSocket close frame ends it at once. The server keeps the socket open afterwards, so only + // the frame can be what the source reacts to (dropping the socket would give the same message). + let port = serve(vec![Box::new(|mut s| { + join(&mut s); + s.write_all(&frame(0x8, &[0x03, 0xe8])).unwrap(); + thread::sleep(Duration::from_secs(3)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let t0 = Instant::now(); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!( + t0.elapsed() < Duration::from_secs(1), + "waited {:?} for a close that had been sent", + t0.elapsed() + ); + assert!( + run.errors[0].contains("closed the connection"), + "{:?}", + run.errors + ); + let port = serve(vec![Box::new(|mut s| { + join(&mut s); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!( + run.errors[0].contains("closed the connection"), + "{:?}", + run.errors + ); + // Nothing listening: a plain connect failure, and it backs off. + let closed = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let mut src = FreeDvSource::with_timing(cfg(closed), fast()); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert!( + run.errors[0].contains("connect to 127.0.0.1"), + "{:?}", + run.errors + ); + // No host is a setting problem, not a network one. + let mut src = FreeDvSource::with_timing( + FreeDvConfig { + host: " ".into(), + ..cfg(80) + }, + fast(), + ); + let run = pump(&mut src, LONG, |_, r| !r.errors.is_empty()); + assert_eq!(run.errors[0], "no host is set"); +} + +/// A hostile server — a masked frame, bad UTF-8, an over-size frame, a reserved bit, garbage in a +/// running session — ends the connection with an error where it must, costs only the packet where +/// it can, and a good session afterwards works. +#[test] +fn fr_spot_08_source_survives_a_hostile_server() { + let hostile: Vec<(&str, Vec, &str)> = vec![ + ( + "a masked frame", + vec![0x81, 0x81, 1, 2, 3, 4, 0x41 ^ 1], + "masked", + ), + ("bad UTF-8", vec![0x81, 0x02, 0xff, 0xfe], "UTF-8"), + ( + "an over-size frame", + vec![0x82, 0x7f, 0, 0, 0, 0, 0, 0x10, 0, 0], + "larger than", + ), + ("a reserved bit", vec![0xc1, 0x00], "reserved"), + ("a reserved opcode", vec![0x83, 0x00], "reserved opcode"), + ]; + let mut scripts: Vec> = Vec::new(); + for (_, bytes, _) in &hostile { + let bytes = bytes.clone(); + scripts.push(Box::new(move |mut s| { + join(&mut s); + s.write_all(&bytes).unwrap(); + thread::sleep(Duration::from_millis(300)); + })); + } + // Then a good session that must work. + scripts.push(Box::new(|mut s| { + join(&mut s); + s.write_all(&ev( + "freq_change", + r#"{"sid":"a","freq":14236000,"callsign":"DD4DDD"}"#, + )) + .unwrap(); + thread::sleep(Duration::from_millis(500)); + })); + let port = serve(scripts); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, Duration::from_secs(15), |_, r| { + r.spots.iter().any(|s| s.call == "DD4DDD") + }); + assert_eq!(run.errors.len(), hostile.len(), "{:?}", run.errors); + for (e, (name, _, want)) in run.errors.iter().zip(&hostile) { + assert!(e.contains(want), "{name}: {e}"); + } + assert!( + run.spots.iter().any(|s| s.call == "DD4DDD"), + "the good session after the bad ones works" + ); + + // Garbage *inside* a running session costs only that packet. + let port = serve(vec![Box::new(|mut s| { + join(&mut s); + for junk in [ + "", + "x", + "42", + "42nope", + r#"42["e",]"#, + r#"42[5]"#, + "4", + "0{", + ] { + s.write_all(&text(junk)).unwrap(); + } + s.write_all(&ev( + "freq_change", + r#"{"sid":"a","freq":14236000,"callsign":"EE5EEE"}"#, + )) + .unwrap(); + thread::sleep(Duration::from_millis(600)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let run = pump(&mut src, LONG, |_, r| { + r.spots.iter().any(|s| s.call == "EE5EEE") + }); + assert!( + run.errors.is_empty(), + "a bad packet in a running session must not end it: {:?}", + run.errors + ); + assert!(src.stats().rejected >= 5, "{:?}", src.stats()); +} + +/// After a lost connection the source reconnects with a fresh roster: a station that had left in +/// the meantime is not refreshed into the new session. +#[test] +fn fr_spot_08_source_reconnects_with_a_fresh_roster() { + let port = serve(vec![ + Box::new(|mut s| { + join(&mut s); + s.write_all(&ev( + "freq_change", + r#"{"sid":"old","freq":14236000,"callsign":"AA1AAA"}"#, + )) + .unwrap(); + thread::sleep(Duration::from_millis(250)); + }), + Box::new(|mut s| { + join(&mut s); + s.write_all(&ev( + "freq_change", + r#"{"sid":"new","freq":14236000,"callsign":"BB2BBB"}"#, + )) + .unwrap(); + thread::sleep(Duration::from_millis(1500)); + }), + ]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + src.set_refresh(Duration::from_millis(60)); + let mut after_reconnect = Vec::new(); + let mut reconnected = false; + let t0 = Instant::now(); + let mut errors = Vec::new(); + while t0.elapsed() < Duration::from_secs(5) && after_reconnect.len() < 4 { + let mut got = Vec::new(); + if let Err(e) = src.poll(&mut |s| got.push(s)) { + errors.push(e.to_string()); + reconnected = true; + } + if reconnected { + after_reconnect.extend(got); + } + thread::sleep(Duration::from_millis(5)); + } + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(after_reconnect.iter().any(|s| s.call == "BB2BBB")); + assert!( + after_reconnect.iter().all(|s| s.call != "AA1AAA"), + "a station from the old session was refreshed into the new one" + ); + assert_eq!(src.stats().connects, 2); + assert!(src.attempts() >= 2); +} + +/// A station that stays on the roster keeps being re-stamped, so it stays on the overlay; one that +/// leaves stops. The re-stamp carries the current time. +#[test] +fn fr_spot_08_source_refreshes_stations_that_are_still_there() { + let port = serve(vec![Box::new(|mut s| { + join(&mut s); + s.write_all(&ev( + "bulk_update", + r#"[["freq_change",{"sid":"stay","freq":14236000,"callsign":"AA1AAA"}],["freq_change",{"sid":"go","freq":14237000,"callsign":"BB2BBB"}]]"#, + )) + .unwrap(); + thread::sleep(Duration::from_millis(500)); + s.write_all(&ev("remove_connection", r#"{"sid":"go"}"#)) + .unwrap(); + thread::sleep(Duration::from_millis(1500)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + src.set_refresh(Duration::from_millis(100)); + let start = Instant::now(); + let mut log: Vec<(Duration, String, u64)> = Vec::new(); + while start.elapsed() < Duration::from_millis(1800) { + let mut got = Vec::new(); + let _ = src.poll(&mut |s| got.push(s)); + for s in got { + log.push((start.elapsed(), s.call.clone(), s.time)); + } + thread::sleep(Duration::from_millis(5)); + } + let count = |call: &str| log.iter().filter(|(_, c, _)| c == call).count(); + // ...once per interval, not on every poll: 1.8 s at 100 ms is about 18. + assert!( + count("AA1AAA") <= 25, + "refreshed far too often: {}", + count("AA1AAA") + ); + assert!( + count("AA1AAA") >= 6, + "a station that stays is refreshed: {}", + count("AA1AAA") + ); + assert!( + count("BB2BBB") >= 2, + "and so is one that has not yet left: {}", + count("BB2BBB") + ); + let last_b = log.iter().rev().find(|(_, c, _)| c == "BB2BBB").unwrap().0; + let last_a = log.iter().rev().find(|(_, c, _)| c == "AA1AAA").unwrap().0; + assert!( + last_b < Duration::from_millis(900), + "the station that left was refreshed until {last_b:?}" + ); + assert!( + last_a > Duration::from_millis(1400), + "the one that stayed stopped at {last_a:?}" + ); + // Re-stamps carry the time they were made, so age counts from the last confirmation. + let times: Vec = log + .iter() + .filter(|(_, c, _)| c == "AA1AAA") + .map(|(_, _, t)| *t) + .collect(); + assert!(times.windows(2).all(|w| w[0] <= w[1]), "{times:?}"); +} + +/// A flood of events is shed at the rate limit and counted, not delivered. +#[test] +fn fr_spot_08_source_sheds_a_flood() { + let port = serve(vec![Box::new(|mut s| { + join(&mut s); + for i in 0..300 { + s.write_all(&ev( + "freq_change", + &format!( + r#"{{"sid":"s{i}","freq":14236000,"callsign":"AA{}AAA"}}"#, + i % 10 + ), + )) + .unwrap(); + } + thread::sleep(Duration::from_millis(800)); + })]); + let timing = Timing { + rate_per_sec: 20, + ..fast() + }; + let mut src = FreeDvSource::with_timing(cfg(port), timing); + let run = pump(&mut src, Duration::from_secs(2), |s, _| { + s.stats().shed > 100 + }); + assert!(run.errors.is_empty(), "{:?}", run.errors); + let st = src.stats(); + assert!(st.shed > 100, "{st:?}"); + assert!(run.spots.len() < 100, "{} delivered", run.spots.len()); + assert_eq!( + src.stations(), + 300, + "the roster still holds everyone; only delivery is limited" + ); +} + +/// A server that speaks out of turn does not get the client to treat it as joined: a Socket.IO +/// `connect` before Engine.IO's `open`, and events before the client has been accepted, are +/// ignored; the state says so until the acknowledgement really comes. +#[test] +fn fr_spot_08_source_ignores_a_server_that_speaks_out_of_turn() { + let (go_tx, go_rx) = std::sync::mpsc::channel::<()>(); + let (seen_tx, seen_rx) = std::sync::mpsc::channel::<()>(); + let port = serve(vec![Box::new(move |mut s| { + let (_, key) = read_upgrade(&mut s); + accept(&mut s, &key); + // Out of turn: a connect, and an event, before `open`. + s.write_all(&text(r#"40{"sid":"early"}"#)).unwrap(); + s.write_all(&ev( + "freq_change", + r#"{"sid":"x","freq":14236000,"callsign":"EA1RLY"}"#, + )) + .unwrap(); + s.write_all(&text(OPEN)).unwrap(); + let connect = read_frame(&mut s, Duration::from_secs(3)).expect("the view connect"); + assert_eq!(connect.1, VIEW.as_bytes()); + // Still before the acknowledgement: another event. + s.write_all(&ev( + "freq_change", + r#"{"sid":"y","freq":14236000,"callsign":"BE2FOR"}"#, + )) + .unwrap(); + seen_tx.send(()).unwrap(); + go_rx.recv_timeout(Duration::from_secs(5)).ok(); + s.write_all(&text(r#"40{"sid":"me"}"#)).unwrap(); + s.write_all(&ev( + "freq_change", + r#"{"sid":"z","freq":14236000,"callsign":"AF3TER"}"#, + )) + .unwrap(); + thread::sleep(Duration::from_millis(500)); + })]); + let mut src = FreeDvSource::with_timing(cfg(port), fast()); + let mut spots = Vec::new(); + // Until the server has seen the client's connect and sent its early event, then a little more. + let t0 = Instant::now(); + let mut waiting = true; + while waiting && t0.elapsed() < LONG { + src.poll(&mut |s| spots.push(s)).unwrap(); + waiting = seen_rx.try_recv().is_err(); + thread::sleep(Duration::from_millis(5)); + } + let t1 = Instant::now(); + while t1.elapsed() < Duration::from_millis(300) { + src.poll(&mut |s| spots.push(s)).unwrap(); + thread::sleep(Duration::from_millis(5)); + } + assert_eq!( + src.state(), + ConnState::AwaitingLogin, + "not joined until the acknowledgement" + ); + assert!( + spots.is_empty(), + "events before the acknowledgement were used: {spots:?}" + ); + assert_eq!(src.stations(), 0); + // Now the acknowledgement. + go_tx.send(()).unwrap(); + let run = pump(&mut src, LONG, |_, r| { + r.spots.iter().any(|s| s.call == "AF3TER") + }); + assert!(run.errors.is_empty(), "{:?}", run.errors); + assert_eq!(src.state(), ConnState::Connected); + assert_eq!( + src.stations(), + 1, + "only the one sent after the acknowledgement" + ); + assert_eq!(src.stats().connects, 1); +} + +/// Backoff doubles up to its cap while attempts fail, waits at least that long each time, and starts +/// over from the initial wait once a session has succeeded. +#[test] +fn fr_spot_08_source_backs_off_and_starts_over_after_a_success() { + use std::sync::{Arc, Mutex}; + type Marks = Arc>>; + let marks: Marks = Arc::default(); + let failing = |marks: &Marks| { + let m = Arc::clone(marks); + Box::new(move |mut s: TcpStream| { + m.lock().unwrap().push(("accept", Instant::now())); + read_upgrade(&mut s); + // Close without answering: the client sees the connection end. + drop(s); + m.lock().unwrap().push(("close", Instant::now())); + }) as Box + }; + let good = { + let m = Arc::clone(&marks); + Box::new(move |mut s: TcpStream| { + m.lock().unwrap().push(("accept", Instant::now())); + join(&mut s); + thread::sleep(Duration::from_millis(100)); + drop(s); + m.lock().unwrap().push(("close", Instant::now())); + }) as Box + }; + let port = serve(vec![ + failing(&marks), + failing(&marks), + failing(&marks), + failing(&marks), + good, + failing(&marks), + ]); + // A longer backoff than the other tests use (100 ms, capped at 400): a retry is scheduled from + // the moment its poll *began*, which can be a read timeout before the close was noticed, and that + // offset must be small next to the gap between "restarted" and "at the cap". + let timing = Timing { + initial_backoff: Duration::from_millis(100), + max_backoff: Duration::from_millis(400), + ..fast() + }; + let mut src = FreeDvSource::with_timing(cfg(port), timing); + let run = pump(&mut src, Duration::from_secs(10), |s, _| { + s.attempts() >= 6 && marks.lock().unwrap().len() >= 12 + }); + assert!(run.errors.len() >= 6, "{:?}", run.errors); + let m = marks.lock().unwrap().clone(); + let accepts: Vec = m + .iter() + .filter(|(k, _)| *k == "accept") + .map(|(_, t)| *t) + .collect(); + let closes: Vec = m + .iter() + .filter(|(k, _)| *k == "close") + .map(|(_, t)| *t) + .collect(); + assert_eq!(accepts.len(), 6); + // The wait between a session ending and the next attempt: 100, 200, 400 (the cap), 400 again + // (a fourth failure would be 800 without the cap), then — after the good session — 100 again. + let gap = |i: usize| accepts[i + 1].duration_since(closes[i]).as_millis(); + let (g0, g1, g2, g3, g4) = (gap(0), gap(1), gap(2), gap(3), gap(4)); + assert!((70..170).contains(&g0), "first wait {g0} ms"); + assert!((170..280).contains(&g1), "second wait {g1} ms (doubled)"); + assert!((370..480).contains(&g2), "third wait {g2} ms (at the cap)"); + assert!( + (370..480).contains(&g3), + "fourth wait {g3} ms (held at the cap)" + ); + assert!( + (70..170).contains(&g4), + "the wait after a success was {g4} ms, not restarted at 100" + ); +} + +/// The per-poll byte cap holds: a server streaming as fast as it can does not let one poll read it +/// all, so the source stays responsive. +#[test] +fn fr_spot_08_source_bounds_what_one_poll_reads() { + let port = serve(vec![Box::new(|mut s| { + join(&mut s); + let mut blob = Vec::new(); + for i in 0..3000 { + blob.extend(ev( + "freq_change", + &format!(r#"{{"sid":"s{i}","freq":14236000,"callsign":"AA1AAA"}}"#), + )); + } + s.write_all(&blob).unwrap(); + thread::sleep(Duration::from_millis(1500)); + })]); + let timing = Timing { + max_bytes_per_poll: 16 * 1024, + // Long enough that only the byte cap, not the time budget, can end a read. + read_budget: Duration::from_secs(5), + rate_per_sec: 1_000_000, + ..fast() + }; + let mut src = FreeDvSource::with_timing(cfg(port), timing); + // Get joined (no spots yet), then let the stream arrive. + let run = pump(&mut src, LONG, |s, _| s.state() == ConnState::Connected); + assert!(run.errors.is_empty(), "{:?}", run.errors); + thread::sleep(Duration::from_millis(400)); + let mut first = Vec::new(); + src.poll(&mut |s| first.push(s)).unwrap(); + assert!(!first.is_empty(), "the first poll read something"); + assert!( + first.len() < 1000, + "one poll delivered {} of 3000: the byte cap is not applied", + first.len() + ); + // Everything still arrives over the following polls. + let rest = pump(&mut src, LONG, |s, _| s.stations() == 3000); + assert_eq!(src.stations(), 3000, "{}", rest.errors.len()); +} diff --git a/docs/references/external-references.md b/docs/references/external-references.md index ea96283..b11282b 100644 --- a/docs/references/external-references.md +++ b/docs/references/external-references.md @@ -1,7 +1,7 @@ --- title: "External References" status: Draft -version: "0.8" +version: "0.9" updated: 2026-09-21 authors: - Simon Keimer (DC0SK) @@ -563,8 +563,43 @@ done for RBN, at DC0SK's invitation ("how did SDRoxide solve the API issues"). server pings every 5 s with a 5 s timeout. Events: `new_connection` (a session id with callsign and grid), `remove_connection`, `freq_change` (the frequency in hertz), `tx_report`, `rx_report` (who heard whom, with SNR), `message_update`, `bulk_update` (the table on connect), `qsy_request`. A row - becomes a nameplate when it has a callsign and a non-zero frequency. **Not tested here; from source - only.** + becomes a nameplate when it has a callsign and a non-zero frequency. **From source only at this + point; observed live below.** + +### FreeDV Reporter — observed live (2026-09-21) + +**Four short sessions** to `ws://qso.freedv.org:80`, each **at most 10 seconds**, in the read-only +`view` role, sending only the WebSocket upgrade (with a `User-Agent` naming a probe), the `view` +connect and the pongs the protocol requires. The probe is `crates/k4-spot/tests/freedv_live.rs` +(`#[ignore]`, run by hand) and **prints counts and shapes only — never a callsign or any text an +operator wrote**. Nothing here contains one. + +- **It works as the other client's source says.** The upgrade, Engine.IO `open`, the `view` connect and + the Socket.IO `connect` acknowledgement all went through on plain `ws://`; the session was joined + in 0.6–1.2 s; **42–45 stations** were on the roster, on **14–16 distinct frequencies**; no error, no + disconnect in the window. +- **The first build of the parser disagreed with the real service, and was wrong.** 10–12 events in + each session were rejected as malformed. The diagnostic (field names and value *kinds*, no values) + showed two causes, both cases of my strictness and not the server's fault: + 1. **`freq_change` with `freq` = 0.** A station with no frequency set (or cleared) sends 0. That is + a normal event meaning "no frequency", not a malformed one. + 2. **`message_update` with non-ASCII text.** Operators write free text with accents and other + characters. A nameplate can only show printable ASCII, so such a message must read as "no + message" and must not cost the station the rest of its data. + The rule now: a field of the **wrong type** rejects the event; a well-typed value that cannot be + displayed **degrades that one field**. After the change the same probe reported **0 rejected**. +- **Every event also carries `last_update`** (a 32-character timestamp string). It is **not used**: a + nameplate's age counts from when the station was last confirmed on the roster (see the design note + in `FR-SPOT-08`), and the timestamp's zone and precision were not examined. +- **Frequencies run from 1 MHz to about 10.49 GHz** on the live roster, so some stations report + values far from any amateur HF band (a satellite path is plausible at 10 GHz; the 1 MHz one is + probably a placeholder). The window filter is what keeps them off a 14 MHz view. +- **Not seen in these sessions:** a `tx_report`, `rx_report` or `qsy_request` were not looked for + specifically (the probe counts what was rejected, not which events arrived), and `wss://` on 443 + was **not tried** — plain `ws://` is what the reference client uses. + +**Decision (DC0SK, 2026-09-21): WSPRnet is dropped** — not built and not planned. SOTA stays blocked on +the API-consumers membership. ### Summary — what is buildable from documentation @@ -572,8 +607,8 @@ done for RBN, at DC0SK's invitation ("how did SDRoxide solve the API issues"). |---|---|---|---| | POTA | schema observed once, no rules | open, unauthenticated | HTTPS GET + JSON | | SOTA | terms yes, schema no (SDRoxide uses the unstable `api-db2` host) | **developer must join API-consumers** | HTTPS GET + JSON, after that | -| WSPRnet | via third parties only | API by contacting the custodian; wspr.live open but third-party | HTTPS GET, or nothing | -| FreeDV Reporter | none; protocol from two other clients | open, read-only `view` role | WebSocket + Socket.IO (plain `ws`, as SDRoxide does; TLS unverified) | +| WSPRnet | via third parties only | **dropped by DC0SK, 2026-09-21** | — | +| FreeDV Reporter | none; protocol from two other clients, **now observed live** | open, read-only `view` role | **built** — WebSocket + Socket.IO over plain `ws` | ### Not yet read The retail DX-cluster login prompt and volume, and RBN's own guidance on rates — see above. diff --git a/docs/requirements/system-requirements.md b/docs/requirements/system-requirements.md index 7ab9bbb..cc4d120 100644 --- a/docs/requirements/system-requirements.md +++ b/docs/requirements/system-requirements.md @@ -1,7 +1,7 @@ --- title: "System Requirements Specification" status: Draft -version: "0.67" +version: "0.68" updated: 2026-09-21 authors: - Simon Keimer (DC0SK) @@ -325,7 +325,7 @@ networks' own published interfaces (`R-EXT-05`) — both to be confirmed at desi | `FR-SPOT-05` | provide a **PSK Reporter** source over its **public live MQTT feed**: connect to the configured broker (default `mqtt.pskreporter.info:1883`, plain TCP, no login), subscribe to **exactly** the band topics (`pskr/filter/v2//#`) for the bands the VFO window overlaps — and to **nothing** when no band is wanted, never to the whole feed — move those subscriptions as the VFOs move, and deliver the spots that arrive, kept only if they lie within the window and rate-limited. Nothing identifying is sent (no credentials; a client id from the process id and clock). The connection is kept alive with pings and a silent broker is detected and reconnected; a refusal, a protocol error or a hostile message is reported or rejected, never trusted (message text is read by a strict flat scanner, over-size packets are refused before any allocation). **The transport was a decision (`OP-7`):** the live feed rather than the documented five-minute query API — real-time nameplates with server-side band scoping, at the cost of a hand-written MQTT client and of PSK Reporter documenting no rate or fair-use rules for the feed. | STK-21 | S | T/D | The MQTT encoders match hand-computed vectors from the MQTT 3.1.1 specification, the decoder reads split, batched and length-encoded packets and is bounded and rejects garbage (tests `fr_spot_05_mqtt_*`); a live-shaped message parses to the right spot and hostile or malformed ones are rejected whole (`fr_spot_05_pskreporter_payload_*`); frequencies map to the band tokens seen on the live feed and a window maps to its topics (`fr_spot_05_bands_and_topics`, `fr_spot_05_topics_follow_the_window`); against a scripted mock broker the source sends exactly a credential-free CONNECT and the one topic asked for, subscribes to nothing with no bands, follows a change of topics without touching the one that stayed, reconnects and resubscribes, pings a quiet connection and detects a dead one, sheds a flood, and survives a hostile broker (`fr_spot_05_source_*`); the worker subscribes to exactly the bands of the window and moves them (`fr_spot_05_worker_follows_the_window_with_band_subscriptions`); in the app against a mock broker (demo) the spots appear as nameplates and an unrequested out-of-window message is dropped. **A real broker's behaviour, and the unverified `60m`/`6m` band tokens, need a live run.** | | `FR-SPOT-06` | define **one network-neutral spot model** (callsign, frequency Hz, mode, time, source, optional SNR/spotter/comment) and a **source interface** every network implements, so a network is added without touching the overlay or the store; the store **de-duplicates** repeated reports of the same station on the same frequency (within a small tolerance) keeping the newest, is **bounded** in count, and normalises callsigns (upper-case, trimmed, restricted to the callsign character set — anything else is rejected, never displayed). | STK-21 | S | T | The store merges duplicates from two sources, keeps the newest, evicts oldest at its cap, and rejects a callsign holding control, whitespace-inside or bidi-override characters (tests `fr_spot_06_store_dedupe_bound_and_sanitise`, `fr_spot_06_store_is_bounded_oldest_out`, `fr_spot_06_callsign_normalised_or_rejected`, `fr_spot_06_text_and_spot_construction`, `fr_spot_06_sources_are_interchangeable_and_report_failure` in `k4-spot`). | | `FR-SPOT-07` | provide **telnet-cluster sources** — the **Reverse Beacon Network** and a user-configured **DX cluster** — connecting to the configured host/port, logging in with the operator's callsign, parsing spot lines into `Spot`s, and reconnecting with back-off after a drop. Line length and the number of spots accepted per second are **bounded**; a line that does not parse is skipped, never fatal. | STK-21 | S | T/D | The cluster-line parser turns documented sample lines (CW/RTTY/FT8 skimmer and human DX spots) into `Spot`s, skips garbage, truncates/rejects over-long lines, and a flood beyond the rate cap is shed without unbounded growth (tests `fr_spot_07_cluster_line_parse`, `fr_spot_07_bounds`); against a mock cluster the source connects, logs in and streams (demo). | -| `FR-SPOT-08` | **[Could]** provide further sources behind the same interface, each selectable and configurable per `FR-SPOT-04`: **POTA** (built), and **WSPRnet**, **SOTA** and **FreeDV Reporter** (not built — each is blocked on a decision or a prerequisite recorded in `OP-7`). **POTA** is a *polled* source: the public spot list at `api.pota.app/spot/activator` is asked for at a configurable interval (default 60 s, never below 30 s or above 1 h, kept in bounds by the settings and by the source), **off by default**, kept to spots near a VFO, coloured green. The request runs on its own short-lived thread so a slow server never delays the other sources, and is bounded: a timeout, a size cap that refuses rather than truncates, and no redirects; it is an anonymous GET carrying only the program name and version. A failure is reported against POTA with its reason, backs off with doubling, and is retried by itself. The reply is read by a strict scanner: a record that is not a valid spot is counted and dropped without costing the others, while anything that is not a list of flat records (nesting, bad text, over-size, trailing junk) is an error shown to the operator. **POTA documents no schema:** the format is from one observation of the live service (`R-EXT-05`). | STK-21 | C | T/D | POTA: a reply shaped like the live one parses to the right spots — the frequency taken from kilohertz to the hertz, the time read as UTC to the second against `date -u`, the park in the comment (`fr_spot_08_pota_reply_parses`, `fr_spot_08_frequency_and_time_arithmetic`, `fr_spot_08_optional_fields_and_time_cap`); hostile and malformed replies are refused and a bad record costs only itself (`fr_spot_08_pota_rejects_hostile_input`); the source asks once per interval, never twice at once, never blocks `poll`, backs off and recovers, abandons a hung request and does not deliver its late answer, filters by window and caps a reply (`fr_spot_08_polled_source_*`, `fr_spot_08_a_slow_request_does_not_block_poll`, `fr_spot_08_failures_back_off_and_recover`, `fr_spot_08_a_hung_request_is_abandoned`, `fr_spot_08_window_and_caps`, `fr_spot_08_interval_is_clamped`); the HTTP request is an anonymous GET, and an error status, an over-size body, a redirect and a silent server are each reported and bounded (`fr_spot_08_fetch_*`); the worker runs POTA into the store, reports its failure without stopping RBN, and does not spin a core (`fr_spot_08_worker_*`, `fr_spot_08_polled_status_wording`); the setting persists, defaults off, and stays in bounds (`fr_spot_04_networks_default_off_and_persist`). **Run once end to end** with the app pointed at a local stand-in (`K4_POTA_URL`): the request went out with the expected headers, the interval was honoured, and the spot appeared as a green nameplate while one outside the view was not drawn. **Not verified:** the real service over TLS, that `spotTime` is UTC, and what `expire` and `invalid` mean. The other three networks: see `OP-7`. | +| `FR-SPOT-08` | **[Could]** provide further sources behind the same interface, each selectable and configurable per `FR-SPOT-04`: **POTA** and **FreeDV Reporter** (built), **SOTA** (not built — blocked on API-consumers membership, `OP-7`), and **WSPRnet** (**dropped by DC0SK, 2026-09-21**). **POTA** is a *polled* source: the public spot list at `api.pota.app/spot/activator` is asked for at a configurable interval (default 60 s, never below 30 s or above 1 h, kept in bounds by the settings and by the source), **off by default**, kept to spots near a VFO, coloured green. The request runs on its own short-lived thread so a slow server never delays the other sources, and is bounded: a timeout, a size cap that refuses rather than truncates, and no redirects; it is an anonymous GET carrying only the program name and version. A failure is reported against POTA with its reason, backs off with doubling, and is retried by itself. The reply is read by a strict scanner: a record that is not a valid spot is counted and dropped without costing the others, while anything that is not a list of flat records (nesting, bad text, over-size, trailing junk) is an error shown to the operator. **POTA documents no schema:** the format is from one observation of the live service (`R-EXT-05`). **FreeDV Reporter** is a *live* source over a **WebSocket** (Engine.IO 4 / Socket.IO 4), built from another client's public source and then **observed on the real service** (`R-EXT-05`): it joins in the **read-only `view` role**, so the operator is **not listed as a station and nothing identifying is sent** — the connect is `{"role":"view","protocol_version":2}` and nothing else, the request carries only the program name and version, and the only other thing sent is a pong to each ping (`FR-SPOT-12`). It connects over **plain `ws://`** (port 80), as the reference client does; `wss` was not tried. **Off by default**, host and port configurable, coloured **coral**. The service reports who is on the air *now* — a presence list, not timestamped reports — so a station's spot is stamped when its event arrives **and re-stamped every 30 s while the station stays on the roster**; one that leaves stops being refreshed and fades with age like any other spot (a design choice, DC0SK to overrule; the events also carry a `last_update` timestamp, unused). A `freq` of 0 means "no frequency" and removes the plate; a well-typed value that cannot be shown (a non-ASCII message, an over-long grid) degrades **that field only**, while a field of the wrong *type* rejects the event. Hostile input is bounded throughout: the WebSocket upgrade reply is checked in full (status, headers, and the `Sec-WebSocket-Accept` the key implies, so SHA-1 and base64 are here), no extension or sub-protocol is accepted, frames are refused if masked, reserved, oversize (256 KB), or fragmented control frames, and a message is UTF-8 checked; JSON is read by a strict parser bounded to 8 levels, 20 000 values and 4096-byte strings; the roster holds at most 4096 stations and a `bulk_update` at most 10 000 items, with a nested one refused; the ping timing the server announces is clamped to 1–120 s and drives a liveness check; and a connection that stalls at any step is given up on and retried with backoff. | STK-21 | C | T/D | POTA: a reply shaped like the live one parses to the right spots — the frequency taken from kilohertz to the hertz, the time read as UTC to the second against `date -u`, the park in the comment (`fr_spot_08_pota_reply_parses`, `fr_spot_08_frequency_and_time_arithmetic`, `fr_spot_08_optional_fields_and_time_cap`); hostile and malformed replies are refused and a bad record costs only itself (`fr_spot_08_pota_rejects_hostile_input`); the source asks once per interval, never twice at once, never blocks `poll`, backs off and recovers, abandons a hung request and does not deliver its late answer, filters by window and caps a reply (`fr_spot_08_polled_source_*`, `fr_spot_08_a_slow_request_does_not_block_poll`, `fr_spot_08_failures_back_off_and_recover`, `fr_spot_08_a_hung_request_is_abandoned`, `fr_spot_08_window_and_caps`, `fr_spot_08_interval_is_clamped`); the HTTP request is an anonymous GET, and an error status, an over-size body, a redirect and a silent server are each reported and bounded (`fr_spot_08_fetch_*`); the worker runs POTA into the store, reports its failure without stopping RBN, and does not spin a core (`fr_spot_08_worker_*`, `fr_spot_08_polled_status_wording`); the setting persists, defaults off, and stays in bounds (`fr_spot_04_networks_default_off_and_persist`). **Run once end to end** with the app pointed at a local stand-in (`K4_POTA_URL`): the request went out with the expected headers, the interval was honoured, and the spot appeared as a green nameplate while one outside the view was not drawn. **Not verified:** the real service over TLS, that `spotTime` is UTC, and what `expire` and `invalid` mean. The other three networks: see `OP-7`. | **FreeDV Reporter (built, and run four times against the real service for at most ten seconds each — 42–45 stations, 0 events rejected after the fix):** SHA-1, base64 and the accept key against FIPS 180-4, RFC 4648 and the RFC 6455 §1.3 worked example, with the padding-boundary lengths computed independently (`fr_spot_08_ws_hashes_match_published_vectors`); frames byte for byte against RFC 6455 §5.7, split at every byte (`fr_spot_08_ws_frames_match_the_rfc_examples`); a hostile server's masked, reserved, oversize and fragmented frames and invalid UTF-8 (`fr_spot_08_ws_reader_refuses_hostile_frames`); the upgrade reply checked in full and every prefix asking for more (`fr_spot_08_ws_response_is_checked_in_full`); header injection refused (`fr_spot_08_ws_request_is_well_formed_and_cannot_inject`); a strict bounded JSON parser (`fr_spot_08_json_*`); Socket.IO packets (`fr_spot_08_sio_*`) and the connect being view-only and anonymous (`fr_spot_12_the_connect_is_view_only_and_anonymous`); the roster, its bounds, the shapes seen on the real service, and a diagnostic that never contains a value (`fr_spot_08_freedv_*`); against a scripted server whose frames are built and read by hand, independently of the crate's own codec: the client joins as a viewer only and sends nothing else unprompted, answers Engine.IO and WebSocket pings, refuses a bad upgrade five ways, gives up on a silent server at each stage and on a quiet one by the ping timing it announced, reports a refusal or an end, survives hostile frames and garbage inside a running session, reconnects with a fresh roster, refreshes stations that stay and not those that leave, sheds a flood, ignores a server that speaks out of turn, backs off with a cap and starts over after a success, and bounds what one poll reads (`fr_spot_08_source_*`); the worker runs it into the store, reports its failure without stopping RBN, and switches it off (`fr_spot_08_worker_runs_freedv_and_isolates_its_failure`); the setting persists and defaults off even when a file names the network but not the switch (`fr_spot_04_networks_default_off_and_persist`); and every hand-off from the settings to the worker is carried (`fr_spot_08_freedv_settings_are_wired_end_to_end`). **Not verified:** `wss` (TLS), the meaning and zone of `last_update`, the nameplates on screen against the live feed, and whether the 30 s refresh is the right interval. | `FR-SPOT-09` | run every spot source **off the UI and radio-control paths**: sources own their own I/O threads and publish a snapshot the UI copies on its tick, so a slow, blocked or failing network can never delay CAT, audio or rendering. A source error (refused, timeout, malformed feed, rate-limited) is **surfaced** in the Settings entry for that network — not silent — and does not disable the other sources. | STK-21/11 | S | T/D | With one source blocked or erroring against a mock, the UI tick and the other sources are unaffected and the failing network reports its reason (test `fr_spot_09_source_failure_isolated`); the shared-snapshot model matches `FR-UI-07`. | | `FR-SPOT-10` | let the operator **click a nameplate to tune** the VFO to **that spot's own frequency** — not to wherever on the plate the pointer was — through the same path as a panadapter click (`FR-PAN-04`), and show a **tooltip** on hover with the spot's callsign, frequency, mode, source, spotter, SNR and age, placed beside the pointer and never cut off by the pane. A click anywhere else still tunes to the pointer's position. Tuning never keys the transmitter. The tune lands the VFO on the spot's **reported frequency**; whether a network reports the dial or the signal frequency (a CW pitch offset) is `OP-7` item 4, unresolved. | STK-21 | C | T/D | `hit_test` finds the plate under the pointer by the index it was given, with left/top edges inside and right/bottom outside, and misses gaps, lane separators and the empty pane (test `fr_spot_10_hit_test_finds_the_plate_under_the_pointer`); driving the real `Spectrum` program with synthetic mouse events, a click on a plate returns the tune message for the spot's exact frequency, a click beside it the pointer's position, and an aged-out or out-of-view spot cannot be clicked (`fr_spot_10_click_on_a_nameplate_tunes_to_the_spot`); a right click, a click outside the pane and a pane with no known span do nothing (`fr_spot_10_only_a_left_click_inside_the_pane_tunes`); the tooltip text, its omission of what a network did not say, the age wording at each boundary and its placement inside the pane at every pointer position (`fr_spot_10_tooltip_*`). **The hover tooltip itself was not seen on screen** (that needs a pointer); click and hover need a run at the radio. | | `FR-SPOT-11` | **colour-code** nameplates by source and **fade** them with age: amber for RBN, sky blue for PSK Reporter, lilac for a DX cluster, each fully opaque when fresh and falling in a straight line to half strength at the age limit (never rising as a spot ages). The colours differ in hue and lightness and stay legible on the plate at full strength (contrast at least 7:1) and at the faintest (at least 3:1), against the fixed dark spectrum background the canvas paints in every theme (`FR-UI-17`). **The originally-specified "show only spots on the current band" filter is dropped:** the overlay draws only spots inside the visible span, at most 368 kHz, which is always within one band, so such a filter could never hide anything. | STK-21 | C | T/D | The fade is 1.0 at age 0, monotonic, linear, floored at the limit and beyond it, and absent for a zero limit (test `fr_spot_11_age_fade_is_monotonic_and_floored`); the three source colours are pairwise well apart and each meets the contrast bounds at both ends of the fade, checked with WCAG relative-luminance arithmetic that is itself held to the standard's fixed values (`fr_spot_11_source_colours_are_distinct_and_legible`, `fr_spot_11_contrast_arithmetic_matches_wcag`); on screen (`--demo`) the three colours and the dimming of older spots are visible. **Legibility was computed, not judged by eye across themes** — only the dark theme's screen was captured. | @@ -366,7 +366,7 @@ networks' own published interfaces (`R-EXT-05`) — both to be confirmed at desi - `OP-4` Decide CW source: physical paddle via serial/USB at the client, on-screen, or keyboard — affects `FR-TX-CW-01` input layer. *(QK4 supports hardware keyers/K-Pod; out of our v1 scope.)* - `OP-5` Confirm required regulatory identification behaviour for `STK-13`/`FR-VFO-ID`. - `OP-6` Choose default transport security: plaintext+SHA-384 (9205) vs TLS-PSK (9204) for Internet use (`NFR-SEC-02`, `FR-AUTH-02`). -- `OP-7` Spot-source interface details (`FR-SPOT-05`/`-07`), to settle **from each network's current published documentation before implementation** (the `RO`/`RA` lesson — read, don't guess). **Resolved for the telnet sources (`R-EXT-05`):** the cluster spot-line format (from a third-party manual; the unit is inferred), RBN's hosts and ports, and the relay's login prompt (observed once). **Resolved for PSK Reporter (`R-EXT-05`):** both interfaces read and observed; **decided by DC0SK: the live MQTT feed over plain TCP.** **Decided by DC0SK for RBN:** the relay (prefilled, off) and a DX cluster both stay selectable, with a note on the RBN entry. **Still open:** (1) a real DX cluster's login prompt, and whether its lines match; (2) whether the RBN relay accepts filter commands (RBN says not; SDRoxide's manual says so) — the app filters by frequency window instead; (3) PSK Reporter's rate and fair-use rules for the MQTT feed (undocumented), the band tokens `60m` and `6m` (unseen), and TLS (port 1884) — **done (`FR-SPOT-13`)**: the real server's certificate is publicly trusted; (4) whether spot frequencies are dial or signal frequency per network. Also confirm the **all-networks-default-off** choice (`FR-SPOT-04`) with DC0SK. **POTA (`FR-SPOT-08`), read and observed 2026-09-21 (`R-EXT-05`):** no published schema; the format is from one observation; built as a polled source. **Still open, each for DC0SK (2026-09-21):** (5) **SOTA** — its terms require the *developer* to be a member of the SOTA Reflector's "API-consumers" group before using the API, which this project cannot satisfy on DC0SK's behalf, and the documented endpoint's schema was not found; SDRoxide sidesteps the question by using a different host that SOTA calls unstable (`R-EXT-05`), which is not a reason for us to; (6) **WSPRnet** — its own API is by contacting the custodian, and the open route is the third-party wspr.live (free for projects whose results are free to everyone, no commercial use, 20 requests a minute); SDRoxide reads WSPRnet only for the operator's own callsign, so it offers no precedent for a whole-view feed; PSK Reporter's feed already carried WSPR spots, so a separate source may add little; (7) **FreeDV Reporter** — undocumented, but SDRoxide's client (read for interface facts, `R-EXT-05`) shows a plain `ws://` Socket.IO connection in a read-only `view` role with no login, so it **does not need TLS** (an earlier draft of this note said it did; that was an assumption). It needs a WebSocket + Socket.IO client, and lists stations *currently on* a frequency rather than timestamped spots. **Buildable without any decision from DC0SK except whether to build it.** +- `OP-7` Spot-source interface details (`FR-SPOT-05`/`-07`), to settle **from each network's current published documentation before implementation** (the `RO`/`RA` lesson — read, don't guess). **Resolved for the telnet sources (`R-EXT-05`):** the cluster spot-line format (from a third-party manual; the unit is inferred), RBN's hosts and ports, and the relay's login prompt (observed once). **Resolved for PSK Reporter (`R-EXT-05`):** both interfaces read and observed; **decided by DC0SK: the live MQTT feed over plain TCP.** **Decided by DC0SK for RBN:** the relay (prefilled, off) and a DX cluster both stay selectable, with a note on the RBN entry. **Still open:** (1) a real DX cluster's login prompt, and whether its lines match; (2) whether the RBN relay accepts filter commands (RBN says not; SDRoxide's manual says so) — the app filters by frequency window instead; (3) PSK Reporter's rate and fair-use rules for the MQTT feed (undocumented), the band tokens `60m` and `6m` (unseen), and TLS (port 1884) — **done (`FR-SPOT-13`)**: the real server's certificate is publicly trusted; (4) whether spot frequencies are dial or signal frequency per network. Also confirm the **all-networks-default-off** choice (`FR-SPOT-04`) with DC0SK. **POTA (`FR-SPOT-08`), read and observed 2026-09-21 (`R-EXT-05`):** no published schema; the format is from one observation; built as a polled source. **Still open, each for DC0SK (2026-09-21):** (5) **SOTA** — its terms require the *developer* to be a member of the SOTA Reflector's "API-consumers" group before using the API, which this project cannot satisfy on DC0SK's behalf, and the documented endpoint's schema was not found; SDRoxide sidesteps the question by using a different host that SOTA calls unstable (`R-EXT-05`), which is not a reason for us to; (6) **WSPRnet — dropped by DC0SK, 2026-09-21** (not built, not planned); (7) **FreeDV Reporter — built** (`R-EXT-05`): undocumented, so the format is from another client's public source and was **checked against the real service**, which showed two cases where the first build was too strict (a `freq` of 0, and non-ASCII message text); plain `ws://` works, **`wss` is untried**; the events carry a `last_update` timestamp that is **unused**, and the **30 s presence refresh is a design choice for DC0SK to overrule**. ## Change history @@ -421,6 +421,7 @@ networks' own published interfaces (`R-EXT-05`) — both to be confirmed at desi | 2026-07-25 | 0.48 | DC0SK | Added FR-FM-02 as a **DTMF keypad** (`DM`) — the part of the gap-analysis item that is both buildable and useful remotely: sending DTMF for repeater/link control cannot be done any other way over the link. A 4×4 popup opened from the FM panel, one `DM;` per key. **Scoped down from the gap analysis on purpose:** the '6 stored DTMF sequences' are config work deferred to a follow-up, and the **1750 Hz tone burst has no documented CAT command** in D12 (searched), so it is not buildable now rather than guessed at — the `RO`/`RA` lesson. `send_dtmf` refuses a non-DTMF character rather than emitting a malformed `DM`. | | 2026-07-25 | 0.49 | DC0SK | Added FR-XVTR-01 (transverter band setup), the last substantial backlog item — complex and niche (transverter operators), but fully documented so buildable without hardware-guessing. Six `XV*` encoders (`XVN`/`XVM`/`XVR`/`XVI`/`XVO`/`XVP`), a read-back parser for each field, and a setup form on the BAND screen. The design turns on `XVN` being **stateful** — it selects the band the other commands target — so every field send is prefixed with `XVN`, and the form re-reads all fields when a band is picked, keyed on the `XVN` the radio confirms so a stale value never lands. The form outgrew the fixed-height config-screen slot and clipped; fixed by compacting it to three rows and wrapping the BAND screen in a scrollable. Deferred, and said so: the **mW power scale on XVTR bands** (showing mW instead of W when operating on a configured transverter band) — it needs the current-band-is-XVTR state wired through, and is an operating-display concern separate from this setup form. | | 2026-07-26 | 0.50 | DC0SK | Added FR-UI-UPD-02 (automatic update check + top-area notification), requested by DC0SK; **recorded, not yet implemented**. It is a deliberate, operator-chosen relaxation of FR-UI-UPD-01, which made the update check *manual-only* on the reasoning that "a radio-control app should not make unannounced outbound connections, and a remote station may be on a metered link." The automatic check is therefore constrained to bound that cost: default-on but **opt-out in Settings**, **once per start** rather than on a timer, and **silent** unless it finds a substantiated newer release — so a metered link sees at most one small request per launch, and only a real update ever draws attention. The notification lives in the top status area beside the connection indicator (not a modal), as a clickable link to the release page, reusing FR-UI-UPD-01's numeric, never-spurious comparison. Also fixed a stale/duplicated `version` block in this document's YAML frontmatter (a merge artifact: two `version:` keys) — set to 0.50 / 2026-07-26. | +| 2026-09-21 | 0.68 | DC0SK | Built **FreeDV Reporter** (`FR-SPOT-08`) at DC0SK's direction, and **dropped WSPRnet**. Needed a WebSocket client, a JSON parser and Engine.IO/Socket.IO framing, all hand-written, dependency-free and bounded like the MQTT client. **Format from another client's public source, then checked live:** four sessions of at most ten seconds each on the real service (read-only `view` role, counts and shapes printed, no callsign or text) found **10–12 events per session rejected** — the first parser was too strict about two normal things (`freq` 0, meaning "no frequency"; and non-ASCII message text) — after which **0 were rejected**. **Findings beyond this feature (in the ledger):** nearly every bound in the new modules, and in the POTA parser from the earlier change, was **unpinned** — the tests built their boundary inputs from the constants they were testing, so a constant could change by one and nothing noticed. Fixed for both; the POTA fix is also applied to that branch. **Not done:** `wss`, SOTA. | | 2026-09-21 | 0.67 | DC0SK | Built **FR-PAN-14 (spectrum decay / afterglow)**, the last of the four items DC0SK asked for. **Design choices (mine, for DC0SK to overrule):** decay is a constant dB per second — an exponential decay of *power* with time constant τ, the standard analyser peak-decay — with an instant attack; the setting is one number in milliseconds, **off by default** so nothing changes until chosen; a ghost outline and faint fill beside the live trace rather than replacing it; and it is advanced **per row on arrival** in the shared pan history, deliberately not per drawn frame, because `FR-PAN-13` now redraws at the row rate and the look must not depend on it. **Built:** `app/src/afterglow.rs` (pure), `PanShared` feeding one per receiver, `trace_points` shared by both traces, `spectrum_afterglow_ms` in the settings with a field in Settings. **Bugs the tests found:** the "off" state was enforced in three places, each masking the others (one deleted); nothing checked that `Afterglow::set_ms` clamps; and an early version of my drawing guard matched the helper it was guarding. **Also fixed:** the Networks summary said "of 3 on" after POTA made four. **Not seen on screen** — the display was asleep. | | 2026-09-21 | 0.66 | DC0SK | **GPU load reduction: `FR-PAN-13` amended from the display rate to the row rate.** DC0SK reported the integrated GPU ~40 % busy while data flows. The cause is in the code: while rows arrive, every frame asked for the next vsync, so a ~30 rows/s stream redrew the whole window at the panel's rate (60 Hz, more on a fast panel) although a frame between two rows shows nothing new. **Now** each redraw asks for the next one a smoothed row interval ahead, kept between 8 and 100 ms. **Requirement changed**, with its cost stated (a row can appear up to one row interval later than it would have on the vsync chain). **Built:** `RedrawState::next_frame_at`, `RedrawRequest::At`, an opt-in `K4_FPS=1` frame-rate line. **Not measured on screen:** the display was asleep (`eDP-1` `dpms=Off`), so no window was presented and a first attempt at a baseline recorded 0–1 frames a second, which measures nothing and was discarded; the GPU comparison is parked with DC0SK. **Tooling note:** an XWayland window (about 1 frame a second) and a native Wayland one (none) both showed no real frames while the display was off, and the display state was read only afterwards, so the two could not be told apart; whether XWayland windows are presented on this compositor with the display on was **not tested** (earlier sessions captured XWayland windows fine). A measurement needs a lit display, and the display state should be checked *first*. **Not done:** cheaper frames (the whole window is still redrawn each frame), a user-set frame cap. | | 2026-09-21 | 0.65 | DC0SK | Built **FR-SPOT-13 (TLS for PSK Reporter, with manual approval of an untrusted certificate)**. **Observed first:** one TLS handshake with `mqtt.pskreporter.info:1884` (nothing sent after it) showed its certificate is **trusted by the public authorities**, so on the real service the approval path is a fallback (a self-hosted broker, a filtering proxy), not the normal case. **Design (mine, for DC0SK to overrule):** pin the exact certificate by SHA-256 per host and port, show the fingerprint and the reason before anything is sent, never accept a changed certificate silently, and still require proof of the private key. **Built:** `k4-spot::mqtt_source` gains a `Connector` (plain TCP by default), a structured `CertInfo`/`ConnectError::Untrusted`, `retry_now` and `pending_cert`; `app/src/tls` (rustls with a verifier that checks normally first, then consults the approvals; the signature is always verified); `TrustedCert` and `PskReporterPrefs.tls` in the settings; a TLS switch, an approval prompt and an approved-certificate list in Networks. **Bugs the tests found:** `openssl req -x509` marks a self-signed certificate as an authority, so rustls refuses it as `CaUsedAsEndEntity` before looking at its issuer — the most common real self-signed case would have shown as "not acceptable"; my structural wiring guard matched its own text and passed with the real line deleted; two config tests never exercised the checks they claimed to. **Not done:** TLS for RBN/DX cluster and POTA-style approval; subject, issuer and dates in the prompt; the prompt seen on screen. | diff --git a/docs/test/coverage.generated.md b/docs/test/coverage.generated.md index 3567180..a22bbd8 100644 --- a/docs/test/coverage.generated.md +++ b/docs/test/coverage.generated.md @@ -126,7 +126,7 @@ Legend: ✅ test-traced · 🟡 waived (see r3-waivers.md) · ⚪ not test-requi | `FR-SPOT-09` | S | T/D | ✅ | | `FR-SPOT-10` | C | T/D | ✅ | | `FR-SPOT-11` | C | T/D | ✅ | -| `FR-SPOT-12` | S | I/D | ⚪ | +| `FR-SPOT-12` | S | I/D | ✅ | | `FR-SPOT-13` | S | T/D | ✅ | | `FR-STREAM-01` | M | T | ✅ | | `FR-STREAM-02` | M | T | ✅ | diff --git a/docs/test/test-strategy.md b/docs/test/test-strategy.md index 066017a..5cf7ec2 100644 --- a/docs/test/test-strategy.md +++ b/docs/test/test-strategy.md @@ -1,7 +1,7 @@ --- title: "Test Strategy & Traceability" status: Draft -version: "4.18" +version: "4.19" updated: 2026-09-21 authors: - Simon Keimer (DC0SK) @@ -437,6 +437,7 @@ FR-SES-MULTI, FR-DIAG-02, etc. — get `TC` IDs when promoted to `Approved`.)* | 2026-07-25 | 4.0 | DC0SK | Release **v0.8.0**. Minor: six backlog features and two operating fixes since 0.7.0, nearly all validated on DC0SK's live K4. Added: VFO lock read-back + tuning refusal, DATA rate select, `ACN` antenna names, on-screen macros (Fn → MACROS, reusing the K-Pod table), a DTMF keypad, and transverter band setup (two-column BAND screen). Fixed: TX TEST now flashes distinct from a real transmit (finishing FR-TX-TUNE-01's flashing indication), and DATA sub-mode/rate switching lag — the same read-back fight the sliders had, fixed with the standing optimistic-override pattern. **What is left is now honestly the hard part:** the audio-character (`MX`/`BL`/`FX`/`AL`) and message (`DARM`) items are blocked on two hardware questions only the operator can answer — whether radio-side audio settings reach the remote stream, and whose microphone `DARM` records. Version bumped in Cargo.toml (workspace), lockfile, README, user manual. 326 tests. | | 2026-07-26 | 4.1 | DC0SK | **FR-UI-UPD-02 implemented** — automatic update check + top-area notification. Default-on preference (opt-out in Settings), one start-up check off the UI thread reusing `update::check_now` (so the whole never-spurious comparison from FR-UI-UPD-01 comes for free), and a clickable `● update ` link beside the connection indicator, shown only for an `Available` result. The metered-link caution FR-UI-UPD-01 was written around is met by construction, not overridden: one request per launch, opt-out, silent unless there is a real update. Tested at the config layer (default-on + persistence) and verified on screen — including watching the start-up check overwrite a seeded status, which confirmed it runs. 327 tests. | | 2026-07-27 | 4.2 | DC0SK | Release **v0.9.0**. Minor: the addition since 0.8.0 is FR-UI-UPD-02, the automatic update check (opt-out, once per start, silent unless a real update is found, clickable link in the top status area). Numbered **0.9.0, not 0.8.1** — a new feature is a minor bump under semver, and the changelog files it under Added. It was briefly tagged v0.8.1 by mistake; the tag and its in-flight release build were removed before any release artifact published, and it was retagged. Version bumped in Cargo.toml (workspace), lockfile, README, user manual. 327 tests. | +| 2026-09-21 | 4.19 | DC0SK | **FR-SPOT-08: FreeDV Reporter as a live WebSocket source.** New in `k4-spot`: `json` (a strict, bounded parser), `ws` (SHA-1, base64, the upgrade, frames), `sio` (Engine.IO/Socket.IO), `freedv` (the roster), `freedv_source`; a fifth worker slot, `FreeDvPrefs`, a Networks section, coral nameplates. **L1:** `fr_spot_08_json_parses_and_reads`, `..._json_refuses_what_it_would_repair_and_is_bounded` (38 malformed inputs; every bound at the boundary; linear time on 256 KB); `fr_spot_08_ws_*` (SHA-1 against FIPS 180-4 and independently computed padding-boundary lengths, base64 against RFC 4648, the accept key against RFC 6455 §1.3, frames byte for byte against RFC 6455 §5.7, split at every byte, hostile frames, the upgrade reply checked in full); `fr_spot_08_sio_*` and `fr_spot_12_the_connect_is_view_only_and_anonymous`; `fr_spot_08_freedv_*` (events, `bulk_update`, hostile events, the bounds, the shapes seen on the real service, the diagnostic, stored text at its limits). **L2 (a scripted server whose frames are built and read by hand):** `fr_spot_08_source_*` ×12 (view-only join and the exact bytes sent, pings, five bad upgrades, silence at every stage, a refusal and an end, a hostile server, reconnect with a fresh roster, refresh of stations that stay, a flood, out-of-turn packets, backoff with cap and reset, the per-poll byte cap), `fr_spot_08_worker_runs_freedv_and_isolates_its_failure`, `fr_spot_08_freedv_settings_are_wired_end_to_end` (structural, whitespace-insensitive, reading only the code above its module). **Live, four times, at most ten seconds each, not part of the suite:** `freedv_live` (ignored) — see below. **Sabotage: more than 250 mutations** (a floor, not a count — several rounds were repeated after fixes). **What they found, beyond this feature:** (1) **Nearly every bound was unpinned.** The tests built their boundary inputs from the constants they tested (`nest(MAX_DEPTH + 1)`), so `MAX_DEPTH`, `MAX_NODES`, `MAX_STRING`, `MAX_HEADERS`, `MAX_HEADER_LINES`, the event-name and session-id limits, the ping limits, `MAX_STATIONS` and `MAX_BULK` could each change by one with nothing failing — 22 survivors, after the *checks* themselves had all been mutation-tested. The same was true of the **POTA parser of the earlier change** (`MAX_BODY`, `MAX_SPOTS`, the 300 GHz ceiling, the default interval — six more). All are now written as numbers; the POTA fix is its own commit. One is an **equivalent mutation** (`MAX_TOKEN`: the same 64 is applied again downstream). (2) **The real service disagreed with my parser.** The first build rejected 10–12 events per session as malformed; two cases, both my strictness, not the server's: `freq` 0 (meaning no frequency) and non-ASCII message text. Hand-written test data had never contained either. The rule that came out of it — a wrong *type* rejects the event, a well-typed value that cannot be shown degrades that one field — is now tested (`fr_spot_08_freedv_real_service_shapes`); the same probe then reported **0 rejected**. (3) **Vectors written from memory were wrong** — the SHA-1 of 65 `a` bytes differed in its last hex digit; replaced by values computed with `hashlib`, and the implementation agrees with them. (4) **Redundant guards that masked each other under mutation** were deleted rather than papered over: a leading-zero check (the grammar already catches it), a general frame-size check (the 64-bit branch does all the work), a `freq == 0` guard in `spot()` (`Spot::new` refuses it), a `live` check before the refresh (the roster is empty until live), duplicate shed counting, and a name cap that could never be reached. (5) **A performance flaw found by reading:** the JSON string reader re-validated the whole remaining input as UTF-8 per character (quadratic); now linear, with a test. (6) **A backoff test with a wrong premise:** the retry is scheduled from when its poll *began*, up to a read timeout before the close was noticed, so tight lower bounds failed on every run; the test now uses a scale where that offset is small. (7) **A clippy lint on my SHA-1** (`chunks_exact`) blocked the commit hook, correctly. **Process trap, again:** several edits silently did nothing because rustfmt had reflowed the text they targeted; each was caught only by re-running the mutation, so every such edit is now checked with a grep. **Not covered:** `wss`; the meaning and zone of `last_update`; the nameplates on screen. | | 2026-09-21 | 4.18 | DC0SK | **FR-PAN-14: spectrum decay / afterglow.** New `app/src/afterglow.rs`, `PanShared` feeding it, `trace_points` in `spectrum.rs`, `spectrum_afterglow_ms` in `k4-config`, and a Settings field. **L1 (maths, against closed-form values, not the code's own output):** `fr_pan_14_fall_is_exponential_power_decay` (4.3429 dB per τ; the implied power ratio is 1/e), `..._the_ghost_attacks_at_once_and_decays_at_the_set_rate` (−50 dB peak after 990 ms is −54.30 ± 0.02), `..._time_constant_and_row_rate` (the same second as 30 rows or 4 falls the same; a 60 s stall is a 5 s step; out-of-range settings clamp — measured by fall), `..._the_ghost_follows_the_pan_and_the_setting`, `..._bad_input_cannot_poison_the_ghost`, `..._the_history_feeds_the_afterglow_per_receiver`, `..._ghost_and_trace_share_their_geometry`. **L1 (config):** `fr_pan_14_afterglow_setting_persists_and_is_bounded`. **Structural:** `..._the_ghost_is_drawn_under_the_live_trace` and `..._the_setting_is_wired_end_to_end` (four hand-offs, one of which — the save — ends in `..Default::default()` and would silently reset a forgotten field), both reading only the code above their own module so they cannot match themselves. **Sabotage:** 32 mutations in the first round, 3 more after the fixes. **Real survivors:** the "off" state enforced three times over so each masked the others (one deleted); `Afterglow::set_ms` clamp untested (a test measuring the fall for 1 ms and for 99 999 ms now covers it); one **equivalent mutation** — the draw-time width check guards a race between two lock acquisitions that no test can reach without a renderer, and is commented as such. **Not covered:** how it looks on screen — **the display was asleep**. | | 2026-09-21 | 4.17 | DC0SK | **FR-PAN-13 amended: redraw at the row rate, not the display's (GPU load reduction).** `RedrawState` now answers *when* the next frame is due, not *whether* one is wanted. **L1:** `fr_pan_13_redraw_chain_follows_the_row_stream` (rewritten for the new signature; same behaviours), `fr_pan_13_frames_follow_the_row_rate_not_the_display` (a simulated display: **30 rows/s → 30.3 frames/s**, 20 → 19.7, 25 → 24.7, 62.5 → 61.8, 500 → 124.9 capped, 4 → 10.1), `fr_pan_13_the_row_interval_is_learned_and_bounded`, and the structural `fr_pan_13_the_widget_requests_the_time_the_state_gives`. **Sabotage:** 24 mutations across two rounds. **Survivors that were real:** the estimate was clamped twice (once stored, once when scheduling), so removing either clamp was masked by the other — the redundant one was deleted; my tolerances were wide enough that an estimate with **no smoothing** still passed — a test that one odd frame must not swing it now fails it; the wiring line `RedrawRequest::At(at)` was invisible to pure tests — a structural guard reading only the code above its own module now catches it (one written earlier in this session matched its own text). **Not measured:** GPU busy and frames per second on screen — **the display was asleep**, so nothing was presented; a first "baseline" of 0–1 frames a second, 0 % GPU, was discarded as a measurement of a blanked screen, not of the app. **Parked with DC0SK:** run `K4_FPS=1 k4remote --demo` with the panel on, and read `/sys/class/drm/card0/device/gpu_busy_percent`, before and after. | | 2026-09-21 | 4.16 | DC0SK | **FR-SPOT-13: TLS for PSK Reporter with manual approval of an untrusted certificate.** New `app/src/tls` (with a shared test server), `Connector`/`CertInfo` in `k4-spot::mqtt_source`, `TrustedCert` in `k4-config`, and the Networks prompt. **L2 (real TLS on loopback, two throwaway P-256 certificates whose fingerprints were computed by `openssl`, not by the code under test):** `fr_spot_13_untrusted_certificate_is_refused_with_its_fingerprint`, `..._an_approved_certificate_connects_and_carries_data`, `..._an_approval_is_exact`, `..._an_approved_certificate_still_has_to_prove_its_key` (a replayed public certificate with the wrong key, on TLS 1.3), `..._tls12_also_checks_the_key`, `..._only_certificate_problems_can_be_approved` (the ordinary check replaced by a stub that fails in ways a certificate cannot cause), `..._other_failures_are_plain_failures`, `..._fingerprint_arithmetic` (FIPS 180-4 "abc" and both certificates), `..._pins_follow_the_configuration`; `fr_spot_05_tls_never_falls_back_to_plain_text` and `..._untrusted_certificate_is_kept_until_decided` (source, scripted connector); `fr_spot_13_worker_asks_before_trusting_and_connects_once_approved` (worker, real TLS: the broker sees no MQTT before approval, and the connection is up within 700 ms of it). **L1:** `fr_spot_13_trusted_certificates_persist_and_are_validated`, `fr_spot_13_a_click_approves_only_the_certificate_shown`, `..._the_port_follows_the_tls_switch`, and the structural `..._the_approval_is_wired`. **Sabotage:** every new test mutated (verifier 28 runs in two rounds, source 11, configuration 17, wiring and decisions 17, worker 5). **Survivors that were real:** a host-case test that used identical strings; the "only certificate problems" guard (untestable until the inner check was made injectable); two config checks never fed a printable-but-forbidden character or a 65-digit fingerprint; a retry test that allowed 5 s so ignoring the retry still passed; and **a structural guard whose needles also occurred in its own source, so deleting the real line left it green** (fixed by searching only the code above the test module). One equivalent mutation (a fingerprint swapped for an equal one). **Live, once, not part of the suite:** `live_psk_reporter_tls` (ignored) — the real server's certificate is publicly trusted. **Not covered:** the approval prompt on screen; TLS for the telnet sources. | diff --git a/docs/user-manual.md b/docs/user-manual.md index 3d328bf..16beddc 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -462,6 +462,10 @@ Everything is **off until you turn it on**. Open **Settings → Spot nameplates **SHA-256 fingerprint** shown and press **Trust this certificate**, which approves that one certificate for that host and port only. If it changes later you are asked again, with a warning. Approved certificates are listed below and can be withdrawn with **Forget**. +- **FreeDV Reporter** — the stations on the air right now on FreeDV Reporter, shown at the frequency each + one reports. You join **read-only**: you are not listed as a station, and nothing that identifies you is + sent. It is a presence list, so a plate stays while its station is connected and fades after it leaves. + The host and port are prefilled. - **POTA** — Parks on the Air activators currently on the air. POTA's public list is asked for every 60 seconds by default (set **Every (s)** between 30 and 3600); nothing but that request is sent. The park reference is shown in the hover text. Unlike the others this is a list fetched now and then, not a @@ -477,7 +481,7 @@ tunes to the spot under the pointer). Nothing here transmits. **Hover a plate** frequency and mode, which network reported it and who heard it, the signal report, and how long ago. Plates are coloured by where the spot came from — **amber** RBN, **blue** PSK Reporter, **lilac** DX -cluster, **green** POTA — and fade as they age, down to half strength at the age limit, so the freshest stand out. +cluster, **green** POTA, **coral** FreeDV Reporter — and fade as they age, down to half strength at the age limit, so the freshest stand out. To see the display without connecting anything, start the app with `--demo`: it shows sample spots. From c1506fbc9a622077feb58006d2763ade92c305ff Mon Sep 17 00:00:00 2001 From: Simon Keimer Date: Mon, 21 Sep 2026 17:20:59 +0200 Subject: [PATCH 3/3] fix(spot): count degraded FreeDV fields so the live probe cannot go blind The fix for the strict parser (a wrong type rejects an event; a well-typed value that is not kept degrades only that field) left the degrade path with no counter and no shape. A probe printing only `rejected` would then report 0 while every accented message was being cleared, which is the class of problem it had found. - Roster::degraded counts well-typed but unusable text fields (too long, or not printable ASCII); blank, null and absent are ordinary and are not counted, and a wrong type stays a rejection. - Value-free shapes are kept for degraded events as for rejected ones, and the live probe prints both. - The fifth live session then read 0 rejected and 3 degraded: three non-ASCII messages that had been dropped all along. - The docs said a nameplate cannot show non-ASCII text; it can. Keeping only printable ASCII is a deliberate policy for untrusted text, and now says so. - json tests pin each bound once and derive their fixtures from that one number. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ma4bQKqGNFTYyVSEKPcXRk --- crates/k4-spot/src/freedv.rs | 195 ++++++++++++++++++----- crates/k4-spot/src/freedv_source.rs | 11 ++ crates/k4-spot/src/json.rs | 32 ++-- crates/k4-spot/tests/freedv_live.rs | 8 +- docs/references/external-references.md | 13 +- docs/requirements/system-requirements.md | 4 +- docs/test/test-strategy.md | 2 +- 7 files changed, 208 insertions(+), 57 deletions(-) diff --git a/crates/k4-spot/src/freedv.rs b/crates/k4-spot/src/freedv.rs index 6d925c5..e46893f 100644 --- a/crates/k4-spot/src/freedv.rs +++ b/crates/k4-spot/src/freedv.rs @@ -62,6 +62,12 @@ pub struct Roster { pub rejected: u64, /// Stations not added because the roster was full. pub dropped: u64, + /// Text fields that were well-typed but unusable (too long, or not printable ASCII) and were + /// dropped while the rest of their event applied. Not an error, but counted: it is the class of + /// thing an over-strict parser used to reject whole. + pub degraded: u64, + /// As `rejected_shapes`, for degraded fields. + degraded_shapes: BTreeMap, /// For diagnosis: per event name, how many were rejected and the *shape* of the last one (its /// field names and the kinds of their values — never a value, so nothing an operator or a /// station typed can end up in a log). @@ -123,26 +129,29 @@ fn sid_of(args: &Value) -> Option { .map(str::to_string) } -/// A text field. `Err` only if it is present with the **wrong type** — that is a malformed event. -/// Absent, `null`, or a string that cannot be kept (longer than `max`, or not printable ASCII — -/// operators write free text with accents and emoji, which a nameplate cannot show) is `Ok(None)`: -/// the field degrades, the rest of the event still applies. (Found by looking at the real service: -/// rejecting the whole event for a non-ASCII message lost the station's other data.) -fn text_of(args: &Value, key: &str, max: usize) -> Result, ()> { - match args.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::Str(s)) => { - let t = s.trim(); - Ok( - (t.len() <= max && t.bytes().all(|b| (b' '..=b'~').contains(&b))) - .then(|| t.to_string()), - ) +impl Roster { + /// A text field. `Err` only if it is present with the **wrong type** — that is a malformed + /// event. Absent, `null` or blank is `Ok(None)`. A string that cannot be kept (longer than + /// `max`, or not printable ASCII) is also `Ok(None)` — the field degrades and the rest of the + /// event still applies — but it is **counted** in [`Roster::degraded`], so a probe cannot report + /// "nothing rejected" while dropping what operators wrote. (This client keeps only printable + /// ASCII by choice, as a policy for untrusted text; it is not a limit of the display.) + fn text(&mut self, args: &Value, key: &str, max: usize) -> Result, ()> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Str(s)) => { + let t = s.trim(); + if t.len() <= max && t.bytes().all(|b| (b' '..=b'~').contains(&b)) { + Ok(Some(t.to_string())) + } else { + self.degraded += 1; + Ok(None) + } + } + Some(_) => Err(()), } - Some(_) => Err(()), } -} -impl Roster { /// How many stations are known. pub fn len(&self) -> usize { self.by_sid.len() @@ -212,17 +221,24 @@ impl Roster { &self.rejected_shapes } + /// Per event name, how many had a field degraded and the shape of the last (diagnosis only). + pub fn degraded_shapes(&self) -> &BTreeMap { + &self.degraded_shapes + } + fn one(&mut self, name: &str, args: &Value) -> Vec { - let before = self.rejected; + let (rejected, degraded) = (self.rejected, self.degraded); let changed = self.apply(name, args); - // Only the six known events can be rejected, so this holds at most six names. - if self.rejected > before { - let entry = self - .rejected_shapes - .entry(name.to_string()) - .or_insert((0, String::new())); - entry.0 += self.rejected - before; - entry.1 = shape(args).chars().take(200).collect(); + // Only the six known events can be rejected or degraded, so each map holds at most six. + for (map, count) in [ + (&mut self.rejected_shapes, self.rejected - rejected), + (&mut self.degraded_shapes, self.degraded - degraded), + ] { + if count > 0 { + let entry = map.entry(name.to_string()).or_insert((0, String::new())); + entry.0 += count; + entry.1 = shape(args).chars().take(200).collect(); + } } changed } @@ -254,8 +270,8 @@ impl Roster { fn new_connection(&mut self, args: &Value) -> Vec { let (Some(sid), Ok(call), Ok(grid)) = ( sid_of(args), - text_of(args, "callsign", MAX_CALL), - text_of(args, "grid_square", MAX_GRID), + self.text(args, "callsign", MAX_CALL), + self.text(args, "grid_square", MAX_GRID), ) else { return self.reject(); }; @@ -273,8 +289,8 @@ impl Roster { let (Some(sid), Some(freq), Ok(call), Ok(grid)) = ( sid_of(args), args.get("freq").and_then(Value::as_u64), - text_of(args, "callsign", MAX_CALL), - text_of(args, "grid_square", MAX_GRID), + self.text(args, "callsign", MAX_CALL), + self.text(args, "grid_square", MAX_GRID), ) else { return self.reject(); }; @@ -302,9 +318,9 @@ impl Roster { let (Some(sid), Some(tx), Ok(mode), Ok(call), Ok(grid)) = ( sid_of(args), args.get("transmitting").and_then(Value::as_bool), - text_of(args, "mode", MAX_MODE), - text_of(args, "callsign", MAX_CALL), - text_of(args, "grid_square", MAX_GRID), + self.text(args, "mode", MAX_MODE), + self.text(args, "callsign", MAX_CALL), + self.text(args, "grid_square", MAX_GRID), ) else { return self.reject(); }; @@ -327,8 +343,8 @@ impl Roster { fn rx_report(&mut self, args: &Value) -> Vec { // Keyed by the *heard* station's callsign, not by session: `sid` here is the receiver. let (Ok(Some(heard)), Ok(Some(receiver)), Some(snr)) = ( - text_of(args, "callsign", MAX_CALL), - text_of(args, "receiver_callsign", MAX_CALL), + self.text(args, "callsign", MAX_CALL), + self.text(args, "receiver_callsign", MAX_CALL), args.get("snr").and_then(Value::as_f64), ) else { return self.reject(); @@ -355,11 +371,11 @@ impl Roster { fn message_update(&mut self, args: &Value) -> Vec { // The key must be there (an event without it is malformed); its value may be empty, `null` - // or text a nameplate cannot show, all of which clear the message. + // or text this client does not keep (printable ASCII only, by policy), all of which clear it. let (Some(sid), Some(_), Ok(message)) = ( sid_of(args), args.get("message"), - text_of(args, "message", MAX_MESSAGE), + self.text(args, "message", MAX_MESSAGE), ) else { return self.reject(); }; @@ -1073,6 +1089,111 @@ mod tests { assert_eq!(r.rejected, 0); } + /// FR-SPOT-08: a field that is well-typed but cannot be kept is **counted** as degraded, with a + /// value-free shape, so it stays visible: without this the probe that found the strict-parser + /// problem would report "0 rejected" while every accented message was being cleared. Blank, + /// `null` and absent are ordinary and are not counted; a wrong type is a rejection, not a + /// degrade. + /// trace: FR-SPOT-08 + #[test] + fn fr_spot_08_freedv_degraded_fields_are_counted() { + let mut r = Roster::default(); + // Ordinary: usable, blank, whitespace only, null, absent — none is degraded. + ev(&mut r, "message_update", r#"{"sid":"s","message":"CQ"}"#); + ev(&mut r, "message_update", r#"{"sid":"s","message":""}"#); + ev(&mut r, "message_update", r#"{"sid":"s","message":" "}"#); + ev(&mut r, "message_update", r#"{"sid":"s","message":null}"#); + ev( + &mut r, + "freq_change", + r#"{"sid":"s","freq":14236000,"callsign":"aa1aaa"}"#, + ); + assert_eq!((r.degraded, r.rejected), (0, 0)); + assert!(r.degraded_shapes().is_empty()); + + // One unusable field per event: a non-ASCII message, a long grid, a long mode. + ev( + &mut r, + "message_update", + "{\"sid\":\"s\",\"message\":\"caf\u{e9}\"}", + ); + assert_eq!(r.degraded, 1); + let long = "x".repeat(200); + ev( + &mut r, + "freq_change", + &format!(r#"{{"sid":"s","freq":14236000,"grid_square":"{long}"}}"#), + ); + assert_eq!(r.degraded, 2); + ev( + &mut r, + "tx_report", + &format!(r#"{{"sid":"s","transmitting":true,"mode":"{long}"}}"#), + ); + assert_eq!(r.degraded, 3); + // Two unusable fields in one event count twice, and the event still applies. + ev( + &mut r, + "freq_change", + &format!( + r#"{{"sid":"s","freq":7177000,"callsign":"F5{accent}","grid_square":"{long}"}}"#, + accent = "\u{e9}" + ), + ); + assert_eq!(r.degraded, 5); + assert_eq!(r.rejected, 0); + assert_eq!(r.spot("s", 1).unwrap().freq_hz, 7_177_000); + + // The shapes name the event and the kind of each field, never a value. + let shapes = r.degraded_shapes(); + assert_eq!( + shapes["message_update"], + ( + 1, + "message:str(len=5,non-ascii),sid:str(len=1,ascii)".to_string() + ) + ); + assert_eq!(shapes["tx_report"].0, 1); + assert_eq!( + shapes["freq_change"].0, 3, + "the long grid, then the event with two" + ); + assert!( + shapes["freq_change"] + .1 + .contains("callsign:str(len=4,non-ascii)"), + "{shapes:?}" + ); + assert!( + shapes["freq_change"] + .1 + .contains("grid_square:str(len=200,ascii)"), + "{shapes:?}" + ); + assert!(!format!("{shapes:?}").contains("xxxx"), "no value leaks"); + + // A wrong type is a rejection, not a degrade; the two counts stay apart. + let before = (r.degraded, r.rejected); + ev(&mut r, "message_update", r#"{"sid":"s","message":5}"#); + assert_eq!((r.degraded, r.rejected), (before.0, before.1 + 1)); + assert_eq!(r.rejected_shapes()["message_update"].0, 1); + assert_eq!( + r.degraded_shapes()["message_update"].0, + 1, + "the degrade map is untouched" + ); + + // Inside a bulk_update each item is counted on its own. + let mut r = Roster::default(); + ev( + &mut r, + "bulk_update", + "[[\"message_update\",{\"sid\":\"a\",\"message\":\"\u{e9}\"}],[\"message_update\",{\"sid\":\"b\",\"message\":\"\u{e9}\"}],[\"message_update\",{\"sid\":\"c\",\"message\":\"ok\"}]]", + ); + assert_eq!(r.degraded, 2); + assert_eq!(r.degraded_shapes()["message_update"].0, 2); + } + /// FR-SPOT-08: the roster is capped; a flood of invented session ids cannot grow it, and the /// stations already there keep working. /// trace: FR-SPOT-08 diff --git a/crates/k4-spot/src/freedv_source.rs b/crates/k4-spot/src/freedv_source.rs index db394a0..ebbb537 100644 --- a/crates/k4-spot/src/freedv_source.rs +++ b/crates/k4-spot/src/freedv_source.rs @@ -167,6 +167,17 @@ impl FreeDvSource { self.roster.rejected_shapes() } + /// Text fields dropped for being unusable (too long, or not printable ASCII) while the rest of + /// their event applied: not an error, but counted so it can be seen. + pub fn degraded(&self) -> u64 { + self.roster.degraded + } + + /// As [`rejected_shapes`](Self::rejected_shapes), for degraded fields. + pub fn degraded_shapes(&self) -> &std::collections::BTreeMap { + self.roster.degraded_shapes() + } + /// Stations on the roster now. pub fn stations(&self) -> usize { self.roster.len() diff --git a/crates/k4-spot/src/json.rs b/crates/k4-spot/src/json.rs index bff335d..d9b9a7f 100644 --- a/crates/k4-spot/src/json.rs +++ b/crates/k4-spot/src/json.rs @@ -349,6 +349,12 @@ pub fn parse(text: &str) -> Result { mod tests { use super::*; + // Each bound is pinned once, as a number (a test built from the constant under test would move + // with it and never notice a change); the fixtures are derived from these. + const DEPTH: usize = 8; + const NODES: usize = 20_000; + const STRING: usize = 4096; + /// FR-SPOT-08: real-shaped messages parse to the right tree, and the accessors read what they /// should — exactly, and no more. /// trace: FR-SPOT-08 @@ -462,14 +468,14 @@ mod tests { for (name, text) in &bad { assert!(parse(text).is_err(), "{name} must be refused: {text:?}"); } - // The limits are written as numbers, not taken from the constants: a test built from a constant - // moves with it and never notices a change. Depth: 8 levels are read, a ninth is not. + // The bounds first: pinned to the numbers above. Then depth: DEPTH levels are read, one more is not. + assert_eq!((MAX_DEPTH, MAX_NODES, MAX_STRING), (DEPTH, NODES, STRING)); let nest = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n)); - assert!(parse(&nest(8)).is_ok()); - assert!(parse(&nest(9)).is_err()); + assert!(parse(&nest(DEPTH)).is_ok()); + assert!(parse(&nest(DEPTH + 1)).is_err()); let objects = |n: usize| format!("{}1{}", r#"{"a":"#.repeat(n), "}".repeat(n)); - assert!(parse(&objects(8)).is_ok()); - assert!(parse(&objects(9)).is_err()); + assert!(parse(&objects(DEPTH)).is_ok()); + assert!(parse(&objects(DEPTH + 1)).is_err()); assert!( parse(&nest(100_000)).is_err(), "a deep bomb must not recurse without limit" @@ -477,16 +483,16 @@ mod tests { // Nodes: exactly MAX_NODES values are read; one more is not. let flat = |n: usize| format!("[{}]", vec!["0"; n - 1].join(",")); assert!( - parse(&flat(20_000)).is_ok(), + parse(&flat(NODES)).is_ok(), "the array and its elements make MAX_NODES" ); - assert!(parse(&flat(20_001)).is_err()); + assert!(parse(&flat(NODES + 1)).is_err()); // Strings: MAX_STRING bytes are read, one more is not — keys as well as values. let s = |n: usize| format!("\"{}\"", "a".repeat(n)); - assert!(parse(&s(4096)).is_ok()); - assert!(parse(&s(4097)).is_err()); - assert!(parse(&format!("{{{}:1}}", s(4097))).is_err()); - assert!(parse(&format!("{{{}:1}}", s(4096))).is_ok()); + assert!(parse(&s(STRING)).is_ok()); + assert!(parse(&s(STRING + 1)).is_err()); + assert!(parse(&format!("{{{}:1}}", s(STRING + 1))).is_err()); + assert!(parse(&format!("{{{}:1}}", s(STRING))).is_ok()); // Time is linear in the text: 256 KB of short strings is read at once, not in seconds. let big = format!("[{}]", vec!["\"abcdefghij\u{4e2d}\""; 15_000].join(",")); let t = std::time::Instant::now(); @@ -497,7 +503,7 @@ mod tests { t.elapsed() ); // Multi-byte characters count as their bytes. - let wide = format!("\"{}\"", "\u{4e2d}".repeat(1366)); + let wide = format!("\"{}\"", "\u{4e2d}".repeat(STRING / 3 + 1)); assert!(parse(&wide).is_err()); } } diff --git a/crates/k4-spot/tests/freedv_live.rs b/crates/k4-spot/tests/freedv_live.rs index 702aec5..cf08265 100644 --- a/crates/k4-spot/tests/freedv_live.rs +++ b/crates/k4-spot/tests/freedv_live.rs @@ -47,11 +47,12 @@ fn live_freedv_reporter() { joined_at ); println!( - "PROBE: stations={} spots_delivered={} distinct_frequencies={} rejected={} connects={}", + "PROBE: stations={} spots_delivered={} distinct_frequencies={} rejected={} degraded={} connects={}", src.stations(), spots, freqs.len(), st.rejected, + src.degraded(), st.connects ); if let (Some(lo), Some(hi)) = (freqs.first(), freqs.last()) { @@ -61,4 +62,9 @@ fn live_freedv_reporter() { for (event, (count, shape)) in src.rejected_shapes() { println!("PROBE: rejected {count} x {event}: {shape}"); } + // A field that could not be kept is not an error, but it is data the parser dropped: a probe + // that printed only `rejected` would say "0" while every accented message was being cleared. + for (event, (count, shape)) in src.degraded_shapes() { + println!("PROBE: degraded {count} x {event}: {shape}"); + } } diff --git a/docs/references/external-references.md b/docs/references/external-references.md index b11282b..59f84f0 100644 --- a/docs/references/external-references.md +++ b/docs/references/external-references.md @@ -568,7 +568,7 @@ done for RBN, at DC0SK's invitation ("how did SDRoxide solve the API issues"). ### FreeDV Reporter — observed live (2026-09-21) -**Four short sessions** to `ws://qso.freedv.org:80`, each **at most 10 seconds**, in the read-only +**Five short sessions** to `ws://qso.freedv.org:80`, each **at most 10 seconds**, in the read-only `view` role, sending only the WebSocket upgrade (with a `User-Agent` naming a probe), the `view` connect and the pongs the protocol requires. The probe is `crates/k4-spot/tests/freedv_live.rs` (`#[ignore]`, run by hand) and **prints counts and shapes only — never a callsign or any text an @@ -584,10 +584,17 @@ operator wrote**. Nothing here contains one. 1. **`freq_change` with `freq` = 0.** A station with no frequency set (or cleared) sends 0. That is a normal event meaning "no frequency", not a malformed one. 2. **`message_update` with non-ASCII text.** Operators write free text with accents and other - characters. A nameplate can only show printable ASCII, so such a message must read as "no + characters. This client keeps only printable ASCII in a spot's text — a **policy for untrusted + text**, not a limit of the display, which renders Unicode — so such a message must read as "no message" and must not cost the station the rest of its data. The rule now: a field of the **wrong type** rejects the event; a well-typed value that cannot be - displayed **degrades that one field**. After the change the same probe reported **0 rejected**. + kept **degrades that one field**. After the change the same probe reported **0 rejected**. +- **But "0 rejected" hid the degrading, and the probe was blind to it.** The degrade path first had no + counter, so a probe that printed only `rejected` would have said "0" while every accented message + was being cleared. A second counter and value-free shapes for degraded fields were added; the + **fifth session** then showed **0 rejected and 3 degraded** — three `message_update`s with + non-ASCII text of 51 bytes — which is what had been happening all along. A clean result exonerates + the parser only against the shapes the few seconds contained. - **Every event also carries `last_update`** (a 32-character timestamp string). It is **not used**: a nameplate's age counts from when the station was last confirmed on the roster (see the design note in `FR-SPOT-08`), and the timestamp's zone and precision were not examined. diff --git a/docs/requirements/system-requirements.md b/docs/requirements/system-requirements.md index cc4d120..83fd2dd 100644 --- a/docs/requirements/system-requirements.md +++ b/docs/requirements/system-requirements.md @@ -325,7 +325,7 @@ networks' own published interfaces (`R-EXT-05`) — both to be confirmed at desi | `FR-SPOT-05` | provide a **PSK Reporter** source over its **public live MQTT feed**: connect to the configured broker (default `mqtt.pskreporter.info:1883`, plain TCP, no login), subscribe to **exactly** the band topics (`pskr/filter/v2//#`) for the bands the VFO window overlaps — and to **nothing** when no band is wanted, never to the whole feed — move those subscriptions as the VFOs move, and deliver the spots that arrive, kept only if they lie within the window and rate-limited. Nothing identifying is sent (no credentials; a client id from the process id and clock). The connection is kept alive with pings and a silent broker is detected and reconnected; a refusal, a protocol error or a hostile message is reported or rejected, never trusted (message text is read by a strict flat scanner, over-size packets are refused before any allocation). **The transport was a decision (`OP-7`):** the live feed rather than the documented five-minute query API — real-time nameplates with server-side band scoping, at the cost of a hand-written MQTT client and of PSK Reporter documenting no rate or fair-use rules for the feed. | STK-21 | S | T/D | The MQTT encoders match hand-computed vectors from the MQTT 3.1.1 specification, the decoder reads split, batched and length-encoded packets and is bounded and rejects garbage (tests `fr_spot_05_mqtt_*`); a live-shaped message parses to the right spot and hostile or malformed ones are rejected whole (`fr_spot_05_pskreporter_payload_*`); frequencies map to the band tokens seen on the live feed and a window maps to its topics (`fr_spot_05_bands_and_topics`, `fr_spot_05_topics_follow_the_window`); against a scripted mock broker the source sends exactly a credential-free CONNECT and the one topic asked for, subscribes to nothing with no bands, follows a change of topics without touching the one that stayed, reconnects and resubscribes, pings a quiet connection and detects a dead one, sheds a flood, and survives a hostile broker (`fr_spot_05_source_*`); the worker subscribes to exactly the bands of the window and moves them (`fr_spot_05_worker_follows_the_window_with_band_subscriptions`); in the app against a mock broker (demo) the spots appear as nameplates and an unrequested out-of-window message is dropped. **A real broker's behaviour, and the unverified `60m`/`6m` band tokens, need a live run.** | | `FR-SPOT-06` | define **one network-neutral spot model** (callsign, frequency Hz, mode, time, source, optional SNR/spotter/comment) and a **source interface** every network implements, so a network is added without touching the overlay or the store; the store **de-duplicates** repeated reports of the same station on the same frequency (within a small tolerance) keeping the newest, is **bounded** in count, and normalises callsigns (upper-case, trimmed, restricted to the callsign character set — anything else is rejected, never displayed). | STK-21 | S | T | The store merges duplicates from two sources, keeps the newest, evicts oldest at its cap, and rejects a callsign holding control, whitespace-inside or bidi-override characters (tests `fr_spot_06_store_dedupe_bound_and_sanitise`, `fr_spot_06_store_is_bounded_oldest_out`, `fr_spot_06_callsign_normalised_or_rejected`, `fr_spot_06_text_and_spot_construction`, `fr_spot_06_sources_are_interchangeable_and_report_failure` in `k4-spot`). | | `FR-SPOT-07` | provide **telnet-cluster sources** — the **Reverse Beacon Network** and a user-configured **DX cluster** — connecting to the configured host/port, logging in with the operator's callsign, parsing spot lines into `Spot`s, and reconnecting with back-off after a drop. Line length and the number of spots accepted per second are **bounded**; a line that does not parse is skipped, never fatal. | STK-21 | S | T/D | The cluster-line parser turns documented sample lines (CW/RTTY/FT8 skimmer and human DX spots) into `Spot`s, skips garbage, truncates/rejects over-long lines, and a flood beyond the rate cap is shed without unbounded growth (tests `fr_spot_07_cluster_line_parse`, `fr_spot_07_bounds`); against a mock cluster the source connects, logs in and streams (demo). | -| `FR-SPOT-08` | **[Could]** provide further sources behind the same interface, each selectable and configurable per `FR-SPOT-04`: **POTA** and **FreeDV Reporter** (built), **SOTA** (not built — blocked on API-consumers membership, `OP-7`), and **WSPRnet** (**dropped by DC0SK, 2026-09-21**). **POTA** is a *polled* source: the public spot list at `api.pota.app/spot/activator` is asked for at a configurable interval (default 60 s, never below 30 s or above 1 h, kept in bounds by the settings and by the source), **off by default**, kept to spots near a VFO, coloured green. The request runs on its own short-lived thread so a slow server never delays the other sources, and is bounded: a timeout, a size cap that refuses rather than truncates, and no redirects; it is an anonymous GET carrying only the program name and version. A failure is reported against POTA with its reason, backs off with doubling, and is retried by itself. The reply is read by a strict scanner: a record that is not a valid spot is counted and dropped without costing the others, while anything that is not a list of flat records (nesting, bad text, over-size, trailing junk) is an error shown to the operator. **POTA documents no schema:** the format is from one observation of the live service (`R-EXT-05`). **FreeDV Reporter** is a *live* source over a **WebSocket** (Engine.IO 4 / Socket.IO 4), built from another client's public source and then **observed on the real service** (`R-EXT-05`): it joins in the **read-only `view` role**, so the operator is **not listed as a station and nothing identifying is sent** — the connect is `{"role":"view","protocol_version":2}` and nothing else, the request carries only the program name and version, and the only other thing sent is a pong to each ping (`FR-SPOT-12`). It connects over **plain `ws://`** (port 80), as the reference client does; `wss` was not tried. **Off by default**, host and port configurable, coloured **coral**. The service reports who is on the air *now* — a presence list, not timestamped reports — so a station's spot is stamped when its event arrives **and re-stamped every 30 s while the station stays on the roster**; one that leaves stops being refreshed and fades with age like any other spot (a design choice, DC0SK to overrule; the events also carry a `last_update` timestamp, unused). A `freq` of 0 means "no frequency" and removes the plate; a well-typed value that cannot be shown (a non-ASCII message, an over-long grid) degrades **that field only**, while a field of the wrong *type* rejects the event. Hostile input is bounded throughout: the WebSocket upgrade reply is checked in full (status, headers, and the `Sec-WebSocket-Accept` the key implies, so SHA-1 and base64 are here), no extension or sub-protocol is accepted, frames are refused if masked, reserved, oversize (256 KB), or fragmented control frames, and a message is UTF-8 checked; JSON is read by a strict parser bounded to 8 levels, 20 000 values and 4096-byte strings; the roster holds at most 4096 stations and a `bulk_update` at most 10 000 items, with a nested one refused; the ping timing the server announces is clamped to 1–120 s and drives a liveness check; and a connection that stalls at any step is given up on and retried with backoff. | STK-21 | C | T/D | POTA: a reply shaped like the live one parses to the right spots — the frequency taken from kilohertz to the hertz, the time read as UTC to the second against `date -u`, the park in the comment (`fr_spot_08_pota_reply_parses`, `fr_spot_08_frequency_and_time_arithmetic`, `fr_spot_08_optional_fields_and_time_cap`); hostile and malformed replies are refused and a bad record costs only itself (`fr_spot_08_pota_rejects_hostile_input`); the source asks once per interval, never twice at once, never blocks `poll`, backs off and recovers, abandons a hung request and does not deliver its late answer, filters by window and caps a reply (`fr_spot_08_polled_source_*`, `fr_spot_08_a_slow_request_does_not_block_poll`, `fr_spot_08_failures_back_off_and_recover`, `fr_spot_08_a_hung_request_is_abandoned`, `fr_spot_08_window_and_caps`, `fr_spot_08_interval_is_clamped`); the HTTP request is an anonymous GET, and an error status, an over-size body, a redirect and a silent server are each reported and bounded (`fr_spot_08_fetch_*`); the worker runs POTA into the store, reports its failure without stopping RBN, and does not spin a core (`fr_spot_08_worker_*`, `fr_spot_08_polled_status_wording`); the setting persists, defaults off, and stays in bounds (`fr_spot_04_networks_default_off_and_persist`). **Run once end to end** with the app pointed at a local stand-in (`K4_POTA_URL`): the request went out with the expected headers, the interval was honoured, and the spot appeared as a green nameplate while one outside the view was not drawn. **Not verified:** the real service over TLS, that `spotTime` is UTC, and what `expire` and `invalid` mean. The other three networks: see `OP-7`. | **FreeDV Reporter (built, and run four times against the real service for at most ten seconds each — 42–45 stations, 0 events rejected after the fix):** SHA-1, base64 and the accept key against FIPS 180-4, RFC 4648 and the RFC 6455 §1.3 worked example, with the padding-boundary lengths computed independently (`fr_spot_08_ws_hashes_match_published_vectors`); frames byte for byte against RFC 6455 §5.7, split at every byte (`fr_spot_08_ws_frames_match_the_rfc_examples`); a hostile server's masked, reserved, oversize and fragmented frames and invalid UTF-8 (`fr_spot_08_ws_reader_refuses_hostile_frames`); the upgrade reply checked in full and every prefix asking for more (`fr_spot_08_ws_response_is_checked_in_full`); header injection refused (`fr_spot_08_ws_request_is_well_formed_and_cannot_inject`); a strict bounded JSON parser (`fr_spot_08_json_*`); Socket.IO packets (`fr_spot_08_sio_*`) and the connect being view-only and anonymous (`fr_spot_12_the_connect_is_view_only_and_anonymous`); the roster, its bounds, the shapes seen on the real service, and a diagnostic that never contains a value (`fr_spot_08_freedv_*`); against a scripted server whose frames are built and read by hand, independently of the crate's own codec: the client joins as a viewer only and sends nothing else unprompted, answers Engine.IO and WebSocket pings, refuses a bad upgrade five ways, gives up on a silent server at each stage and on a quiet one by the ping timing it announced, reports a refusal or an end, survives hostile frames and garbage inside a running session, reconnects with a fresh roster, refreshes stations that stay and not those that leave, sheds a flood, ignores a server that speaks out of turn, backs off with a cap and starts over after a success, and bounds what one poll reads (`fr_spot_08_source_*`); the worker runs it into the store, reports its failure without stopping RBN, and switches it off (`fr_spot_08_worker_runs_freedv_and_isolates_its_failure`); the setting persists and defaults off even when a file names the network but not the switch (`fr_spot_04_networks_default_off_and_persist`); and every hand-off from the settings to the worker is carried (`fr_spot_08_freedv_settings_are_wired_end_to_end`). **Not verified:** `wss` (TLS), the meaning and zone of `last_update`, the nameplates on screen against the live feed, and whether the 30 s refresh is the right interval. +| `FR-SPOT-08` | **[Could]** provide further sources behind the same interface, each selectable and configurable per `FR-SPOT-04`: **POTA** and **FreeDV Reporter** (built), **SOTA** (not built — blocked on API-consumers membership, `OP-7`), and **WSPRnet** (**dropped by DC0SK, 2026-09-21**). **POTA** is a *polled* source: the public spot list at `api.pota.app/spot/activator` is asked for at a configurable interval (default 60 s, never below 30 s or above 1 h, kept in bounds by the settings and by the source), **off by default**, kept to spots near a VFO, coloured green. The request runs on its own short-lived thread so a slow server never delays the other sources, and is bounded: a timeout, a size cap that refuses rather than truncates, and no redirects; it is an anonymous GET carrying only the program name and version. A failure is reported against POTA with its reason, backs off with doubling, and is retried by itself. The reply is read by a strict scanner: a record that is not a valid spot is counted and dropped without costing the others, while anything that is not a list of flat records (nesting, bad text, over-size, trailing junk) is an error shown to the operator. **POTA documents no schema:** the format is from one observation of the live service (`R-EXT-05`). **FreeDV Reporter** is a *live* source over a **WebSocket** (Engine.IO 4 / Socket.IO 4), built from another client's public source and then **observed on the real service** (`R-EXT-05`): it joins in the **read-only `view` role**, so the operator is **not listed as a station and nothing identifying is sent** — the connect is `{"role":"view","protocol_version":2}` and nothing else, the request carries only the program name and version, and the only other thing sent is a pong to each ping (`FR-SPOT-12`). It connects over **plain `ws://`** (port 80), as the reference client does; `wss` was not tried. **Off by default**, host and port configurable, coloured **coral**. The service reports who is on the air *now* — a presence list, not timestamped reports — so a station's spot is stamped when its event arrives **and re-stamped every 30 s while the station stays on the roster**; one that leaves stops being refreshed and fades with age like any other spot (a design choice, DC0SK to overrule; the events also carry a `last_update` timestamp, unused). A `freq` of 0 means "no frequency" and removes the plate; a well-typed value this client does not keep (a non-ASCII message — a policy for untrusted text, not a limit of the display — or an over-long grid) degrades **that field only**, and is **counted** apart from rejections so it stays visible, while a field of the wrong *type* rejects the event. Hostile input is bounded throughout: the WebSocket upgrade reply is checked in full (status, headers, and the `Sec-WebSocket-Accept` the key implies, so SHA-1 and base64 are here), no extension or sub-protocol is accepted, frames are refused if masked, reserved, oversize (256 KB), or fragmented control frames, and a message is UTF-8 checked; JSON is read by a strict parser bounded to 8 levels, 20 000 values and 4096-byte strings; the roster holds at most 4096 stations and a `bulk_update` at most 10 000 items, with a nested one refused; the ping timing the server announces is clamped to 1–120 s and drives a liveness check; and a connection that stalls at any step is given up on and retried with backoff. | STK-21 | C | T/D | POTA: a reply shaped like the live one parses to the right spots — the frequency taken from kilohertz to the hertz, the time read as UTC to the second against `date -u`, the park in the comment (`fr_spot_08_pota_reply_parses`, `fr_spot_08_frequency_and_time_arithmetic`, `fr_spot_08_optional_fields_and_time_cap`); hostile and malformed replies are refused and a bad record costs only itself (`fr_spot_08_pota_rejects_hostile_input`); the source asks once per interval, never twice at once, never blocks `poll`, backs off and recovers, abandons a hung request and does not deliver its late answer, filters by window and caps a reply (`fr_spot_08_polled_source_*`, `fr_spot_08_a_slow_request_does_not_block_poll`, `fr_spot_08_failures_back_off_and_recover`, `fr_spot_08_a_hung_request_is_abandoned`, `fr_spot_08_window_and_caps`, `fr_spot_08_interval_is_clamped`); the HTTP request is an anonymous GET, and an error status, an over-size body, a redirect and a silent server are each reported and bounded (`fr_spot_08_fetch_*`); the worker runs POTA into the store, reports its failure without stopping RBN, and does not spin a core (`fr_spot_08_worker_*`, `fr_spot_08_polled_status_wording`); the setting persists, defaults off, and stays in bounds (`fr_spot_04_networks_default_off_and_persist`). **Run once end to end** with the app pointed at a local stand-in (`K4_POTA_URL`): the request went out with the expected headers, the interval was honoured, and the spot appeared as a green nameplate while one outside the view was not drawn. **Not verified:** the real service over TLS, that `spotTime` is UTC, and what `expire` and `invalid` mean. The other three networks: see `OP-7`. | **FreeDV Reporter (built, and run five times against the real service for at most ten seconds each — 39–45 stations, 0 events rejected after the fix and, in the last, 3 messages degraded):** SHA-1, base64 and the accept key against FIPS 180-4, RFC 4648 and the RFC 6455 §1.3 worked example, with the padding-boundary lengths computed independently (`fr_spot_08_ws_hashes_match_published_vectors`); frames byte for byte against RFC 6455 §5.7, split at every byte (`fr_spot_08_ws_frames_match_the_rfc_examples`); a hostile server's masked, reserved, oversize and fragmented frames and invalid UTF-8 (`fr_spot_08_ws_reader_refuses_hostile_frames`); the upgrade reply checked in full and every prefix asking for more (`fr_spot_08_ws_response_is_checked_in_full`); header injection refused (`fr_spot_08_ws_request_is_well_formed_and_cannot_inject`); a strict bounded JSON parser (`fr_spot_08_json_*`); Socket.IO packets (`fr_spot_08_sio_*`) and the connect being view-only and anonymous (`fr_spot_12_the_connect_is_view_only_and_anonymous`); the roster, its bounds, the shapes seen on the real service, and a diagnostic that never contains a value (`fr_spot_08_freedv_*`); against a scripted server whose frames are built and read by hand, independently of the crate's own codec: the client joins as a viewer only and sends nothing else unprompted, answers Engine.IO and WebSocket pings, refuses a bad upgrade five ways, gives up on a silent server at each stage and on a quiet one by the ping timing it announced, reports a refusal or an end, survives hostile frames and garbage inside a running session, reconnects with a fresh roster, refreshes stations that stay and not those that leave, sheds a flood, ignores a server that speaks out of turn, backs off with a cap and starts over after a success, and bounds what one poll reads (`fr_spot_08_source_*`); the worker runs it into the store, reports its failure without stopping RBN, and switches it off (`fr_spot_08_worker_runs_freedv_and_isolates_its_failure`); the setting persists and defaults off even when a file names the network but not the switch (`fr_spot_04_networks_default_off_and_persist`); and every hand-off from the settings to the worker is carried (`fr_spot_08_freedv_settings_are_wired_end_to_end`). **Not verified:** `wss` (TLS), the meaning and zone of `last_update`, the nameplates on screen against the live feed, and whether the 30 s refresh is the right interval. | `FR-SPOT-09` | run every spot source **off the UI and radio-control paths**: sources own their own I/O threads and publish a snapshot the UI copies on its tick, so a slow, blocked or failing network can never delay CAT, audio or rendering. A source error (refused, timeout, malformed feed, rate-limited) is **surfaced** in the Settings entry for that network — not silent — and does not disable the other sources. | STK-21/11 | S | T/D | With one source blocked or erroring against a mock, the UI tick and the other sources are unaffected and the failing network reports its reason (test `fr_spot_09_source_failure_isolated`); the shared-snapshot model matches `FR-UI-07`. | | `FR-SPOT-10` | let the operator **click a nameplate to tune** the VFO to **that spot's own frequency** — not to wherever on the plate the pointer was — through the same path as a panadapter click (`FR-PAN-04`), and show a **tooltip** on hover with the spot's callsign, frequency, mode, source, spotter, SNR and age, placed beside the pointer and never cut off by the pane. A click anywhere else still tunes to the pointer's position. Tuning never keys the transmitter. The tune lands the VFO on the spot's **reported frequency**; whether a network reports the dial or the signal frequency (a CW pitch offset) is `OP-7` item 4, unresolved. | STK-21 | C | T/D | `hit_test` finds the plate under the pointer by the index it was given, with left/top edges inside and right/bottom outside, and misses gaps, lane separators and the empty pane (test `fr_spot_10_hit_test_finds_the_plate_under_the_pointer`); driving the real `Spectrum` program with synthetic mouse events, a click on a plate returns the tune message for the spot's exact frequency, a click beside it the pointer's position, and an aged-out or out-of-view spot cannot be clicked (`fr_spot_10_click_on_a_nameplate_tunes_to_the_spot`); a right click, a click outside the pane and a pane with no known span do nothing (`fr_spot_10_only_a_left_click_inside_the_pane_tunes`); the tooltip text, its omission of what a network did not say, the age wording at each boundary and its placement inside the pane at every pointer position (`fr_spot_10_tooltip_*`). **The hover tooltip itself was not seen on screen** (that needs a pointer); click and hover need a run at the radio. | | `FR-SPOT-11` | **colour-code** nameplates by source and **fade** them with age: amber for RBN, sky blue for PSK Reporter, lilac for a DX cluster, each fully opaque when fresh and falling in a straight line to half strength at the age limit (never rising as a spot ages). The colours differ in hue and lightness and stay legible on the plate at full strength (contrast at least 7:1) and at the faintest (at least 3:1), against the fixed dark spectrum background the canvas paints in every theme (`FR-UI-17`). **The originally-specified "show only spots on the current band" filter is dropped:** the overlay draws only spots inside the visible span, at most 368 kHz, which is always within one band, so such a filter could never hide anything. | STK-21 | C | T/D | The fade is 1.0 at age 0, monotonic, linear, floored at the limit and beyond it, and absent for a zero limit (test `fr_spot_11_age_fade_is_monotonic_and_floored`); the three source colours are pairwise well apart and each meets the contrast bounds at both ends of the fade, checked with WCAG relative-luminance arithmetic that is itself held to the standard's fixed values (`fr_spot_11_source_colours_are_distinct_and_legible`, `fr_spot_11_contrast_arithmetic_matches_wcag`); on screen (`--demo`) the three colours and the dimming of older spots are visible. **Legibility was computed, not judged by eye across themes** — only the dark theme's screen was captured. | @@ -421,7 +421,7 @@ networks' own published interfaces (`R-EXT-05`) — both to be confirmed at desi | 2026-07-25 | 0.48 | DC0SK | Added FR-FM-02 as a **DTMF keypad** (`DM`) — the part of the gap-analysis item that is both buildable and useful remotely: sending DTMF for repeater/link control cannot be done any other way over the link. A 4×4 popup opened from the FM panel, one `DM;` per key. **Scoped down from the gap analysis on purpose:** the '6 stored DTMF sequences' are config work deferred to a follow-up, and the **1750 Hz tone burst has no documented CAT command** in D12 (searched), so it is not buildable now rather than guessed at — the `RO`/`RA` lesson. `send_dtmf` refuses a non-DTMF character rather than emitting a malformed `DM`. | | 2026-07-25 | 0.49 | DC0SK | Added FR-XVTR-01 (transverter band setup), the last substantial backlog item — complex and niche (transverter operators), but fully documented so buildable without hardware-guessing. Six `XV*` encoders (`XVN`/`XVM`/`XVR`/`XVI`/`XVO`/`XVP`), a read-back parser for each field, and a setup form on the BAND screen. The design turns on `XVN` being **stateful** — it selects the band the other commands target — so every field send is prefixed with `XVN`, and the form re-reads all fields when a band is picked, keyed on the `XVN` the radio confirms so a stale value never lands. The form outgrew the fixed-height config-screen slot and clipped; fixed by compacting it to three rows and wrapping the BAND screen in a scrollable. Deferred, and said so: the **mW power scale on XVTR bands** (showing mW instead of W when operating on a configured transverter band) — it needs the current-band-is-XVTR state wired through, and is an operating-display concern separate from this setup form. | | 2026-07-26 | 0.50 | DC0SK | Added FR-UI-UPD-02 (automatic update check + top-area notification), requested by DC0SK; **recorded, not yet implemented**. It is a deliberate, operator-chosen relaxation of FR-UI-UPD-01, which made the update check *manual-only* on the reasoning that "a radio-control app should not make unannounced outbound connections, and a remote station may be on a metered link." The automatic check is therefore constrained to bound that cost: default-on but **opt-out in Settings**, **once per start** rather than on a timer, and **silent** unless it finds a substantiated newer release — so a metered link sees at most one small request per launch, and only a real update ever draws attention. The notification lives in the top status area beside the connection indicator (not a modal), as a clickable link to the release page, reusing FR-UI-UPD-01's numeric, never-spurious comparison. Also fixed a stale/duplicated `version` block in this document's YAML frontmatter (a merge artifact: two `version:` keys) — set to 0.50 / 2026-07-26. | -| 2026-09-21 | 0.68 | DC0SK | Built **FreeDV Reporter** (`FR-SPOT-08`) at DC0SK's direction, and **dropped WSPRnet**. Needed a WebSocket client, a JSON parser and Engine.IO/Socket.IO framing, all hand-written, dependency-free and bounded like the MQTT client. **Format from another client's public source, then checked live:** four sessions of at most ten seconds each on the real service (read-only `view` role, counts and shapes printed, no callsign or text) found **10–12 events per session rejected** — the first parser was too strict about two normal things (`freq` 0, meaning "no frequency"; and non-ASCII message text) — after which **0 were rejected**. **Findings beyond this feature (in the ledger):** nearly every bound in the new modules, and in the POTA parser from the earlier change, was **unpinned** — the tests built their boundary inputs from the constants they were testing, so a constant could change by one and nothing noticed. Fixed for both; the POTA fix is also applied to that branch. **Not done:** `wss`, SOTA. | +| 2026-09-21 | 0.68 | DC0SK | Built **FreeDV Reporter** (`FR-SPOT-08`) at DC0SK's direction, and **dropped WSPRnet**. Needed a WebSocket client, a JSON parser and Engine.IO/Socket.IO framing, all hand-written, dependency-free and bounded like the MQTT client. **Format from another client's public source, then checked live:** five sessions of at most ten seconds each on the real service (read-only `view` role, counts and shapes printed, no callsign or text) found **10–12 events per session rejected** — the first parser was too strict about two normal things (`freq` 0, meaning "no frequency"; and non-ASCII message text) — after which **0 were rejected**. **Findings beyond this feature (in the ledger):** nearly every bound in the new modules, and in the POTA parser from the earlier change, was **unpinned** — the tests built their boundary inputs from the constants they were testing, so a constant could change by one and nothing noticed. Fixed for both; the POTA fix is also applied to that branch. **Not done:** `wss`, SOTA. | | 2026-09-21 | 0.67 | DC0SK | Built **FR-PAN-14 (spectrum decay / afterglow)**, the last of the four items DC0SK asked for. **Design choices (mine, for DC0SK to overrule):** decay is a constant dB per second — an exponential decay of *power* with time constant τ, the standard analyser peak-decay — with an instant attack; the setting is one number in milliseconds, **off by default** so nothing changes until chosen; a ghost outline and faint fill beside the live trace rather than replacing it; and it is advanced **per row on arrival** in the shared pan history, deliberately not per drawn frame, because `FR-PAN-13` now redraws at the row rate and the look must not depend on it. **Built:** `app/src/afterglow.rs` (pure), `PanShared` feeding one per receiver, `trace_points` shared by both traces, `spectrum_afterglow_ms` in the settings with a field in Settings. **Bugs the tests found:** the "off" state was enforced in three places, each masking the others (one deleted); nothing checked that `Afterglow::set_ms` clamps; and an early version of my drawing guard matched the helper it was guarding. **Also fixed:** the Networks summary said "of 3 on" after POTA made four. **Not seen on screen** — the display was asleep. | | 2026-09-21 | 0.66 | DC0SK | **GPU load reduction: `FR-PAN-13` amended from the display rate to the row rate.** DC0SK reported the integrated GPU ~40 % busy while data flows. The cause is in the code: while rows arrive, every frame asked for the next vsync, so a ~30 rows/s stream redrew the whole window at the panel's rate (60 Hz, more on a fast panel) although a frame between two rows shows nothing new. **Now** each redraw asks for the next one a smoothed row interval ahead, kept between 8 and 100 ms. **Requirement changed**, with its cost stated (a row can appear up to one row interval later than it would have on the vsync chain). **Built:** `RedrawState::next_frame_at`, `RedrawRequest::At`, an opt-in `K4_FPS=1` frame-rate line. **Not measured on screen:** the display was asleep (`eDP-1` `dpms=Off`), so no window was presented and a first attempt at a baseline recorded 0–1 frames a second, which measures nothing and was discarded; the GPU comparison is parked with DC0SK. **Tooling note:** an XWayland window (about 1 frame a second) and a native Wayland one (none) both showed no real frames while the display was off, and the display state was read only afterwards, so the two could not be told apart; whether XWayland windows are presented on this compositor with the display on was **not tested** (earlier sessions captured XWayland windows fine). A measurement needs a lit display, and the display state should be checked *first*. **Not done:** cheaper frames (the whole window is still redrawn each frame), a user-set frame cap. | | 2026-09-21 | 0.65 | DC0SK | Built **FR-SPOT-13 (TLS for PSK Reporter, with manual approval of an untrusted certificate)**. **Observed first:** one TLS handshake with `mqtt.pskreporter.info:1884` (nothing sent after it) showed its certificate is **trusted by the public authorities**, so on the real service the approval path is a fallback (a self-hosted broker, a filtering proxy), not the normal case. **Design (mine, for DC0SK to overrule):** pin the exact certificate by SHA-256 per host and port, show the fingerprint and the reason before anything is sent, never accept a changed certificate silently, and still require proof of the private key. **Built:** `k4-spot::mqtt_source` gains a `Connector` (plain TCP by default), a structured `CertInfo`/`ConnectError::Untrusted`, `retry_now` and `pending_cert`; `app/src/tls` (rustls with a verifier that checks normally first, then consults the approvals; the signature is always verified); `TrustedCert` and `PskReporterPrefs.tls` in the settings; a TLS switch, an approval prompt and an approved-certificate list in Networks. **Bugs the tests found:** `openssl req -x509` marks a self-signed certificate as an authority, so rustls refuses it as `CaUsedAsEndEntity` before looking at its issuer — the most common real self-signed case would have shown as "not acceptable"; my structural wiring guard matched its own text and passed with the real line deleted; two config tests never exercised the checks they claimed to. **Not done:** TLS for RBN/DX cluster and POTA-style approval; subject, issuer and dates in the prompt; the prompt seen on screen. | diff --git a/docs/test/test-strategy.md b/docs/test/test-strategy.md index 5cf7ec2..15fc7f3 100644 --- a/docs/test/test-strategy.md +++ b/docs/test/test-strategy.md @@ -437,7 +437,7 @@ FR-SES-MULTI, FR-DIAG-02, etc. — get `TC` IDs when promoted to `Approved`.)* | 2026-07-25 | 4.0 | DC0SK | Release **v0.8.0**. Minor: six backlog features and two operating fixes since 0.7.0, nearly all validated on DC0SK's live K4. Added: VFO lock read-back + tuning refusal, DATA rate select, `ACN` antenna names, on-screen macros (Fn → MACROS, reusing the K-Pod table), a DTMF keypad, and transverter band setup (two-column BAND screen). Fixed: TX TEST now flashes distinct from a real transmit (finishing FR-TX-TUNE-01's flashing indication), and DATA sub-mode/rate switching lag — the same read-back fight the sliders had, fixed with the standing optimistic-override pattern. **What is left is now honestly the hard part:** the audio-character (`MX`/`BL`/`FX`/`AL`) and message (`DARM`) items are blocked on two hardware questions only the operator can answer — whether radio-side audio settings reach the remote stream, and whose microphone `DARM` records. Version bumped in Cargo.toml (workspace), lockfile, README, user manual. 326 tests. | | 2026-07-26 | 4.1 | DC0SK | **FR-UI-UPD-02 implemented** — automatic update check + top-area notification. Default-on preference (opt-out in Settings), one start-up check off the UI thread reusing `update::check_now` (so the whole never-spurious comparison from FR-UI-UPD-01 comes for free), and a clickable `● update ` link beside the connection indicator, shown only for an `Available` result. The metered-link caution FR-UI-UPD-01 was written around is met by construction, not overridden: one request per launch, opt-out, silent unless there is a real update. Tested at the config layer (default-on + persistence) and verified on screen — including watching the start-up check overwrite a seeded status, which confirmed it runs. 327 tests. | | 2026-07-27 | 4.2 | DC0SK | Release **v0.9.0**. Minor: the addition since 0.8.0 is FR-UI-UPD-02, the automatic update check (opt-out, once per start, silent unless a real update is found, clickable link in the top status area). Numbered **0.9.0, not 0.8.1** — a new feature is a minor bump under semver, and the changelog files it under Added. It was briefly tagged v0.8.1 by mistake; the tag and its in-flight release build were removed before any release artifact published, and it was retagged. Version bumped in Cargo.toml (workspace), lockfile, README, user manual. 327 tests. | -| 2026-09-21 | 4.19 | DC0SK | **FR-SPOT-08: FreeDV Reporter as a live WebSocket source.** New in `k4-spot`: `json` (a strict, bounded parser), `ws` (SHA-1, base64, the upgrade, frames), `sio` (Engine.IO/Socket.IO), `freedv` (the roster), `freedv_source`; a fifth worker slot, `FreeDvPrefs`, a Networks section, coral nameplates. **L1:** `fr_spot_08_json_parses_and_reads`, `..._json_refuses_what_it_would_repair_and_is_bounded` (38 malformed inputs; every bound at the boundary; linear time on 256 KB); `fr_spot_08_ws_*` (SHA-1 against FIPS 180-4 and independently computed padding-boundary lengths, base64 against RFC 4648, the accept key against RFC 6455 §1.3, frames byte for byte against RFC 6455 §5.7, split at every byte, hostile frames, the upgrade reply checked in full); `fr_spot_08_sio_*` and `fr_spot_12_the_connect_is_view_only_and_anonymous`; `fr_spot_08_freedv_*` (events, `bulk_update`, hostile events, the bounds, the shapes seen on the real service, the diagnostic, stored text at its limits). **L2 (a scripted server whose frames are built and read by hand):** `fr_spot_08_source_*` ×12 (view-only join and the exact bytes sent, pings, five bad upgrades, silence at every stage, a refusal and an end, a hostile server, reconnect with a fresh roster, refresh of stations that stay, a flood, out-of-turn packets, backoff with cap and reset, the per-poll byte cap), `fr_spot_08_worker_runs_freedv_and_isolates_its_failure`, `fr_spot_08_freedv_settings_are_wired_end_to_end` (structural, whitespace-insensitive, reading only the code above its module). **Live, four times, at most ten seconds each, not part of the suite:** `freedv_live` (ignored) — see below. **Sabotage: more than 250 mutations** (a floor, not a count — several rounds were repeated after fixes). **What they found, beyond this feature:** (1) **Nearly every bound was unpinned.** The tests built their boundary inputs from the constants they tested (`nest(MAX_DEPTH + 1)`), so `MAX_DEPTH`, `MAX_NODES`, `MAX_STRING`, `MAX_HEADERS`, `MAX_HEADER_LINES`, the event-name and session-id limits, the ping limits, `MAX_STATIONS` and `MAX_BULK` could each change by one with nothing failing — 22 survivors, after the *checks* themselves had all been mutation-tested. The same was true of the **POTA parser of the earlier change** (`MAX_BODY`, `MAX_SPOTS`, the 300 GHz ceiling, the default interval — six more). All are now written as numbers; the POTA fix is its own commit. One is an **equivalent mutation** (`MAX_TOKEN`: the same 64 is applied again downstream). (2) **The real service disagreed with my parser.** The first build rejected 10–12 events per session as malformed; two cases, both my strictness, not the server's: `freq` 0 (meaning no frequency) and non-ASCII message text. Hand-written test data had never contained either. The rule that came out of it — a wrong *type* rejects the event, a well-typed value that cannot be shown degrades that one field — is now tested (`fr_spot_08_freedv_real_service_shapes`); the same probe then reported **0 rejected**. (3) **Vectors written from memory were wrong** — the SHA-1 of 65 `a` bytes differed in its last hex digit; replaced by values computed with `hashlib`, and the implementation agrees with them. (4) **Redundant guards that masked each other under mutation** were deleted rather than papered over: a leading-zero check (the grammar already catches it), a general frame-size check (the 64-bit branch does all the work), a `freq == 0` guard in `spot()` (`Spot::new` refuses it), a `live` check before the refresh (the roster is empty until live), duplicate shed counting, and a name cap that could never be reached. (5) **A performance flaw found by reading:** the JSON string reader re-validated the whole remaining input as UTF-8 per character (quadratic); now linear, with a test. (6) **A backoff test with a wrong premise:** the retry is scheduled from when its poll *began*, up to a read timeout before the close was noticed, so tight lower bounds failed on every run; the test now uses a scale where that offset is small. (7) **A clippy lint on my SHA-1** (`chunks_exact`) blocked the commit hook, correctly. **Process trap, again:** several edits silently did nothing because rustfmt had reflowed the text they targeted; each was caught only by re-running the mutation, so every such edit is now checked with a grep. **Not covered:** `wss`; the meaning and zone of `last_update`; the nameplates on screen. | +| 2026-09-21 | 4.19 | DC0SK | **FR-SPOT-08: FreeDV Reporter as a live WebSocket source.** New in `k4-spot`: `json` (a strict, bounded parser), `ws` (SHA-1, base64, the upgrade, frames), `sio` (Engine.IO/Socket.IO), `freedv` (the roster), `freedv_source`; a fifth worker slot, `FreeDvPrefs`, a Networks section, coral nameplates. **L1:** `fr_spot_08_json_parses_and_reads`, `..._json_refuses_what_it_would_repair_and_is_bounded` (38 malformed inputs; every bound at the boundary; linear time on 256 KB); `fr_spot_08_ws_*` (SHA-1 against FIPS 180-4 and independently computed padding-boundary lengths, base64 against RFC 4648, the accept key against RFC 6455 §1.3, frames byte for byte against RFC 6455 §5.7, split at every byte, hostile frames, the upgrade reply checked in full); `fr_spot_08_sio_*` and `fr_spot_12_the_connect_is_view_only_and_anonymous`; `fr_spot_08_freedv_*` (events, `bulk_update`, hostile events, the bounds, the shapes seen on the real service, the diagnostic, stored text at its limits). **L2 (a scripted server whose frames are built and read by hand):** `fr_spot_08_source_*` ×12 (view-only join and the exact bytes sent, pings, five bad upgrades, silence at every stage, a refusal and an end, a hostile server, reconnect with a fresh roster, refresh of stations that stay, a flood, out-of-turn packets, backoff with cap and reset, the per-poll byte cap), `fr_spot_08_worker_runs_freedv_and_isolates_its_failure`, `fr_spot_08_freedv_settings_are_wired_end_to_end` (structural, whitespace-insensitive, reading only the code above its module). **Live, five times, at most ten seconds each, not part of the suite:** `freedv_live` (ignored) — see below. **Sabotage: more than 250 mutations** (a floor, not a count — several rounds were repeated after fixes). **What they found, beyond this feature:** (1) **Nearly every bound was unpinned.** The tests built their boundary inputs from the constants they tested (`nest(MAX_DEPTH + 1)`), so `MAX_DEPTH`, `MAX_NODES`, `MAX_STRING`, `MAX_HEADERS`, `MAX_HEADER_LINES`, the event-name and session-id limits, the ping limits, `MAX_STATIONS` and `MAX_BULK` could each change by one with nothing failing — 22 survivors, after the *checks* themselves had all been mutation-tested. The same was true of the **POTA parser of the earlier change** (`MAX_BODY`, `MAX_SPOTS`, the 300 GHz ceiling, the default interval — six more). All are now written as numbers; the POTA fix is its own commit. One is an **equivalent mutation** (`MAX_TOKEN`: the same 64 is applied again downstream). (2) **The real service disagreed with my parser.** The first build rejected 10–12 events per session as malformed; two cases, both my strictness, not the server's: `freq` 0 (meaning no frequency) and non-ASCII message text. Hand-written test data had never contained either. The rule that came out of it — a wrong *type* rejects the event, a well-typed value that is not kept degrades that one field — is now tested (`fr_spot_08_freedv_real_service_shapes`); the same probe then reported **0 rejected**. (3) **Vectors written from memory were wrong** — the SHA-1 of 65 `a` bytes differed in its last hex digit; replaced by values computed with `hashlib`, and the implementation agrees with them. (4) **Redundant guards that masked each other under mutation** were deleted rather than papered over: a leading-zero check (the grammar already catches it), a general frame-size check (the 64-bit branch does all the work), a `freq == 0` guard in `spot()` (`Spot::new` refuses it), a `live` check before the refresh (the roster is empty until live), duplicate shed counting, and a name cap that could never be reached. (5) **A performance flaw found by reading:** the JSON string reader re-validated the whole remaining input as UTF-8 per character (quadratic); now linear, with a test. (6) **A backoff test with a wrong premise:** the retry is scheduled from when its poll *began*, up to a read timeout before the close was noticed, so tight lower bounds failed on every run; the test now uses a scale where that offset is small. (7) **A clippy lint on my SHA-1** (`chunks_exact`) blocked the commit hook, correctly. (8) **The fix for (2) blinded the probe to the class it had found** — a review caught it: rejecting only wrong *types* left the degrade path with no counter and no shape, so the probe would report "0 rejected" while every accented message was cleared. It now counts and shapes degraded fields separately (`fr_spot_08_freedv_degraded_fields_are_counted`, with blank/null/absent not counted and a wrong type not counted as a degrade), and the fifth live session read **0 rejected, 3 degraded** — three non-ASCII messages that had been dropped all along. Also corrected: the docs said a nameplate *cannot* show non-ASCII text; it can — keeping only printable ASCII is a deliberate policy for untrusted text. **Process trap, again:** several edits silently did nothing because rustfmt had reflowed the text they targeted; each was caught only by re-running the mutation, so every such edit is now checked with a grep. **Not covered:** `wss`; the meaning and zone of `last_update`; the nameplates on screen. | | 2026-09-21 | 4.18 | DC0SK | **FR-PAN-14: spectrum decay / afterglow.** New `app/src/afterglow.rs`, `PanShared` feeding it, `trace_points` in `spectrum.rs`, `spectrum_afterglow_ms` in `k4-config`, and a Settings field. **L1 (maths, against closed-form values, not the code's own output):** `fr_pan_14_fall_is_exponential_power_decay` (4.3429 dB per τ; the implied power ratio is 1/e), `..._the_ghost_attacks_at_once_and_decays_at_the_set_rate` (−50 dB peak after 990 ms is −54.30 ± 0.02), `..._time_constant_and_row_rate` (the same second as 30 rows or 4 falls the same; a 60 s stall is a 5 s step; out-of-range settings clamp — measured by fall), `..._the_ghost_follows_the_pan_and_the_setting`, `..._bad_input_cannot_poison_the_ghost`, `..._the_history_feeds_the_afterglow_per_receiver`, `..._ghost_and_trace_share_their_geometry`. **L1 (config):** `fr_pan_14_afterglow_setting_persists_and_is_bounded`. **Structural:** `..._the_ghost_is_drawn_under_the_live_trace` and `..._the_setting_is_wired_end_to_end` (four hand-offs, one of which — the save — ends in `..Default::default()` and would silently reset a forgotten field), both reading only the code above their own module so they cannot match themselves. **Sabotage:** 32 mutations in the first round, 3 more after the fixes. **Real survivors:** the "off" state enforced three times over so each masked the others (one deleted); `Afterglow::set_ms` clamp untested (a test measuring the fall for 1 ms and for 99 999 ms now covers it); one **equivalent mutation** — the draw-time width check guards a race between two lock acquisitions that no test can reach without a renderer, and is commented as such. **Not covered:** how it looks on screen — **the display was asleep**. | | 2026-09-21 | 4.17 | DC0SK | **FR-PAN-13 amended: redraw at the row rate, not the display's (GPU load reduction).** `RedrawState` now answers *when* the next frame is due, not *whether* one is wanted. **L1:** `fr_pan_13_redraw_chain_follows_the_row_stream` (rewritten for the new signature; same behaviours), `fr_pan_13_frames_follow_the_row_rate_not_the_display` (a simulated display: **30 rows/s → 30.3 frames/s**, 20 → 19.7, 25 → 24.7, 62.5 → 61.8, 500 → 124.9 capped, 4 → 10.1), `fr_pan_13_the_row_interval_is_learned_and_bounded`, and the structural `fr_pan_13_the_widget_requests_the_time_the_state_gives`. **Sabotage:** 24 mutations across two rounds. **Survivors that were real:** the estimate was clamped twice (once stored, once when scheduling), so removing either clamp was masked by the other — the redundant one was deleted; my tolerances were wide enough that an estimate with **no smoothing** still passed — a test that one odd frame must not swing it now fails it; the wiring line `RedrawRequest::At(at)` was invisible to pure tests — a structural guard reading only the code above its own module now catches it (one written earlier in this session matched its own text). **Not measured:** GPU busy and frames per second on screen — **the display was asleep**, so nothing was presented; a first "baseline" of 0–1 frames a second, 0 % GPU, was discarded as a measurement of a blanked screen, not of the app. **Parked with DC0SK:** run `K4_FPS=1 k4remote --demo` with the panel on, and read `/sys/class/drm/card0/device/gpu_busy_percent`, before and after. | | 2026-09-21 | 4.16 | DC0SK | **FR-SPOT-13: TLS for PSK Reporter with manual approval of an untrusted certificate.** New `app/src/tls` (with a shared test server), `Connector`/`CertInfo` in `k4-spot::mqtt_source`, `TrustedCert` in `k4-config`, and the Networks prompt. **L2 (real TLS on loopback, two throwaway P-256 certificates whose fingerprints were computed by `openssl`, not by the code under test):** `fr_spot_13_untrusted_certificate_is_refused_with_its_fingerprint`, `..._an_approved_certificate_connects_and_carries_data`, `..._an_approval_is_exact`, `..._an_approved_certificate_still_has_to_prove_its_key` (a replayed public certificate with the wrong key, on TLS 1.3), `..._tls12_also_checks_the_key`, `..._only_certificate_problems_can_be_approved` (the ordinary check replaced by a stub that fails in ways a certificate cannot cause), `..._other_failures_are_plain_failures`, `..._fingerprint_arithmetic` (FIPS 180-4 "abc" and both certificates), `..._pins_follow_the_configuration`; `fr_spot_05_tls_never_falls_back_to_plain_text` and `..._untrusted_certificate_is_kept_until_decided` (source, scripted connector); `fr_spot_13_worker_asks_before_trusting_and_connects_once_approved` (worker, real TLS: the broker sees no MQTT before approval, and the connection is up within 700 ms of it). **L1:** `fr_spot_13_trusted_certificates_persist_and_are_validated`, `fr_spot_13_a_click_approves_only_the_certificate_shown`, `..._the_port_follows_the_tls_switch`, and the structural `..._the_approval_is_wired`. **Sabotage:** every new test mutated (verifier 28 runs in two rounds, source 11, configuration 17, wiring and decisions 17, worker 5). **Survivors that were real:** a host-case test that used identical strings; the "only certificate problems" guard (untestable until the inner check was made injectable); two config checks never fed a printable-but-forbidden character or a 65-digit fingerprint; a retry test that allowed 5 s so ignoring the retry still passed; and **a structural guard whose needles also occurred in its own source, so deleting the real line left it green** (fixed by searching only the code above the test module). One equivalent mutation (a fingerprint swapped for an equal one). **Live, once, not part of the suite:** `live_psk_reporter_tls` (ignored) — the real server's certificate is publicly trusted. **Not covered:** the approval prompt on screen; TLS for the telnet sources. |