diff --git a/Cargo.lock b/Cargo.lock index eb7ed921..3cd763f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -722,6 +722,35 @@ dependencies = [ "objc2", ] +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "bluez-async" version = "0.9.0" @@ -1422,8 +1451,11 @@ dependencies = [ name = "connetto-peer" version = "0.0.0" dependencies = [ + "bluer", "connetto-core", + "futures-util", "mdns-sd", + "nix", "parking_lot", "rcgen", "ring", @@ -1439,6 +1471,7 @@ dependencies = [ "tokio-rustls", "tracing", "uuid", + "zbus", ] [[package]] @@ -1546,6 +1579,7 @@ dependencies = [ "axum", "base64 0.22.1", "connetto-ca", + "connetto-client", "connetto-core", "connetto-file-core", "connetto-file-server", @@ -1563,6 +1597,7 @@ dependencies = [ "ring", "rls2fga", "rustls", + "serde", "serde_json", "sqlite-diff-rs", "sqlparser", @@ -1958,6 +1993,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -1978,6 +2045,20 @@ dependencies = [ "darling_macro 0.24.1", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -2004,6 +2085,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -2059,6 +2151,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + [[package]] name = "dbus-tokio" version = "0.7.6" @@ -5177,6 +5278,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + [[package]] name = "macro-string" version = "0.1.4" @@ -5585,6 +5692,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -8771,6 +8890,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subql" version = "0.1.0" diff --git a/codecov.yml b/codecov.yml index 87acf812..dc5fe9ee 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,6 +10,7 @@ coverage: ignore: - crates/connetto-test-harness/src/bin/** + - crates/connetto-test-harness/src/linux_leg.rs - crates/connetto-test-harness/src/stack.rs - crates/connetto-test-harness/src/peer_devices.rs - crates/connetto-web/** diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index fc5e1a79..c1925be4 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -258,8 +258,11 @@ open = { version = "5.4", optional = true } # iOS. [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] webbrowser = { version = "1.2", optional = true } + # The peer's Bluetooth, the central the joiner runs on Android and on the -# Apple side through CoreBluetooth (R76 decision 22). +# Apple side through CoreBluetooth, and on Linux through btleplug's BlueZ +# half (R76 decisions 17 and 22). +[target.'cfg(any(target_os = "android", target_os = "ios", target_os = "linux"))'.dependencies] btleplug = { version = "0.13.5", optional = true } # Android: the Keystore-backed store of the same family. The NDK ships no diff --git a/crates/connetto-client/src/bluetooth.rs b/crates/connetto-client/src/bluetooth.rs index 9b124bbb..08ed8fb0 100644 --- a/crates/connetto-client/src/bluetooth.rs +++ b/crates/connetto-client/src/bluetooth.rs @@ -31,15 +31,19 @@ use crate::hotspot::{ mod android; #[cfg(target_os = "ios")] mod apple; -#[cfg(any(target_os = "android", target_os = "ios"))] +#[cfg(any(target_os = "android", target_os = "ios", target_os = "linux"))] mod central; +#[cfg(target_os = "linux")] +mod linux; #[cfg(target_os = "android")] pub(crate) use android::AndroidBluetoothBackend; #[cfg(target_os = "ios")] pub(crate) use apple::AppleBluetoothBackend; -#[cfg(any(target_os = "android", target_os = "ios"))] +#[cfg(any(target_os = "android", target_os = "ios", target_os = "linux"))] pub(crate) use central::BtleplugCentral; +#[cfg(target_os = "linux")] +pub(crate) use linux::LinuxBluetoothBackend; /// The pace of the machine's look while an exchange runs. const FAST_TICK: Duration = Duration::from_millis(20); @@ -184,9 +188,21 @@ pub enum BeaconState { }, } +/// A nearby host the scan stands on, for the application's panel (R76). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NearbyHost { + /// The host's device, the key the scan stands on. + pub host: HostId, + /// The beacon's prefix last seen, where the beacon carries one (R76 + /// decision 25). + pub prefix: Option<[u8; 8]>, + /// The signal last seen. + pub rssi: Option, +} + /// An event the host's GATT service reports (R76). #[cfg_attr( - not(any(target_os = "android", target_os = "ios")), + not(any(target_os = "android", target_os = "ios", target_os = "linux")), expect( dead_code, reason = "the platform backends construct these events, a non-Apple build reads them through the tables only" @@ -219,7 +235,7 @@ pub enum PeripheralEvent { /// An event the joiner's central reports (R76). #[cfg_attr( - not(any(test, target_os = "android", target_os = "ios")), + not(any(test, target_os = "android", target_os = "ios", target_os = "linux")), expect( dead_code, reason = "btleplug's central runs on Android and iOS, so elsewhere only the proofs construct these events" @@ -302,10 +318,10 @@ pub trait CentralBackend: Send + Sync { } /// The backends on a system with no Bluetooth. -#[cfg(not(target_os = "ios"))] +#[cfg(any(test, not(target_os = "ios")))] pub struct UnsupportedBackend; -#[cfg(not(target_os = "ios"))] +#[cfg(any(test, not(target_os = "ios")))] impl ReadinessBackend for UnsupportedBackend { fn state(&self) -> BluetoothState { BluetoothState::Unsupported @@ -324,7 +340,7 @@ impl ReadinessBackend for UnsupportedBackend { } } -#[cfg(not(target_os = "ios"))] +#[cfg(any(test, not(target_os = "ios")))] impl PeripheralBackend for UnsupportedBackend { fn start(&self, _beacon: [u8; 9]) -> Result<(), BluetoothError> { Err(BluetoothError::Unsupported) @@ -345,7 +361,7 @@ impl PeripheralBackend for UnsupportedBackend { /// A system's readiness, its peripheral, and its central's starter where it /// runs one. -#[cfg(not(target_os = "ios"))] +#[cfg(any(test, not(target_os = "ios")))] pub(crate) type Backends = ( Arc, Arc, @@ -353,7 +369,7 @@ pub(crate) type Backends = ( ); /// The backends on a system with no Bluetooth, which runs no central. -#[cfg(not(target_os = "ios"))] +#[cfg(any(test, not(target_os = "ios")))] pub(crate) fn unsupported_backends() -> Backends { ( Arc::new(UnsupportedBackend) as Arc, @@ -420,6 +436,9 @@ struct HostExchange { enum HostBtState { /// Not advertising. Off, + /// Off after a platform failure, whose advertisement may still stand, + /// since a refused renewal keeps the old one. + Failed, /// Advertising under `prefix`, with the running exchanges. Advertising { /// The beacon's prefix the advertisement carries. @@ -581,7 +600,7 @@ pub(crate) type CentralStarter = Arc CentralStart + Send + Sync>; /// btleplug's central as the machine's starter, which the machine runs once /// Bluetooth reads Ready and again after a failed start (R76 decision 22). -#[cfg(any(target_os = "android", target_os = "ios"))] +#[cfg(any(target_os = "android", target_os = "ios", target_os = "linux"))] pub(crate) fn btleplug_starter() -> CentralStarter { Arc::new(|| -> CentralStart { Box::pin(async { @@ -641,6 +660,8 @@ pub(crate) struct Machine { /// The calls waiting for the platform's action to start. action_waiters: Vec, beacon: watch::Sender, + /// The nearby hosts' standing, beside the beacon's, for the panel (R76). + nearby: watch::Sender>, /// The personal hotspot's landing bound, started when an exchange /// hands the offer out (R76 decision 26). handed_out: Arc, @@ -649,8 +670,8 @@ pub(crate) struct Machine { impl Machine { /// The machine over the backends, the central's starter where the system /// runs one, the peer node, the scan and autojoin standings, the prompt - /// standing, and the serving, join and event seams. The receiver stands - /// the host's beacon for the panel. + /// standing, and the serving, join and event seams. The receivers stand + /// the host's beacon and the nearby hosts for the panel. #[expect( clippy::too_many_arguments, reason = "the three backends and the three seams join the node and its three standings, and a config struct would hide the same arity behind another type" @@ -667,11 +688,16 @@ impl Machine { join: JoinSeam, emit: Arc, handed_out: Arc, - ) -> (Self, watch::Receiver) { + ) -> ( + Self, + watch::Receiver, + watch::Receiver>, + ) { let serving_present = (serving)().is_some(); let (beacon, beacon_rx) = watch::channel(BeaconState::Off { reason: Self::beacon_reason(readiness.state(), false, serving_present), }); + let (nearby, nearby_rx) = watch::channel(Vec::new()); ( Self { readiness, @@ -696,8 +722,10 @@ impl Machine { prompting: None, action_waiters: Vec::new(), beacon, + nearby, }, beacon_rx, + nearby_rx, ) } @@ -988,6 +1016,7 @@ impl Machine { self.answer_hosted(state); self.answer_joined(now); self.update_beacon(state, hosting.is_some(), serving.is_some()); + self.update_nearby(); } /// The central's rows (R76 central table). @@ -1049,29 +1078,33 @@ impl Machine { hosting: Option<&HotspotOffer>, serving: Option<&Fingerprint>, ) { - match &mut self.host { - // The device hosts a hotspot and serves, so the beacon starts. - HostBtState::Off if state == BluetoothState::Ready => { - if let (Some(_), Some(fingerprint)) = (hosting, serving) { - let beacon = Beacon::of(fingerprint); - if self.peripheral.start(beacon.to_service_data()).is_ok() { - self.host = HostBtState::Advertising { - prefix: beacon.prefix, - exchanges: HashMap::new(), - }; - } else { - // The platform will not advertise, so the standing - // retries on its next look. - tracing::warn!( - "the beacon could not start, so it retries on the next look" - ); - } + let ready = state == BluetoothState::Ready && hosting.is_some(); + match (&mut self.host, serving) { + // The device hosts a hotspot and serves, so the beacon starts, + // over any advertisement a failure left standing. + (HostBtState::Off | HostBtState::Failed, Some(fingerprint)) if ready => { + let beacon = Beacon::of(fingerprint); + if self.peripheral.start(beacon.to_service_data()).is_ok() { + self.host = HostBtState::Advertising { + prefix: beacon.prefix, + exchanges: HashMap::new(), + }; + } else { + // The platform will not advertise, so the standing + // retries on its next look. + tracing::warn!("the beacon could not start, so it retries on the next look"); } } - HostBtState::Advertising { prefix, exchanges } => { + // The beacon would not restart, so an advertisement a failure + // left standing stops. + (HostBtState::Failed, _) => { + self.peripheral.stop(); + self.host = HostBtState::Off; + } + (HostBtState::Advertising { prefix, exchanges }, _) => { // The hotspot stops, the device stops serving, or Bluetooth // leaves Ready, so the beacon stops with its exchanges. - if state != BluetoothState::Ready || hosting.is_none() || serving.is_none() { + if !ready || serving.is_none() { self.peripheral.stop(); self.host = HostBtState::Off; return; @@ -1097,7 +1130,7 @@ impl Machine { } } } - HostBtState::Off => {} + (HostBtState::Off, _) => {} } // The service's events. for event in self.peripheral.poll() { @@ -1140,10 +1173,17 @@ impl Machine { self.peripheral.disconnect(device); } } - // The platform's failure stands logged, the exchanges end by - // their own bound. + // The platform's failure stands logged, the host's column + // Off, so the next look restarts the beacon, and the + // exchanges' devices leave. PeripheralEvent::Failed(reason) => { tracing::warn!(%reason, "the platform's Bluetooth failed"); + if let HostBtState::Advertising { exchanges, .. } = &mut self.host { + for device in core::mem::take(exchanges).into_keys() { + self.peripheral.disconnect(device); + } + self.host = HostBtState::Failed; + } } } } @@ -1786,7 +1826,7 @@ impl Machine { fn update_beacon(&mut self, state: BluetoothState, hosting: bool, serving: bool) { let beacon = match &self.host { HostBtState::Advertising { prefix, .. } => BeaconState::Advertising { prefix: *prefix }, - HostBtState::Off => BeaconState::Off { + HostBtState::Off | HostBtState::Failed => BeaconState::Off { reason: Self::beacon_reason(state, hosting, serving), }, }; @@ -1795,6 +1835,23 @@ impl Machine { } } + /// The nearby hosts' standing, for the panel, published whenever the + /// scan's host map changes (R76). + fn update_nearby(&self) { + let nearby: Vec = self + .hosts + .iter() + .map(|(host, record)| NearbyHost { + host: *host, + prefix: record.prefix, + rssi: record.rssi, + }) + .collect(); + if *self.nearby.borrow() != nearby { + self.nearby.send_replace(nearby); + } + } + /// Apply the "Client closed" rows, for the loop's end and a dropped /// client (R76). pub(crate) fn close(&mut self) { @@ -1819,6 +1876,7 @@ impl Machine { // The beacon's standing settles, for the panel. let state = self.readiness.state(); self.update_beacon(state, false, (self.serving)().is_some()); + self.update_nearby(); } /// Whether an exchange runs, so the loop's pace tightens. @@ -2154,7 +2212,7 @@ mod tests { } /// A node with no identity, so its exchanges answer `NotServing`. - fn quiet_node() -> Node { + pub(super) fn quiet_node() -> Node { let (tx, _rx) = mpsc::unbounded_channel::(); Node::new( Trust { @@ -2233,6 +2291,8 @@ mod tests { wire: Arc, started: Mutex>, stopped: Mutex, + /// Whether `start` refuses, the test's switch. + start_refuse: Mutex, disconnects: Mutex>, notifies: Mutex>, queue: Mutex>, @@ -2245,6 +2305,7 @@ mod tests { wire, started: Mutex::new(Vec::new()), stopped: Mutex::new(0), + start_refuse: Mutex::new(false), disconnects: Mutex::new(Vec::new()), notifies: Mutex::new(Vec::new()), queue: Mutex::new(VecDeque::new()), @@ -2260,6 +2321,11 @@ mod tests { impl PeripheralBackend for FakePeripheral { fn start(&self, beacon: [u8; 9]) -> Result<(), BluetoothError> { self.started.lock().push(beacon); + if *self.start_refuse.lock() { + return Err(BluetoothError::Failed( + "the platform will not advertise".into(), + )); + } Ok(()) } @@ -2411,14 +2477,16 @@ mod tests { /// The landing's bound starts, counted (R76 decision 26). handed: Arc>, beacon: watch::Receiver, + /// The nearby hosts' standing, beside the beacon's. + nearby: watch::Receiver>, } - fn fp(seed: u8) -> Fingerprint { + pub(super) fn fp(seed: u8) -> Fingerprint { Fingerprint::of(&[seed; 32]) } /// The offer the fakes' hotspot hands out. - fn offer() -> HotspotOffer { + pub(super) fn offer() -> HotspotOffer { HotspotOffer::new("hotspot", "secret", HotspotSecurity::Wpa2, Some(54321)) } @@ -2458,7 +2526,7 @@ mod tests { let joins: Joins = Arc::new(Mutex::new(Vec::new())); let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let handed: Arc> = Arc::new(Mutex::new(0)); - let (machine, beacon) = Machine::new( + let (machine, beacon, nearby) = Machine::new( Arc::clone(&readiness) as Arc, Arc::clone(&peripheral) as Arc, Some({ @@ -2507,6 +2575,7 @@ mod tests { events, handed, beacon, + nearby, } } @@ -2514,6 +2583,11 @@ mod tests { self.beacon.borrow().clone() } + /// The nearby hosts' standing, beside the beacon's. + fn nearby_hosts(&self) -> Vec { + self.nearby.borrow().clone() + } + /// Tick the machine once, hosting `hosting` and joined to nothing. fn tick(&mut self, hosting: Option<&HotspotOffer>) { self.machine.tick(hosting, false); @@ -2608,7 +2682,7 @@ mod tests { let h_serving = Arc::new(Mutex::new(Some(host_fp))); let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); let handed: Arc> = Arc::new(Mutex::new(0)); - let (host, _h_beacon) = Machine::new( + let (host, _h_beacon, _h_nearby) = Machine::new( h_readiness, h_peripheral.clone(), None, @@ -2637,7 +2711,7 @@ mod tests { let joins: Arc>> = Arc::new(Mutex::new(Vec::new())); let j_events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (mut joiner, _j_beacon) = Machine::new( + let (mut joiner, _j_beacon, _j_nearby) = Machine::new( j_readiness, j_peripheral, Some(instant_starter(Arc::clone(&j_central))), @@ -2715,6 +2789,139 @@ mod tests { assert_eq!(h.beacon_state(), BeaconState::Advertising { prefix }); } + /// The platform's refusal stands the host Off, so the next look + /// restarts the beacon while the hotspot stands. + #[tokio::test] + async fn a_failed_bluetooth_standings_move_the_host_off_and_retry() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + assert_eq!( + h.beacon_state(), + BeaconState::Advertising { + prefix: fp(1).prefix() + } + ); + // The platform's refusal, the beacon down while the hotspot stands. + h.peripheral + .push(PeripheralEvent::Failed("the refusal".into())); + h.tick(Some(&offer())); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + // The next look restarts the beacon. + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 2); + assert_eq!( + h.beacon_state(), + BeaconState::Advertising { + prefix: fp(1).prefix() + } + ); + } + + /// A platform failure while an exchange runs disconnects the + /// exchange's device, so the joiner does not stay attached to a dead + /// host. + #[tokio::test] + async fn a_platform_failure_while_an_exchange_runs_disconnects_the_exchange() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + h.peripheral.push(PeripheralEvent::Connected { + device: 7, + mtu: 517, + }); + h.tick(Some(&offer())); + // The exchange runs, so the platform still serves the device. + assert!(h.peripheral.disconnects.lock().is_empty()); + h.peripheral + .push(PeripheralEvent::Failed("the platform dropped".into())); + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.disconnects.lock(), vec![7]); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + } + + /// A platform failure leaves an advertisement that may still stand, + /// which the next look stops once the hotspot is gone. + #[tokio::test] + async fn a_failure_then_a_stopped_hotspot_stops_the_standing_advertisement() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + h.peripheral + .push(PeripheralEvent::Failed("the renewal's refusal".into())); + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.stopped.lock(), 0); + h.tick(None); + assert_eq!(*h.peripheral.stopped.lock(), 1); + h.tick(None); + assert_eq!(*h.peripheral.stopped.lock(), 1, "the stop runs once"); + } + + /// A platform failure with the hotspot still up restarts the beacon + /// over the standing advertisement, with no stop in between. + #[tokio::test] + async fn a_failure_with_the_hotspot_up_restarts_without_a_stop() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + h.peripheral + .push(PeripheralEvent::Failed("the renewal's refusal".into())); + h.tick(Some(&offer())); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 2); + assert_eq!(*h.peripheral.stopped.lock(), 0); + // The restarted beacon stops as any other when the hotspot goes. + h.tick(None); + assert_eq!(*h.peripheral.stopped.lock(), 1); + } + + /// The platform's refusal from Off leaves the beacon down, so the + /// next look with a willing platform advertises. + #[tokio::test] + async fn a_refused_beacon_start_stays_off_and_retries() { + let mut h = Harness::new(BluetoothState::Ready); + *h.peripheral.start_refuse.lock() = true; + h.tick(Some(&offer())); + // The attempt went out, but the platform refused the + // advertisement. + assert_eq!(h.peripheral.started.lock().len(), 1); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + assert_eq!(*h.peripheral.stopped.lock(), 0); + // The platform wills, and the next look advertises. + *h.peripheral.start_refuse.lock() = false; + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 2); + assert_eq!( + h.beacon_state(), + BeaconState::Advertising { + prefix: fp(1).prefix() + } + ); + } + + /// A platform failure while no beacon stands leaves the host and the + /// beacon unchanged, with no stop or disconnect to issue. + #[tokio::test] + async fn a_failure_without_a_beacon_leaves_the_host_unchanged() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + // The platform fails, so the host stands Failed. + h.peripheral + .push(PeripheralEvent::Failed("the platform dropped".into())); + h.tick(Some(&offer())); + // The hotspot goes, so the Failed row stops the standing + // advertisement. + h.tick(None); + assert_eq!(*h.peripheral.stopped.lock(), 1); + // A second failure while no beacon stands changes nothing. + h.peripheral + .push(PeripheralEvent::Failed("the platform dropped again".into())); + h.tick(None); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + assert_eq!(*h.peripheral.stopped.lock(), 1); + assert!(h.peripheral.disconnects.lock().is_empty()); + assert_eq!(h.peripheral.started.lock().len(), 1); + } + #[tokio::test] async fn a_stopped_hotspot_stops_the_beacon() { let mut h = Harness::new(BluetoothState::Ready); @@ -2842,7 +3049,7 @@ mod tests { h.machine.starter = None; h.tick(Some(&offer())); let joiner_central = Arc::new(FakeCentral::new(wire.clone())); - let (mut joiner, _joiner_beacon) = Machine::new( + let (mut joiner, _joiner_beacon, _joiner_nearby) = Machine::new( Arc::new(FakeReadiness::new(BluetoothState::Ready)), Arc::new(FakePeripheral::new(Arc::new(Wire::new()))), Some(instant_starter(Arc::clone(&joiner_central))), @@ -3266,6 +3473,72 @@ mod tests { ); } + /// The nearby read stands the scan's hosts with their beacon prefix and + /// signal, follows a refresh and a changed beacon, and drops a host when + /// it goes or the machine closes (R76). + #[tokio::test(start_paused = true)] + async fn the_nearby_read_follows_the_scan_hosts() { + let mut h = Harness::new(BluetoothState::Ready); + let host_fp = fp(9); + h.settle().await; + assert!( + h.nearby_hosts().is_empty(), + "nothing stands before the scan sees a beacon" + ); + h.host_seen(1, &host_fp, -70); + h.tick(None); + assert_eq!( + h.nearby_hosts(), + vec![NearbyHost { + host: 1, + prefix: Some(host_fp.prefix()), + rssi: Some(-70) + }], + "a seen beacon stands with its prefix and signal" + ); + // A known beacon refreshes its signal. + h.host_seen(1, &host_fp, -60); + h.tick(None); + assert_eq!( + h.nearby_hosts(), + vec![NearbyHost { + host: 1, + prefix: Some(host_fp.prefix()), + rssi: Some(-60) + }], + "the refresh moves the signal" + ); + // A second beacon stands beside the first. + h.host_seen(2, &fp(4), -50); + h.tick(None); + assert_eq!(h.nearby_hosts().len(), 2, "both hosts stand"); + // A changed beacon stands the host's new prefix and signal beside + // its first host. + h.host_seen(1, &fp(5), -65); + h.tick(None); + let hosts = h.nearby_hosts(); + assert_eq!(hosts.len(), 2, "the hosts stand by their address"); + let changed = hosts + .iter() + .find(|host| host.host == 1) + .expect("the host stands under its address"); + assert_eq!(changed.prefix, Some(fp(5).prefix())); + assert_eq!(changed.rssi, Some(-65)); + // A host unseen for its bound leaves the read. + tokio::time::sleep(GONE_AFTER + Duration::from_secs(1)).await; + h.tick(None); + assert!( + h.nearby_hosts().is_empty(), + "the gone bound drops every host" + ); + // The machine's close clears the read. + h.host_seen(3, &fp(6), -40); + h.tick(None); + assert_eq!(h.nearby_hosts().len(), 1, "a host stands again"); + h.machine.close(); + assert!(h.nearby_hosts().is_empty(), "the close clears the read"); + } + #[tokio::test(start_paused = true)] async fn an_unseen_beacon_is_reported_gone_after_its_bound() { let mut h = Harness::new(BluetoothState::Ready); @@ -3697,7 +3970,7 @@ mod tests { let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); let h_serving = Arc::new(Mutex::new(Some(fp(1)))); let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (bt_machine, _bt_beacon) = Machine::new( + let (bt_machine, _bt_beacon, _bt_nearby) = Machine::new( h_readiness, h_peripheral, None, @@ -3767,7 +4040,7 @@ mod tests { let h_central = Arc::new(FakeCentral::new(wire)); let h_serving = Arc::new(Mutex::new(Some(fp(1)))); let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (bt_machine, _bt_beacon) = Machine::new( + let (bt_machine, _bt_beacon, _bt_nearby) = Machine::new( h_readiness, h_peripheral, Some(instant_starter(h_central)), @@ -3844,7 +4117,7 @@ mod tests { #[test] fn a_dropped_runner_clears_the_dial_bind() { let wire = Arc::new(Wire::new()); - let (bt_machine, _bt_beacon) = Machine::new( + let (bt_machine, _bt_beacon, _bt_nearby) = Machine::new( Arc::new(FakeReadiness::new(BluetoothState::Ready)), Arc::new(FakePeripheral::new(wire)), None, diff --git a/crates/connetto-client/src/bluetooth/central.rs b/crates/connetto-client/src/bluetooth/central.rs index b65d1c74..2fde29c6 100644 --- a/crates/connetto-client/src/bluetooth/central.rs +++ b/crates/connetto-client/src/bluetooth/central.rs @@ -182,7 +182,7 @@ fn prepare_thread() -> Result<(), CentralError> { } /// The thread needs no preparation off Android, where btleplug is plain -/// `CoreBluetooth` (R76). +/// `CoreBluetooth` on iOS and `BlueZ` on Linux (R76). #[cfg(not(target_os = "android"))] #[expect( clippy::unnecessary_wraps, @@ -211,7 +211,7 @@ async fn drive(adapter: Adapter, mut commands: mpsc::UnboundedReceiver, match command { Command::StartScan => { // iOS scans only connetto's service (R76 decision - // 25), Android scans wide. + // 25), Android and Linux scan wide. #[cfg(target_os = "ios")] let filter = ScanFilter { services: vec![Uuid::from_bytes(SERVICE_UUID)], diff --git a/crates/connetto-client/src/bluetooth/linux.rs b/crates/connetto-client/src/bluetooth/linux.rs new file mode 100644 index 00000000..225615e2 --- /dev/null +++ b/crates/connetto-client/src/bluetooth/linux.rs @@ -0,0 +1,1140 @@ +//! The Linux Bluetooth backend, over `BlueZ` (R76 slice 9b). +//! +//! The backend drives the `BlueZ` peripheral on one thread of its own, the +//! machine's polled calls reaching it over a channel and the service's +//! events coming back through the shared queue. The adapter's standing, the +//! peripheral's column and the open notify sessions stand on the shared +//! standing, the thread's first read standing the adapter's. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use connetto_peer::linux::{AdapterStanding, BluezIo, DeviceKey, PeripheralAnswer}; +use parking_lot::Mutex; +use tokio::sync::mpsc; + +use super::{ + BluetoothError, BluetoothState, PeripheralBackend, PeripheralEvent, PromptOutcome, + ReadinessBackend, +}; + +/// What the machine's polled calls ask of the thread. +enum Command { + /// The peripheral table's start row, the request's generation under the + /// beacon. + Start(u64, [u8; 9]), + /// The peripheral table's stop row. + Stop, + /// The readiness table's prompt row. + Prompt, + /// The peripheral table's notify row, the device's key and the chunk. + Notify(DeviceKey, Vec), + /// The peripheral table's disconnect row, the device's key. + Disconnect(DeviceKey), +} + +/// The peripheral table's column. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Advertising { + /// Off, nothing running. + Off, + /// The start is in flight, under the beacon. + Starting([u8; 9]), + /// Advertising, under the beacon. + Running([u8; 9]), +} + +/// The backend's shared state, the polled calls' read. +struct State { + /// The adapter's standing, the thread's read and its powered signal. + adapter: AdapterStanding, + /// The peripheral table's column. + column: Advertising, + /// The notify sessions open, by the device's key. + sessions: BTreeSet, + /// The prompt's outcome, once the action stands. + prompt: Option, + /// The start request's generation, the stale answers' refusal. + start_gen: u64, +} + +impl Default for State { + fn default() -> Self { + Self { + adapter: AdapterStanding::None, + column: Advertising::Off, + sessions: BTreeSet::new(), + prompt: None, + start_gen: 0, + } + } +} + +/// The backend's shared standing, the polled calls' read. +type Standing = Arc>; + +/// The service's events, drained by the machine. +type Queue = Arc>>; + +/// The Linux Bluetooth backend, over `BlueZ` (R76 slice 9b). +pub(crate) struct LinuxBluetoothBackend { + commands: mpsc::UnboundedSender, + standing: Standing, + events: Queue, +} + +impl LinuxBluetoothBackend { + /// The backend over `io`, its thread driving the tables on its own + /// current-thread runtime. + pub(crate) fn new(io: Arc) -> Self + where + IO: BluezIo + Send + 'static, + { + let (commands, receiver) = mpsc::unbounded_channel(); + let standing: Standing = Arc::new(Mutex::new(State::default())); + let events: Queue = Arc::new(Mutex::new(Vec::new())); + let drive_standing = Arc::clone(&standing); + let drive_events = Arc::clone(&events); + match std::thread::Builder::new() + .name("connetto-bluez".into()) + .spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + tracing::error!("BlueZ's thread could not run"); + return; + }; + runtime.block_on(Self::drive(io, drive_standing, drive_events, receiver)); + }) { + Ok(_) => {} + Err(err) => tracing::error!("BlueZ's thread did not start: {err}"), + } + Self { + commands, + standing, + events, + } + } + + /// The backend's channel, standing and queue, the thread's loop left to + /// the caller's runtime, for the table proofs. + #[cfg(test)] + fn unstarted() -> (Self, mpsc::UnboundedReceiver) { + let (commands, receiver) = mpsc::unbounded_channel(); + ( + Self { + commands, + standing: Arc::new(Mutex::new(State::default())), + events: Arc::new(Mutex::new(Vec::new())), + }, + receiver, + ) + } + + fn send(&self, command: Command) { + if self.commands.send(command).is_err() { + tracing::warn!("BlueZ's thread has ended"); + } + } + + /// The thread's loop: the machine's commands and `BlueZ`'s answers + /// against the tables, until the client drops its end. + async fn drive( + io: Arc, + standing: Standing, + events: Queue, + mut commands: mpsc::UnboundedReceiver, + ) where + IO: BluezIo + Send + 'static, + { + let io: &IO = io.as_ref(); + standing.lock().adapter = io.standing().await; + loop { + let Some(item) = next_item(io, &mut commands).await else { + break; + }; + match item { + Item::Command(Command::Start(generation, beacon)) => { + start_row(io, &standing, &events, beacon, generation).await; + } + Item::Command(Command::Stop) => { + io.stop().await; + } + Item::Command(Command::Prompt) => prompt_row(io, &standing).await, + Item::Command(Command::Notify(device, bytes)) => { + if let Err(err) = io.notify(device, &bytes).await { + tracing::debug!(?device, %err, "the notification will not send"); + standing.lock().sessions.remove(&device); + } + } + Item::Command(Command::Disconnect(device)) => { + io.disconnect(device).await; + } + Item::Event(answer) => handle_answer(io, &standing, &events, answer).await, + } + } + } +} + +/// What the thread's look takes, from either end. +enum Item { + /// The machine's command. + Command(Command), + /// `BlueZ`'s answer. + Event(PeripheralAnswer), +} + +/// The machine's command or `BlueZ`'s answer, whichever stands first. +async fn next_item( + io: &IO, + commands: &mut mpsc::UnboundedReceiver, +) -> Option { + tokio::select! { + command = commands.recv() => command.map(Item::Command), + event = io.next_event() => Some(Item::Event(event)), + } +} + +/// The peripheral table's start row, which stands the `GATT` application +/// and the advertisement under the beacon. The column takes the answer of +/// the current generation, and a stale answer orphans without touching it. +async fn start_row( + io: &IO, + standing: &Standing, + events: &Queue, + beacon: [u8; 9], + generation: u64, +) { + let outcome = io.start(beacon).await; + let (orphaned, refused) = { + let mut s = standing.lock(); + if s.start_gen == generation { + match &outcome { + Ok(()) => { + s.column = Advertising::Running(beacon); + (false, false) + } + Err(err) => { + // A fresh start's refusal stands the column Off, and a + // renewal's keeps the old advertisement until its + // replacement stands. Both reach the machine, whose next + // look retries under the current certificate. + if matches!(s.column, Advertising::Starting(_)) { + s.column = Advertising::Off; + } + tracing::warn!(%err, "the beacon could not stand"); + (false, true) + } + } + } else { + // A later row superseded this start, so its objects orphan + // to the stop, the stale refusal's new row owning the answer. + (true, false) + } + }; + if orphaned && outcome.is_ok() { + io.stop().await; + } + if refused && let Err(err) = outcome { + push(events, PeripheralEvent::Failed(err.to_string())); + } +} + +/// The readiness table's prompt row: the adapter's `Powered` set true, the +/// outcome the refusal's or the powered signal's. +async fn prompt_row(io: &IO, standing: &Standing) { + if let Err(err) = io.set_powered(true).await { + // `BlueZ`'s refusal, the rfkill's hard block. + tracing::debug!(%err, "the adapter will not power"); + standing.lock().prompt = Some(PromptOutcome::Blocked); + return; + } + let mut s = standing.lock(); + // The standing already the call's, so the outcome stands. + if matches!(s.adapter, AdapterStanding::Powered(true)) { + s.prompt = Some(PromptOutcome::NotAsked); + } +} + +/// `BlueZ`'s answer, the peripheral table's rows. +async fn handle_answer( + io: &IO, + standing: &Standing, + events: &Queue, + answer: PeripheralAnswer, +) { + match answer { + PeripheralAnswer::Connected { device, mtu } => { + standing.lock().sessions.insert(device); + push(events, PeripheralEvent::Connected { device, mtu }); + } + PeripheralAnswer::Chunk { device, bytes } => { + push(events, PeripheralEvent::Chunk { device, bytes }); + } + PeripheralAnswer::Disconnected { device } => { + standing.lock().sessions.remove(&device); + push(events, PeripheralEvent::Disconnected { device }); + } + PeripheralAnswer::Powered(on) => { + let powered_off = { + let mut s = standing.lock(); + s.adapter = AdapterStanding::Powered(on); + if on { + // The action's outcome, once the adapter reports + // powered. + s.prompt = Some(PromptOutcome::NotAsked); + false + } else { + let running = !matches!(s.column, Advertising::Off); + if running { + s.column = Advertising::Off; + // The power-off supersedes the in-flight start, its + // stale answer orphaning without the column. + s.start_gen += 1; + } + running + } + }; + if powered_off { + // The row: the column stands Off, the machine's look + // stands the failure's event, the objects release. + push( + events, + PeripheralEvent::Failed("the adapter powered off".into()), + ); + io.stop().await; + } + } + } +} + +fn push(events: &Queue, event: PeripheralEvent) { + events.lock().push(event); +} + +impl ReadinessBackend for LinuxBluetoothBackend { + fn state(&self) -> BluetoothState { + let s = self.standing.lock(); + match s.adapter { + AdapterStanding::None => BluetoothState::Unsupported, + AdapterStanding::Powered(true) => BluetoothState::Ready, + AdapterStanding::Powered(false) => BluetoothState::Off, + } + } + + fn missing_permissions(&self) -> Vec { + // The default D-Bus policy lets any user reach `BlueZ`. + Vec::new() + } + + fn prompt(&self) -> Result<(), BluetoothError> { + self.standing.lock().prompt = None; + self.send(Command::Prompt); + Ok(()) + } + + fn prompt_outcome(&self) -> Option { + self.standing.lock().prompt + } +} + +impl PeripheralBackend for LinuxBluetoothBackend { + fn start(&self, beacon: [u8; 9]) -> Result<(), BluetoothError> { + let (send, generation) = { + let mut s = self.standing.lock(); + let send = match s.column { + // The advertisement already stands the beacon's service + // data. + Advertising::Running(standing_beacon) if standing_beacon == beacon => false, + Advertising::Off => { + s.column = Advertising::Starting(beacon); + true + } + Advertising::Starting(_) | Advertising::Running(_) => true, + }; + if send { + s.start_gen += 1; + } + (send, s.start_gen) + }; + if send { + self.send(Command::Start(generation, beacon)); + } + Ok(()) + } + + fn stop(&self) { + let send = { + let mut s = self.standing.lock(); + let send = !matches!(s.column, Advertising::Off); + if send { + s.start_gen += 1; + s.column = Advertising::Off; + s.sessions.clear(); + } + send + }; + if send { + self.send(Command::Stop); + } + } + + fn poll(&self) -> Vec { + core::mem::take(&mut *self.events.lock()) + } + + fn notify(&self, device: u64, bytes: &[u8]) -> Result<(), BluetoothError> { + let s = self.standing.lock(); + if matches!(s.column, Advertising::Off) { + return Err(BluetoothError::Failed("the beacon is off".to_string())); + } + if !s.sessions.contains(&device) { + return Err(BluetoothError::Failed( + "the device's notify session is closed".to_string(), + )); + } + drop(s); + self.send(Command::Notify(device, bytes.to_vec())); + Ok(()) + } + + fn disconnect(&self, device: u64) { + if matches!(self.standing.lock().column, Advertising::Off) { + return; + } + self.send(Command::Disconnect(device)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use connetto_peer::linux::BluezError; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicU32, Ordering}; + + const BEACON: [u8; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const BEACON_RENEWED: [u8; 9] = [9, 8, 7, 6, 5, 4, 3, 2, 1]; + const DEVICE: DeviceKey = 7; + + /// The fake `BlueZ` I/O, the table's rows driven. + struct FakeBluez { + adapter: Mutex, + /// The queued `set_powered` answers, in order. + set_powered: Mutex>>, + /// The `set_powered` calls' standing. + powered_set: Mutex>, + /// The queued `start` answers, in order. + start_answers: Mutex>>, + /// The `start` hold, the test's gate on the answer. + start_gate: Mutex>>, + /// The beacons the GATT application registered under. + started: Mutex>, + stops: AtomicU32, + /// The queued `notify` answers, in order. + notify_answers: Mutex>>, + notified: Mutex)>>, + disconnected: Mutex>, + events: tokio::sync::Mutex>, + } + + impl FakeBluez { + fn new(adapter: AdapterStanding) -> (Arc, mpsc::UnboundedSender) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + adapter: Mutex::new(adapter), + set_powered: Mutex::new(VecDeque::new()), + powered_set: Mutex::new(Vec::new()), + start_answers: Mutex::new(VecDeque::new()), + start_gate: Mutex::new(None), + started: Mutex::new(Vec::new()), + stops: AtomicU32::new(0), + notify_answers: Mutex::new(VecDeque::new()), + notified: Mutex::new(Vec::new()), + disconnected: Mutex::new(Vec::new()), + events: tokio::sync::Mutex::new(rx), + }), + tx, + ) + } + + fn hold_start(&self) -> tokio::sync::oneshot::Sender<()> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.start_gate.lock().replace(rx); + tx + } + + fn stops(&self) -> u32 { + self.stops.load(Ordering::Relaxed) + } + } + + impl BluezIo for FakeBluez { + fn standing(&self) -> impl Future { + std::future::ready(*self.adapter.lock()) + } + + fn set_powered(&self, on: bool) -> impl Future> { + self.powered_set.lock().push(on); + std::future::ready(self.set_powered.lock().pop_front().unwrap_or(Ok(()))) + } + + async fn start(&self, beacon: [u8; 9]) -> Result<(), BluezError> { + self.started.lock().push(beacon); + let gate = self.start_gate.lock().take(); + if let Some(gate) = gate { + assert!( + gate.await.is_ok(), + "the test's gate never released the start" + ); + } + self.start_answers.lock().pop_front().unwrap_or(Ok(())) + } + + fn stop(&self) -> impl Future { + self.stops.fetch_add(1, Ordering::Relaxed); + std::future::ready(()) + } + + fn notify( + &self, + device: DeviceKey, + bytes: &[u8], + ) -> impl Future> { + self.notified.lock().push((device, bytes.to_vec())); + std::future::ready(self.notify_answers.lock().pop_front().unwrap_or(Ok(()))) + } + + fn disconnect(&self, device: DeviceKey) -> impl Future { + self.disconnected.lock().push(device); + std::future::ready(()) + } + + async fn next_event(&self) -> PeripheralAnswer { + let mut events = self.events.lock().await; + events + .recv() + .await + .expect("the test's events end with the fake") + } + } + + struct Harness { + backend: LinuxBluetoothBackend, + fake: Arc, + events: mpsc::UnboundedSender, + driver: tokio::task::JoinHandle<()>, + } + + impl Drop for Harness { + fn drop(&mut self) { + self.driver.abort(); + } + } + + fn harness(adapter: AdapterStanding) -> Harness { + let (fake, events) = FakeBluez::new(adapter); + let (backend, receiver) = LinuxBluetoothBackend::unstarted(); + let standing = backend.standing.clone(); + let queue = backend.events.clone(); + let driver = tokio::spawn(LinuxBluetoothBackend::drive( + fake.clone(), + standing, + queue, + receiver, + )); + Harness { + backend, + fake, + events, + driver, + } + } + + async fn pump_until(mut condition: impl FnMut() -> bool) { + let start = std::time::Instant::now(); + while !condition() { + assert!( + start.elapsed() < Duration::from_secs(5), + "the standing did not reach" + ); + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + + /// The pump's bound, the rows that stand done already. + async fn pump() { + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_millis(20) { + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + + fn beacon(h: &Harness) -> Vec<[u8; 9]> { + h.fake.started.lock().clone() + } + + fn notified(h: &Harness) -> Vec<(DeviceKey, Vec)> { + h.fake.notified.lock().clone() + } + + fn disconnected(h: &Harness) -> Vec { + h.fake.disconnected.lock().clone() + } + + // The readiness table. + + #[tokio::test] + async fn readiness_without_an_adapter_is_unsupported() { + tokio::time::pause(); + let h = harness(AdapterStanding::None); + pump_until(|| h.backend.state() == BluetoothState::Unsupported).await; + assert_eq!(h.backend.state(), BluetoothState::Unsupported); + } + + #[tokio::test] + async fn readiness_with_the_adapter_off() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(false)); + pump_until(|| h.backend.state() == BluetoothState::Off).await; + assert_eq!(h.backend.state(), BluetoothState::Off); + } + + #[tokio::test] + async fn readiness_with_the_adapter_on() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert_eq!(h.backend.state(), BluetoothState::Ready); + } + + #[tokio::test] + async fn readiness_reports_no_missing_permissions() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert_eq!(h.backend.missing_permissions(), Vec::::new()); + } + + #[tokio::test] + async fn prompt_powers_the_adapter() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(false)); + assert!(h.backend.prompt().is_ok()); + pump_until(|| h.fake.powered_set.lock().len() == 1).await; + assert_eq!(*h.fake.powered_set.lock(), vec![true]); + // The adapter's powered signal stands the action's outcome. + h.events + .send(PeripheralAnswer::Powered(true)) + .expect("the event goes in"); + pump_until(|| h.backend.prompt_outcome() == Some(PromptOutcome::NotAsked)).await; + } + + #[tokio::test] + async fn prompt_answered_blocked_by_the_refusal() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(false)); + pump_until(|| h.backend.state() == BluetoothState::Off).await; + h.fake + .set_powered + .lock() + .push_back(Err(BluezError::Refused("the rfkill's block".into()))); + assert!(h.backend.prompt().is_ok()); + pump_until(|| h.backend.prompt_outcome() == Some(PromptOutcome::Blocked)).await; + } + + #[tokio::test] + async fn prompt_while_powered_answers_not_asked() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.prompt().is_ok()); + pump_until(|| h.backend.prompt_outcome() == Some(PromptOutcome::NotAsked)).await; + } + + #[tokio::test] + async fn readiness_follows_the_powered_signal() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.events + .send(PeripheralAnswer::Powered(false)) + .expect("the event goes in"); + pump_until(|| h.backend.state() == BluetoothState::Off).await; + h.events + .send(PeripheralAnswer::Powered(true)) + .expect("the event goes in"); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + } + + // The peripheral table. + + #[tokio::test] + async fn start_registers_the_service_and_advertisement() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + assert_eq!(beacon(&h), vec![BEACON]); + } + + #[tokio::test] + async fn start_replaces_the_service_data() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + assert!(h.backend.start(BEACON_RENEWED).is_ok()); + pump_until(|| beacon(&h).len() == 2).await; + assert_eq!(beacon(&h), vec![BEACON, BEACON_RENEWED]); + } + + #[tokio::test] + async fn start_is_a_no_op_under_the_same_beacon() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + assert!(h.backend.start(BEACON).is_ok()); + pump().await; + assert_eq!(beacon(&h).len(), 1); + } + + #[tokio::test] + async fn stop_unregisters_both() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + h.backend.stop(); + pump_until(|| h.fake.stops() == 1).await; + // The column stands Off, the notify's refusal the table's. + assert!(matches!( + h.backend.notify(DEVICE, &[1]), + Err(BluetoothError::Failed(_)) + )); + } + + #[tokio::test] + async fn stop_does_nothing_while_off() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.backend.stop(); + pump().await; + assert_eq!(h.fake.stops(), 0); + } + + #[tokio::test] + async fn a_central_notify_session_stands_connected() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.events + .send(PeripheralAnswer::Connected { + device: DEVICE, + mtu: 517, + }) + .expect("the event goes in"); + pump_until(|| !h.backend.events.lock().is_empty()).await; + let polled = h.backend.poll(); + assert!(matches!( + polled.as_slice(), + [PeripheralEvent::Connected { device, mtu }] if *device == DEVICE && *mtu == 517 + )); + } + + #[tokio::test] + async fn a_central_write_stands_a_chunk() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.events + .send(PeripheralAnswer::Chunk { + device: DEVICE, + bytes: vec![1, 2, 3], + }) + .expect("the event goes in"); + pump_until(|| !h.backend.events.lock().is_empty()).await; + let polled = h.backend.poll(); + assert!(matches!( + polled.as_slice(), + [PeripheralEvent::Chunk { device, bytes }] if *device == DEVICE && *bytes == vec![1, 2, 3] + )); + } + + #[tokio::test] + async fn a_session_end_stands_disconnected() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.events + .send(PeripheralAnswer::Disconnected { device: DEVICE }) + .expect("the event goes in"); + pump_until(|| !h.backend.events.lock().is_empty()).await; + let polled = h.backend.poll(); + assert!(matches!( + polled.as_slice(), + [PeripheralEvent::Disconnected { device }] if *device == DEVICE + )); + } + + #[tokio::test] + async fn notify_sends_on_the_session() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + h.events + .send(PeripheralAnswer::Connected { + device: DEVICE, + mtu: 517, + }) + .expect("the event goes in"); + pump_until(|| !h.backend.poll().is_empty()).await; + assert!(h.backend.notify(DEVICE, &[4, 5]).is_ok()); + pump_until(|| notified(&h).len() == 1).await; + assert_eq!(notified(&h), vec![(DEVICE, vec![4, 5])]); + } + + #[tokio::test] + async fn notify_answers_failed_while_off() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(matches!( + h.backend.notify(DEVICE, &[1]), + Err(BluetoothError::Failed(_)) + )); + assert_eq!(notified(&h), Vec::<(DeviceKey, Vec)>::new()); + } + + #[tokio::test] + async fn notify_answers_failed_on_a_closed_session() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + // The session not open, so the notify's refusal the table's. + assert!(matches!( + h.backend.notify(DEVICE, &[1]), + Err(BluetoothError::Failed(_)) + )); + h.events + .send(PeripheralAnswer::Connected { + device: DEVICE, + mtu: 517, + }) + .expect("the event goes in"); + pump_until(|| !h.backend.poll().is_empty()).await; + assert!(h.backend.notify(DEVICE, &[1]).is_ok()); + pump_until(|| notified(&h).len() == 1).await; + } + + #[tokio::test] + async fn notify_answered_closed_releases_the_session() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + h.events + .send(PeripheralAnswer::Connected { + device: DEVICE, + mtu: 517, + }) + .expect("the event goes in"); + pump_until(|| !h.backend.poll().is_empty()).await; + // The session's closure answers the send, the standing follows. + h.fake + .notify_answers + .lock() + .push_back(Err(BluezError::Closed)); + assert!(h.backend.notify(DEVICE, &[1]).is_ok()); + pump_until(|| notified(&h).len() == 1).await; + pump().await; + assert!(matches!( + h.backend.notify(DEVICE, &[2]), + Err(BluetoothError::Failed(_)) + )); + } + + #[tokio::test] + async fn disconnect_ends_the_device() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + h.backend.disconnect(DEVICE); + pump_until(|| disconnected(&h).len() == 1).await; + assert_eq!(disconnected(&h), vec![DEVICE]); + } + + #[tokio::test] + async fn disconnect_does_nothing_while_off() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.backend.disconnect(DEVICE); + pump().await; + assert_eq!(disconnected(&h), Vec::::new()); + } + + #[tokio::test] + async fn the_adapter_powering_off_ends_the_beacon() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + h.events + .send(PeripheralAnswer::Powered(false)) + .expect("the event goes in"); + pump_until(|| h.fake.stops() >= 1).await; + // The row's event stands the machine's look. + pump_until(|| { + h.backend.poll().iter().any(|event| { + matches!(event, PeripheralEvent::Failed(reason) + if reason == "the adapter powered off") + }) + }) + .await; + // The column stands Off, the machine restarting the beacon once + // readiness stands Ready again. + assert!(matches!( + h.backend.notify(DEVICE, &[1]), + Err(BluetoothError::Failed(_)) + )); + h.events + .send(PeripheralAnswer::Powered(true)) + .expect("the event goes in"); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 2).await; + } + + #[tokio::test] + async fn the_adapter_powering_off_while_off_is_a_no_op() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.events + .send(PeripheralAnswer::Powered(false)) + .expect("the event goes in"); + pump_until(|| h.backend.state() == BluetoothState::Off).await; + assert_eq!(h.fake.stops(), 0); + assert!(h.backend.poll().is_empty()); + } + + #[tokio::test] + async fn a_start_refusal_stands_the_refusal_event() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + h.fake + .start_answers + .lock() + .push_back(Err(BluezError::Refused("the refusal".into()))); + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| { + h.backend + .poll() + .iter() + .any(|event| matches!(event, PeripheralEvent::Failed(_))) + }) + .await; + // The column stands Off, so the machine's next look retries the + // row. + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 2).await; + } + + #[tokio::test] + async fn a_renewal_refusal_keeps_the_old_advertisement() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + // The renewal's refusal: the old advertisement still stands. + h.fake + .start_answers + .lock() + .push_back(Err(BluezError::Refused("the refusal".into()))); + assert!(h.backend.start(BEACON_RENEWED).is_ok()); + pump_until(|| beacon(&h).len() == 2).await; + // The refusal reaches the machine, so its next look retries the + // renewal. + pump_until(|| { + h.backend + .poll() + .iter() + .any(|event| matches!(event, PeripheralEvent::Failed(_))) + }) + .await; + assert!(matches!( + h.backend.standing.lock().column, + Advertising::Running(BEACON) + )); + // The column keeps the old beacon's standing, the notify's rows + // the old session's. + h.events + .send(PeripheralAnswer::Connected { + device: DEVICE, + mtu: 517, + }) + .expect("the event goes in"); + pump_until(|| !h.backend.poll().is_empty()).await; + assert!(h.backend.notify(DEVICE, &[1]).is_ok()); + } + + /// A refused renewal, through the real machine over this backend, is + /// retried until the current certificate's beacon replaces the old one. + #[tokio::test] + async fn a_refused_renewal_is_retried_under_the_current_certificate() { + use crate::bluetooth::tests::{fp, offer, quiet_node}; + use crate::bluetooth::{Machine, PeripheralBackend, ReadinessBackend}; + use connetto_peer::Beacon; + + let (fake, _events) = FakeBluez::new(AdapterStanding::Powered(true)); + // The first beacon stands, the renewal is refused once, and every + // later start stands. + fake.start_answers.lock().extend([ + Ok(()), + Err(BluezError::Refused("the renewal's refusal".into())), + ]); + let backend = Arc::new(LinuxBluetoothBackend::new(Arc::clone(&fake))); + let serving = Arc::new(Mutex::new(Some(fp(1)))); + let (mut machine, _beacon, _nearby) = Machine::new( + Arc::clone(&backend) as Arc, + Arc::clone(&backend) as Arc, + None, + quiet_node(), + false, + false, + false, + Arc::new({ + let serving = Arc::clone(&serving); + move || *serving.lock() + }), + Arc::new(|_offer, _reply| {}), + Arc::new(|_event| {}), + Arc::new(|| {}), + ); + let first = Beacon::of(&fp(1)).to_service_data(); + let renewed = Beacon::of(&fp(2)).to_service_data(); + let hosting = offer(); + let tick_until = |machine: &mut Machine, done: &dyn Fn() -> bool, what: &str| { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !done() { + assert!( + std::time::Instant::now() < deadline, + "{what} within the bound" + ); + machine.tick(Some(&hosting), false); + std::thread::sleep(Duration::from_millis(20)); + } + }; + tick_until( + &mut machine, + &|| matches!(backend.standing.lock().column, Advertising::Running(b) if b == first), + "the first beacon stands", + ); + *serving.lock() = Some(fp(2)); + tick_until( + &mut machine, + &|| matches!(backend.standing.lock().column, Advertising::Running(b) if b == renewed), + "the renewed beacon replaces the old one", + ); + let started = fake.started.lock().clone(); + assert_eq!(started.first(), Some(&first)); + assert_eq!(started.last(), Some(&renewed)); + assert!( + started.len() >= 3, + "the refused renewal was retried, {started:?}" + ); + } + + /// The stop's queue ahead of the start's answer, the new beacon's + /// standing kept by the start's own generation. + #[tokio::test] + async fn a_stop_then_a_start_stands_the_new_beacon() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + // The stop queues before the start's answer, and the thread + // answers the rows in order. + h.backend.stop(); + assert!(h.backend.start(BEACON_RENEWED).is_ok()); + pump_until(|| beacon(&h).len() == 2).await; + pump_until(|| { + matches!( + h.backend.standing.lock().column, + Advertising::Running(BEACON_RENEWED) + ) + }) + .await; + } + + /// The stale start's success orphaned to the stop, the fresh start's + /// refusal standing the column Off and the machine's `Failed`. + #[tokio::test] + async fn a_stale_success_and_a_fresh_refusal_stand_the_refusal() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + let gate = h.fake.hold_start(); + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + h.backend.stop(); + // The stale start's answer is the success, the fresh start's + // the refusal, in the thread's order. + h.fake.start_answers.lock().push_back(Ok(())); + h.fake + .start_answers + .lock() + .push_back(Err(BluezError::Refused("the refusal".into()))); + assert!(h.backend.start(BEACON_RENEWED).is_ok()); + gate.send(()).expect("the gate opens"); + // The stale start's objects release, then the stop's row runs. + pump_until(|| h.fake.stops() == 2).await; + pump_until(|| { + h.backend + .poll() + .iter() + .any(|event| matches!(event, PeripheralEvent::Failed(_))) + }) + .await; + assert!(matches!(h.backend.standing.lock().column, Advertising::Off)); + assert!(matches!( + h.backend.notify(DEVICE, &[1]), + Err(BluetoothError::Failed(_)) + )); + } + + #[tokio::test] + async fn a_start_after_the_stop_releases_the_orphan() { + tokio::time::pause(); + let h = harness(AdapterStanding::Powered(true)); + pump_until(|| h.backend.state() == BluetoothState::Ready).await; + let gate = h.fake.hold_start(); + assert!(h.backend.start(BEACON).is_ok()); + pump_until(|| beacon(&h).len() == 1).await; + // The stop's column move, the start's answer orphaning to it. + h.backend.stop(); + gate.send(()).expect("the gate opens"); + pump_until(|| h.fake.stops() == 2).await; + assert_eq!(h.fake.stops(), 2); + } +} diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index 5235e217..9d1a9664 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -305,6 +305,8 @@ where #[cfg(feature = "peer")] beacon: None, #[cfg(feature = "peer")] + nearby: None, + #[cfg(feature = "peer")] bt_state: None, }, pump, @@ -792,6 +794,10 @@ where tokio::sync::watch::Receiver, > = None; #[cfg(feature = "peer")] + let mut nearby: Option< + tokio::sync::watch::Receiver>, + > = None; + #[cfg(feature = "peer")] let mut bt_state: Option> = None; #[cfg(feature = "device-identity")] let (device, pump) = match keys { @@ -853,7 +859,7 @@ where ); let enrolment = core.client().enrolment(enroller); #[cfg(feature = "peer")] - let (runner, beacon_rx, bt_backend) = { + let (runner, beacon_rx, nearby_rx, bt_backend) = Box::pin(async { let backend: Arc = { #[cfg(target_os = "android")] { @@ -868,7 +874,23 @@ where { Arc::new(crate::hotspot::AppleHotspotBackend::new()) } - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(target_os = "linux")] + { + match connetto_peer::linux::ZbusNm::new().await { + Ok(io) => { + Arc::new(crate::hotspot::LinuxHotspotBackend::new(Arc::new(io))) + } + Err(err) => { + tracing::warn!(%err, "the system bus did not stand"); + Arc::new(crate::hotspot::UnsupportedBackend) + } + } + } + #[cfg(not(any( + target_os = "android", + target_os = "ios", + target_os = "linux" + )))] { Arc::new(crate::hotspot::UnsupportedBackend) } @@ -949,7 +971,32 @@ where Some(crate::bluetooth::btleplug_starter()), ) }; - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(target_os = "linux")] + let (bt_readiness, bt_peripheral, bt_starter) = { + match connetto_peer::linux::BluerBluez::new().await { + Ok(io) => { + let backend = Arc::new( + crate::bluetooth::LinuxBluetoothBackend::new(Arc::new(io)), + ); + ( + Arc::clone(&backend) + as Arc, + Arc::clone(&backend) + as Arc, + Some(crate::bluetooth::btleplug_starter()), + ) + } + Err(err) => { + tracing::warn!(%err, "the BlueZ session did not stand"); + crate::bluetooth::unsupported_backends() + } + } + }; + #[cfg(not(any( + target_os = "android", + target_os = "ios", + target_os = "linux" + )))] let (bt_readiness, bt_peripheral, bt_starter) = crate::bluetooth::unsupported_backends(); let bt_state = Arc::clone(&bt_readiness); @@ -987,9 +1034,10 @@ where ( crate::bluetooth::run(machine, hotspot_rx, bt_machine.0, bluetooth_rx), bt_machine.1, + bt_machine.2, bt_state, ) - }; + }).await; #[cfg(feature = "peer")] let pump: CorePump = Box::pin(async move { tokio::join!(pump, enrolment, runner); @@ -1001,6 +1049,7 @@ where #[cfg(feature = "peer")] { beacon = Some(beacon_rx); + nearby = Some(nearby_rx); bt_state = Some(bt_backend); } (Some(handle), pump) @@ -1019,6 +1068,8 @@ where #[cfg(feature = "peer")] beacon, #[cfg(feature = "peer")] + nearby, + #[cfg(feature = "peer")] bt_state, }, pump, @@ -1082,6 +1133,9 @@ pub struct NativeClient { /// The host's beacon standing, for the application's panel (R76). beacon: Option>, #[cfg(feature = "peer")] + /// The nearby hosts' standing, for the application's panel (R76). + nearby: Option>>, + #[cfg(feature = "peer")] /// The platform's Bluetooth standing, readable at any time (R76). bt_state: Option>, } @@ -1200,6 +1254,16 @@ where self.device.as_ref().and_then(EnrolHandle::peer_address) } + /// The peers this device's node reports linked, an empty list without a + /// device or for a build without the `peer` feature (R76). + #[cfg(feature = "peer")] + #[must_use] + pub fn linked_peers(&self) -> Vec { + self.device + .as_ref() + .map_or_else(Vec::new, EnrolHandle::linked_peers) + } + /// Dial `addr`, refusing by this device's standing before any socket /// opens and handing back the peer's identity once the link is live /// (R76). @@ -1355,6 +1419,17 @@ where } } + /// The nearby hosts the Bluetooth scan stands on, each with its host + /// key, beacon prefix and last signal, an empty list without a device + /// or without Bluetooth (R76). + #[cfg(feature = "peer")] + #[must_use] + pub fn nearby_hosts(&self) -> Vec { + self.nearby + .as_ref() + .map_or_else(Vec::new, |receiver| receiver.borrow().clone()) + } + /// Enable Bluetooth, the platform's action inside the call, within the /// machine's bound (R76 decision 21). /// @@ -1655,6 +1730,8 @@ impl NativeClient { #[cfg(feature = "peer")] beacon: None, #[cfg(feature = "peer")] + nearby: None, + #[cfg(feature = "peer")] bt_state: None, } } @@ -1949,6 +2026,14 @@ mod tests { client.peer_address().is_none(), "nothing serves without the device" ); + assert!( + client.linked_peers().is_empty(), + "nothing links without the device" + ); + assert!( + client.nearby_hosts().is_empty(), + "no nearby host without the device" + ); assert!(client.device_certificate().is_none()); assert!(client.device_key_home().is_none()); let address: std::net::SocketAddr = "127.0.0.1:0".parse().expect("loopback"); @@ -2119,6 +2204,12 @@ mod tests { ); drop(enroller); let (_beacon, beacon_rx) = watch::channel(BeaconState::Advertising { prefix: [7; 8] }); + let (nearby_tx, nearby_rx) = watch::channel(Vec::new()); + nearby_tx.send_replace(vec![crate::bluetooth::NearbyHost { + host: 9, + prefix: Some([3; 8]), + rssi: Some(-55), + }]); let (readiness, _, _) = crate::bluetooth::unsupported_backends(); let client = NativeClient { core, @@ -2131,6 +2222,8 @@ mod tests { #[cfg(feature = "peer")] beacon: Some(beacon_rx), #[cfg(feature = "peer")] + nearby: Some(nearby_rx), + #[cfg(feature = "peer")] bt_state: Some(readiness), }; @@ -2212,6 +2305,19 @@ mod tests { BeaconState::Advertising { prefix: [7; 8] }, "the beacon's standing stands beside the device" ); + assert_eq!( + client.nearby_hosts(), + vec![crate::bluetooth::NearbyHost { + host: 9, + prefix: Some([3; 8]), + rssi: Some(-55) + }], + "the nearby read stands beside the device" + ); + assert!( + client.linked_peers().is_empty(), + "nothing links before the device serves a link" + ); client.stop_hotspot().await; client.leave_hotspot().await; client.set_hotspot_scan(true); diff --git a/crates/connetto-client/src/enrolment/task.rs b/crates/connetto-client/src/enrolment/task.rs index 6016c230..e87c389b 100644 --- a/crates/connetto-client/src/enrolment/task.rs +++ b/crates/connetto-client/src/enrolment/task.rs @@ -432,6 +432,12 @@ impl EnrolHandle { self.peer.local_addr() } + /// The peers the device's node reports linked (R76). + #[cfg(feature = "peer")] + pub(crate) fn linked_peers(&self) -> Vec { + self.peer.linked() + } + /// Dial `addr`, refusing by the device's standing before any socket /// opens and handing back the peer's identity once the link is live /// (R76). diff --git a/crates/connetto-client/src/enrolment/tests.rs b/crates/connetto-client/src/enrolment/tests.rs index e3523f1d..00ec4eb4 100644 --- a/crates/connetto-client/src/enrolment/tests.rs +++ b/crates/connetto-client/src/enrolment/tests.rs @@ -1820,6 +1820,10 @@ async fn a_certificate_less_device_refuses_to_link() { /// links and the listener (R76 proof 5). #[cfg(feature = "peer")] #[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "the enrolment, the live link and the closing look are one scenario, in the order the expiry crosses them" +)] async fn a_look_at_the_expiry_closes_the_live_peer_links() { let (key, der) = attesting_key(); let now = SystemTime::now(); @@ -1888,6 +1892,20 @@ async fn a_look_at_the_expiry_closes_the_live_peer_links() { .any(|event| matches!(event, ClientEvent::PeerLinked { .. })) }) .await; + let linked_peer = state + .lock() + .emitted + .iter() + .find_map(|event| match event { + ClientEvent::PeerLinked { peer } => Some(peer.clone()), + _ => None, + }) + .expect("the link reports its peer"); + assert_eq!( + handle.linked_peers(), + vec![linked_peer], + "the device's node reports its live peer" + ); // The look wakes at the expiry plus the tolerance and closes the link. tokio::time::sleep( diff --git a/crates/connetto-client/src/hotspot.rs b/crates/connetto-client/src/hotspot.rs index b6e6c83b..41786b0d 100644 --- a/crates/connetto-client/src/hotspot.rs +++ b/crates/connetto-client/src/hotspot.rs @@ -23,6 +23,8 @@ use crate::ClientEvent; pub(crate) mod android; #[cfg(target_os = "ios")] pub(crate) mod apple; +#[cfg(target_os = "linux")] +pub(crate) mod linux; mod wifi_payload; pub use wifi_payload::WifiPayloadError; @@ -31,6 +33,8 @@ pub use wifi_payload::WifiPayloadError; pub(crate) use android::{AndroidHotspotBackend, JoinedBind}; #[cfg(target_os = "ios")] pub(crate) use apple::AppleHotspotBackend; +#[cfg(target_os = "linux")] +pub(crate) use linux::LinuxHotspotBackend; /// The time a hosted hotspot gets to start. pub(crate) const HOST_BOUND: Duration = Duration::from_secs(30); @@ -120,6 +124,9 @@ pub enum HotspotError { /// Tethering is disallowed. #[error("tethering is disallowed")] Disallowed, + /// The device is already joined to a network. + #[error("the device is already joined to a network")] + Joined, /// The hotspot did not start within its bound. #[error("the hotspot did not start within its bound")] TimedOut, @@ -137,6 +144,9 @@ pub enum JoinError { /// The permission `name` is not granted. #[error("the permission {0} is not granted")] MissingPermission(String), + /// The device is already hosting a hotspot. + #[error("the device is already hosting a hotspot")] + Hosting, /// The network is not available to this device. #[error("the network is not available to this device")] Declined, @@ -150,13 +160,13 @@ pub enum JoinError { /// The host side's outcome, as the backend stands it (R76). #[cfg_attr( - not(any(target_os = "android", test)), + not(any(target_os = "android", target_os = "linux", test)), expect( dead_code, - reason = "Android's backend and the tests construct these standings, the other systems host no regular hotspot" + reason = "the platform backends and the proofs construct these standings, the other systems host no regular hotspot" ) )] -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum HostStatus { /// Nothing requested, or the request in flight. Pending, @@ -177,7 +187,7 @@ pub(crate) enum HostStatus { /// The join side's outcome, as the backend stands it (R76). #[cfg_attr( - not(any(target_os = "android", target_os = "ios", test)), + not(any(target_os = "android", target_os = "ios", target_os = "linux", test)), expect( dead_code, reason = "the platform backends and the proofs construct these standings, a non-Apple build reads them through the tables only" diff --git a/crates/connetto-client/src/hotspot/linux.rs b/crates/connetto-client/src/hotspot/linux.rs new file mode 100644 index 00000000..7e7e8a82 --- /dev/null +++ b/crates/connetto-client/src/hotspot/linux.rs @@ -0,0 +1,2797 @@ +//! The Linux hotspot backend, over `NetworkManager` (R76 slice 9b). +//! +//! The backend drives `NetworkManager` on one thread of its own, the machine's +//! polled calls reaching it over a channel and the host and join standings +//! coming back through the shared standing. The volatile profile bound to +//! the D-Bus connection keeps the hosted credentials in memory only, and a +//! device whose `5 GHz` attempt failed is remembered for the life of the +//! process. + +use std::collections::{BTreeSet, HashMap}; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use connetto_peer::linux::{ + AddError, AddRequest, Band, NETWORK_CONTROL, NmEvent, NmIo, SETTINGS_MODIFY_OWN, + STATE_ACTIVATED, STATE_DEACTIVATED, STATE_DEACTIVATING, Security, WIFI_SHARE_PROTECTED, + WifiDevice, +}; + +use super::{ + HostStatus, HotspotBackend, HotspotError, HotspotOffer, HotspotSecurity, JoinError, JoinStatus, + TICK, +}; + +/// The bound the `5 GHz` attempt gets, on the monotonic clock. +const FIVE_GHZ_BOUND: Duration = Duration::from_secs(10); +/// The hotspot's SSID prefix, the four random hex digits following it. +const SSID_PREFIX: &str = "connetto-"; +/// The hotspot's passphrase length. +const PASSPHRASE_LEN: usize = 16; +/// The alphabet the passphrase draws from. +const PASSPHRASE_ALPHABET: [u8; 36] = *b"0123456789abcdefghijklmnopqrstuvwxyz"; + +/// The host table's column. +#[derive(Clone, Debug, Default)] +enum HostStanding { + /// Idle, nothing requested. + #[default] + Idle, + /// The `5 GHz` request in flight. + Five { + /// The request's generation, the stale answers' refusal. + generation: u64, + /// The request's origin, the bound's. + sent: Instant, + /// The device's hardware address, the `5 GHz` memory's key. + hw_address: String, + /// The device's D-Bus object path. + device: String, + /// The active connection's path, once the request answers. + active: Option, + /// The network name, kept for the fallback. + ssid: String, + /// The passphrase, kept for the fallback. + passphrase: String, + }, + /// The `2.4 GHz` request in flight. + Two { + /// The request's generation, the stale answers' refusal. + generation: u64, + /// The active connection's path, once the request answers. + active: Option, + /// The network name, kept for the fallback. + ssid: String, + /// The passphrase, kept for the fallback. + passphrase: String, + }, + /// The hotspot is up. + Active { + /// The active connection's path. + active: String, + /// The network name. + ssid: String, + /// The passphrase. + passphrase: String, + }, + /// The stop is draining. + Ending, + /// Ended, with the column's reason. + Ended(HostOutcome), +} + +/// The host table's ended column's reason. +#[derive(Clone, Debug)] +enum HostOutcome { + /// The request's refusal, with the mapped reason. + Failed(HotspotError), + /// The hotspot stopped. + Stopped, +} + +/// The join table's column. +#[derive(Clone, Debug, Default)] +enum JoinStanding { + /// Idle, nothing requested. + #[default] + Idle, + /// The request in flight. + Requesting { + /// The request's generation, the stale answers' refusal. + generation: u64, + /// The active connection's path, once the request answers. + active: Option, + /// The connection is activated without a gateway yet. + awaiting_gateway: bool, + }, + /// Joined, with the network's details. + Joined { + /// The active connection's path. + active: String, + /// The device's address on the network. + address: Ipv4Addr, + /// The subnet's prefix length. + prefix: u8, + /// The network's gateway. + gateway: IpAddr, + }, + /// Ended, with the column's reason. + Ended(JoinOutcome), +} + +/// The join table's ended column's reason. +#[derive(Clone, Debug)] +enum JoinOutcome { + /// The network will not stand. + Unavailable, + /// The joined network is gone. + Lost, +} + +/// The environment the pre-checks answer from, the thread's read. +#[derive(Clone, Debug, Default)] +struct Env { + /// Wi-Fi's enabled standing. + wifi_enabled: bool, + /// The permission table, by name. + permissions: HashMap, + /// The Wi-Fi devices, in `NetworkManager`'s order. + devices: Vec, + /// Whether a device read has answered, the table standing unread + /// until one does. + devices_read: bool, + /// The devices whose `5 GHz` attempt failed, held for the life of the + /// process. + five_ghz_failed: BTreeSet, +} + +/// The backend's shared state, the polled calls' read. +#[derive(Default)] +struct State { + /// The host table's column. + host: HostStanding, + /// The host request's generation, the stale answers' refusal. + host_gen: u64, + /// The join table's column. + join: JoinStanding, + /// The join request's generation, the stale answers' refusal. + join_gen: u64, + /// The pre-checks' environment. + env: Env, +} + +/// The backend's shared standing, the polled calls' read. +type Standing = Arc>; + +/// What the machine's polled calls ask of the thread. +enum Command { + /// The host table's `request_host` row's request, under its generation. + Host { + /// The request's generation. + generation: u64, + /// The request `NetworkManager` activates. + req: AddRequest, + }, + /// The join table's `request_join` row's request, under its generation. + Join { + /// The request's generation. + generation: u64, + /// The request `NetworkManager` activates. + req: AddRequest, + }, + /// The host or join table's deactivate row's connection. + Deactivate(String), +} + +/// The Linux hotspot backend, over `NetworkManager` (R76 slice 9b). +pub(crate) struct LinuxHotspotBackend { + commands: mpsc::UnboundedSender, + standing: Standing, +} + +impl LinuxHotspotBackend { + /// The backend over `io`, its thread driving the tables on its own + /// current-thread runtime. + pub(crate) fn new(io: Arc) -> Self + where + IO: NmIo + Send + 'static, + { + let (commands, receiver) = mpsc::unbounded_channel(); + let standing: Standing = Arc::new(Mutex::new(State::default())); + let drive_standing = Arc::clone(&standing); + match std::thread::Builder::new() + .name("connetto-nm".into()) + .spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + tracing::error!("NetworkManager's thread could not run"); + return; + }; + runtime.block_on(Self::drive(io, drive_standing, receiver)); + }) { + Ok(_) => {} + Err(err) => tracing::error!("NetworkManager's thread did not start: {err}"), + } + Self { commands, standing } + } + + /// The backend's channel and standing, the thread's loop left to the + /// caller's runtime, for the table proofs. + #[cfg(test)] + fn unstarted() -> (Self, mpsc::UnboundedReceiver) { + let (commands, receiver) = mpsc::unbounded_channel(); + ( + Self { + commands, + standing: Arc::new(Mutex::new(State::default())), + }, + receiver, + ) + } + + fn send(&self, command: Command) { + if self.commands.send(command).is_err() { + tracing::warn!("NetworkManager's thread has ended"); + } + } + + /// The thread's loop: the machine's commands and `NetworkManager`'s + /// signals against the tables, until either end is dropped. + async fn drive( + io: Arc, + standing: Standing, + mut commands: mpsc::UnboundedReceiver, + ) where + IO: NmIo + Send + 'static, + { + let io: &IO = io.as_ref(); + read_env(io, &standing).await; + loop { + settle(&standing); + if five_ghz_bound_due(&standing) { + five_ghz_bound_row(io, &standing).await; + continue; + } + gateway_repoll(io, &standing).await; + let Some(item) = next_item(io, &mut commands, next_deadline(&standing)).await else { + break; + }; + match item { + Item::Command(Command::Host { generation, req }) => { + match io.add_and_activate(&req).await { + Ok(active) => host_add_answered(io, &standing, generation, active).await, + Err(err) => { + let failed = matches!(err, AddError::Failed(_)); + host_add_refused(io, &standing, &req, generation, err).await; + // The device path may be gone, so the table re-reads. + if failed { + read_devices(io, &standing).await; + } + } + } + } + Item::Command(Command::Join { generation, req }) => { + match io.add_and_activate(&req).await { + Ok(active) => join_add_answered(io, &standing, generation, active).await, + Err(err) => { + let failed = matches!(err, AddError::Failed(_)); + join_add_refused(&standing, generation, err); + if failed { + read_devices(io, &standing).await; + } + } + } + } + Item::Command(Command::Deactivate(path)) => { + if let Err(err) = io.deactivate(&path).await { + tracing::debug!(%path, %err, "the active connection will not deactivate"); + } + } + Item::Event(event) => handle_event(io, &standing, event).await, + Item::Deadline => { + if !standing.lock().env.devices_read { + read_devices(io, &standing).await; + } + } + } + } + } +} + +/// What the thread's look takes, from either end. +enum Item { + /// The machine's command. + Command(Command), + /// `NetworkManager`'s signal. + Event(NmEvent), + /// The `5 GHz` bound or the gateway's repoll ran. + Deadline, +} + +/// The machine's command, `NetworkManager`'s signal, or the bound's run. +async fn next_item( + io: &IO, + commands: &mut mpsc::UnboundedReceiver, + deadline: Option, +) -> Option { + let deadline = deadline.map(tokio::time::sleep_until); + Some(tokio::select! { + command = commands.recv() => match command { + Some(command) => Item::Command(command), + None => return None, + }, + event = io.next_event() => match event { + Some(event) => Item::Event(event), + None => return None, + }, + () = wait_deadline(deadline) => Item::Deadline, + }) +} + +/// The bound's run, standing pending where no bound is due. +async fn wait_deadline(deadline: Option) { + match deadline { + Some(deadline) => deadline.await, + None => std::future::pending().await, + } +} + +/// The host table's `stop_host` row's end: the stop's drain is done. +fn settle(standing: &Standing) { + let mut s = standing.lock(); + if matches!(s.host, HostStanding::Ending) { + s.host = HostStanding::Idle; + } +} + +/// Whether the `5 GHz` bound has run: the column stands `5 GHz` and the bound's +/// instant is past. +fn five_ghz_bound_due(standing: &Standing) -> bool { + let s = standing.lock(); + match &s.host { + HostStanding::Five { sent, .. } => Instant::now() >= *sent + FIVE_GHZ_BOUND, + _ => false, + } +} + +/// The next bound or repoll the thread's look waits for, where one stands. +fn next_deadline(standing: &Standing) -> Option { + let s = standing.lock(); + let mut due = None; + if let HostStanding::Five { sent, .. } = &s.host { + due = Some(*sent + FIVE_GHZ_BOUND); + } + if matches!( + &s.join, + JoinStanding::Requesting { + awaiting_gateway: true, + .. + } + ) { + let repoll = Instant::now() + TICK; + due = due.map_or(Some(repoll), |due| Some(due.min(repoll))); + } + if !s.env.devices_read { + let reread = Instant::now() + TICK; + due = due.map_or(Some(reread), |due| Some(due.min(reread))); + } + due +} + +/// The host table's `FIVE_GHZ_BOUND runs` row: deactivate, remember the +/// device, send band `bg` under the same SSID and passphrase. +async fn five_ghz_bound_row(io: &IO, standing: &Standing) { + let (active, req, generation) = { + let mut s = standing.lock(); + let HostStanding::Five { + generation: slot_gen, + hw_address, + device, + active, + ssid, + passphrase, + .. + } = &s.host + else { + return; + }; + let (generation, hw_address, device, ssid, passphrase, active) = ( + *slot_gen, + hw_address.clone(), + device.clone(), + ssid.clone(), + passphrase.clone(), + active.clone(), + ); + s.env.five_ghz_failed.insert(hw_address.clone()); + s.host = HostStanding::Two { + generation, + active: None, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }; + ( + active, + AddRequest::Host { + device: device.clone(), + band: Band::Two, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }, + generation, + ) + }; + if let Some(path) = active + && let Err(err) = io.deactivate(&path).await + { + tracing::debug!(%path, %err, "the 5 GHz connection will not deactivate"); + } + send_two_ghz(io, standing, generation, req).await; +} + +/// The host table's `2.4 GHz` attempt: the band-`bg` request and its answers, +/// the column already standing it. +async fn send_two_ghz(io: &IO, standing: &Standing, generation: u64, req: AddRequest) { + match io.add_and_activate(&req).await { + Ok(active) => host_add_answered(io, standing, generation, active).await, + // The refusal row can send the `2.4 GHz` request again, so its future + // is boxed to give the recursion a size. + Err(err) => Box::pin(host_add_refused(io, standing, &req, generation, err)).await, + } +} + +/// The join table's `activated without a gateway yet` row's repoll, +/// standing the gateway once it arrives. +async fn gateway_repoll(io: &IO, standing: &Standing) { + let path = { + let s = standing.lock(); + match &s.join { + JoinStanding::Requesting { + active: Some(path), + awaiting_gateway: true, + .. + } => Some(path.clone()), + _ => None, + } + }; + let Some(path) = path else { + return; + }; + match io.ipv4(&path).await { + Ok(ipv4) => { + let Some(gateway) = ipv4.gateway else { + return; + }; + let mut s = standing.lock(); + if let JoinStanding::Requesting { + active: Some(slot), + awaiting_gateway: true, + .. + } = &mut s.join + && slot == &path + { + s.join = JoinStanding::Joined { + active: slot.clone(), + address: ipv4.address, + prefix: ipv4.prefix, + gateway, + }; + } + } + Err(err) => tracing::debug!(%path, %err, "the IPv4 standing will not read"), + } +} + +/// The host table's request row's answer: the active connection's path +/// stands on the column, or the column has moved and the connection orphans +/// to the stop. +async fn host_add_answered( + io: &IO, + standing: &Standing, + generation: u64, + active: String, +) { + let stale = { + let mut s = standing.lock(); + match &mut s.host { + HostStanding::Five { + generation: slot_gen, + active: slot, + .. + } + | HostStanding::Two { + generation: slot_gen, + active: slot, + .. + } if *slot_gen == generation => { + *slot = Some(active.clone()); + false + } + _ => true, + } + }; + if stale { + release_orphan(io, active).await; + } +} + +/// The host table's request row's refusal: `PermissionDenied` ends the +/// column, another error on the `5 GHz` band remembers the device and sends +/// band `bg`, another on the `2.4 GHz` band ends the column. +async fn host_add_refused( + io: &IO, + standing: &Standing, + req: &AddRequest, + generation: u64, + err: AddError, +) { + let current = { + let s = standing.lock(); + matches!( + &s.host, + HostStanding::Five { generation: slot, .. } + | HostStanding::Two { generation: slot, .. } if *slot == generation + ) + }; + if !current { + return; + } + // The row's band-`bg` attempt, where the `5 GHz` request refused for a + // D-Bus error, the device remembered for the life of the process. + if let AddError::Failed(_) = err + && let AddRequest::Host { + device, + band: Band::Five, + ssid, + passphrase, + } = req + { + let two = { + let mut s = standing.lock(); + let HostStanding::Five { + generation: slot_gen, + hw_address, + .. + } = &s.host + else { + return; + }; + if *slot_gen != generation { + return; + } + let (slot_gen, hw_address) = (*slot_gen, hw_address.clone()); + s.env.five_ghz_failed.insert(hw_address.clone()); + s.host = HostStanding::Two { + generation: slot_gen, + active: None, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }; + AddRequest::Host { + device: device.clone(), + band: Band::Two, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + } + }; + send_two_ghz(io, standing, generation, two).await; + return; + } + // The column ends, the `PermissionDenied` refusal's mapped reason, the + // `2.4 GHz` band's failure's. + let outcome = match err { + AddError::PermissionDenied => HostOutcome::Failed(HotspotError::MissingPermission( + WIFI_SHARE_PROTECTED.to_string(), + )), + AddError::Failed(_) => HostOutcome::Failed(HotspotError::Failed), + }; + let mut s = standing.lock(); + if matches!( + &s.host, + HostStanding::Five { generation: slot, .. } | HostStanding::Two { generation: slot, .. } if *slot == generation + ) { + s.host = HostStanding::Ended(outcome); + } +} + +/// The join table's request row's answer: the active connection's path +/// stands on the column, or the column has moved and the connection orphans +/// to the leave. +async fn join_add_answered( + io: &IO, + standing: &Standing, + generation: u64, + active: String, +) { + let stale = { + let mut s = standing.lock(); + match &mut s.join { + JoinStanding::Requesting { + generation: slot_gen, + active: slot, + .. + } if *slot_gen == generation => { + *slot = Some(active.clone()); + false + } + _ => true, + } + }; + if stale { + release_orphan(io, active).await; + } +} + +/// The join table's request row's refusal: the column ends unavailable, the +/// refused right where `NetworkManager` names it. +fn join_add_refused(standing: &Standing, generation: u64, err: AddError) { + let mut s = standing.lock(); + match &mut s.join { + JoinStanding::Requesting { + generation: slot_gen, + .. + } if *slot_gen == generation => match err { + AddError::PermissionDenied => { + tracing::debug!("NetworkManager refused the join request for lack of a permission"); + s.join = JoinStanding::Ended(JoinOutcome::Unavailable); + } + AddError::Failed(reason) => { + tracing::debug!(%reason, "NetworkManager refused the join request"); + s.join = JoinStanding::Ended(JoinOutcome::Unavailable); + } + }, + _ => {} + } +} + +/// `NetworkManager`'s signal, against the host and join tables' rows. +async fn handle_event(io: &IO, standing: &Standing, event: NmEvent) { + match event { + NmEvent::CheckPermissions => read_permissions(io, standing).await, + NmEvent::WirelessEnabled(on) => { + standing.lock().env.wifi_enabled = on; + } + NmEvent::StateChanged { + active, + state, + reason, + } => state_changed(io, standing, &active, state, reason).await, + NmEvent::DevicesChanged => read_devices(io, standing).await, + } +} + +/// The active connection's state change, the host and join tables' rows. +async fn state_changed( + io: &IO, + standing: &Standing, + active: &str, + state: u32, + reason: u32, +) { + match state { + STATE_ACTIVATED => { + host_activated(standing, active); + join_activated(io, standing, active).await; + } + STATE_DEACTIVATING | STATE_DEACTIVATED => { + if let Some((req, generation)) = host_deactivated(standing, active, reason) { + send_two_ghz(io, standing, generation, req).await; + } + join_deactivated(standing, active, reason); + } + _ => {} + } +} + +/// The host's `activated` row: the column stands the hotspot. +fn host_activated(standing: &Standing, active: &str) { + let mut s = standing.lock(); + let row = match &mut s.host { + HostStanding::Five { + active: slot, + ssid, + passphrase, + .. + } + | HostStanding::Two { + active: slot, + ssid, + passphrase, + .. + } if slot.as_deref() == Some(active) => Some((ssid.clone(), passphrase.clone())), + _ => None, + }; + if let Some((ssid, passphrase)) = row { + s.host = HostStanding::Active { + active: active.to_string(), + ssid, + passphrase, + }; + } +} + +/// The join's `activated` row: the `IPv4` standing, the gateway where it +/// stands, the repoll where it does not. +async fn join_activated(io: &IO, standing: &Standing, active: &str) { + let due = { + let s = standing.lock(); + matches!( + &s.join, + JoinStanding::Requesting { + active: Some(slot), + .. + } if slot == active + ) + }; + if !due { + return; + } + match io.ipv4(active).await { + Ok(ipv4) => { + let mut s = standing.lock(); + if let JoinStanding::Requesting { + generation, + active: Some(slot), + .. + } = &mut s.join + && slot == active + { + let generation = *generation; + s.join = match ipv4.gateway { + Some(gateway) => JoinStanding::Joined { + active: slot.clone(), + address: ipv4.address, + prefix: ipv4.prefix, + gateway, + }, + None => JoinStanding::Requesting { + generation, + active: Some(slot.clone()), + awaiting_gateway: true, + }, + }; + } + } + Err(err) => { + tracing::debug!(%active, %err, "the IPv4 standing will not read"); + join_awaiting_gateway(standing, active); + } + } +} + +/// The join's `deactivated` row: the column ends unavailable. +fn join_deactivated(standing: &Standing, active: &str, reason: u32) { + let mut s = standing.lock(); + match &s.join { + JoinStanding::Requesting { + active: Some(slot), .. + } if slot == active => { + tracing::debug!(%reason, "the joined connection will not stand"); + s.join = JoinStanding::Ended(JoinOutcome::Unavailable); + } + JoinStanding::Joined { active: slot, .. } if slot == active => { + s.join = JoinStanding::Ended(JoinOutcome::Lost); + } + _ => {} + } +} + +/// The join's `activated` row's repoll's standing, the gateway's read gone. +fn join_awaiting_gateway(standing: &Standing, active: &str) { + let mut s = standing.lock(); + if let JoinStanding::Requesting { + generation, + active: Some(slot), + .. + } = &mut s.join + && slot == active + { + let generation = *generation; + s.join = JoinStanding::Requesting { + generation, + active: Some(slot.clone()), + awaiting_gateway: true, + }; + } +} + +/// The host's `deactivated` rows: the `5 GHz` column remembers the device and +/// sends band `bg`, the `2.4 GHz` column ends failed, the active column ends +/// stopped. +fn host_deactivated(standing: &Standing, active: &str, reason: u32) -> Option<(AddRequest, u64)> { + let mut s = standing.lock(); + match &s.host { + HostStanding::Five { + hw_address, + device, + active: Some(slot), + ssid, + passphrase, + generation, + .. + } if slot == active => { + let (hw_address, device, ssid, passphrase, generation) = ( + hw_address.clone(), + device.clone(), + ssid.clone(), + passphrase.clone(), + *generation, + ); + s.env.five_ghz_failed.insert(hw_address.clone()); + s.host = HostStanding::Two { + generation, + active: None, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }; + Some(( + AddRequest::Host { + device: device.clone(), + band: Band::Two, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }, + generation, + )) + } + HostStanding::Two { + active: Some(slot), .. + } if slot == active => { + tracing::debug!(%reason, "the hosted connection will not stand"); + s.host = HostStanding::Ended(HostOutcome::Failed(HotspotError::Failed)); + None + } + HostStanding::Active { active: slot, .. } if slot == active => { + s.host = HostStanding::Ended(HostOutcome::Stopped); + None + } + _ => None, + } +} + +/// The thread's first reads, the pre-checks' standing. +async fn read_env(io: &IO, standing: &Standing) { + if let Ok(permissions) = io.permissions().await { + standing.lock().env.permissions = permissions; + } + if let Ok(on) = io.wifi_enabled().await { + standing.lock().env.wifi_enabled = on; + } + read_devices(io, standing).await; +} + +/// The device table's re-read, the old table kept on a refusal. +async fn read_devices(io: &IO, standing: &Standing) { + match io.wifi_devices().await { + Ok(devices) => { + let mut s = standing.lock(); + s.env.devices = devices; + s.env.devices_read = true; + } + Err(err) => tracing::debug!(%err, "the Wi-Fi devices will not read"), + } +} + +/// The `CheckPermissions` signal's row: the permission table re-read. +async fn read_permissions(io: &IO, standing: &Standing) { + if let Ok(permissions) = io.permissions().await { + standing.lock().env.permissions = permissions; + } +} + +/// The orphans' release: the request's column moved, so the activated +/// connection is the stop's or the leave's, and it deactivates. +async fn release_orphan(io: &IO, active: String) { + if let Err(err) = io.deactivate(&active).await { + tracing::debug!(%active, %err, "the orphaned connection will not deactivate"); + } +} + +/// The fresh SSID and passphrase a hotspot takes, held in memory only. +fn mint_credentials() -> (String, String) { + let mut suffix = [0u8; 2]; + getrandom::fill(&mut suffix).expect("the platform RNG fills the SSID's suffix"); + let ssid = format!("{SSID_PREFIX}{:02x}{:02x}", suffix[0], suffix[1]); + let mut bytes = [0u8; PASSPHRASE_LEN]; + getrandom::fill(&mut bytes).expect("the platform RNG fills the passphrase"); + let passphrase: String = bytes + .iter() + .map(|byte| char::from(PASSPHRASE_ALPHABET[usize::from(*byte) % PASSPHRASE_ALPHABET.len()])) + .collect(); + (ssid, passphrase) +} + +/// The device a host or join request goes to: the first Wi-Fi device whose +/// capabilities carry the access point flag, the first where they do not. +fn device_of(env: &Env) -> Option<&WifiDevice> { + env.devices + .iter() + .find(|device| device.is_access_point()) + .or_else(|| env.devices.first()) +} + +impl HotspotBackend for LinuxHotspotBackend { + fn request_host(&self) -> Result<(), HotspotError> { + let mut s = self.standing.lock(); + // The column already hosts or asks, so the machine already waits. + if matches!( + s.host, + HostStanding::Five { .. } | HostStanding::Two { .. } | HostStanding::Active { .. } + ) { + return Ok(()); + } + // An unread table is no missing Wi-Fi, so the request may retry. + if !s.env.devices_read { + return Err(HotspotError::Failed); + } + let device: WifiDevice = match s.env.devices.iter().find(|device| device.is_access_point()) + { + Some(device) => device.clone(), + None => { + return if s.env.devices.is_empty() { + Err(HotspotError::Unsupported) + } else { + Err(HotspotError::Incompatible) + }; + } + }; + if !s.env.wifi_enabled { + return Err(HotspotError::Disallowed); + } + if s.env + .permissions + .get(WIFI_SHARE_PROTECTED) + .is_none_or(|granted| granted != "yes") + { + return Err(HotspotError::MissingPermission( + WIFI_SHARE_PROTECTED.to_string(), + )); + } + if matches!( + s.join, + JoinStanding::Requesting { .. } | JoinStanding::Joined { .. } + ) { + return Err(HotspotError::Joined); + } + let (ssid, passphrase) = mint_credentials(); + let generation = s.host_gen + 1; + s.host_gen = generation; + let hw_address = device.hw_address.clone(); + let device_path = device.path.clone(); + if s.env.five_ghz_failed.contains(&device.hw_address) || !device.has_five_ghz() { + s.host = HostStanding::Two { + generation, + active: None, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }; + } else { + s.host = HostStanding::Five { + generation, + sent: Instant::now(), + hw_address: hw_address.clone(), + device: device_path.clone(), + active: None, + ssid: ssid.clone(), + passphrase: passphrase.clone(), + }; + } + let band = match &s.host { + HostStanding::Five { .. } => Band::Five, + _ => Band::Two, + }; + drop(s); + self.send(Command::Host { + generation, + req: AddRequest::Host { + device: device_path, + band, + ssid, + passphrase, + }, + }); + Ok(()) + } + + fn stop_host(&self) { + let path = { + let mut s = self.standing.lock(); + if matches!(s.host, HostStanding::Idle | HostStanding::Ending) { + None + } else if matches!(s.host, HostStanding::Ended(_)) { + s.host = HostStanding::Idle; + None + } else { + let path = match &s.host { + HostStanding::Five { active, .. } | HostStanding::Two { active, .. } => { + active.clone() + } + HostStanding::Active { active, .. } => Some(active.clone()), + _ => None, + }; + s.host = HostStanding::Ending; + path + } + }; + if let Some(path) = path { + self.send(Command::Deactivate(path)); + } + } + + fn host_status(&self) -> HostStatus { + let s = self.standing.lock(); + match &s.host { + HostStanding::Idle | HostStanding::Five { .. } | HostStanding::Two { .. } => { + HostStatus::Pending + } + HostStanding::Active { + ssid, passphrase, .. + } => HostStatus::Started { + ssid: ssid.clone(), + passphrase: passphrase.clone(), + security: HotspotSecurity::Wpa2, + }, + HostStanding::Ending | HostStanding::Ended(HostOutcome::Stopped) => HostStatus::Stopped, + HostStanding::Ended(HostOutcome::Failed(err)) => HostStatus::Failed(err.clone()), + } + } + + fn request_join(&self, offer: &HotspotOffer) -> Result<(), JoinError> { + let mut s = self.standing.lock(); + let replacing = matches!( + s.join, + JoinStanding::Requesting { .. } | JoinStanding::Joined { .. } + ); + if !replacing { + if !s.env.devices_read { + return Err(JoinError::Failed); + } + if s.env.devices.is_empty() { + return Err(JoinError::Unsupported); + } + if !s.env.wifi_enabled { + return Err(JoinError::Failed); + } + for name in [NETWORK_CONTROL, SETTINGS_MODIFY_OWN] { + if s.env + .permissions + .get(name) + .is_none_or(|granted| granted != "yes") + { + return Err(JoinError::MissingPermission(name.to_string())); + } + } + if matches!( + s.host, + HostStanding::Five { .. } + | HostStanding::Two { .. } + | HostStanding::Active { .. } + | HostStanding::Ending + ) { + return Err(JoinError::Hosting); + } + } + let Some(device) = device_of(&s.env).cloned() else { + return Err(JoinError::Unsupported); + }; + let security = match offer.security { + HotspotSecurity::Wpa2 => Security::Wpa2, + HotspotSecurity::Wpa3 => Security::Wpa3, + }; + let old_active = match &s.join { + JoinStanding::Requesting { active, .. } => active.clone(), + JoinStanding::Joined { active, .. } => Some(active.clone()), + _ => None, + }; + let generation = s.join_gen + 1; + s.join_gen = generation; + s.join = JoinStanding::Requesting { + generation, + active: None, + awaiting_gateway: false, + }; + let req = AddRequest::Join { + device: device.path.clone(), + ssid: offer.ssid.clone(), + passphrase: offer.passphrase.clone(), + security, + }; + drop(s); + if let Some(path) = old_active { + self.send(Command::Deactivate(path)); + } + self.send(Command::Join { generation, req }); + Ok(()) + } + + fn leave_join(&self) { + let path = { + let mut s = self.standing.lock(); + if matches!(s.join, JoinStanding::Idle | JoinStanding::Ended(_)) { + None + } else { + let path = match &s.join { + JoinStanding::Requesting { active, .. } => active.clone(), + JoinStanding::Joined { active, .. } => Some(active.clone()), + _ => None, + }; + s.join = JoinStanding::Idle; + path + } + }; + if let Some(path) = path { + self.send(Command::Deactivate(path)); + } + } + + fn join_status(&self) -> JoinStatus { + let s = self.standing.lock(); + match &s.join { + JoinStanding::Idle | JoinStanding::Requesting { .. } => JoinStatus::Pending, + JoinStanding::Joined { + address, + prefix, + gateway, + .. + } => JoinStatus::Available { + address: *address, + prefix: *prefix, + gateway: *gateway, + }, + JoinStanding::Ended(JoinOutcome::Unavailable) => JoinStatus::Unavailable, + JoinStanding::Ended(JoinOutcome::Lost) => JoinStatus::Lost, + } + } + + fn request_personal_host(&self, _ssid: &str, _passphrase: &str) -> Result<(), HotspotError> { + Err(HotspotError::Unsupported) + } + + fn personal_landed(&self) -> bool { + false + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use connetto_peer::linux::{CAP_ACCESS_POINT, CAP_FREQ_5GHZ, Ipv4Standing, NmIoError}; + + const DEVICE_PATH: &str = "/org/freedesktop/NetworkManager/Devices/3"; + const HW_ADDRESS: &str = "aa:bb:cc:dd:ee:ff"; + const ACTIVE_PATH: &str = "/org/freedesktop/NetworkManager/ActiveConnection/1"; + /// The access point's flag, the table's rows' device. + const AP_FLAG: u32 = CAP_ACCESS_POINT; + /// The `5 GHz` band's flag, the table's rows' device. + const FIVE_FLAG: u32 = CAP_FREQ_5GHZ; + + fn device(capabilities: u32) -> WifiDevice { + WifiDevice { + path: DEVICE_PATH.to_string(), + hw_address: HW_ADDRESS.to_string(), + capabilities, + managed: true, + } + } + + fn granted_permissions() -> HashMap { + HashMap::from([ + (WIFI_SHARE_PROTECTED.to_string(), "yes".to_string()), + (NETWORK_CONTROL.to_string(), "yes".to_string()), + (SETTINGS_MODIFY_OWN.to_string(), "yes".to_string()), + ]) + } + + fn offer(ssid: &str, security: HotspotSecurity) -> HotspotOffer { + HotspotOffer::new(ssid, "the-passphrase", security, Some(5432)) + } + + fn ipv4_standing(gateway: Option) -> Ipv4Standing { + Ipv4Standing { + address: Ipv4Addr::new(10, 42, 0, 2), + prefix: 24, + gateway, + } + } + + /// The fake `NetworkManager` I/O, the table's rows driven. + struct FakeNm { + permissions: Mutex>, + wifi_enabled: Mutex, + devices: Mutex>, + /// The queued `add_and_activate` answers, in order, the default + /// answer the active connection's path. + adds: Mutex>>, + /// The `add_and_activate` hold, the test's gate on the answer. + add_gate: Mutex>>, + adds_sent: Mutex>, + deactivates: Mutex>, + /// Whether `deactivate` refuses, the test's switch. + deactivate_refuse: Mutex, + ipv4: Mutex>, + env_reads: AtomicUsize, + env_ready: AtomicBool, + /// How many device reads refuse before one answers, the test's + /// switch. + device_refusals: Mutex, + events: tokio::sync::Mutex>, + } + + impl FakeNm { + fn new( + devices: Vec, + permissions: HashMap, + wifi_enabled: bool, + ) -> (Arc, mpsc::UnboundedSender) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + permissions: Mutex::new(permissions), + wifi_enabled: Mutex::new(wifi_enabled), + devices: Mutex::new(devices), + adds: Mutex::new(VecDeque::new()), + add_gate: Mutex::new(None), + adds_sent: Mutex::new(Vec::new()), + deactivates: Mutex::new(Vec::new()), + deactivate_refuse: Mutex::new(false), + ipv4: Mutex::new(HashMap::new()), + env_reads: AtomicUsize::new(0), + env_ready: AtomicBool::new(false), + device_refusals: Mutex::new(0), + events: tokio::sync::Mutex::new(rx), + }), + tx, + ) + } + + /// Whether the thread's first reads have stood. + fn env_ready(&self) -> bool { + self.env_ready.load(Ordering::Acquire) + } + + fn hold_add(&self) -> tokio::sync::oneshot::Sender<()> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.add_gate.lock().replace(rx); + tx + } + } + + impl NmIo for FakeNm { + fn permissions(&self) -> impl Future, NmIoError>> { + std::future::ready(Ok(self.permissions.lock().clone())) + } + + fn wifi_enabled(&self) -> impl Future> { + std::future::ready(Ok(*self.wifi_enabled.lock())) + } + + fn wifi_devices(&self) -> impl Future, NmIoError>> { + self.env_reads.fetch_add(1, Ordering::Relaxed); + let devices = self.devices.lock().clone(); + self.env_ready.store(true, Ordering::Release); + let refused = { + let mut refusals = self.device_refusals.lock(); + let left = refusals.checked_sub(1); + *refusals = left.unwrap_or(0); + left.is_some() + }; + if refused { + return std::future::ready(Err(NmIoError::Failed( + "the device table will not read".into(), + ))); + } + std::future::ready(Ok(devices)) + } + + async fn add_and_activate(&self, req: &AddRequest) -> Result { + self.adds_sent.lock().push(req.clone()); + let gate = self.add_gate.lock().take(); + if let Some(gate) = gate { + assert!(gate.await.is_ok(), "the test's gate never released the add"); + } + self.adds + .lock() + .pop_front() + .unwrap_or_else(|| Ok(ACTIVE_PATH.to_string())) + } + + fn deactivate(&self, active: &str) -> impl Future> { + self.deactivates.lock().push(active.to_string()); + if *self.deactivate_refuse.lock() { + return std::future::ready(Err(NmIoError::Failed( + "the active connection will not deactivate".into(), + ))); + } + std::future::ready(Ok(())) + } + + fn ipv4(&self, active: &str) -> impl Future> { + std::future::ready( + self.ipv4 + .lock() + .get(active) + .cloned() + .ok_or_else(|| NmIoError::Failed("no IPv4 standing".to_string())), + ) + } + + async fn next_event(&self) -> Option { + let mut events = self.events.lock().await; + events.recv().await + } + } + + struct Harness { + backend: LinuxHotspotBackend, + fake: Arc, + events: mpsc::UnboundedSender, + driver: tokio::task::JoinHandle<()>, + } + + impl Drop for Harness { + fn drop(&mut self) { + self.driver.abort(); + } + } + + fn harness( + devices: Vec, + permissions: HashMap, + wifi_enabled: bool, + ) -> Harness { + let (fake, events) = FakeNm::new(devices, permissions, wifi_enabled); + let (backend, receiver) = LinuxHotspotBackend::unstarted(); + let standing = backend.standing.clone(); + let driver = tokio::spawn(LinuxHotspotBackend::drive(fake.clone(), standing, receiver)); + Harness { + backend, + fake, + events, + driver, + } + } + + fn sent(h: &Harness) -> Vec { + h.fake.adds_sent.lock().clone() + } + + fn deactivates(h: &Harness) -> Vec { + h.fake.deactivates.lock().clone() + } + + fn band_of(adds: &[AddRequest], index: usize) -> Option { + match adds.get(index)? { + AddRequest::Host { band, .. } => Some(*band), + AddRequest::Join { .. } => None, + } + } + + fn awaiting_gateway(h: &Harness) -> bool { + matches!( + h.backend.standing.lock().join, + JoinStanding::Requesting { + awaiting_gateway: true, + active: Some(_), + .. + } + ) + } + + fn activated_event(events: &mpsc::UnboundedSender) { + events + .send(NmEvent::StateChanged { + active: ACTIVE_PATH.to_string(), + state: STATE_ACTIVATED, + reason: 0, + }) + .expect("the event goes in"); + } + + fn deactivated_event(events: &mpsc::UnboundedSender, reason: u32) { + events + .send(NmEvent::StateChanged { + active: ACTIVE_PATH.to_string(), + state: STATE_DEACTIVATING, + reason, + }) + .expect("the event goes in"); + } + + async fn pump_until(mut condition: impl FnMut() -> bool) { + let start = std::time::Instant::now(); + while !condition() { + assert!( + start.elapsed() < Duration::from_secs(5), + "the standing did not reach" + ); + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + + /// One look for the thread, a bounded step of the virtual clock far + /// inside the `5 GHz` bound. + async fn pump() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // The host table. + + #[tokio::test] + async fn host_request_without_a_wifi_device() { + tokio::time::pause(); + let h = harness(Vec::new(), granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Unsupported)); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn host_request_without_the_access_point_flag() { + tokio::time::pause(); + let h = harness(vec![device(0)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Incompatible)); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn host_request_with_wifi_disabled() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + false, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Disallowed)); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn host_request_without_the_share_permission() { + tokio::time::pause(); + let mut permissions = granted_permissions(); + permissions.remove(WIFI_SHARE_PROTECTED); + let h = harness(vec![device(AP_FLAG | FIVE_FLAG)], permissions, true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend.request_host(), + Err(HotspotError::MissingPermission( + WIFI_SHARE_PROTECTED.to_string() + )) + ); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn host_request_while_the_join_requests() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + assert_eq!(h.backend.request_host(), Err(HotspotError::Joined)); + } + + #[tokio::test] + async fn host_request_while_joined() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Joined)); + } + + #[tokio::test] + async fn host_request_starts_on_five_ghz() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + let adds = sent(&h); + let AddRequest::Host { + device, + band, + ssid, + passphrase, + } = &adds[0] + else { + panic!("the request is the host's"); + }; + assert_eq!(device, DEVICE_PATH); + assert_eq!(*band, Band::Five); + assert!(ssid.starts_with(SSID_PREFIX)); + assert_eq!(ssid.len(), SSID_PREFIX.len() + 4); + assert!( + ssid[SSID_PREFIX.len()..] + .bytes() + .all(|b| b.is_ascii_hexdigit()) + ); + assert_eq!(passphrase.len(), PASSPHRASE_LEN); + assert!(matches!(h.backend.host_status(), HostStatus::Pending)); + } + + #[tokio::test] + async fn host_request_without_the_five_ghz_capability() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + assert_eq!(band_of(&sent(&h), 0), Some(Band::Two)); + } + + #[tokio::test] + async fn host_request_remembers_the_five_ghz_failure() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the refusal".to_string()))); + assert_eq!(h.backend.request_host(), Ok(())); + // The `5 GHz` request's D-Bus error: the device remembered, band + // `bg` sent, the refusal's row carrying no deactivate. + pump_until(|| sent(&h).len() == 2).await; + assert_eq!(band_of(&sent(&h), 0), Some(Band::Five)); + assert_eq!(band_of(&sent(&h), 1), Some(Band::Two)); + assert_eq!(deactivates(&h), Vec::::new()); + // The stop, the memory kept for the life of the process. + h.backend.stop_host(); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Pending)).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 3).await; + assert_eq!(band_of(&sent(&h), 2), Some(Band::Two)); + } + + #[tokio::test] + async fn host_request_permission_denied() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + h.fake + .adds + .lock() + .push_back(Err(AddError::PermissionDenied)); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Failed(_))).await; + assert_eq!( + h.backend.host_status(), + HostStatus::Failed(HotspotError::MissingPermission( + WIFI_SHARE_PROTECTED.to_string() + )) + ); + // The row's answer, no band-`bg` attempt. + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn host_activated_stands_started() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + let AddRequest::Host { + ssid: asked_ssid, + passphrase: asked_passphrase, + .. + } = &sent(&h)[0] + else { + panic!("the request is the host's"); + }; + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + let HostStatus::Started { + ssid, + passphrase, + security, + } = h.backend.host_status() + else { + panic!("the host side stands started"); + }; + assert_eq!(&ssid, asked_ssid); + assert_eq!(&passphrase, asked_passphrase); + assert_eq!(security, HotspotSecurity::Wpa2); + } + + #[tokio::test] + async fn host_credentials_are_fresh_per_hotspot() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + let first = match &sent(&h)[0] { + AddRequest::Host { ssid, .. } => ssid.clone(), + AddRequest::Join { .. } => panic!("the request is the host's"), + }; + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + h.backend.stop_host(); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Pending)).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 2).await; + let second = match &sent(&h)[1] { + AddRequest::Host { ssid, .. } => ssid.clone(), + AddRequest::Join { .. } => panic!("the request is the host's"), + }; + assert_ne!(first, second); + } + + #[tokio::test] + async fn host_request_while_activating_is_a_no_op() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + // The machine already waits, so the request stands the column. + assert_eq!(h.backend.request_host(), Ok(())); + pump().await; + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn host_dbus_error_ends_the_two_ghz() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the refusal".to_string()))); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Failed(_))).await; + assert_eq!( + h.backend.host_status(), + HostStatus::Failed(HotspotError::Failed) + ); + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn host_deactivated_falls_back_to_two_ghz() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + deactivated_event(&h.events, 13); + // The row: the device remembered, band `bg` under the same + // credentials, the connection already gone and not deactivated. + pump_until(|| sent(&h).len() == 2).await; + assert_eq!(band_of(&sent(&h), 1), Some(Band::Two)); + assert_eq!(deactivates(&h), Vec::::new()); + h.backend.stop_host(); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Pending)).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 3).await; + assert_eq!(band_of(&sent(&h), 2), Some(Band::Two)); + } + + #[tokio::test] + async fn host_deactivated_ends_the_two_ghz() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + deactivated_event(&h.events, 13); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Failed(_))).await; + assert_eq!( + h.backend.host_status(), + HostStatus::Failed(HotspotError::Failed) + ); + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn host_deactivated_stops_the_hosting() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + deactivated_event(&h.events, 13); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Stopped)).await; + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn host_five_ghz_bound_falls_back() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + // The bound's run: deactivate, remember, band `bg` under the same + // credentials. + pump_until(|| sent(&h).len() == 2).await; + assert_eq!(band_of(&sent(&h), 1), Some(Band::Two)); + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + // The `2.4 GHz` attempt's activation stands the hotspot. + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + } + + #[tokio::test] + async fn host_stop_cancels_the_activating() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + h.backend.stop_host(); + // The drain answers Stopped to the machine's look. + assert!(matches!(h.backend.host_status(), HostStatus::Stopped)); + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Pending)).await; + } + + #[tokio::test] + async fn host_stop_does_nothing_while_idle() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + h.backend.stop_host(); + pump().await; + assert!(sent(&h).is_empty()); + assert_eq!(deactivates(&h), Vec::::new()); + assert!(matches!(h.backend.host_status(), HostStatus::Pending)); + } + + #[tokio::test] + async fn host_stale_add_after_stop_is_released() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + let gate = h.fake.hold_add(); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + h.backend.stop_host(); + assert!(matches!(h.backend.host_status(), HostStatus::Stopped)); + gate.send(()).expect("the gate opens"); + // The request's column moved, so the activated connection + // deactivates as the stop's. + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Pending)).await; + } + + #[tokio::test] + async fn host_stop_resets_the_failed() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the refusal".to_string()))); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Failed(_))).await; + h.backend.stop_host(); + // The Ended column resets to Idle, with nothing to deactivate. + assert!(matches!(h.backend.host_status(), HostStatus::Pending)); + assert_eq!(deactivates(&h), Vec::::new()); + } + + #[tokio::test] + async fn host_stop_survives_a_refused_deactivate() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + *h.fake.deactivate_refuse.lock() = true; + h.backend.stop_host(); + // The refusal does not stop the drain, which still settles Idle. + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Pending)).await; + } + + #[tokio::test] + async fn host_five_ghz_bound_survives_a_refused_deactivate() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + *h.fake.deactivate_refuse.lock() = true; + h.fake.adds.lock().push_back(Ok(ACTIVE_PATH.to_string())); + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the band bg refusal".to_string()))); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + // The bound's run finds the deactivate and the band-`bg` request + // both refused. + tokio::time::sleep(FIVE_GHZ_BOUND + Duration::from_millis(100)).await; + pump_until(|| matches!(h.backend.host_status(), HostStatus::Failed(_))).await; + assert_eq!( + h.backend.host_status(), + HostStatus::Failed(HotspotError::Failed) + ); + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + assert_eq!(sent(&h).len(), 2); + assert_eq!(band_of(&sent(&h), 1), Some(Band::Two)); + } + + #[tokio::test] + async fn host_stale_refusal_leaves_the_column() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + let gate = h.fake.hold_add(); + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the stale refusal".to_string()))); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + h.backend.stop_host(); + assert_eq!(h.backend.request_host(), Ok(())); + gate.send(()).expect("the gate opens"); + // The stale refusal leaves the newer column standing. + pump_until(|| sent(&h).len() == 2).await; + assert!(matches!(h.backend.host_status(), HostStatus::Pending)); + assert_eq!(deactivates(&h), Vec::::new()); + } + + /// The bound's row is a no-op where the column stands no `5 GHz` + /// attempt, so nothing deactivates or sends. + #[tokio::test] + async fn five_ghz_bound_row_without_the_column() { + tokio::time::pause(); + let (fake, _events) = FakeNm::new( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + let (backend, _receiver) = LinuxHotspotBackend::unstarted(); + let standing = backend.standing.clone(); + five_ghz_bound_row(fake.as_ref(), &standing).await; + assert!(matches!(standing.lock().host, HostStanding::Idle)); + assert!(fake.adds_sent.lock().is_empty()); + assert!(fake.deactivates.lock().is_empty()); + } + + /// The band-`Five` refusal's row is a no-op where the column stands + /// `2.4 GHz`, so no band-`bg` retry goes out. + #[tokio::test] + async fn host_band_five_refusal_leaves_the_two_ghz_column() { + tokio::time::pause(); + let (fake, _events) = FakeNm::new( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + let (backend, _receiver) = LinuxHotspotBackend::unstarted(); + let standing = backend.standing.clone(); + { + let mut s = standing.lock(); + s.host = HostStanding::Two { + generation: 1, + active: None, + ssid: "the-ssid".to_string(), + passphrase: "the-passphrase".to_string(), + }; + } + let req = AddRequest::Host { + device: DEVICE_PATH.to_string(), + band: Band::Five, + ssid: "the-ssid".to_string(), + passphrase: "the-passphrase".to_string(), + }; + host_add_refused( + fake.as_ref(), + &standing, + &req, + 1, + AddError::Failed("the refusal".to_string()), + ) + .await; + assert!(matches!( + standing.lock().host, + HostStanding::Two { + generation: 1, + active: None, + .. + } + )); + assert!(fake.adds_sent.lock().is_empty()); + assert!(fake.deactivates.lock().is_empty()); + } + + #[tokio::test] + async fn host_other_state_leaves_the_column() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + h.events + .send(NmEvent::StateChanged { + active: ACTIVE_PATH.to_string(), + state: 1, + reason: 0, + }) + .expect("the event goes in"); + pump().await; + assert!(matches!( + h.backend.standing.lock().host, + HostStanding::Two { + active: Some(_), + .. + } + )); + assert_eq!(deactivates(&h), Vec::::new()); + } + + #[tokio::test] + async fn check_permissions_rereads_the_permission_table() { + tokio::time::pause(); + let mut permissions = granted_permissions(); + permissions.remove(WIFI_SHARE_PROTECTED); + let h = harness(vec![device(AP_FLAG)], permissions, true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend.request_host(), + Err(HotspotError::MissingPermission( + WIFI_SHARE_PROTECTED.to_string() + )) + ); + // The right stands granted, and the signal tells the thread to + // re-read the table. + h.fake + .permissions + .lock() + .insert(WIFI_SHARE_PROTECTED.to_string(), "yes".to_string()); + h.events + .send(NmEvent::CheckPermissions) + .expect("the event goes in"); + pump_until(|| h.backend.request_host().is_ok()).await; + pump_until(|| sent(&h).len() == 1).await; + } + + /// A device table whose read failed answers the retryable `Failed`, + /// and the tick's re-read stands it read. + #[tokio::test] + async fn an_unread_device_table_answers_failed_until_the_tick_reads_it() { + tokio::time::pause(); + let (fake, events) = FakeNm::new(vec![device(AP_FLAG)], granted_permissions(), true); + *fake.device_refusals.lock() = 1; + let (backend, receiver) = LinuxHotspotBackend::unstarted(); + let driver = tokio::spawn(LinuxHotspotBackend::drive( + fake.clone(), + backend.standing.clone(), + receiver, + )); + let h = Harness { + backend, + fake, + events, + driver, + }; + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Failed)); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Failed), + "an unread table is no missing Wi-Fi" + ); + tokio::time::sleep(TICK + Duration::from_millis(50)).await; + pump_until(|| h.backend.request_host().is_ok()).await; + pump_until(|| sent(&h).len() == 1).await; + } + + /// A device `NetworkManager` adds after the start stands in the table. + #[tokio::test] + async fn a_device_change_rereads_the_table() { + tokio::time::pause(); + let h = harness(Vec::new(), granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Unsupported)); + h.fake.devices.lock().push(device(AP_FLAG)); + h.events + .send(NmEvent::DevicesChanged) + .expect("the event goes in"); + pump_until(|| h.backend.request_host().is_ok()).await; + pump_until(|| sent(&h).len() == 1).await; + } + + /// A device `NetworkManager` removes leaves the table, and a failed + /// re-read keeps the table it had. + #[tokio::test] + async fn a_removed_device_leaves_and_a_failed_reread_keeps_the_table() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + *h.fake.device_refusals.lock() = 1; + h.fake.devices.lock().clear(); + h.events + .send(NmEvent::DevicesChanged) + .expect("the event goes in"); + pump_until(|| *h.fake.device_refusals.lock() == 0).await; + tokio::task::yield_now().await; + assert!( + h.backend.standing.lock().env.devices.len() == 1, + "a failed re-read keeps the table" + ); + h.events + .send(NmEvent::DevicesChanged) + .expect("the event goes in"); + pump_until(|| h.backend.standing.lock().env.devices.is_empty()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Unsupported)); + } + + /// An add `NetworkManager` refuses with a failure re-reads the device + /// table, since the device path may be gone. + #[tokio::test] + async fn a_failed_add_rereads_the_device_table() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + let reads = h.fake.env_reads.load(Ordering::Relaxed); + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the device is gone".into()))); + h.fake.devices.lock().clear(); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| h.fake.env_reads.load(Ordering::Relaxed) > reads).await; + pump_until(|| h.backend.standing.lock().env.devices.is_empty()).await; + } + + #[tokio::test] + async fn wireless_enabled_stands_the_flag() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), false); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Err(HotspotError::Disallowed)); + h.events + .send(NmEvent::WirelessEnabled(true)) + .expect("the event goes in"); + pump_until(|| h.backend.request_host().is_ok()).await; + pump_until(|| sent(&h).len() == 1).await; + } + + #[tokio::test] + async fn host_request_after_the_stop_starts_fresh() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + h.backend.stop_host(); + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 2).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + } + + #[test] + fn personal_host_is_unsupported() { + let (backend, _receiver) = LinuxHotspotBackend::unstarted(); + assert_eq!( + backend.request_personal_host("the-ssid", "the-passphrase"), + Err(HotspotError::Unsupported) + ); + assert!(!backend.personal_landed()); + } + + // The join table. + + #[tokio::test] + async fn join_request_without_a_wifi_device() { + tokio::time::pause(); + let h = harness(Vec::new(), granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Unsupported) + ); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn join_request_with_wifi_disabled() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), false); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Failed) + ); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn join_request_without_network_control() { + tokio::time::pause(); + let mut permissions = granted_permissions(); + permissions.remove(NETWORK_CONTROL); + let h = harness(vec![device(AP_FLAG)], permissions, true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::MissingPermission(NETWORK_CONTROL.to_string())) + ); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn join_request_without_settings_modify_own() { + tokio::time::pause(); + let mut permissions = granted_permissions(); + permissions.remove(SETTINGS_MODIFY_OWN); + let h = harness(vec![device(AP_FLAG)], permissions, true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::MissingPermission( + SETTINGS_MODIFY_OWN.to_string() + )) + ); + assert!(sent(&h).is_empty()); + } + + #[tokio::test] + async fn join_request_while_hosting() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + // The host side's activating column refuses the join. + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Hosting) + ); + // The active column refuses it too. + activated_event(&h.events); + pump_until(|| matches!(h.backend.host_status(), HostStatus::Started { .. })).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Hosting) + ); + } + + #[tokio::test] + async fn join_request_while_the_host_stop_drains() { + tokio::time::pause(); + let h = harness( + vec![device(AP_FLAG | FIVE_FLAG)], + granted_permissions(), + true, + ); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + h.backend.stop_host(); + // The stop's drain refuses the join, the device never losing its + // network as a side effect. + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Hosting) + ); + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + } + + #[tokio::test] + async fn join_request_prefers_the_missing_permission() { + tokio::time::pause(); + let mut permissions = granted_permissions(); + permissions.remove(NETWORK_CONTROL); + let h = harness(vec![device(AP_FLAG | FIVE_FLAG)], permissions, true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!(h.backend.request_host(), Ok(())); + pump_until(|| sent(&h).len() == 1).await; + // The table's order: the missing right before the busy device. + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::MissingPermission(NETWORK_CONTROL.to_string())) + ); + } + + #[tokio::test] + async fn join_request_sends_the_offer() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-first", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + let AddRequest::Join { + device, + ssid, + passphrase, + security, + } = &sent(&h)[0] + else { + panic!("the request is the join's"); + }; + assert_eq!(device, DEVICE_PATH); + assert_eq!(ssid, "the-first"); + assert_eq!(passphrase, "the-passphrase"); + assert_eq!(*security, Security::Wpa2); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + // The Wpa3 offer's security, the earlier request released. + h.backend.leave_join(); + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!( + h.backend + .request_join(&offer("the-second", HotspotSecurity::Wpa3)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 2).await; + let AddRequest::Join { security, .. } = &sent(&h)[1] else { + panic!("the request is the join's"); + }; + assert_eq!(*security, Security::Wpa3); + } + + #[tokio::test] + async fn join_activated_stands_available() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + let JoinStatus::Available { + address, + prefix, + gateway, + } = h.backend.join_status() + else { + panic!("the join side stands available"); + }; + assert_eq!(address, Ipv4Addr::new(10, 42, 0, 2)); + assert_eq!(prefix, 24); + assert_eq!(gateway, IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1))); + } + + #[tokio::test] + async fn join_gateway_stands_when_it_arrives() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .ipv4 + .lock() + .insert(ACTIVE_PATH.to_string(), ipv4_standing(None)); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + // The row stays Requesting, Pending, for the machine's bound. + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Pending)).await; + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + // The gateway arrives. + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + } + + #[tokio::test] + async fn join_request_permission_denied() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .adds + .lock() + .push_back(Err(AddError::PermissionDenied)); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Unavailable)).await; + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn join_deactivated_ends_the_requesting() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + deactivated_event(&h.events, 13); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Unavailable)).await; + } + + #[tokio::test] + async fn join_deactivated_ends_the_joined() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + deactivated_event(&h.events, 13); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Lost)).await; + } + + #[tokio::test] + async fn join_request_replaces_the_requesting() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-first", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + // The new offer: the earlier deactivated, the new one sent. + assert_eq!( + h.backend + .request_join(&offer("the-second", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 2).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + let AddRequest::Join { ssid, .. } = &sent(&h)[1] else { + panic!("the request is the join's"); + }; + assert_eq!(ssid, "the-second"); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_request_replaces_the_joined() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + assert_eq!( + h.backend + .request_join(&offer("the-first", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + assert_eq!( + h.backend + .request_join(&offer("the-second", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 2).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_leave_cancels_the_requesting() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + h.backend.leave_join(); + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_leave_does_nothing_while_idle() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.backend.leave_join(); + pump().await; + assert!(sent(&h).is_empty()); + assert_eq!(deactivates(&h), Vec::::new()); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_stale_add_after_leave_is_released() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + let gate = h.fake.hold_add(); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + h.backend.leave_join(); + // The column moved without an active connection yet, so the + // leave carries no deactivate. + assert_eq!(deactivates(&h), Vec::::new()); + gate.send(()).expect("the gate opens"); + // The activated connection deactivates as the leave's. + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_gateway_repoll_stands_joined() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .ipv4 + .lock() + .insert(ACTIVE_PATH.to_string(), ipv4_standing(None)); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + // The activated row's standing holds no gateway, so the column + // stands it awaiting, and the repoll's deadline stands. + pump_until(|| awaiting_gateway(&h)).await; + // The deadline's look runs, and the gateway arrives on the later + // look, whose repoll stands the join. + tokio::time::sleep(TICK + Duration::from_millis(50)).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + let JoinStatus::Available { + address, + prefix, + gateway, + } = h.backend.join_status() + else { + panic!("the join side stands available"); + }; + assert_eq!(address, Ipv4Addr::new(10, 42, 0, 2)); + assert_eq!(prefix, 24); + assert_eq!(gateway, IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1))); + } + + #[tokio::test] + async fn join_without_a_gateway_stays_awaiting() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .ipv4 + .lock() + .insert(ACTIVE_PATH.to_string(), ipv4_standing(None)); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| awaiting_gateway(&h)).await; + // The repoll's standing stands no gateway, so the column keeps + // waiting. + tokio::time::sleep(TICK * 2).await; + assert!(awaiting_gateway(&h)); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_activated_without_an_ipv4_standing() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + // No IPv4 standing for the activated row to read. + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + // The read's refusal stands the column awaiting the gateway. + pump_until(|| awaiting_gateway(&h)).await; + // The deadline's look re-reads, and the standing arrives on the + // later look, whose repoll stands the join. + tokio::time::sleep(TICK + Duration::from_millis(50)).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + } + + #[tokio::test] + async fn join_request_failed() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the refusal".to_string()))); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Unavailable)).await; + assert_eq!(sent(&h).len(), 1); + } + + #[tokio::test] + async fn join_stale_refusal_leaves_the_column() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + let gate = h.fake.hold_add(); + h.fake + .adds + .lock() + .push_back(Err(AddError::Failed("the stale refusal".to_string()))); + assert_eq!( + h.backend + .request_join(&offer("the-first", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + // The newer offer replaces the column while the earlier stands. + assert_eq!( + h.backend + .request_join(&offer("the-second", HotspotSecurity::Wpa2)), + Ok(()) + ); + gate.send(()).expect("the gate opens"); + // The stale refusal leaves the newer column standing. + pump_until(|| sent(&h).len() == 2).await; + let AddRequest::Join { ssid, .. } = &sent(&h)[1] else { + panic!("the request is the join's"); + }; + assert_eq!(ssid, "the-second"); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + assert_eq!(band_of(&sent(&h), 0), None); + assert_eq!(band_of(&sent(&h), 5), None); + } + + /// The replacing request has no device to send to, so it refuses + /// unsupported, the column left standing. + #[test] + fn join_request_replacing_without_a_device() { + let (backend, _receiver) = LinuxHotspotBackend::unstarted(); + { + let mut s = backend.standing.lock(); + s.join = JoinStanding::Requesting { + generation: 1, + active: None, + awaiting_gateway: false, + }; + } + assert_eq!( + backend.request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Err(JoinError::Unsupported) + ); + assert!(matches!( + backend.standing.lock().join, + JoinStanding::Requesting { generation: 1, .. } + )); + } + + #[tokio::test] + async fn join_request_without_the_access_point_flag() { + tokio::time::pause(); + let h = harness(vec![device(0)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + let AddRequest::Join { device, .. } = &sent(&h)[0] else { + panic!("the request is the join's"); + }; + assert_eq!(device, DEVICE_PATH); + } + + #[tokio::test] + async fn join_leave_deactivates_the_joined() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + h.fake.ipv4.lock().insert( + ACTIVE_PATH.to_string(), + ipv4_standing(Some(IpAddr::V4(Ipv4Addr::new(10, 42, 0, 1)))), + ); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + activated_event(&h.events); + pump_until(|| matches!(h.backend.join_status(), JoinStatus::Available { .. })).await; + h.backend.leave_join(); + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } + + #[tokio::test] + async fn join_stale_add_survives_a_refused_deactivate() { + tokio::time::pause(); + let h = harness(vec![device(AP_FLAG)], granted_permissions(), true); + pump_until(|| h.fake.env_ready()).await; + let gate = h.fake.hold_add(); + assert_eq!( + h.backend + .request_join(&offer("the-offer", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 1).await; + *h.fake.deactivate_refuse.lock() = true; + h.backend.leave_join(); + gate.send(()).expect("the gate opens"); + // The orphan's refused deactivate does not stop the driver. + pump_until(|| deactivates(&h).len() == 1).await; + assert_eq!(deactivates(&h), vec![ACTIVE_PATH.to_string()]); + // The driver still answers the machine's next request. + assert_eq!( + h.backend + .request_join(&offer("the-second", HotspotSecurity::Wpa2)), + Ok(()) + ); + pump_until(|| sent(&h).len() == 2).await; + assert!(matches!(h.backend.join_status(), JoinStatus::Pending)); + } +} diff --git a/crates/connetto-client/src/lib.rs b/crates/connetto-client/src/lib.rs index 92cc74e6..4033b303 100644 --- a/crates/connetto-client/src/lib.rs +++ b/crates/connetto-client/src/lib.rs @@ -106,7 +106,8 @@ mod synced_schema; #[cfg(feature = "peer")] pub use bluetooth::{ - BeaconState, BluetoothError, BluetoothState, HostId, Hosted, JoinNearbyError, PromptOutcome, + BeaconState, BluetoothError, BluetoothState, HostId, Hosted, JoinNearbyError, NearbyHost, + PromptOutcome, }; #[cfg(feature = "device-identity")] pub use enrolment::{CertificateError, DeviceEntry}; diff --git a/crates/connetto-client/tests/it/enrolment.rs b/crates/connetto-client/tests/it/enrolment.rs index b96fcc43..66b7585d 100644 --- a/crates/connetto-client/tests/it/enrolment.rs +++ b/crates/connetto-client/tests/it/enrolment.rs @@ -136,7 +136,7 @@ async fn enrolment_phase() { return; }; match phase.as_str() { - "first" => first_connect().await, + "first" => Box::pin(first_connect()).await, "renewal" => Box::pin(renewal()).await, "reissue" => Box::pin(reissue()).await, "cap" => cap().await, diff --git a/crates/connetto-peer/Cargo.toml b/crates/connetto-peer/Cargo.toml index 7163c9c1..33b5cee1 100644 --- a/crates/connetto-peer/Cargo.toml +++ b/crates/connetto-peer/Cargo.toml @@ -60,12 +60,23 @@ tracing = { version = "0.1", default-features = false, features = ["std"] } # The dial sockets take their domain here and hand a `SockRef` to the # socket prep before their connect. socket2 = "0.6" +# Linux: the system bus NetworkManager answers on and the BlueZ daemon the +# beacon's peripheral runs on, the platform I/O the hotspot and Bluetooth +# backends drive (R76 slice 9b). +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { version = "5.12", default-features = false, features = ["tokio"] } +futures-util = { version = "0.3", default-features = false, features = ["std"] } +bluer = { version = "0.17.4", default-features = false, features = ["bluetoothd"] } +# The NetworkManager profile names the caller as NSS resolves its uid. +nix = { version = "0.29", default-features = false, features = ["user"] } + [dev-dependencies] # The proofs mint roots, issuers, lists and device certificates through the # same machinery the enrolment path uses. rcgen = { version = "0.14.10", default-features = false, features = ["ring", "x509-parser"] } -# `time::pause` drives the 15/45 s liveness clocks in the proofs. -tokio = { version = "1", default-features = false, features = ["test-util"] } +# `time::pause` drives the 15/45 s liveness clocks in the proofs, and the +# `BlueZ` probe waits its re-executed child through `process`. +tokio = { version = "1", default-features = false, features = ["test-util", "process"] } uuid = { version = "1", default-features = false } diff --git a/crates/connetto-peer/src/lib.rs b/crates/connetto-peer/src/lib.rs index 8a866c27..6e07f074 100644 --- a/crates/connetto-peer/src/lib.rs +++ b/crates/connetto-peer/src/lib.rs @@ -28,6 +28,8 @@ mod policy; mod beacon; #[cfg(feature = "bluetooth")] mod exchange; +#[cfg(all(feature = "bluetooth", target_os = "linux"))] +pub mod linux; #[cfg(test)] mod tests; diff --git a/crates/connetto-peer/src/linux/bluez.rs b/crates/connetto-peer/src/linux/bluez.rs new file mode 100644 index 00000000..27cc1948 --- /dev/null +++ b/crates/connetto-peer/src/linux/bluez.rs @@ -0,0 +1,1748 @@ +//! The `BlueZ` peripheral I/O the Bluetooth backend drives over `bluer` +//! (R76 slice 9b). +//! +//! The host's GATT service and its legacy advertisement live behind one +//! `bluer` session on the system bus. The outbox characteristic uses the +//! `Io` notify method, so a central's notify session hands the backend the +//! device and the session's MTU, and the inbox characteristic uses the +//! `Fun` write method, so a central's write hands the backend its bytes. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +use bluer::adv::{Advertisement, AdvertisementHandle, Type}; +use bluer::gatt::CharacteristicWriter; +use bluer::gatt::local::{ + Application, ApplicationHandle, Characteristic, CharacteristicControl, + CharacteristicControlEvent, CharacteristicControlHandle, CharacteristicNotify, + CharacteristicNotifyMethod, CharacteristicRead, CharacteristicWrite, CharacteristicWriteMethod, + Service, characteristic_control, service_control, +}; +use bluer::{Adapter, Address, ErrorKind, Session, Uuid}; +use futures_util::{FutureExt, StreamExt}; +use parking_lot::Mutex; + +/// How long a single `BlueZ` call gets, on the monotonic clock. +const CALL_BOUND: Duration = Duration::from_secs(5); + +/// The device of a nearby central, its 48-bit address folded to one `u64`. +pub type DeviceKey = u64; + +/// The adapter's standing, as the I/O reads it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AdapterStanding { + /// `BlueZ` shows no adapter. + None, + /// The adapter, and whether it is powered. + Powered(bool), +} + +/// Why a `BlueZ` call will not go. +#[derive(Debug, thiserror::Error)] +pub enum BluezError { + /// `BlueZ` refused the call, with its reason. + #[error("BlueZ refused: {0}")] + Refused(String), + /// The device's notify session is closed. + #[error("the device's notify session is closed")] + Closed, + /// The call did not answer within its bound. + #[error("the call did not answer within {0:?}")] + TimedOut(Duration), +} + +/// An event the peripheral's I/O reports. +#[derive(Clone, Debug)] +pub enum PeripheralAnswer { + /// A central started a notify session on the outbox, with its MTU. + Connected { + /// The device's key. + device: DeviceKey, + /// The session's MTU. + mtu: u16, + }, + /// A central wrote the inbox, with its bytes. + Chunk { + /// The device's key. + device: DeviceKey, + /// The chunk's bytes. + bytes: Vec, + }, + /// A notify session ended, or the device disconnected. + Disconnected { + /// The device's key. + device: DeviceKey, + }, + /// The adapter's powered state, once it stands. + Powered(bool), +} + +/// The I/O the Bluetooth backend needs from `BlueZ`. +#[expect( + async_fn_in_trait, + reason = "the backend runs its calls on its own thread's current-thread runtime, so the futures are consumed there" +)] +pub trait BluezIo: Send + Sync { + /// The adapter's standing. + async fn standing(&self) -> AdapterStanding; + /// Set the adapter powered, `Refused` on a hard block. + async fn set_powered(&self, on: bool) -> Result<(), BluezError>; + /// Register the GATT application and the advertisement under `beacon`. + async fn start(&self, beacon: [u8; 9]) -> Result<(), BluezError>; + /// Stop the advertisement and the GATT application. + async fn stop(&self); + /// Send a chunk on the device's notify session. + async fn notify(&self, device: DeviceKey, bytes: &[u8]) -> Result<(), BluezError>; + /// End the device's connection. + async fn disconnect(&self, device: DeviceKey); + /// The next peripheral event. + async fn next_event(&self) -> PeripheralAnswer; +} + +/// The real `BlueZ` peripheral I/O, over `bluer`. +pub struct BluerBluez { + app: Mutex>, + adv: Mutex>, + sessions: Arc>>>, + events: tokio::sync::mpsc::Sender, + event_rx: Mutex>, + #[expect( + dead_code, + reason = "the field owns the session's D-Bus connection, the GATT application and the advertisement run on it" + )] + session: Option, + adapter: Option, +} + +type Sessions = Arc>>>; + +impl BluerBluez { + /// Connect to `bluer` and its default adapter. + /// + /// # Errors + /// + /// [`BluezError`] when the session or the adapter will not stand. A + /// system with no adapter answers the `None` standing, not an error. + pub async fn new() -> Result { + let session = bounded(CALL_BOUND, Session::new()).await?; + let adapter = match tokio::time::timeout(CALL_BOUND, session.default_adapter()).await { + Ok(Ok(adapter)) => Some(adapter), + Ok(Err(err)) if err.kind == ErrorKind::NotFound => None, + Ok(Err(err)) => return Err(refuse(&err)), + Err(_) => return Err(BluezError::TimedOut(CALL_BOUND)), + }; + let (tx, rx) = tokio::sync::mpsc::channel(64); + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + if let Some(adapter) = &adapter { + tokio::spawn(forward_adapter_events( + adapter.clone(), + sessions.clone(), + tx.clone(), + )); + } + Ok(Self { + app: Mutex::new(None), + adv: Mutex::new(None), + sessions, + events: tx, + event_rx: Mutex::new(rx), + session: Some(session), + adapter, + }) + } +} + +/// Run `fut` bounded on `bound`, a refusal and a timeout each mapped. +async fn bounded(bound: Duration, fut: F) -> Result +where + F: Future>, +{ + match tokio::time::timeout(bound, fut).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(err)) => Err(refuse(&err)), + Err(_) => Err(BluezError::TimedOut(bound)), + } +} + +/// Map a `bluer` refusal to the I/O's reason. +fn refuse(err: &bluer::Error) -> BluezError { + BluezError::Refused(format!("{err:?}")) +} + +/// The 48-bit address folded to the device's key, big-endian. +#[must_use] +fn key(address: Address) -> DeviceKey { + let mut folded = 0u64; + for byte in address.0 { + folded = (folded << 8) | u64::from(byte); + } + folded +} + +/// The device's key unfolded to its 48-bit address. +#[must_use] +fn address_of(key: DeviceKey) -> Address { + let bytes = key.to_be_bytes(); + Address(bytes[2..8].try_into().unwrap()) +} + +/// Build the host's GATT application, its outbox's control stream kept. +fn gatt_app( + events: &tokio::sync::mpsc::Sender, +) -> (Application, CharacteristicControl) { + let (_service_control, service_control_handle) = service_control(); + + let inbox_events = events.clone(); + let inbox = Characteristic { + uuid: Uuid::from_bytes(crate::beacon::INBOX_UUID), + handle: None, + broadcast: false, + writable_auxiliaries: false, + authorize: false, + descriptors: Vec::new(), + read: None, + write: Some(CharacteristicWrite { + write: true, + write_without_response: false, + reliable_write: false, + authenticated_signed_writes: false, + encrypt_write: false, + encrypt_authenticated_write: false, + secure_write: false, + method: CharacteristicWriteMethod::Fun(Box::new(move |bytes, request| { + let device = key(request.device_address); + let events = inbox_events.clone(); + async move { + let _ = events.send(PeripheralAnswer::Chunk { device, bytes }).await; + Ok(()) + } + .boxed() + })), + _non_exhaustive: (), + }), + notify: None, + control_handle: CharacteristicControlHandle::default(), + _non_exhaustive: (), + }; + + let (control, control_handle) = characteristic_control(); + let outbox = Characteristic { + uuid: Uuid::from_bytes(crate::beacon::OUTBOX_UUID), + handle: None, + broadcast: false, + writable_auxiliaries: false, + authorize: false, + descriptors: Vec::new(), + read: Some(CharacteristicRead { + read: true, + encrypt_read: false, + encrypt_authenticated_read: false, + secure_read: false, + fun: Box::new(|_request| async move { Ok(Vec::::new()) }.boxed()), + _non_exhaustive: (), + }), + write: None, + notify: Some(CharacteristicNotify { + notify: true, + indicate: false, + method: CharacteristicNotifyMethod::Io, + _non_exhaustive: (), + }), + control_handle, + _non_exhaustive: (), + }; + + let service = Service { + uuid: Uuid::from_bytes(crate::beacon::SERVICE_UUID), + handle: None, + primary: true, + characteristics: vec![inbox, outbox], + control_handle: service_control_handle, + _non_exhaustive: (), + }; + ( + Application { + services: vec![service], + _non_exhaustive: (), + }, + control, + ) +} + +/// Follow the outbox's control stream, standing the notify sessions. +async fn drive_control( + control: CharacteristicControl, + sessions: Sessions, + events: tokio::sync::mpsc::Sender, +) { + let mut control = control; + while let Some(event) = control.next().await { + let CharacteristicControlEvent::Notify(writer) = event else { + continue; + }; + let device = key(writer.device_address()); + let mtu = u16::try_from(writer.mtu()).unwrap_or(512); + let writer = Arc::new(writer); + sessions.lock().insert(device, writer.clone()); + let _ = events + .send(PeripheralAnswer::Connected { device, mtu }) + .await; + let sessions = sessions.clone(); + let events = events.clone(); + tokio::spawn(async move { + let _ = writer.closed().await; + sessions.lock().remove(&device); + let _ = events.send(PeripheralAnswer::Disconnected { device }).await; + }); + } +} + +/// Follow the adapter's events, the powered change and a device's removal. +async fn forward_adapter_events( + adapter: Adapter, + sessions: Sessions, + events: tokio::sync::mpsc::Sender, +) { + let Ok(mut stream) = adapter.events().await else { + return; + }; + while let Some(event) = stream.next().await { + match event { + bluer::AdapterEvent::DeviceRemoved(address) => { + let device = key(address); + sessions.lock().remove(&device); + let _ = events.send(PeripheralAnswer::Disconnected { device }).await; + } + bluer::AdapterEvent::PropertyChanged(bluer::AdapterProperty::Powered(on)) => { + let _ = events.send(PeripheralAnswer::Powered(on)).await; + } + _ => {} + } + } +} + +impl BluezIo for BluerBluez { + async fn standing(&self) -> AdapterStanding { + let Some(adapter) = self.adapter.clone() else { + return AdapterStanding::None; + }; + match bounded(CALL_BOUND, adapter.is_powered()).await { + Ok(powered) => AdapterStanding::Powered(powered), + Err(_) => AdapterStanding::None, + } + } + + async fn set_powered(&self, on: bool) -> Result<(), BluezError> { + let Some(adapter) = self.adapter.clone() else { + return Err(BluezError::Refused("no adapter".into())); + }; + bounded(CALL_BOUND, adapter.set_powered(on)).await + } + + async fn start(&self, beacon: [u8; 9]) -> Result<(), BluezError> { + let Some(adapter) = self.adapter.clone() else { + return Err(BluezError::Refused("no adapter".into())); + }; + // The application runs, so the advertisement's service data is + // replaced, the GATT application kept. + if self.app.lock().is_some() { + let advertisement = advertisement(beacon); + let handle = bounded(CALL_BOUND, adapter.advertise(advertisement)).await?; + self.adv.lock().replace(handle); + return Ok(()); + } + // The handle stands local until the advertisement stands, so a + // refusal unregisters the application and the retry's stand is + // complete. + let (app, control) = gatt_app(&self.events); + let app_handle = bounded(CALL_BOUND, adapter.serve_gatt_application(app)).await?; + let advertisement = advertisement(beacon); + let handle = bounded(CALL_BOUND, adapter.advertise(advertisement)).await?; + self.app.lock().replace(app_handle); + self.adv.lock().replace(handle); + tokio::spawn(drive_control( + control, + self.sessions.clone(), + self.events.clone(), + )); + Ok(()) + } + + async fn stop(&self) { + self.app.lock().take(); + self.adv.lock().take(); + self.sessions.lock().clear(); + } + + async fn notify(&self, device: DeviceKey, bytes: &[u8]) -> Result<(), BluezError> { + let Some(writer) = self.sessions.lock().get(&device).cloned() else { + return Err(BluezError::Closed); + }; + match tokio::time::timeout(CALL_BOUND, writer.send(bytes)).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(BluezError::Closed), + Err(_) => Err(BluezError::TimedOut(CALL_BOUND)), + } + } + + async fn disconnect(&self, device: DeviceKey) { + self.sessions.lock().remove(&device); + let Some(adapter) = self.adapter.clone() else { + return; + }; + let Ok(device) = adapter.device(address_of(device)) else { + return; + }; + let _ = bounded(CALL_BOUND, device.disconnect()).await; + } + + #[expect( + clippy::await_holding_lock, + reason = "the receiver's guard is held only across its own bounded recv" + )] + async fn next_event(&self) -> PeripheralAnswer { + self.event_rx + .lock() + .recv() + .await + .unwrap_or(PeripheralAnswer::Powered(false)) + } +} + +/// The peripheral's legacy advertisement, the service data under the UUID. +fn advertisement(beacon: [u8; 9]) -> Advertisement { + let mut service_data = BTreeMap::new(); + service_data.insert( + Uuid::from_bytes(crate::beacon::SERVICE_UUID), + beacon.to_vec(), + ); + Advertisement { + advertisement_type: Type::Peripheral, + service_uuids: BTreeSet::new(), + manufacturer_data: BTreeMap::new(), + solicit_uuids: BTreeSet::new(), + service_data, + advertising_data: BTreeMap::new(), + discoverable: None, + discoverable_timeout: None, + system_includes: BTreeSet::new(), + local_name: None, + appearance: None, + duration: None, + timeout: None, + secondary_channel: None, + min_interval: None, + max_interval: None, + tx_power: None, + _non_exhaustive: (), + } +} + +#[cfg(test)] +mod tests { + use super::{ + AdapterStanding, BluerBluez, BluezError, BluezIo, CALL_BOUND, PeripheralAnswer, address_of, + advertisement, key, + }; + use bluer::{Address, Uuid}; + use parking_lot::Mutex; + use std::collections::HashMap; + use std::io::Read as _; + use std::sync::Arc; + use std::time::Duration; + use zbus::message::Header; + use zbus::zvariant::{ObjectPath, OwnedFd, OwnedObjectPath, OwnedValue}; + + #[test] + fn the_key_folds_the_address_big_endian() { + let address = Address([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + assert_eq!(key(address), 0xaabb_ccdd_eeff); + assert_eq!(key(Address([0; 6])), 0); + } + + #[test] + fn the_address_unfolds_the_key() { + for device_key in [0u64, 1, 0xff, 0xaabb_ccdd_eeff, 0xffff_ffff_ffff] { + let address = address_of(device_key); + assert_eq!(key(address), device_key); + } + assert_eq!( + address_of(0xaabb_ccdd_eeff), + Address([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]) + ); + } + + #[test] + fn the_advertisement_carries_the_service_data() { + let beacon = [0x01, 2, 3, 4, 5, 6, 7, 8, 9]; + let advertisement = advertisement(beacon); + let uuid = Uuid::from_bytes(crate::beacon::SERVICE_UUID); + assert_eq!( + advertisement.service_data.get(&uuid), + Some(&beacon.to_vec()) + ); + assert_eq!(advertisement.service_data.len(), 1); + assert!(matches!( + advertisement.advertisement_type, + bluer::adv::Type::Peripheral + )); + } + + #[test] + fn the_answer_carries_the_session() { + let answer = PeripheralAnswer::Connected { + device: 0xaabb_ccdd_eeff, + mtu: 247, + }; + match answer { + PeripheralAnswer::Connected { device, mtu } => { + assert_eq!(device, 0xaabb_ccdd_eeff); + assert_eq!(mtu, 247); + } + other => panic!("the answer is not a connect, {other:?}"), + } + } + + const PROBE_NAME: &str = + "linux::bluez::tests::a_refused_advertisement_restands_its_control_stream"; + + /// A refused advertisement's retry restands the control stream, the + /// notify session and the delivery onto it, over a fake `BlueZ` on a + /// private bus. + /// + /// The `bluer` session connects to whatever `DBUS_SYSTEM_BUS_ADDRESS` + /// names and the process's environment stays fixed, so the scenario + /// runs in a re-executed child of this binary, the child's bus the + /// private daemon's. + #[tokio::test] + async fn a_refused_advertisement_restands_its_control_stream() { + if std::env::var_os("CONNETTO_BLUEZ_PROBE").is_some() { + bluez_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + std::time::Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", PROBE_NAME]) + .env("CONNETTO_BLUEZ_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + const STANDING_PROBE_NAME: &str = + "linux::bluez::tests::the_standing_reads_the_power_and_the_events_follow_the_adapter"; + + /// The standing reads the adapter's power, a set stands on the + /// fake and reports its change, and a removed device arrives as + /// its disconnect, over a fake `BlueZ` on a private bus. + #[tokio::test] + async fn the_standing_reads_the_power_and_the_events_follow_the_adapter() { + if std::env::var_os("CONNETTO_BLUEZ_STANDING_PROBE").is_some() { + standing_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", STANDING_PROBE_NAME]) + .env("CONNETTO_BLUEZ_STANDING_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// The probe's scenario, the fake `BlueZ` standing on the private + /// bus the parent pointed `DBUS_SYSTEM_BUS_ADDRESS` at. + #[expect( + clippy::too_many_lines, + reason = "the probe runs the scenario as one straight line" + )] + async fn standing_probe() { + let powered = Arc::new(Mutex::new(false)); + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .serve_at( + "/org/bluez/hci0", + FakeAdapter { + powered: powered.clone(), + }, + ) + .expect("the adapter") + .build() + .await + .expect("the bluez service"); + + let io = BluerBluez::new().await.expect("the bluez client"); + assert_eq!( + io.standing().await, + AdapterStanding::Powered(false), + "the standing reads the power" + ); + + io.set_powered(true).await.expect("the set stands"); + assert!(*powered.lock(), "the fake recorded the set"); + assert_eq!( + io.standing().await, + AdapterStanding::Powered(true), + "the standing follows the set" + ); + + let mut changed = HashMap::new(); + changed.insert("Powered".to_owned(), OwnedValue::from(true)); + let invalidated: Vec = Vec::new(); + let change = ("org.bluez.Adapter1", &changed, &invalidated); + let answer = answer_after( + &io, + Duration::from_secs(10), + || { + emit_signal( + &server, + "/org/bluez/hci0", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &change, + ) + }, + |answer| matches!(answer, PeripheralAnswer::Powered(true)), + ) + .await; + assert!( + matches!(answer, PeripheralAnswer::Powered(true)), + "the powered change is its answer" + ); + + io.set_powered(false).await.expect("the set stands"); + assert!(!*powered.lock(), "the fake recorded the set"); + assert_eq!( + io.standing().await, + AdapterStanding::Powered(false), + "the standing follows the set" + ); + + changed.insert("Powered".to_owned(), OwnedValue::from(false)); + let change = ("org.bluez.Adapter1", &changed, &invalidated); + let answer = answer_after( + &io, + Duration::from_secs(10), + || { + emit_signal( + &server, + "/org/bluez/hci0", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &change, + ) + }, + |answer| matches!(answer, PeripheralAnswer::Powered(false)), + ) + .await; + assert!( + matches!(answer, PeripheralAnswer::Powered(false)), + "the powered change is its answer" + ); + + // The other property change passes over, the removal arrives + // as its disconnect. + let mut other = HashMap::new(); + other.insert("Discovering".to_owned(), OwnedValue::from(true)); + let change = ("org.bluez.Adapter1", &other, &invalidated); + let removed = ( + ObjectPath::try_from("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF").expect("the device path"), + vec!["org.bluez.Device1".to_owned()], + ); + let answer = answer_after( + &io, + Duration::from_secs(10), + || async { + emit_signal( + &server, + "/org/bluez/hci0", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &change, + ) + .await; + emit_signal( + &server, + "/org/freedesktop/DBus/ObjectManager", + "org.freedesktop.DBus.ObjectManager", + "InterfacesRemoved", + &removed, + ) + .await; + }, + |answer| matches!(answer, PeripheralAnswer::Disconnected { .. }), + ) + .await; + let device = key(Address([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])); + assert!( + matches!(&answer, PeripheralAnswer::Disconnected { device: other } if *other == device), + "the removal is its disconnect, {answer:?}" + ); + } + + const NO_ADAPTER_PROBE_NAME: &str = + "linux::bluez::tests::the_missing_adapter_stands_none_and_refuses_the_calls"; + + /// The missing adapter stands the none standing and refuses the + /// power and start calls, over a `BlueZ` service with no adapter + /// on a private bus. + #[tokio::test] + async fn the_missing_adapter_stands_none_and_refuses_the_calls() { + if std::env::var_os("CONNETTO_BLUEZ_NO_ADAPTER_PROBE").is_some() { + missing_adapter_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", NO_ADAPTER_PROBE_NAME]) + .env("CONNETTO_BLUEZ_NO_ADAPTER_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// The probe's scenario, the `BlueZ` service with no adapter on + /// the private bus. + async fn missing_adapter_probe() { + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let _server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .build() + .await + .expect("the bluez service"); + + let io = BluerBluez::new() + .await + .expect("the client stands without an adapter"); + assert_eq!( + io.standing().await, + AdapterStanding::None, + "the standing is the missing adapter" + ); + let err = io.set_powered(true).await.expect_err("the set is refused"); + assert!( + matches!(err, BluezError::Refused(_)), + "the refusal is the missing adapter, {err:?}" + ); + let err = io.start([1; 9]).await.expect_err("the start is refused"); + assert!( + matches!(err, BluezError::Refused(_)), + "the refusal is the missing adapter, {err:?}" + ); + io.disconnect(0).await; + let err = io + .notify(0, b"none") + .await + .expect_err("the notify is refused"); + assert!( + matches!(err, BluezError::Closed), + "the refusal is the closed session, {err:?}" + ); + } + + const RESTART_PROBE_NAME: &str = + "linux::bluez::tests::a_second_start_readvertises_under_the_standing_application"; + + /// A second start restands the advertisement under the standing + /// GATT application, the application itself kept, over a fake + /// `BlueZ` on a private bus. + #[tokio::test] + async fn a_second_start_readvertises_under_the_standing_application() { + if std::env::var_os("CONNETTO_BLUEZ_RESTART_PROBE").is_some() { + restart_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", RESTART_PROBE_NAME]) + .env("CONNETTO_BLUEZ_RESTART_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// The probe's scenario, the standing application and the two + /// advertisements, over a fake `BlueZ` on the private bus the + /// parent pointed `DBUS_SYSTEM_BUS_ADDRESS` at. + async fn restart_probe() { + let registration = Arc::new(Mutex::new(None)); + let unregisters = Arc::new(Mutex::new(0)); + let registers = Arc::new(Mutex::new(0)); + let attempts = Arc::new(Mutex::new(0)); + let adv_unregisters = Arc::new(Mutex::new(0)); + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let _server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .serve_at( + "/org/bluez/hci0", + FakeAdapter { + powered: Arc::new(Mutex::new(true)), + }, + ) + .expect("the adapter") + .serve_at( + "/org/bluez/hci0", + FakeGatt { + registration: registration.clone(), + unregisters: unregisters.clone(), + registers: registers.clone(), + }, + ) + .expect("the gatt manager") + .serve_at( + "/org/bluez/hci0", + FakeAds { + attempts: attempts.clone(), + refuse_first: false, + adv_unregisters: adv_unregisters.clone(), + }, + ) + .expect("the advertisement manager") + .build() + .await + .expect("the bluez service"); + + let io = BluerBluez::new().await.expect("the bluez client"); + io.start([1; 9]).await.expect("the first start stands"); + io.start([2; 9]).await.expect("the second start stands"); + assert_eq!(*registers.lock(), 1, "the application stands once"); + assert_eq!(*attempts.lock(), 2, "the advertisement stands twice"); + io.stop().await; + } + + const SESSIONS_PROBE_NAME: &str = + "linux::bluez::tests::the_central_write_and_the_sessions_drive_their_answers"; + + /// The central's write arrives as its chunk, the notify sessions + /// hand their MTUs and end as their disconnects, a write past the + /// session's MTU or without a session is refused, a dropped + /// session's device gets its disconnect, and a full delivery + /// queue times out, over a fake `BlueZ` on a private bus. + #[tokio::test] + async fn the_central_write_and_the_sessions_drive_their_answers() { + if std::env::var_os("CONNETTO_BLUEZ_SESSIONS_PROBE").is_some() { + sessions_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", SESSIONS_PROBE_NAME]) + .env("CONNETTO_BLUEZ_SESSIONS_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + #[expect( + clippy::too_many_lines, + reason = "the probe runs the whole scenario as one straight line" + )] + /// The probe's scenario, the fake `BlueZ` standing on the private + /// bus the parent pointed `DBUS_SYSTEM_BUS_ADDRESS` at. + async fn sessions_probe() { + let registration = Arc::new(Mutex::new(None)); + let unregisters = Arc::new(Mutex::new(0)); + let registers = Arc::new(Mutex::new(0)); + let attempts = Arc::new(Mutex::new(0)); + let disconnects_aa = Arc::new(Mutex::new(0)); + let disconnects_bb = Arc::new(Mutex::new(0)); + let adv_unregisters = Arc::new(Mutex::new(0)); + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .serve_at( + "/org/bluez/hci0", + FakeAdapter { + powered: Arc::new(Mutex::new(true)), + }, + ) + .expect("the adapter") + .serve_at( + "/org/bluez/hci0", + FakeGatt { + registration: registration.clone(), + unregisters: unregisters.clone(), + registers: registers.clone(), + }, + ) + .expect("the gatt manager") + .serve_at( + "/org/bluez/hci0", + FakeAds { + attempts: attempts.clone(), + refuse_first: false, + adv_unregisters: adv_unregisters.clone(), + }, + ) + .expect("the advertisement manager") + .serve_at( + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + FakeDevice { + disconnects: disconnects_aa.clone(), + }, + ) + .expect("the device") + .serve_at( + "/org/bluez/hci0/dev_11_22_33_44_55_66", + FakeDevice { + disconnects: disconnects_bb.clone(), + }, + ) + .expect("the device") + .build() + .await + .expect("the bluez service"); + + let io = BluerBluez::new().await.expect("the bluez client"); + io.start([1; 9]).await.expect("the start stands"); + + let (sender, application) = registration.lock().clone().expect("the gatt application"); + let manager = zbus::fdo::ObjectManagerProxy::builder(&server) + .destination(sender.as_str()) + .expect("the application owner") + .path(application.as_str()) + .expect("the application path") + .build() + .await + .expect("the application manager proxy"); + let paths: Vec<(String, Vec)> = manager + .get_managed_objects() + .await + .expect("the application objects") + .into_iter() + .filter_map(|(path, interfaces)| { + let props = interfaces.get("org.bluez.GattCharacteristic1")?; + let flags = Vec::::try_from(props.get("Flags")?.try_clone().ok()?).ok()?; + Some((path.to_string(), flags)) + }) + .collect(); + let outbox = paths + .iter() + .find(|(_, flags)| flags.iter().any(|flag| flag == "notify")) + .expect("the outbox characteristic") + .0 + .clone(); + let inbox = paths + .iter() + .find(|(_, flags)| flags.iter().any(|flag| flag == "write")) + .expect("the inbox characteristic") + .0 + .clone(); + + let device_aa = key(Address([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])); + let device_bb = key(Address([0x11, 0x22, 0x33, 0x44, 0x55, 0x66])); + + let outbox_proxy = zbus::Proxy::new( + &server, + sender.as_str(), + outbox.as_str(), + "org.bluez.GattCharacteristic1", + ) + .await + .expect("the outbox proxy"); + let options = HashMap::from([ + ( + "device".to_owned(), + OwnedValue::from( + ObjectPath::try_from("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") + .expect("the device path"), + ), + ), + ("mtu".to_owned(), OwnedValue::from(64_u16)), + ]); + let read: Vec = outbox_proxy + .call("ReadValue", &(&options,)) + .await + .expect("the read stands"); + assert!(read.is_empty(), "the read answers empty"); + + let inbox_proxy = zbus::Proxy::new( + &server, + sender.as_str(), + inbox.as_str(), + "org.bluez.GattCharacteristic1", + ) + .await + .expect("the inbox proxy"); + let inbox_options = HashMap::from([ + ( + "device".to_owned(), + OwnedValue::from( + ObjectPath::try_from("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") + .expect("the device path"), + ), + ), + ("mtu".to_owned(), OwnedValue::from(23_u16)), + ]); + inbox_proxy + .call::<_, _, ()>("WriteValue", &(vec![1_u8, 2, 3], &inbox_options)) + .await + .expect("the write stands"); + + let answer = answer_bounded(&io).await; + assert!( + matches!(&answer, PeripheralAnswer::Chunk { device, bytes } if *device == device_aa && *bytes == [1, 2, 3]), + "the write is its chunk, {answer:?}" + ); + + let (fd, _mtu): (OwnedFd, u16) = outbox_proxy + .call("AcquireNotify", &(options,)) + .await + .expect("acquire the notify session"); + + let answer = answer_bounded(&io).await; + assert!( + matches!(&answer, PeripheralAnswer::Connected { device, mtu } if *device == device_aa && *mtu == 59), + "the session is its connect, {answer:?}" + ); + + io.notify(device_aa, b"ping") + .await + .expect("the delivery stands"); + let fd = std::os::fd::OwnedFd::from(fd); + let mut file = std::fs::File::from(fd); + let mut bytes = [0_u8; 4]; + file.read_exact(&mut bytes) + .expect("the bytes reach the central"); + assert_eq!(&bytes, b"ping"); + + // The session's end arrives as its disconnect. + drop(file); + let answer = answer_bounded(&io).await; + assert!( + matches!(&answer, PeripheralAnswer::Disconnected { device } if *device == device_aa), + "the session's end is its disconnect, {answer:?}" + ); + + let err = io + .notify(device_aa, b"none") + .await + .expect_err("the refusal stands"); + assert!( + matches!(err, BluezError::Closed), + "the refusal is the closed session, {err:?}" + ); + + // A session below the write's length refuses the delivery. + let options = HashMap::from([ + ( + "device".to_owned(), + OwnedValue::from( + ObjectPath::try_from("/org/bluez/hci0/dev_11_22_33_44_55_66") + .expect("the device path"), + ), + ), + ("mtu".to_owned(), OwnedValue::from(5_u16)), + ]); + let (_fd, _mtu): (OwnedFd, u16) = outbox_proxy + .call("AcquireNotify", &(options,)) + .await + .expect("acquire the session"); + + let answer = answer_bounded(&io).await; + assert!( + matches!(&answer, PeripheralAnswer::Connected { device, mtu } if *device == device_bb && *mtu == 0), + "the session is its connect, {answer:?}" + ); + + let err = io + .notify(device_bb, b"ping") + .await + .expect_err("the refusal stands"); + assert!( + matches!(err, BluezError::Closed), + "the refusal is the closed write, {err:?}" + ); + + // The disconnect ends the session and calls the device's + // disconnect. + io.disconnect(device_bb).await; + assert_eq!(*disconnects_bb.lock(), 1, "the device's disconnect stands"); + let err = io + .notify(device_bb, b"none") + .await + .expect_err("the refusal stands"); + assert!( + matches!(err, BluezError::Closed), + "the refusal is the dropped session, {err:?}" + ); + + // A delivery past the bound is the timeout's refusal, the + // central's queue full. + let options = HashMap::from([ + ( + "device".to_owned(), + OwnedValue::from( + ObjectPath::try_from("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") + .expect("the device path"), + ), + ), + ("mtu".to_owned(), OwnedValue::from(64_u16)), + ]); + let (fd, _mtu): (OwnedFd, u16) = outbox_proxy + .call("AcquireNotify", &(options,)) + .await + .expect("acquire the session"); + + let answer = answer_bounded(&io).await; + assert!( + matches!(&answer, PeripheralAnswer::Connected { device, mtu } if *device == device_aa && *mtu == 59), + "the session is its connect, {answer:?}" + ); + + let chunk: [u8; 59] = [0xab; 59]; + let mut timed_out = false; + for _ in 0..10000 { + match io.notify(device_aa, &chunk).await { + Ok(()) => {} + Err(BluezError::TimedOut(bound)) => { + assert_eq!(bound, CALL_BOUND, "the bound is the call's"); + timed_out = true; + break; + } + Err(other) => panic!("the delivery is refused, {other:?}"), + } + } + assert!(timed_out, "the full queue does not stall the delivery"); + drop(fd); + io.stop().await; + tokio::time::timeout(Duration::from_secs(2), async { + while *adv_unregisters.lock() == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the stop unregisters the advertisement"); + } + + #[expect( + clippy::too_many_lines, + reason = "the probe runs the whole scenario as one straight line" + )] + /// The probe's scenario, the fake `BlueZ` standing on the private bus + /// the parent pointed `DBUS_SYSTEM_BUS_ADDRESS` at. + async fn bluez_probe() { + let registration: Arc>> = + Arc::new(Mutex::new(None)); + let unregisters: Arc> = Arc::new(Mutex::new(0)); + let attempts: Arc> = Arc::new(Mutex::new(0)); + let registers: Arc> = Arc::new(Mutex::new(0)); + let adv_unregisters: Arc> = Arc::new(Mutex::new(0)); + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .serve_at( + "/org/bluez/hci0", + FakeAdapter { + powered: Arc::new(Mutex::new(true)), + }, + ) + .expect("the adapter") + .serve_at( + "/org/bluez/hci0", + FakeGatt { + registration: registration.clone(), + unregisters: unregisters.clone(), + registers: registers.clone(), + }, + ) + .expect("the gatt manager") + .serve_at( + "/org/bluez/hci0", + FakeAds { + attempts: attempts.clone(), + refuse_first: true, + adv_unregisters: adv_unregisters.clone(), + }, + ) + .expect("the advertisement manager") + .build() + .await + .expect("the bluez service"); + + let io = BluerBluez::new().await.expect("the bluez client"); + assert!( + io.start([1; 9]).await.is_err(), + "the first advertisement stands" + ); + the_refusal_unregisters(&unregisters).await; + io.start([2; 9]) + .await + .expect("the retry's advertisement stands"); + + let (sender, application) = registration.lock().clone().expect("the gatt application"); + let manager = zbus::fdo::ObjectManagerProxy::builder(&server) + .destination(sender.as_str()) + .expect("the application owner") + .path(application.as_str()) + .expect("the application path") + .build() + .await + .expect("the application manager proxy"); + let outbox = manager + .get_managed_objects() + .await + .expect("the application objects") + .into_iter() + .find_map(|(path, interfaces)| { + let props = interfaces.get("org.bluez.GattCharacteristic1")?; + let flags = Vec::::try_from(props.get("Flags")?.try_clone().ok()?).ok()?; + flags.iter().any(|flag| flag == "notify").then_some(path) + }) + .expect("the outbox characteristic"); + let proxy = zbus::Proxy::new( + &server, + sender.as_str(), + outbox.as_str(), + "org.bluez.GattCharacteristic1", + ) + .await + .expect("the outbox proxy"); + let options = HashMap::from([ + ( + "device".to_owned(), + OwnedValue::from( + ObjectPath::try_from("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") + .expect("the device path"), + ), + ), + ("mtu".to_owned(), OwnedValue::from(64_u16)), + ]); + let (fd, _mtu): (OwnedFd, u16) = proxy + .call("AcquireNotify", &(options,)) + .await + .expect("acquire the notify session"); + + let device = loop { + let answer = tokio::time::timeout(std::time::Duration::from_secs(10), io.next_event()) + .await + .expect("no peripheral event within the bound"); + if let PeripheralAnswer::Connected { device, .. } = answer { + break device; + } + }; + assert_eq!(device, key(Address([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]))); + + io.notify(device, b"ping") + .await + .expect("the delivery stands"); + let fd = std::os::fd::OwnedFd::from(fd); + let mut file = std::fs::File::from(fd); + let mut bytes = [0_u8; 4]; + file.read_exact(&mut bytes) + .expect("the bytes reach the central"); + assert_eq!(&bytes, b"ping"); + + io.stop().await; + } + + /// Polls the `unregisters` count past zero until its bound. + async fn the_refusal_unregisters(unregisters: &Arc>) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while *unregisters.lock() == 0 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("the refusal unregisters the gatt application"); + } + + const SLOW_PROBE_NAME: &str = "linux::bluez::tests::a_slow_answer_times_out_on_the_call_bound"; + + /// A slow adapter's answer times out the call bound in the + /// constructor, over a slow fake `BlueZ` on a private bus. + #[tokio::test] + async fn a_slow_answer_times_out_on_the_call_bound() { + if std::env::var_os("CONNETTO_BLUEZ_SLOW_PROBE").is_some() { + slow_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", SLOW_PROBE_NAME]) + .env("CONNETTO_BLUEZ_SLOW_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// The probe's scenario, the slow fake adapter on the private bus + /// the parent pointed `DBUS_SYSTEM_BUS_ADDRESS` at. + async fn slow_probe() { + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let _server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .serve_at("/org/bluez/hci0", SlowAdapter) + .expect("the adapter") + .build() + .await + .expect("the bluez service"); + + let Err(err) = BluerBluez::new().await else { + panic!("the session stands over a slow adapter") + }; + assert!( + matches!(err, BluezError::TimedOut(CALL_BOUND)), + "the slow answer times out the call bound, {err:?}" + ); + } + + const BROKEN_OM_PROBE_NAME: &str = + "linux::bluez::tests::a_missing_object_manager_refuses_the_constructor"; + + /// The constructor refuses a `BlueZ` without its object manager, + /// the adapter standing alone on the private bus. + #[tokio::test] + async fn a_missing_object_manager_refuses_the_constructor() { + if std::env::var_os("CONNETTO_BLUEZ_BROKEN_OM_PROBE").is_some() { + broken_om_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", BROKEN_OM_PROBE_NAME]) + .env("CONNETTO_BLUEZ_BROKEN_OM_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// The probe's scenario, the fake `BlueZ` standing without its + /// object manager on the private bus. + async fn broken_om_probe() { + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let _server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at( + "/org/bluez/hci0", + FakeAdapter { + powered: Arc::new(Mutex::new(true)), + }, + ) + .expect("the adapter") + .build() + .await + .expect("the bluez service"); + + let Err(err) = BluerBluez::new().await else { + panic!("the session stands over a missing object manager") + }; + assert!( + matches!(err, BluezError::Refused(_)), + "the refusal is the missing object manager, {err:?}" + ); + } + + const SLOW_SET_PROBE_NAME: &str = "linux::bluez::tests::a_slow_set_times_out_on_the_call_bound"; + + /// A slow set times out the call bound, the standing still reading, + /// over a slow-setter fake `BlueZ` on a private bus. + #[tokio::test] + async fn a_slow_set_times_out_on_the_call_bound() { + if std::env::var_os("CONNETTO_BLUEZ_SLOW_SET_PROBE").is_some() { + slow_set_probe().await; + return; + } + let bus = crate::linux::test_bus::PrivateBus::start(); + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", SLOW_SET_PROBE_NAME]) + .env("CONNETTO_BLUEZ_SLOW_SET_PROBE", "1") + .env("DBUS_SYSTEM_BUS_ADDRESS", bus.address.as_str()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// The probe's scenario, the slow-setter fake adapter on the + /// private bus the parent pointed `DBUS_SYSTEM_BUS_ADDRESS` at. + async fn slow_set_probe() { + let address = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").expect("the probe's bus address"); + let _server = zbus::connection::Builder::address(address.as_str()) + .expect("the bus address") + .name("org.bluez") + .expect("the service name") + .serve_at("/", zbus::fdo::ObjectManager) + .expect("the object manager") + .serve_at( + "/org/bluez/hci0", + SlowSetterAdapter { + powered: Arc::new(Mutex::new(true)), + }, + ) + .expect("the adapter") + .build() + .await + .expect("the bluez service"); + + let io = BluerBluez::new().await.expect("the bluez client"); + assert_eq!( + io.standing().await, + AdapterStanding::Powered(true), + "the standing reads the fast property" + ); + let err = io.set_powered(false).await.expect_err("the set times out"); + assert!( + matches!(err, BluezError::TimedOut(CALL_BOUND)), + "the slow set times out the call bound, {err:?}" + ); + } + + const NEW_PROBE_NAME: &str = "linux::bluez::tests::new_refuses_an_unreachable_system_bus"; + + /// The constructor refuses an unreachable system bus. The `bluer` + /// session reads the process's `DBUS_SYSTEM_BUS_ADDRESS`, so the + /// scenario runs in a re-executed child pointed at an absent + /// socket, the child's environment the only one changed. + #[tokio::test] + async fn new_refuses_an_unreachable_system_bus() { + if std::env::var_os("CONNETTO_BLUEZ_NEW_PROBE").is_some() { + let Err(err) = BluerBluez::new().await else { + panic!("the session stands over an absent bus") + }; + assert!( + matches!(err, BluezError::Refused(_)), + "the refusal is the unreachable bus, {err:?}" + ); + return; + } + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", NEW_PROBE_NAME]) + .env("CONNETTO_BLUEZ_NEW_PROBE", "1") + .env( + "DBUS_SYSTEM_BUS_ADDRESS", + "unix:path=/run/connetto-proof/absent-socket", + ) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } + + /// Emit a signal on the fake's service connection, the client's + /// match rules its receiver. + async fn emit_signal( + bus: &zbus::Connection, + path: &str, + interface: &str, + member: &str, + body: &(impl serde::Serialize + zbus::zvariant::DynamicType), + ) { + let message = zbus::Message::signal(path, interface, member) + .expect("the signal stands") + .build(body) + .expect("the signal body serializes"); + bus.send(&message) + .await + .expect("the signal reaches the bus"); + } + + /// The first answer of the kind `wanted` names, `emit` sent again + /// until it arrives within its bound, answers of other kinds + /// passed over. + async fn answer_after>( + io: &BluerBluez, + bound: Duration, + emit: impl Fn() -> F, + wanted: impl Fn(&PeripheralAnswer) -> bool, + ) -> PeripheralAnswer { + let deadline = tokio::time::Instant::now() + bound; + loop { + assert!( + tokio::time::Instant::now() < deadline, + "the answer did not arrive within its bound" + ); + emit().await; + while let Ok(answer) = + tokio::time::timeout(Duration::from_millis(200), io.next_event()).await + { + if wanted(&answer) { + return answer; + } + } + } + } + + /// The next answer within its bound, the bound's end a failure. + async fn answer_bounded(io: &BluerBluez) -> PeripheralAnswer { + tokio::time::timeout(Duration::from_secs(5), io.next_event()) + .await + .expect("no answer within the bound") + } + + struct FakeAdapter { + powered: Arc>, + } + + #[zbus::interface(name = "org.bluez.Adapter1")] + impl FakeAdapter { + #[zbus(property)] + fn powered(&self) -> bool { + *self.powered.lock() + } + + #[zbus(property)] + fn set_powered(&self, value: bool) { + *self.powered.lock() = value; + } + } + + struct SlowAdapter; + + #[zbus::interface(name = "org.bluez.Adapter1")] + impl SlowAdapter { + #[zbus(property)] + async fn powered(&self) -> bool { + tokio::time::sleep(Duration::from_secs(6)).await; + true + } + } + + struct SlowSetterAdapter { + powered: Arc>, + } + + #[zbus::interface(name = "org.bluez.Adapter1")] + impl SlowSetterAdapter { + #[zbus(property)] + fn powered(&self) -> bool { + *self.powered.lock() + } + + #[zbus(property)] + async fn set_powered(&self, value: bool) { + *self.powered.lock() = value; + tokio::time::sleep(Duration::from_secs(6)).await; + } + } + + struct FakeGatt { + registration: Arc>>, + unregisters: Arc>, + registers: Arc>, + } + + #[zbus::interface(name = "org.bluez.GattManager1")] + impl FakeGatt { + #[expect( + clippy::needless_pass_by_value, + reason = "the zbus header attribute takes the header by value" + )] + fn register_application( + &self, + application: OwnedObjectPath, + options: HashMap, + #[zbus(header)] header: Header<'_>, + ) { + *self.registers.lock() += 1; + std::mem::drop(options); + let sender = header.sender().expect("the request sender").to_string(); + self.registration.lock().replace((sender, application)); + } + + fn unregister_application(&self, application: OwnedObjectPath) { + std::mem::drop(application); + *self.unregisters.lock() += 1; + self.registration.lock().take(); + } + } + + #[derive(Debug, zbus::DBusError)] + #[zbus(prefix = "org.bluez.Error")] + enum ProbeRefusal { + Failed(String), + } + + struct FakeAds { + attempts: Arc>, + refuse_first: bool, + adv_unregisters: Arc>, + } + + #[zbus::interface(name = "org.bluez.LEAdvertisingManager1")] + impl FakeAds { + fn register_advertisement( + &self, + advertisement: OwnedObjectPath, + options: HashMap, + ) -> Result<(), ProbeRefusal> { + std::mem::drop(advertisement); + std::mem::drop(options); + let mut attempts = self.attempts.lock(); + *attempts += 1; + if self.refuse_first && *attempts == 1 { + Err(ProbeRefusal::Failed( + "the first advertisement refused".into(), + )) + } else { + Ok(()) + } + } + + fn unregister_advertisement(&self, advertisement: OwnedObjectPath) { + *self.adv_unregisters.lock() += 1; + std::mem::drop(advertisement); + } + } + + struct FakeDevice { + disconnects: Arc>, + } + + #[zbus::interface(name = "org.bluez.Device1")] + impl FakeDevice { + fn disconnect(&self) { + *self.disconnects.lock() += 1; + } + } +} diff --git a/crates/connetto-peer/src/linux/mod.rs b/crates/connetto-peer/src/linux/mod.rs new file mode 100644 index 00000000..dc153e80 --- /dev/null +++ b/crates/connetto-peer/src/linux/mod.rs @@ -0,0 +1,68 @@ +//! The Linux platform I/O the peer link's hotspot and Bluetooth backends +//! drive (R76 slice 9b). +//! +//! The `NetworkManager` calls over the system bus and the `BlueZ` peripheral +//! daemon sit behind two small I/O traits, so the backends' decision logic +//! runs against a fake in its proofs. The real implementations reach the +//! daemons with one D-Bus connection each, bounded on the monotonic clock. + +#[cfg(target_os = "linux")] +mod bluez; +#[cfg(target_os = "linux")] +mod nm; + +#[cfg(target_os = "linux")] +pub use bluez::{AdapterStanding, BluerBluez, BluezError, BluezIo, DeviceKey, PeripheralAnswer}; +#[cfg(target_os = "linux")] +pub use nm::{ + AddError, AddRequest, Band, CAP_ACCESS_POINT, CAP_FREQ_5GHZ, Ipv4Standing, NETWORK_CONTROL, + NmEvent, NmIo, NmIoError, SETTINGS_MODIFY_OWN, STATE_ACTIVATED, STATE_DEACTIVATED, + STATE_DEACTIVATING, Security, WIFI_SHARE_PROTECTED, WifiDevice, ZbusNm, +}; + +#[cfg(test)] +mod test_bus { + /// A private session bus, stopped on drop. + pub struct PrivateBus { + daemon: std::process::Child, + pub address: String, + } + + impl PrivateBus { + /// Start the daemon, the missing binary a clear test failure. + pub fn start() -> Self { + use std::io::BufRead as _; + let mut daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap_or_else(|err| { + panic!("the private-bus tests need a dbus-daemon on the PATH: {err}") + }); + let mut address = String::new(); + std::io::BufReader::new(daemon.stdout.take().expect("the daemon's stdout")) + .read_line(&mut address) + .expect("the daemon's address"); + Self { + daemon, + address: address.trim().to_owned(), + } + } + + /// Connect a client to the private bus. + pub async fn connect(&self) -> zbus::Connection { + zbus::connection::Builder::address(self.address.as_str()) + .expect("the bus address") + .build() + .await + .expect("connect to the private bus") + } + } + + impl Drop for PrivateBus { + fn drop(&mut self) { + let _ = self.daemon.kill(); + let _ = self.daemon.wait(); + } + } +} diff --git a/crates/connetto-peer/src/linux/nm.rs b/crates/connetto-peer/src/linux/nm.rs new file mode 100644 index 00000000..6a180f41 --- /dev/null +++ b/crates/connetto-peer/src/linux/nm.rs @@ -0,0 +1,2483 @@ +//! The `NetworkManager` I/O the hotspot backend drives over the system bus +//! (R76 slice 9b). +//! +//! The backend asks `NetworkManager` to host and to join hotspots through +//! `AddAndActivateConnection2`, with a volatile profile bound to this +//! client's D-Bus connection, and reads the joined network's address and +//! gateway from the active connection's `IPv4` config. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use futures_util::StreamExt; +use parking_lot::Mutex; +use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value}; + +/// How long a single `NetworkManager` call gets, on the monotonic clock. +const CALL_BOUND: Duration = Duration::from_secs(5); + +/// The permission that lets a user host a protected Wi-Fi. +pub const WIFI_SHARE_PROTECTED: &str = "org.freedesktop.NetworkManager.wifi.share.protected"; +/// The permission that lets a user control the network. +pub const NETWORK_CONTROL: &str = "org.freedesktop.NetworkManager.network-control"; +/// The permission that lets a user modify its own settings. +pub const SETTINGS_MODIFY_OWN: &str = "org.freedesktop.NetworkManager.settings.modify.own"; + +/// The capability that lets a Wi-Fi device host an access point. +pub const CAP_ACCESS_POINT: u32 = 0x40; +/// The capability that lets a Wi-Fi device host on the 5 `GHz` band. +pub const CAP_FREQ_5GHZ: u32 = 0x400; + +/// The active connection's `state` value when it is activated +/// (`NM_ACTIVE_CONNECTION_STATE_ACTIVATED`). +pub const STATE_ACTIVATED: u32 = 2; +/// The active connection's `state` value when it is deactivating +/// (`NM_ACTIVE_CONNECTION_STATE_DEACTIVATING`). +pub const STATE_DEACTIVATING: u32 = 3; +/// The active connection's `state` value once it is deactivated +/// (`NM_ACTIVE_CONNECTION_STATE_DEACTIVATED`). +pub const STATE_DEACTIVATED: u32 = 4; + +/// A Wi-Fi device `NetworkManager` manages. +#[derive(Clone, Debug)] +pub struct WifiDevice { + /// The device's D-Bus object path. + pub path: String, + /// The hardware address, the process's 5 `GHz` memory's key. + pub hw_address: String, + /// The capability bits. + pub capabilities: u32, + /// Whether `NetworkManager` manages the device. + pub managed: bool, +} + +impl WifiDevice { + /// Whether the device can host an access point. + #[must_use] + pub fn is_access_point(&self) -> bool { + self.capabilities & CAP_ACCESS_POINT != 0 + } + + /// Whether the device hosts on the 5 `GHz` band. + #[must_use] + pub fn has_five_ghz(&self) -> bool { + self.capabilities & CAP_FREQ_5GHZ != 0 + } +} + +/// The band a hosted hotspot asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Band { + /// The 5 `GHz` band. + Five, + /// The 2.4 `GHz` band. + Two, +} + +/// The security a joined network carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Security { + /// WPA2 personal. + Wpa2, + /// WPA3 personal. + Wpa3, +} + +/// A host or join request for `NetworkManager` to activate. +#[derive(Clone, Debug)] +pub enum AddRequest { + /// Host a hotspot on `device`, under `ssid` and `passphrase`. + Host { + /// The device's D-Bus object path. + device: String, + /// The band to host on. + band: Band, + /// The network name. + ssid: String, + /// The passphrase. + passphrase: String, + }, + /// Join the network on `device`, under `ssid` and `passphrase`. + Join { + /// The device's D-Bus object path. + device: String, + /// The network name. + ssid: String, + /// The passphrase. + passphrase: String, + /// The security the network carries. + security: Security, + }, +} + +/// The joined network's `IPv4` standing, once it activates. +#[derive(Clone, Debug)] +pub struct Ipv4Standing { + /// The device's address on the network. + pub address: Ipv4Addr, + /// The subnet's prefix length. + pub prefix: u8, + /// The network's gateway, where it stands. + pub gateway: Option, +} + +/// A `NetworkManager` signal the backend reacts to. +#[derive(Clone, Debug)] +pub enum NmEvent { + /// The permission table changed, so re-read it. + CheckPermissions, + /// Wi-Fi got enabled or disabled. + WirelessEnabled(bool), + /// An active connection's state changed. + StateChanged { + /// The active connection's object path. + active: String, + /// The new state. + state: u32, + /// The state's reason. + reason: u32, + }, + /// A device was added or removed, so re-read the Wi-Fi devices. + DevicesChanged, +} + +/// Why a `NetworkManager` call failed. +#[derive(Debug, thiserror::Error)] +pub enum NmIoError { + /// The call failed, with the reason. + #[error("NetworkManager refused: {0}")] + Failed(String), +} + +/// Why `NetworkManager` refused a request. +#[derive(Debug, thiserror::Error)] +pub enum AddError { + /// The caller lacks a permission the request needs. + #[error("NetworkManager refused for lack of a permission")] + PermissionDenied, + /// `NetworkManager` refused the request for another reason. + #[error("NetworkManager refused: {0}")] + Failed(String), +} + +/// The I/O the hotspot backend needs from `NetworkManager`. +#[expect( + async_fn_in_trait, + reason = "the backend runs its calls on its own thread's current-thread runtime, so the futures are consumed there" +)] +pub trait NmIo: Send + Sync { + /// The permission table, by name. + async fn permissions(&self) -> Result, NmIoError>; + /// Whether Wi-Fi is enabled. + async fn wifi_enabled(&self) -> Result; + /// The Wi-Fi devices, in `NetworkManager`'s order. + async fn wifi_devices(&self) -> Result, NmIoError>; + /// Activate a host or join connection, answering the active connection's + /// path. + async fn add_and_activate(&self, req: &AddRequest) -> Result; + /// Deactivate the active connection. + async fn deactivate(&self, active: &str) -> Result<(), NmIoError>; + /// The joined network's `IPv4` standing, from the active connection. + async fn ipv4(&self, active: &str) -> Result; + /// The next `NetworkManager` signal, or `None` once the bus is gone. + async fn next_event(&self) -> Option; +} + +/// The real `NetworkManager` I/O, over the system bus. +pub struct ZbusNm { + bus: zbus::Connection, + events: Mutex>, +} + +impl ZbusNm { + /// Connect to the system bus and subscribe to the signals. + /// + /// # Errors + /// + /// [`NmIoError`] when the system bus will not stand within its bound. + pub async fn new() -> Result { + let bus = tokio::time::timeout(CALL_BOUND, zbus::Connection::system()) + .await + .map_err(|_| { + NmIoError::Failed("the system bus did not answer within its bound".into()) + })? + .map_err(|err| NmIoError::Failed(err.to_string()))?; + Self::new_with_connection(bus).await + } + + /// Subscribe on a given bus connection, the seam the proofs' private + /// buses stand. + /// + /// # Errors + /// + /// [`NmIoError`] when the state subscription will not stand within its + /// bound. + pub async fn new_with_connection(bus: zbus::Connection) -> Result { + let (tx, rx) = tokio::sync::mpsc::channel(64); + // Every active connection's state change, subscribed before the + // constructor answers, so no activation can settle ahead of the + // match rule. + let states = active_states(&bus).await?; + // The signals into the one event channel, the row ending when the + // event receiver closes, so the bus connection's lifetime ends with + // the backend. + let signal_bus = bus.clone(); + tokio::spawn(async move { + tokio::select! { + () = tx.closed() => {} + () = async { + tokio::join!( + forward_check_permissions(signal_bus.clone(), tx.clone()), + forward_devices(signal_bus.clone(), tx.clone()), + forward_wireless(signal_bus, tx.clone()), + forward_active_states(states, tx.clone()), + ); + } => {} + } + }); + Ok(Self { + bus, + events: Mutex::new(rx), + }) + } +} + +/// A bounded call, the timeout and a D-Bus refusal both `Failed`. +async fn call(fut: F) -> Result +where + F: Future>, +{ + match tokio::time::timeout(CALL_BOUND, fut).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(err)) => Err(NmIoError::Failed(err.to_string())), + Err(_) => Err(NmIoError::Failed("the call timed out".into())), + } +} + +/// Whether a D-Bus refusal's name is the polkit permission's. +fn denied_name(name: &str) -> bool { + name.ends_with("AccessDenied") || name.ends_with("PermissionDenied") +} + +/// Whether a D-Bus refusal is the polkit permission's. +fn is_permission_denied(err: &zbus::Error) -> bool { + match err { + zbus::Error::MethodError(name, _, _) => denied_name(name.as_str()), + other => other.to_string().contains("AccessDenied"), + } +} + +/// Forward the `CheckPermissions` signal to the event channel. +async fn forward_check_permissions( + bus: zbus::Connection, + events: tokio::sync::mpsc::Sender, +) { + let Ok(proxy) = NmProxy::new(&bus).await else { + return; + }; + let Ok(mut stream) = proxy.receive_check_permissions().await else { + return; + }; + while let Some(_signal) = stream.next().await { + if events.send(NmEvent::CheckPermissions).await.is_err() { + break; + } + } +} + +/// Forward the `DeviceAdded` and `DeviceRemoved` signals to the event +/// channel as one device change. +async fn forward_devices(bus: zbus::Connection, events: tokio::sync::mpsc::Sender) { + let Ok(proxy) = NmProxy::new(&bus).await else { + return; + }; + let (Ok(added), Ok(removed)) = ( + proxy.receive_device_added().await, + proxy.receive_device_removed().await, + ) else { + return; + }; + let mut changes = futures_util::stream::select(added.map(|_| ()), removed.map(|_| ())); + while changes.next().await.is_some() { + if events.send(NmEvent::DevicesChanged).await.is_err() { + break; + } + } +} + +/// Forward the `wireless-enabled` property changes to the event channel. +async fn forward_wireless(bus: zbus::Connection, events: tokio::sync::mpsc::Sender) { + let Ok(proxy) = PropertiesProxy::new(&bus).await else { + return; + }; + let Ok(mut stream) = proxy.receive_properties_changed().await else { + return; + }; + while let Some(signal) = stream.next().await { + let Ok(args) = signal.args() else { + continue; + }; + if args.interface != "org.freedesktop.NetworkManager" { + continue; + } + let Some(enabled) = args + .properties + .get("WirelessEnabled") + .and_then(|value| value.downcast_ref::().ok()) + else { + continue; + }; + if events + .send(NmEvent::WirelessEnabled(enabled)) + .await + .is_err() + { + break; + } + } +} + +/// The stream of `StateChanged` signals from every active connection under +/// `NetworkManager`'s active connection path. +async fn active_states(bus: &zbus::Connection) -> Result { + let rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .interface("org.freedesktop.NetworkManager.Connection.Active") + .and_then(|rule| rule.member("StateChanged")) + .and_then(|rule| rule.path_namespace("/org/freedesktop/NetworkManager/ActiveConnection")) + .map_err(|err| NmIoError::Failed(err.to_string()))? + .build(); + tokio::time::timeout( + CALL_BOUND, + zbus::MessageStream::for_match_rule(rule, bus, Some(64)), + ) + .await + .map_err(|_| { + NmIoError::Failed("the state subscription did not answer within its bound".into()) + })? + .map_err(|err| NmIoError::Failed(err.to_string())) +} + +/// Forward each active connection's `StateChanged` to the channel, keyed by +/// the connection's path. +async fn forward_active_states( + mut states: zbus::MessageStream, + events: tokio::sync::mpsc::Sender, +) { + while let Some(message) = states.next().await { + let Ok(message) = message else { + continue; + }; + let header = message.header(); + let Some(path) = header.path() else { + continue; + }; + let Ok((state, reason)) = message.body().deserialize::<(u32, u32)>() else { + continue; + }; + let event = NmEvent::StateChanged { + active: path.as_str().to_owned(), + state, + reason, + }; + if events.send(event).await.is_err() { + break; + } + } +} + +impl NmIo for ZbusNm { + async fn permissions(&self) -> Result, NmIoError> { + let proxy = NmProxy::new(&self.bus).await.map_err(dbus_error)?; + call(proxy.get_permissions()).await + } + + async fn wifi_enabled(&self) -> Result { + let proxy = NmProxy::new(&self.bus).await.map_err(dbus_error)?; + call(proxy.wireless_enabled()).await + } + + async fn wifi_devices(&self) -> Result, NmIoError> { + let proxy = NmProxy::new(&self.bus).await.map_err(dbus_error)?; + let paths = call(proxy.get_devices()).await?; + let mut out = Vec::new(); + for path in paths { + let path_str = path.as_str().to_owned(); + let Ok(dev_builder) = DeviceProxy::builder(&self.bus).path(path) else { + return Err(NmIoError::Failed("the device path is invalid".into())); + }; + let dev = dev_builder.build().await.map_err(dbus_error)?; + if call(dev.device_type()).await? != 2 { + continue; + } + let hw = call(dev.hw_address()).await?; + let managed = call(dev.managed()).await?; + let Ok(wire_builder) = WirelessProxy::builder(&self.bus).path(path_str.as_str()) else { + return Err(NmIoError::Failed("the device path is invalid".into())); + }; + let wire = wire_builder.build().await.map_err(dbus_error)?; + let caps = call(wire.wireless_capabilities()).await?; + out.push(WifiDevice { + path: path_str, + hw_address: hw, + capabilities: caps, + managed, + }); + } + Ok(out) + } + + async fn add_and_activate(&self, req: &AddRequest) -> Result { + let proxy = NmProxy::new(&self.bus) + .await + .map_err(|err| AddError::Failed(err.to_string()))?; + let (settings, device) = match req { + AddRequest::Host { + device, + band, + ssid, + passphrase, + } => (host_settings(ssid, passphrase, *band), device), + AddRequest::Join { + device, + ssid, + passphrase, + security, + } => (join_settings(ssid, passphrase, *security), device), + }; + let device_path: ObjectPath = device + .as_str() + .try_into() + .map_err(|_| AddError::Failed("the device path is not a valid object path".into()))?; + let root: ObjectPath = ObjectPath::try_from("/") + .map_err(|_| AddError::Failed("the root object path is invalid".into()))?; + let result = tokio::time::timeout( + CALL_BOUND, + proxy.add_and_activate_connection2(settings, device_path, root, options()), + ) + .await; + match result { + // `AddAndActivateConnection2` answers the saved connection's path + // first and the active connection's second. + Ok(Ok((_connection, active, _result))) => Ok(active.as_str().to_owned()), + Ok(Err(err)) => { + if is_permission_denied(&err) { + Err(AddError::PermissionDenied) + } else { + Err(AddError::Failed(err.to_string())) + } + } + Err(_) => Err(AddError::Failed("the call timed out".into())), + } + } + + async fn deactivate(&self, active: &str) -> Result<(), NmIoError> { + let proxy = NmProxy::new(&self.bus).await.map_err(dbus_error)?; + let path: ObjectPath = active.try_into().map_err(|_| { + NmIoError::Failed("the active connection is not a valid object path".into()) + })?; + call(proxy.deactivate_connection(path)).await + } + + async fn ipv4(&self, active: &str) -> Result { + let Ok(active_builder) = ActiveProxy::builder(&self.bus).path(active) else { + return Err(NmIoError::Failed( + "the active connection is not a valid object path".into(), + )); + }; + let active_proxy = active_builder.build().await.map_err(dbus_error)?; + let ip4_path = call(active_proxy.ip4_config()).await?; + let Ok(ip4_builder) = Ip4Proxy::builder(&self.bus).path(ip4_path.as_str()) else { + return Err(NmIoError::Failed("the IP4 config path is invalid".into())); + }; + let ip4 = ip4_builder.build().await.map_err(dbus_error)?; + let entries = call(ip4.address_data()).await?; + let Some(first) = entries.first() else { + return Err(NmIoError::Failed("the network has no address".into())); + }; + let address = first + .get("address") + .and_then(|value| value.downcast_ref::().ok()) + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| NmIoError::Failed("the network's address is not IPv4".into()))?; + let prefix = prefix_of(first)?; + let gateway = call(ip4.gateway()).await?; + let routers = dhcp4_routers(&self.bus, &active_proxy).await; + Ok(Ipv4Standing { + address, + prefix, + gateway: pick_gateway(&gateway, routers), + }) + } + + #[expect( + clippy::await_holding_lock, + reason = "the receiver's guard is held only across its own bounded recv" + )] + async fn next_event(&self) -> Option { + self.events.lock().recv().await + } +} + +/// The D-Bus refusal to the I/O's failure. +#[expect( + clippy::needless_pass_by_value, + reason = "map_err hands the error by value, so the function pointer takes it by value too" +)] +fn dbus_error(err: zbus::Error) -> NmIoError { + NmIoError::Failed(err.to_string()) +} + +/// The joined network's gateway: the `IP4Config`'s where it stands, the +/// DHCP4 `routers`' first entry otherwise. +fn pick_gateway(config: &str, routers: Option) -> Option { + config.parse::().ok().or(routers) +} + +/// The first router of a DHCP4 `routers` option, its entries separated by +/// whitespace. +fn first_router(routers: &str) -> Option { + routers + .split_whitespace() + .next() + .and_then(|router| router.parse::().ok()) +} + +/// Read the DHCP4 `routers` option, the gateway's fallback. +async fn dhcp4_routers(bus: &zbus::Connection, active: &ActiveProxy<'_>) -> Option { + let Ok(path) = call(active.dhcp4_config()).await else { + return None; + }; + let Ok(builder) = Dhcp4Proxy::builder(bus).path(path.as_str()) else { + return None; + }; + let Ok(dhcp4) = builder.build().await else { + return None; + }; + let Ok(options) = call(dhcp4.options()).await else { + return None; + }; + let routers = options + .get("routers") + .and_then(|value| value.downcast_ref::().ok())?; + first_router(&routers) +} + +type Settings = HashMap>>; +type Options = HashMap>; +/// `NetworkManager`'s D-Bus name for the Wi-Fi settings group. +const WIRELESS: &str = "802-11-wireless"; +/// `NetworkManager`'s D-Bus name for the Wi-Fi security settings group. +const WIRELESS_SECURITY: &str = "802-11-wireless-security"; + +/// The host's settings groups. +fn host_settings(ssid: &str, passphrase: &str, band: Band) -> Settings { + let band = match band { + Band::Five => "a", + Band::Two => "bg", + }; + let mut connection = HashMap::new(); + connection.insert("id".to_owned(), Value::from("connetto-host")); + connection.insert( + "permissions".to_owned(), + Value::from(vec![format!("user:{}:", login())]), + ); + connection.insert("type".to_owned(), Value::from("802-11-wireless")); + + let mut wifi = HashMap::new(); + wifi.insert("ssid".to_owned(), Value::from(ssid.as_bytes().to_vec())); + wifi.insert("mode".to_owned(), Value::from("ap")); + wifi.insert("band".to_owned(), Value::from(band)); + + let mut security = HashMap::new(); + security.insert("key-mgmt".to_owned(), Value::from("wpa-psk")); + security.insert("psk".to_owned(), Value::Str(passphrase.to_string().into())); + for (key, value) in [("proto", "rsn"), ("pairwise", "ccmp"), ("group", "ccmp")] { + security.insert(key.to_owned(), Value::from(vec![value.to_owned()])); + } + + let mut ipv4 = HashMap::new(); + ipv4.insert("method".to_owned(), Value::from("shared")); + + let mut ipv6 = HashMap::new(); + ipv6.insert("method".to_owned(), Value::from("disabled")); + + let mut settings = HashMap::new(); + settings.insert("connection".to_owned(), connection); + settings.insert(WIRELESS.to_owned(), wifi); + settings.insert(WIRELESS_SECURITY.to_owned(), security); + settings.insert("ipv4".to_owned(), ipv4); + settings.insert("ipv6".to_owned(), ipv6); + settings +} + +/// The join's settings groups. +fn join_settings(ssid: &str, passphrase: &str, security: Security) -> Settings { + let key_mgmt = match security { + Security::Wpa2 => "wpa-psk", + Security::Wpa3 => "sae", + }; + let mut connection = HashMap::new(); + connection.insert("id".to_owned(), Value::from("connetto-join")); + connection.insert( + "permissions".to_owned(), + Value::from(vec![format!("user:{}:", login())]), + ); + connection.insert("type".to_owned(), Value::from("802-11-wireless")); + + let mut wifi = HashMap::new(); + wifi.insert("ssid".to_owned(), Value::from(ssid.as_bytes().to_vec())); + wifi.insert("mode".to_owned(), Value::from("infrastructure")); + + let mut sec = HashMap::new(); + sec.insert("key-mgmt".to_owned(), Value::from(key_mgmt)); + sec.insert("psk".to_owned(), Value::Str(passphrase.to_string().into())); + + let mut ipv4 = HashMap::new(); + ipv4.insert("method".to_owned(), Value::from("auto")); + ipv4.insert("never-default".to_owned(), Value::from(true)); + + let mut ipv6 = HashMap::new(); + ipv6.insert("method".to_owned(), Value::from("disabled")); + + let mut settings = HashMap::new(); + settings.insert("connection".to_owned(), connection); + settings.insert(WIRELESS.to_owned(), wifi); + settings.insert(WIRELESS_SECURITY.to_owned(), sec); + settings.insert("ipv4".to_owned(), ipv4); + settings.insert("ipv6".to_owned(), ipv6); + settings +} + +/// The volatile, client-bound options. +fn options() -> Options { + let mut options = HashMap::new(); + options.insert("persist".to_owned(), Value::from("volatile")); + options.insert("bind-activation".to_owned(), Value::from("dbus-client")); + options +} + +/// The login the connection is restricted to, the name the system's user +/// database (NSS) gives the process's real uid, as `NetworkManager` resolves +/// it, the `USER` environment never read. +fn login() -> String { + let uid = nix::unistd::getuid(); + login_name(uid, nix::unistd::User::from_uid(uid)) +} + +/// The login name from the user database's answer, the numeric uid where +/// the database names no user. +fn login_name( + uid: nix::unistd::Uid, + user: Result, nix::Error>, +) -> String { + match user { + Ok(Some(user)) => user.name, + Ok(None) => { + tracing::warn!(%uid, "the uid has no user name, the profile names the uid"); + uid.to_string() + } + Err(err) => { + tracing::warn!(%uid, %err, "the user database did not answer, the profile names the uid"); + uid.to_string() + } + } +} + +/// The address data entry's `prefix` field, a valid length. +fn prefix_of(entry: &HashMap) -> Result { + entry + .get("prefix") + .and_then(|value| value.downcast_ref::().ok()) + .and_then(|prefix| u8::try_from(prefix).ok()) + .ok_or_else(|| NmIoError::Failed("the network's prefix is invalid".into())) +} + +/// The `NetworkManager`'s main interface. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager" +)] +trait Nm { + fn get_devices(&self) -> zbus::Result>; + fn get_permissions(&self) -> zbus::Result>; + #[zbus(property)] + fn wireless_enabled(&self) -> zbus::Result; + fn add_and_activate_connection2( + &self, + settings: Settings, + device: ObjectPath<'_>, + connection: ObjectPath<'_>, + options: Options, + ) -> zbus::Result<( + OwnedObjectPath, + OwnedObjectPath, + HashMap, + )>; + fn deactivate_connection(&self, connection: ObjectPath<'_>) -> zbus::Result<()>; + + #[zbus(signal)] + fn check_permissions(&self) -> zbus::Result<()>; + #[zbus(signal)] + fn device_added(&self, device: OwnedObjectPath) -> zbus::Result<()>; + #[zbus(signal)] + fn device_removed(&self, device: OwnedObjectPath) -> zbus::Result<()>; +} + +/// The `Properties` interface the signal forwarding subscribes to. +#[zbus::proxy( + interface = "org.freedesktop.DBus.Properties", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager" +)] +trait Properties { + #[zbus(signal)] + fn properties_changed( + &self, + interface: String, + properties: HashMap, + invalidated_properties: Vec, + ) -> zbus::Result<()>; +} + +/// The active connection's interface. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager.Connection.Active", + default_service = "org.freedesktop.NetworkManager" +)] +trait Active { + #[zbus(signal)] + fn state_changed(&self, state: u32, reason: u32) -> zbus::Result<()>; + #[zbus(property)] + fn ip4_config(&self) -> zbus::Result>; + #[zbus(property)] + fn dhcp4_config(&self) -> zbus::Result>; +} + +/// The IP4 config's interface. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager.IP4Config", + default_service = "org.freedesktop.NetworkManager" +)] +trait Ip4 { + #[zbus(property)] + fn address_data(&self) -> zbus::Result>>; + #[zbus(property)] + fn gateway(&self) -> zbus::Result; +} + +/// The DHCP4 config's interface. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager.DHCP4Config", + default_service = "org.freedesktop.NetworkManager" +)] +trait Dhcp4 { + #[zbus(property)] + fn options(&self) -> zbus::Result>; +} + +/// The device's interface. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager.Device", + default_service = "org.freedesktop.NetworkManager" +)] +trait Device { + #[zbus(property)] + fn device_type(&self) -> zbus::Result; + #[zbus(property)] + fn hw_address(&self) -> zbus::Result; + #[zbus(property)] + fn managed(&self) -> zbus::Result; +} + +/// The wireless device's interface. +#[zbus::proxy( + interface = "org.freedesktop.NetworkManager.Device.Wireless", + default_service = "org.freedesktop.NetworkManager" +)] +trait Wireless { + #[zbus(property)] + fn wireless_capabilities(&self) -> zbus::Result; +} + +#[cfg(test)] +mod tests { + use super::{ + AddError, AddRequest, Band, CAP_ACCESS_POINT, CAP_FREQ_5GHZ, Ipv4Standing, NmEvent, NmIo, + NmIoError, Security, WIRELESS, WIRELESS_SECURITY, WifiDevice, ZbusNm, first_router, + host_settings, join_settings, options, pick_gateway, + }; + use parking_lot::Mutex; + use std::collections::HashMap; + use std::net::{IpAddr, Ipv4Addr}; + use std::sync::Arc; + use std::sync::atomic::{AtomicU8, Ordering}; + use std::time::Duration; + use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + + type Group = HashMap>; + + /// The settings and options as the bus answers them, owned values. + type OwnedSettings = HashMap>; + type OwnedOptions = HashMap; + + /// One recorded `AddAndActivateConnection2` call, settings, device, + /// saved connection and options. + type Call = ( + OwnedSettings, + OwnedObjectPath, + OwnedObjectPath, + OwnedOptions, + ); + + #[test] + fn capability_bits() { + let device = WifiDevice { + path: "/org/freedesktop/NetworkManager/devices/wifi_0".into(), + hw_address: "42:91:88:6f:0a:10".into(), + capabilities: 0x40 | 0x400, + managed: true, + }; + assert!(device.is_access_point()); + assert!(device.has_five_ghz()); + + let station = WifiDevice { + path: "/org/freedesktop/NetworkManager/devices/wifi_1".into(), + hw_address: "42:91:88:6f:0a:11".into(), + capabilities: 0, + managed: true, + }; + assert!(!station.is_access_point()); + assert!(!station.has_five_ghz()); + } + + fn group<'a>( + settings: &'a HashMap, + name: &str, + ) -> &'a HashMap> { + settings + .get(name) + .unwrap_or_else(|| panic!("the settings lack the {name} group")) + } + + fn str_of(value: &Value, group: &str, key: &str) -> String { + value + .downcast_ref::() + .unwrap_or_else(|_| panic!("the {group} {key} value is not a string")) + } + + /// The `Value`'s owned copy, for a downcast that consumes. + fn owned(value: &Value<'static>) -> Value<'static> { + (*value).clone() + } + + /// The `String` of an owned wire value, the `None` the downcast's + /// refusal. + fn owned_str(value: &OwnedValue) -> Option { + value.downcast_ref::().ok() + } + + #[test] + fn the_profile_names_the_process_identity() { + // `id -un` resolves the real uid through the same user database. + let id = std::process::Command::new("id") + .arg("-un") + .output() + .expect("id runs"); + let expected = String::from_utf8(id.stdout).expect("the name is UTF-8"); + assert_eq!(super::login(), expected.trim()); + } + + #[test] + fn the_prefix_stands_the_u32_wire_value() { + let mut entry = HashMap::new(); + entry.insert("prefix".to_owned(), OwnedValue::from(24u32)); + assert!(matches!(super::prefix_of(&entry), Ok(prefix) if prefix == 24)); + let mut wide = HashMap::new(); + wide.insert("prefix".to_owned(), OwnedValue::from(256u32)); + assert!(matches!(super::prefix_of(&wide), Err(NmIoError::Failed(_)))); + + let mut narrow = HashMap::new(); + narrow.insert("prefix".to_owned(), OwnedValue::from(24u8)); + assert!(matches!( + super::prefix_of(&narrow), + Err(NmIoError::Failed(_)) + )); + + let none = HashMap::new(); + assert!(matches!(super::prefix_of(&none), Err(NmIoError::Failed(_)))); + } + + #[test] + fn the_permission_names_map_to_the_permission() { + assert!(super::denied_name( + "org.freedesktop.NetworkManager.PermissionDenied" + )); + assert!(super::denied_name( + "org.freedesktop.DBus.Error.AccessDenied" + )); + assert!(!super::denied_name( + "org.freedesktop.NetworkManager.PermissionsDenied" + )); + assert!(!super::denied_name("org.freedesktop.NetworkManager.Failed")); + } + + #[test] + fn host_settings_carry_the_plan() { + let settings = host_settings("connetto-1a2b", "supersecret123", Band::Five); + let connection = group(&settings, "connection"); + assert_eq!( + str_of(&connection["id"], "connection", "id"), + "connetto-host" + ); + assert_eq!( + owned(&connection["permissions"]) + .downcast::>() + .unwrap(), + vec![format!("user:{}:", super::login())] + ); + assert_eq!( + str_of(&connection["type"], "connection", "type"), + "802-11-wireless" + ); + + let wifi = group(&settings, WIRELESS); + assert_eq!( + owned(&wifi["ssid"]).downcast::>().unwrap(), + b"connetto-1a2b".to_vec() + ); + assert_eq!(str_of(&wifi["mode"], WIRELESS, "mode"), "ap"); + assert_eq!(str_of(&wifi["band"], WIRELESS, "band"), "a"); + + let security = group(&settings, WIRELESS_SECURITY); + assert_eq!( + str_of(&security["key-mgmt"], WIRELESS_SECURITY, "key-mgmt"), + "wpa-psk" + ); + assert_eq!( + str_of(&security["psk"], WIRELESS_SECURITY, "psk"), + "supersecret123" + ); + for (key, expected) in [("proto", "rsn"), ("pairwise", "ccmp"), ("group", "ccmp")] { + assert_eq!( + owned(&security[key]).downcast::>().unwrap(), + vec![expected.to_owned()], + "{key} is a list of strings" + ); + } + assert_eq!( + settings.len(), + 5, + "no group under a name NetworkManager does not know" + ); + + let ipv4 = group(&settings, "ipv4"); + assert_eq!(str_of(&ipv4["method"], "ipv4", "method"), "shared"); + let ipv6 = group(&settings, "ipv6"); + assert_eq!(str_of(&ipv6["method"], "ipv6", "method"), "disabled"); + } + + #[test] + fn host_settings_band_two_dot_four() { + let settings = host_settings("connetto-1a2b", "supersecret123", Band::Two); + assert_eq!( + str_of(&group(&settings, WIRELESS)["band"], WIRELESS, "band"), + "bg" + ); + } + + #[test] + fn join_settings_carry_the_plan() { + let settings = join_settings("hostnet", "hostpass", Security::Wpa2); + let connection = group(&settings, "connection"); + assert_eq!( + str_of(&connection["id"], "connection", "id"), + "connetto-join" + ); + assert_eq!( + owned(&connection["permissions"]) + .downcast::>() + .unwrap(), + vec![format!("user:{}:", super::login())] + ); + + let wifi = group(&settings, WIRELESS); + assert_eq!( + owned(&wifi["ssid"]).downcast::>().unwrap(), + b"hostnet".to_vec() + ); + assert_eq!(str_of(&wifi["mode"], WIRELESS, "mode"), "infrastructure"); + + let security = group(&settings, WIRELESS_SECURITY); + assert_eq!( + str_of(&security["key-mgmt"], WIRELESS_SECURITY, "key-mgmt"), + "wpa-psk" + ); + assert_eq!( + str_of(&security["psk"], WIRELESS_SECURITY, "psk"), + "hostpass" + ); + assert!( + !security.contains_key("pairwise"), + "the join leaves the cipher to the host's offer" + ); + assert_eq!( + settings.len(), + 5, + "no group under a name NetworkManager does not know" + ); + + let ipv4 = group(&settings, "ipv4"); + assert_eq!(str_of(&ipv4["method"], "ipv4", "method"), "auto"); + assert!(ipv4["never-default"].downcast_ref::().unwrap()); + let ipv6 = group(&settings, "ipv6"); + assert_eq!(str_of(&ipv6["method"], "ipv6", "method"), "disabled"); + } + + #[test] + fn join_settings_sae_for_wpa3() { + let settings = join_settings("hostnet", "hostpass", Security::Wpa3); + assert_eq!( + str_of( + &group(&settings, WIRELESS_SECURITY)["key-mgmt"], + WIRELESS_SECURITY, + "key-mgmt" + ), + "sae" + ); + } + + #[test] + fn options_are_volatile_and_client_bound() { + let options = options(); + assert_eq!( + str_of(&options["persist"], "options", "persist"), + "volatile" + ); + assert_eq!( + str_of(&options["bind-activation"], "options", "bind-activation"), + "dbus-client" + ); + } + + #[test] + fn gateway_falls_back_to_the_routers() { + let router = IpAddr::from(Ipv4Addr::new(10, 42, 0, 1)); + assert_eq!( + pick_gateway("192.168.43.1", Some(router)), + Some(IpAddr::from(Ipv4Addr::new(192, 168, 43, 1))) + ); + assert_eq!(pick_gateway("", Some(router)), Some(router)); + assert_eq!( + pick_gateway("10.42.0.1", None), + Some(IpAddr::from(Ipv4Addr::new(10, 42, 0, 1))) + ); + assert_eq!(pick_gateway("", None), None); + } + + #[test] + fn the_first_router_of_the_option() { + assert_eq!( + first_router("10.42.0.1 10.42.0.2"), + Some(IpAddr::from(Ipv4Addr::new(10, 42, 0, 1))) + ); + assert_eq!(first_router(""), None); + assert_eq!(first_router("not-an-ip"), None); + assert_eq!( + first_router(" 192.168.1.254 "), + Some(IpAddr::from(Ipv4Addr::new(192, 168, 1, 254))) + ); + } + + #[test] + fn the_host_request_carries_the_offer() { + let req = AddRequest::Host { + device: "/org/freedesktop/NetworkManager/devices/wifi_0".into(), + band: Band::Five, + ssid: "connetto-1a2b".into(), + passphrase: "supersecret123".into(), + }; + match req { + AddRequest::Host { band, ssid, .. } => { + assert!(matches!(band, Band::Five)); + assert_eq!(ssid, "connetto-1a2b"); + } + other @ AddRequest::Join { .. } => panic!("the request is not a host, {other:?}"), + } + } + + #[test] + fn the_standing_carries_the_network() { + let standing = Ipv4Standing { + address: Ipv4Addr::new(10, 42, 0, 10), + prefix: 24, + gateway: Some(IpAddr::from(Ipv4Addr::new(10, 42, 0, 1))), + }; + assert_eq!(standing.prefix, 24); + assert_eq!(standing.address, Ipv4Addr::new(10, 42, 0, 10)); + } + + #[test] + fn the_share_right_is_named() { + assert_eq!( + super::WIFI_SHARE_PROTECTED, + "org.freedesktop.NetworkManager.wifi.share.protected" + ); + assert_eq!( + super::NETWORK_CONTROL, + "org.freedesktop.NetworkManager.network-control" + ); + assert_eq!( + super::SETTINGS_MODIFY_OWN, + "org.freedesktop.NetworkManager.settings.modify.own" + ); + } + + #[test] + fn the_state_event_carries_the_reason() { + let event = NmEvent::StateChanged { + active: "/org/freedesktop/NetworkManager/ActiveConnection/1".into(), + state: 3, + reason: 0, + }; + match event { + NmEvent::StateChanged { state, reason, .. } => { + assert_eq!(state, 3); + assert_eq!(reason, 0); + } + other => panic!("the event is not a state change, {other:?}"), + } + } + + async fn bus_names(names: &zbus::fdo::DBusProxy<'_>) -> std::collections::BTreeSet { + names + .list_names() + .await + .expect("the bus's names") + .into_iter() + .map(|name| name.to_string()) + .collect() + } + + /// Poll the bus's names until at least one name beyond `baseline` + /// stands, or `bound` stands, the standing names returned. + async fn names_stand( + names: &zbus::fdo::DBusProxy<'_>, + baseline: &std::collections::BTreeSet, + bound: std::time::Duration, + ) -> std::collections::BTreeSet { + let deadline = std::time::Instant::now() + bound; + loop { + let standing = bus_names(names).await; + if standing.difference(baseline).count() > 0 { + return standing; + } + if std::time::Instant::now() >= deadline { + return standing; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + /// Poll the bus's names until `gone` are all free, or `bound` stands. + async fn names_free( + names: &zbus::fdo::DBusProxy<'_>, + gone: &std::collections::BTreeSet, + bound: std::time::Duration, + ) -> bool { + let deadline = std::time::Instant::now() + bound; + loop { + let standing = bus_names(names).await; + if gone.iter().all(|name| !standing.contains(name)) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + /// A dropped client releases its bus connection, the daemon keeping the + /// client's unique name free, the runtime standing. + #[tokio::test] + async fn a_dropped_client_releases_its_bus_connection() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let observer = bus.connect().await; + let names = zbus::fdo::DBusProxy::new(&observer) + .await + .expect("the bus proxy"); + let baseline = bus_names(&names).await; + let client = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let standing = names_stand(&names, &baseline, std::time::Duration::from_secs(5)).await; + let created: std::collections::BTreeSet = + standing.difference(&baseline).cloned().collect(); + assert_ne!( + created.len(), + 0, + "the client's connection did not stand within its bound" + ); + drop(client); + assert!( + names_free(&names, &created, std::time::Duration::from_secs(5)).await, + "the dropped client retained its bus connection" + ); + } + + const WIFI_DEVICE: &str = "/org/freedesktop/NetworkManager/devices/wifi_0"; + const ETH_DEVICE: &str = "/org/freedesktop/NetworkManager/devices/eth_0"; + const ACTIVE_CONNECTION: &str = "/org/freedesktop/NetworkManager/ActiveConnection/1"; + const IP4_CONFIG: &str = "/org/freedesktop/NetworkManager/IP4Config/1"; + const DHCP4_CONFIG: &str = "/org/freedesktop/NetworkManager/DHCP4Config/1"; + + const MODE_OK: u8 = 0; + const MODE_PERMISSION_DENIED: u8 = 1; + const MODE_FAILED: u8 = 2; + + /// The manager's refusal, its names the polkit permission's and the + /// other failure's. + #[derive(Debug, zbus::DBusError)] + #[zbus(prefix = "org.freedesktop.NetworkManager")] + enum FakeRefusal { + /// The manager refuses for lack of a permission. + PermissionDenied, + /// The manager refuses for another reason. + Failed(String), + } + + /// The fake `NetworkManager` the proofs answer on a private bus, the + /// `AddAndActivateConnection2` requests, the refusal mode and the + /// deactivated connections kept. + struct FakeNm { + /// The requests, settings and options kept. + calls: Arc>>, + /// The refusal mode, the proofs' switch. + mode: Arc, + /// The `DeactivateConnection` calls kept. + deactivated: Arc>>, + } + + #[zbus::interface(name = "org.freedesktop.NetworkManager")] + impl FakeNm { + #[zbus(property)] + #[expect( + clippy::unused_self, + reason = "the zbus property signature keeps the manager reference" + )] + fn wireless_enabled(&self) -> bool { + true + } + + #[expect( + clippy::unused_self, + reason = "the zbus method signature keeps the manager reference" + )] + fn get_devices(&self) -> Vec { + [WIFI_DEVICE, ETH_DEVICE] + .iter() + .map(|path| OwnedObjectPath::try_from(*path).expect("the device path")) + .collect() + } + + fn add_and_activate_connection2( + &self, + settings: OwnedSettings, + device: OwnedObjectPath, + saved: OwnedObjectPath, + options: OwnedOptions, + ) -> Result< + ( + OwnedObjectPath, + OwnedObjectPath, + HashMap, + ), + FakeRefusal, + > { + let mode = self.mode.load(Ordering::Relaxed); + if mode == MODE_PERMISSION_DENIED { + return Err(FakeRefusal::PermissionDenied); + } + if mode == MODE_FAILED { + return Err(FakeRefusal::Failed("another refusal".into())); + } + self.calls.lock().push((settings, device, saved, options)); + let saved = OwnedObjectPath::try_from("/org/freedesktop/NetworkManager/Connection/1") + .expect("the saved connection path"); + let active = OwnedObjectPath::try_from(ACTIVE_CONNECTION).expect("the active path"); + Ok((saved, active, HashMap::new())) + } + + fn deactivate_connection(&self, active: OwnedObjectPath) { + self.deactivated.lock().push(active); + } + } + + /// A `NetworkManager` device the proofs list, its `Device` interface + /// answering the type, the address and the managed flag. + struct FakeDevice { + device_type: u32, + hw_address: String, + managed: bool, + } + + #[zbus::interface(name = "org.freedesktop.NetworkManager.Device")] + impl FakeDevice { + #[zbus(property)] + fn device_type(&self) -> u32 { + self.device_type + } + + #[zbus(property)] + fn hw_address(&self) -> String { + self.hw_address.clone() + } + + #[zbus(property)] + fn managed(&self) -> bool { + self.managed + } + } + + /// The wireless interface the proofs read the capability bits from. + struct FakeWireless { + capabilities: u32, + } + + #[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] + impl FakeWireless { + #[zbus(property)] + fn wireless_capabilities(&self) -> u32 { + self.capabilities + } + } + + /// The active connection the proofs read the standing from, the + /// `IP4Config` and `DHCP4Config` paths named. + struct FakeActive { + ip4_config: String, + dhcp4_config: String, + } + + #[zbus::interface(name = "org.freedesktop.NetworkManager.Connection.Active")] + impl FakeActive { + #[zbus(property)] + fn ip4_config(&self) -> OwnedObjectPath { + OwnedObjectPath::try_from(self.ip4_config.as_str()).expect("the IP4 config path") + } + + #[zbus(property)] + fn dhcp4_config(&self) -> OwnedObjectPath { + OwnedObjectPath::try_from(self.dhcp4_config.as_str()).expect("the DHCP4 config path") + } + } + + /// The `IP4Config` the proofs read the address and the gateway from. + struct FakeIp4 { + address_data: Vec>, + gateway: String, + } + + #[zbus::interface(name = "org.freedesktop.NetworkManager.IP4Config")] + impl FakeIp4 { + #[zbus(property)] + fn address_data(&self) -> Vec> { + self.address_data.clone() + } + + #[zbus(property)] + fn gateway(&self) -> String { + self.gateway.clone() + } + } + + /// The `DHCP4Config` the proofs read the routers from. + struct FakeDhcp4 { + routers: String, + } + + #[zbus::interface(name = "org.freedesktop.NetworkManager.DHCP4Config")] + impl FakeDhcp4 { + #[zbus(property)] + fn options(&self) -> HashMap { + let mut options = HashMap::new(); + options.insert( + "routers".to_owned(), + OwnedValue::from(zbus::zvariant::Str::from(self.routers.clone())), + ); + options + } + } + + /// A `NetworkManager` whose answer sleeps past the client's bound, for + /// the timeout's proof. + struct SlowNm; + + #[zbus::interface(name = "org.freedesktop.NetworkManager")] + impl SlowNm { + async fn get_devices(&self) -> Vec { + tokio::time::sleep(Duration::from_secs(6)).await; + Vec::new() + } + + async fn add_and_activate_connection2( + &self, + settings: OwnedSettings, + device: OwnedObjectPath, + saved: OwnedObjectPath, + options: OwnedOptions, + ) -> ( + OwnedObjectPath, + OwnedObjectPath, + HashMap, + ) { + let _ = (settings, device, saved, options); + tokio::time::sleep(Duration::from_secs(6)).await; + ( + OwnedObjectPath::try_from("/org/freedesktop/NetworkManager/Connection/1") + .expect("the saved connection path"), + OwnedObjectPath::try_from(ACTIVE_CONNECTION).expect("the active path"), + HashMap::new(), + ) + } + } + + /// Stand the fake `NetworkManager` on a private bus, the service + /// connection kept by the caller, the requests, the refusal mode and + /// the deactivated connections shared back. + async fn serve_fake_nm( + bus: &crate::linux::test_bus::PrivateBus, + ) -> ( + zbus::Connection, + Arc>>, + Arc, + Arc>>, + ) { + let calls = Arc::new(Mutex::new(Vec::new())); + let mode = Arc::new(AtomicU8::new(MODE_OK)); + let deactivated = Arc::new(Mutex::new(Vec::new())); + let server = zbus::connection::Builder::address(bus.address.as_str()) + .expect("the bus address") + .name("org.freedesktop.NetworkManager") + .expect("the service name") + .serve_at( + "/org/freedesktop/NetworkManager", + FakeNm { + calls: calls.clone(), + mode: mode.clone(), + deactivated: deactivated.clone(), + }, + ) + .expect("the manager") + .serve_at( + WIFI_DEVICE, + FakeDevice { + device_type: 2, + hw_address: "42:91:88:6f:0a:10".into(), + managed: true, + }, + ) + .expect("the Wi-Fi device") + .serve_at( + WIFI_DEVICE, + FakeWireless { + capabilities: CAP_ACCESS_POINT | CAP_FREQ_5GHZ, + }, + ) + .expect("the Wi-Fi capability") + .serve_at( + ETH_DEVICE, + FakeDevice { + device_type: 1, + hw_address: "42:91:88:6f:0a:11".into(), + managed: true, + }, + ) + .expect("the Ethernet device") + .build() + .await + .expect("the fake manager"); + (server, calls, mode, deactivated) + } + + /// One `AddressData` entry, the address string and the prefix kept. + fn address_entry(address: &str, prefix: u32) -> HashMap { + let mut entry = HashMap::new(); + entry.insert( + "address".to_owned(), + OwnedValue::from(zbus::zvariant::Str::from(address.to_owned())), + ); + entry.insert("prefix".to_owned(), OwnedValue::from(prefix)); + entry + } + + /// Stand the active connection's standing on a private bus, the + /// service connection kept by the caller, the address entries, the + /// `IP4Config` gateway and the `DHCP4Config` routers where they stand. + async fn serve_standing( + bus: &crate::linux::test_bus::PrivateBus, + entries: Vec>, + gateway: &str, + routers: Option<&str>, + ) -> zbus::Connection { + let builder = zbus::connection::Builder::address(bus.address.as_str()) + .expect("the bus address") + .name("org.freedesktop.NetworkManager") + .expect("the service name") + .serve_at( + ACTIVE_CONNECTION, + FakeActive { + ip4_config: IP4_CONFIG.to_owned(), + dhcp4_config: DHCP4_CONFIG.to_owned(), + }, + ) + .expect("the active connection") + .serve_at( + IP4_CONFIG, + FakeIp4 { + address_data: entries, + gateway: gateway.to_owned(), + }, + ) + .expect("the IP4 config"); + match routers { + Some(routers) => builder + .serve_at( + DHCP4_CONFIG, + FakeDhcp4 { + routers: routers.to_owned(), + }, + ) + .expect("the DHCP4 config") + .build() + .await + .expect("the standing service"), + None => builder.build().await.expect("the standing service"), + } + } + + /// Emit a signal on the fake's service connection, the client's match + /// rules its receiver. + async fn emit_signal( + bus: &zbus::Connection, + path: &str, + interface: &str, + member: &str, + body: &(impl serde::Serialize + zbus::zvariant::DynamicType), + ) { + let message = zbus::Message::signal(path, interface, member) + .expect("the signal stands") + .build(body) + .expect("the signal body serializes"); + bus.send(&message) + .await + .expect("the signal reaches the bus"); + } + + /// The device listing keeps the Wi-Fi device and its fields, the + /// non-Wi-Fi device filtered out, over a fake `NetworkManager` on a + /// private bus. + #[tokio::test] + async fn the_listing_keeps_only_the_wifi_devices() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_fake_nm(&bus).await.0; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + assert!( + io.wifi_enabled().await.expect("the read stands"), + "the manager says Wi-Fi is enabled" + ); + let devices = io.wifi_devices().await.expect("the listing stands"); + assert_eq!(devices.len(), 1, "the non-Wi-Fi device is filtered out"); + let device = &devices[0]; + assert_eq!(device.path, WIFI_DEVICE); + assert_eq!(device.hw_address, "42:91:88:6f:0a:10"); + assert!(device.managed, "the device is managed"); + assert!(device.is_access_point()); + assert!(device.has_five_ghz()); + } + + /// The host and join requests reach the manager with their settings, + /// the answer the active connection's path, over a fake + /// `NetworkManager` on a private bus. + #[tokio::test] + async fn the_requests_reach_the_manager_and_answer_the_active_path() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (_server, calls, _mode, _deactivated) = serve_fake_nm(&bus).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + + let active = io + .add_and_activate(&AddRequest::Host { + device: WIFI_DEVICE.to_owned(), + band: Band::Five, + ssid: "connetto-1a2b".into(), + passphrase: "supersecret123".into(), + }) + .await + .expect("the host request stands"); + assert_eq!(active, ACTIVE_CONNECTION); + + let (settings, device, _saved, opts) = + calls.lock().first().expect("the host call stands").clone(); + assert_eq!(device.as_str(), WIFI_DEVICE); + let wifi = &settings[WIRELESS]; + assert_eq!( + owned(&wifi["ssid"]).downcast::>().unwrap(), + b"connetto-1a2b".to_vec() + ); + assert_eq!(owned_str(&wifi["mode"]), Some("ap".to_owned())); + assert_eq!(owned_str(&wifi["band"]), Some("a".to_owned())); + assert_eq!( + owned_str(&settings["connection"]["id"]), + Some("connetto-host".to_owned()) + ); + assert_eq!(owned_str(&opts["persist"]), Some("volatile".to_owned())); + + let active = io + .add_and_activate(&AddRequest::Join { + device: WIFI_DEVICE.to_owned(), + ssid: "hostnet".into(), + passphrase: "hostpass".into(), + security: Security::Wpa3, + }) + .await + .expect("the join request stands"); + assert_eq!(active, ACTIVE_CONNECTION); + + let (settings, device, _saved, _opts) = + calls.lock().last().expect("the join call stands").clone(); + assert_eq!(device.as_str(), WIFI_DEVICE); + assert_eq!( + owned_str(&settings["connection"]["id"]), + Some("connetto-join".to_owned()) + ); + assert_eq!( + owned_str(&settings[WIRELESS]["mode"]), + Some("infrastructure".to_owned()) + ); + assert_eq!( + owned_str(&settings[WIRELESS_SECURITY]["key-mgmt"]), + Some("sae".to_owned()) + ); + } + + /// A permission refusal maps to the missing permission, another + /// refusal to the failed reason, over a fake `NetworkManager` on a + /// private bus. + #[tokio::test] + async fn a_refusal_maps_to_its_reason() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (_server, _calls, mode, _deactivated) = serve_fake_nm(&bus).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let request = AddRequest::Host { + device: WIFI_DEVICE.to_owned(), + band: Band::Five, + ssid: "connetto-1a2b".into(), + passphrase: "supersecret123".into(), + }; + mode.store(MODE_PERMISSION_DENIED, Ordering::Relaxed); + let err = io + .add_and_activate(&request) + .await + .expect_err("the refusal stands"); + assert!( + matches!(err, AddError::PermissionDenied), + "the refusal is the missing permission, {err:?}" + ); + mode.store(MODE_FAILED, Ordering::Relaxed); + let err = io + .add_and_activate(&request) + .await + .expect_err("the refusal stands"); + assert!( + matches!(err, AddError::Failed(_)), + "the refusal is the failed reason, {err:?}" + ); + } + + /// A device `NetworkManager` adds or removes arrives as the one + /// `DevicesChanged` event. + #[tokio::test] + async fn the_device_signals_arrive_as_a_device_change() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (server, _calls, _mode, _deactivated) = serve_fake_nm(&bus).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let device = + zbus::zvariant::OwnedObjectPath::try_from("/org/freedesktop/NetworkManager/Devices/7") + .expect("the device path"); + for member in ["DeviceAdded", "DeviceRemoved"] { + let event = event_after( + &io, + || { + let device = device.clone(); + let server = &server; + async move { + emit_signal( + server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + member, + &(device,), + ) + .await; + } + }, + |event| matches!(event, NmEvent::DevicesChanged), + ) + .await; + assert!(matches!(event, NmEvent::DevicesChanged), "{member}"); + } + } + + /// Each `NetworkManager` signal arrives as its event, over a fake + /// `NetworkManager` on a private bus. + #[tokio::test] + async fn the_signals_arrive_as_their_events() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (server, _calls, _mode, _deactivated) = serve_fake_nm(&bus).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + + // The forwarders register their match rules on a spawned task, so + // each signal is emitted again until its event arrives, bounded. + let event = event_after( + &io, + || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + "CheckPermissions", + &(), + ) + }, + |event| matches!(event, NmEvent::CheckPermissions), + ) + .await; + assert!(matches!(event, NmEvent::CheckPermissions)); + + let mut properties = HashMap::new(); + properties.insert("WirelessEnabled".to_owned(), OwnedValue::from(false)); + let invalidated: Vec = Vec::new(); + let changed = ("org.freedesktop.NetworkManager", &properties, &invalidated); + let event = event_after( + &io, + || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &changed, + ) + }, + |event| matches!(event, NmEvent::WirelessEnabled(_)), + ) + .await; + assert!( + matches!(event, NmEvent::WirelessEnabled(false)), + "the wireless change is its event, {event:?}" + ); + + let event = event_after( + &io, + || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager/ActiveConnection/7", + "org.freedesktop.NetworkManager.Connection.Active", + "StateChanged", + &(3u32, 1u32), + ) + }, + |event| matches!(event, NmEvent::StateChanged { .. }), + ) + .await; + assert!( + matches!( + &event, + NmEvent::StateChanged { active, state, reason } + if active == "/org/freedesktop/NetworkManager/ActiveConnection/7" + && *state == 3 + && *reason == 1 + ), + "the state change is its event, {event:?}" + ); + } + + /// The first event of the kind `wanted` names, `emit` sent again until + /// it arrives within ten seconds, events of other kinds passed over. + async fn event_after>( + io: &ZbusNm, + emit: impl Fn() -> F, + wanted: impl Fn(&NmEvent) -> bool, + ) -> NmEvent { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + assert!( + tokio::time::Instant::now() < deadline, + "the event did not arrive within its bound" + ); + emit().await; + while let Ok(event) = + tokio::time::timeout(Duration::from_millis(200), io.next_event()).await + { + let event = event.expect("the event channel closed"); + if wanted(&event) { + return event; + } + } + } + } + + /// The first event of the kind `wanted` names, `emit` sent again until + /// it arrives within ten seconds, over a bare event receiver. + async fn event_after_rx>( + rx: &mut tokio::sync::mpsc::Receiver, + emit: impl Fn() -> F, + wanted: impl Fn(&NmEvent) -> bool, + ) -> NmEvent { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + assert!( + tokio::time::Instant::now() < deadline, + "the event did not arrive within its bound" + ); + emit().await; + while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await + { + let event = event.expect("the event channel closed"); + if wanted(&event) { + return event; + } + } + } + } + + /// An answer past the bound is the timeout's failure, over a slow + /// fake `NetworkManager` on a private bus. + #[tokio::test] + async fn a_slow_answer_times_out_on_the_call_bound() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = zbus::connection::Builder::address(bus.address.as_str()) + .expect("the bus address") + .name("org.freedesktop.NetworkManager") + .expect("the service name") + .serve_at("/org/freedesktop/NetworkManager", SlowNm) + .expect("the slow manager") + .build() + .await + .expect("the slow manager service"); + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let err = io + .wifi_devices() + .await + .expect_err("the slow answer must time out"); + assert!( + matches!(&err, NmIoError::Failed(text) if text.contains("timed out")), + "the failure is the timeout, {err:?}" + ); + } + + /// The joined network's standing stands the address, the prefix and + /// the gateway, the empty gateway answered by the `DHCP4Config` + /// routers, over a fake `NetworkManager` on a private bus. + #[tokio::test] + async fn the_standing_stands_the_address_the_prefix_and_the_gateway() { + // The `IP4Config` gateway stands, the `DHCP4Config` routers behind it. + { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_standing( + &bus, + vec![address_entry("10.42.0.10", 24)], + "192.168.43.1", + Some("10.42.0.1 10.42.0.2"), + ) + .await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let standing = io + .ipv4(ACTIVE_CONNECTION) + .await + .expect("the standing stands"); + assert_eq!(standing.address, Ipv4Addr::new(10, 42, 0, 10)); + assert_eq!(standing.prefix, 24); + assert_eq!( + standing.gateway, + Some(IpAddr::from(Ipv4Addr::new(192, 168, 43, 1))), + "the IP4Config gateway stands" + ); + } + // The empty gateway stands the first router. + { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_standing( + &bus, + vec![address_entry("10.42.0.10", 24)], + "", + Some("10.42.0.1 10.42.0.2"), + ) + .await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let standing = io + .ipv4(ACTIVE_CONNECTION) + .await + .expect("the standing stands"); + assert_eq!( + standing.gateway, + Some(IpAddr::from(Ipv4Addr::new(10, 42, 0, 1))), + "the first router stands the empty gateway" + ); + } + // A missing `DHCP4Config` leaves the gateway to the `IP4Config`. + { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_standing( + &bus, + vec![address_entry("10.42.0.10", 24)], + "192.168.43.1", + None, + ) + .await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let standing = io + .ipv4(ACTIVE_CONNECTION) + .await + .expect("the standing stands"); + assert_eq!( + standing.gateway, + Some(IpAddr::from(Ipv4Addr::new(192, 168, 43, 1))), + "the IP4Config gateway stands" + ); + } + } + + /// The standing is refused on a network with no address and on an + /// address that is not `IPv4`, over a fake `NetworkManager` on a + /// private bus. + #[tokio::test] + async fn the_standing_refuses_a_network_without_an_ipv4_address() { + { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_standing(&bus, Vec::new(), "192.168.43.1", Some("10.42.0.1")).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let err = io + .ipv4(ACTIVE_CONNECTION) + .await + .expect_err("the refusal stands"); + assert!( + matches!(&err, NmIoError::Failed(text) if text.contains("no address")), + "the network has no address, {err:?}" + ); + } + { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_standing( + &bus, + vec![address_entry("not-an-ip", 24)], + "192.168.43.1", + Some("10.42.0.1"), + ) + .await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let err = io + .ipv4(ACTIVE_CONNECTION) + .await + .expect_err("the refusal stands"); + assert!( + matches!(&err, NmIoError::Failed(text) if text.contains("not IPv4")), + "the address is not IPv4, {err:?}" + ); + } + } + + /// The deactivation reaches the manager with the active connection's + /// path, over a fake `NetworkManager` on a private bus. + #[tokio::test] + async fn the_deactivation_reaches_the_manager() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (_server, _calls, _mode, deactivated) = serve_fake_nm(&bus).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + + io.deactivate(ACTIVE_CONNECTION) + .await + .expect("the deactivation stands"); + let kept = deactivated.lock().clone(); + assert_eq!(kept.len(), 1, "the manager kept the one deactivation"); + assert_eq!(kept[0].as_str(), ACTIVE_CONNECTION); + } + + /// An invalid object path is refused before it reaches the manager, + /// over an absent fake `NetworkManager` on a private bus. + #[tokio::test] + async fn an_invalid_object_path_is_refused() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + + let err = io + .ipv4("not a path") + .await + .expect_err("the path is refused"); + assert!( + matches!(&err, NmIoError::Failed(text) if text.contains("not a valid object path")), + "the refusal is the invalid path, {err:?}" + ); + let err = io + .deactivate("not a path") + .await + .expect_err("the path is refused"); + assert!( + matches!(&err, NmIoError::Failed(text) if text.contains("not a valid object path")), + "the refusal is the invalid path, {err:?}" + ); + } + + /// The signals of the wrong shape, the unrelated interface and the + /// wrong path are passed over, the expected one arrives, over a fake + /// `NetworkManager` on a private bus. + #[tokio::test] + async fn the_unrelated_and_malformed_signals_produce_no_event() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (server, _calls, _mode, _deactivated) = serve_fake_nm(&bus).await; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + + // Each kind lands once, so the match rules are registered before + // the filtered signals are sent. + land_each_signal_kind(&io, &server).await; + + // The wrong shapes, the unrelated interface and the wrong path, + // then the queue must stand empty. + emit_filtered_signals(&server).await; + assert!( + tokio::time::timeout(Duration::from_millis(500), io.next_event()) + .await + .is_err(), + "the filtered signals must produce no event" + ); + + let properties = wireless_change(false); + let invalidated: Vec = Vec::new(); + let changed = ("org.freedesktop.NetworkManager", &properties, &invalidated); + let event = event_after( + &io, + || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &changed, + ) + }, + |event| matches!(event, NmEvent::WirelessEnabled(_)), + ) + .await; + assert!(matches!(event, NmEvent::WirelessEnabled(false))); + } + + /// The `PropertiesChanged` body the wireless proof sends, the + /// `WirelessEnabled` value kept. + fn wireless_change(on: bool) -> HashMap { + let mut properties = HashMap::new(); + properties.insert("WirelessEnabled".to_owned(), OwnedValue::from(on)); + properties + } + + /// Land each signal kind once, the forwarders' match rules standing + /// before the filtered signals are sent. + async fn land_each_signal_kind(io: &ZbusNm, server: &zbus::Connection) { + let _ = event_after( + io, + || { + emit_signal( + server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + "CheckPermissions", + &(), + ) + }, + |event| matches!(event, NmEvent::CheckPermissions), + ) + .await; + + let properties = wireless_change(true); + let invalidated: Vec = Vec::new(); + let changed = ("org.freedesktop.NetworkManager", &properties, &invalidated); + let _ = event_after( + io, + || { + emit_signal( + server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &changed, + ) + }, + |event| matches!(event, NmEvent::WirelessEnabled(_)), + ) + .await; + + let _ = event_after( + io, + || { + emit_signal( + server, + "/org/freedesktop/NetworkManager/ActiveConnection/9", + "org.freedesktop.NetworkManager.Connection.Active", + "StateChanged", + &(1u32, 0u32), + ) + }, + |event| matches!(event, NmEvent::StateChanged { .. }), + ) + .await; + } + + /// The signals the forwarders must pass over, the wrong shapes, the + /// unrelated interface and the wrong path. + async fn emit_filtered_signals(server: &zbus::Connection) { + emit_signal( + server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &"the body is not a change", + ) + .await; + emit_signal( + server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &( + "org.freedesktop.WiFi", + &HashMap::::new(), + &Vec::::new(), + ), + ) + .await; + emit_signal( + server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &( + "org.freedesktop.NetworkManager", + &HashMap::::new(), + &Vec::::new(), + ), + ) + .await; + emit_signal( + server, + "/org/freedesktop/NetworkManager/ActiveConnection/9", + "org.freedesktop.NetworkManager.Connection.Active", + "StateChanged", + &"the body is not a state", + ) + .await; + emit_signal( + server, + "/org/freedesktop/NetworkManager/devices/wifi_0", + "org.freedesktop.NetworkManager.Connection.Active", + "StateChanged", + &(1u32, 0u32), + ) + .await; + } + + /// Wait for the forwarder task to stand down, within its bound. + async fn forwarder_ends(forwarder: tokio::task::JoinHandle<()>) { + assert!( + tokio::time::timeout(Duration::from_secs(10), forwarder) + .await + .expect("the forwarder ends within its bound") + .is_ok(), + "the forwarder task stands" + ); + } + + /// The signal forwarders break once their event receiver is gone, over + /// a fake `NetworkManager` on a private bus. + #[tokio::test] + async fn the_forwarders_end_when_their_receiver_is_gone() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let (server, _calls, _mode, _deactivated) = serve_fake_nm(&bus).await; + + // The `CheckPermissions` signal lands once, the receiver goes, and + // the next signal breaks the loop. + let check_permissions = || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + "CheckPermissions", + &(), + ) + }; + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let forwarder = tokio::spawn(super::forward_check_permissions( + bus.connect().await, + tx.clone(), + )); + event_after_rx(&mut rx, &check_permissions, |event| { + matches!(event, NmEvent::CheckPermissions) + }) + .await; + drop(rx); + check_permissions().await; + forwarder_ends(forwarder).await; + + // The same over the wireless forwarder and `PropertiesChanged`. + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let forwarder = tokio::spawn(super::forward_wireless(bus.connect().await, tx.clone())); + let on_properties = wireless_change(true); + let off_properties = wireless_change(false); + let invalidated: Vec = Vec::new(); + let wireless_on = ( + "org.freedesktop.NetworkManager", + &on_properties, + &invalidated, + ); + let wireless_off = ( + "org.freedesktop.NetworkManager", + &off_properties, + &invalidated, + ); + event_after_rx( + &mut rx, + || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &wireless_on, + ) + }, + |event| matches!(event, NmEvent::WirelessEnabled(_)), + ) + .await; + drop(rx); + emit_signal( + &server, + "/org/freedesktop/NetworkManager", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &wireless_off, + ) + .await; + forwarder_ends(forwarder).await; + + // The same over the active states forwarder and `StateChanged`. + let conn = bus.connect().await; + let states = super::active_states(&conn) + .await + .expect("the state subscription stands"); + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let forwarder = tokio::spawn(super::forward_active_states(states, tx.clone())); + event_after_rx( + &mut rx, + || { + emit_signal( + &server, + "/org/freedesktop/NetworkManager/ActiveConnection/8", + "org.freedesktop.NetworkManager.Connection.Active", + "StateChanged", + &(2u32, 0u32), + ) + }, + |event| matches!(event, NmEvent::StateChanged { .. }), + ) + .await; + drop(rx); + emit_signal( + &server, + "/org/freedesktop/NetworkManager/ActiveConnection/8", + "org.freedesktop.NetworkManager.Connection.Active", + "StateChanged", + &(3u32, 0u32), + ) + .await; + forwarder_ends(forwarder).await; + } + + /// The event stream ends once the bus is closed, over a fake + /// `NetworkManager` on a private bus. + #[tokio::test] + async fn the_events_end_when_the_bus_is_closed() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = serve_fake_nm(&bus).await.0; + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + + drop(bus); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let ended = loop { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + if left.is_zero() { + break false; + } + match tokio::time::timeout(left, io.next_event()).await { + Ok(None) => break true, + Err(_) => break false, + Ok(Some(_)) => {} + } + }; + assert!(ended, "the events did not end within their bound"); + } + + /// A slow `AddAndActivateConnection2` is the timeout's failure, over a + /// slow fake `NetworkManager` on a private bus. + #[tokio::test] + async fn a_slow_activation_times_out_on_the_call_bound() { + let bus = crate::linux::test_bus::PrivateBus::start(); + let _server = zbus::connection::Builder::address(bus.address.as_str()) + .expect("the bus address") + .name("org.freedesktop.NetworkManager") + .expect("the service name") + .serve_at("/org/freedesktop/NetworkManager", SlowNm) + .expect("the slow manager") + .build() + .await + .expect("the slow manager service"); + let io = ZbusNm::new_with_connection(bus.connect().await) + .await + .expect("the network manager client"); + let err = io + .add_and_activate(&AddRequest::Host { + device: WIFI_DEVICE.to_owned(), + band: Band::Five, + ssid: "connetto-1a2b".into(), + passphrase: "supersecret123".into(), + }) + .await + .expect_err("the slow answer must time out"); + assert!( + matches!(&err, AddError::Failed(text) if text.contains("timed out")), + "the failure is the timeout, {err:?}" + ); + } + + #[test] + fn the_login_name_falls_back_to_the_numeric_uid() { + let uid = nix::unistd::Uid::from_raw(1000); + let user = nix::unistd::User { + name: "luca".to_owned(), + passwd: std::ffi::CString::new("").expect("the empty password stands"), + uid, + gid: nix::unistd::Gid::from_raw(1000), + dir: std::path::PathBuf::new(), + gecos: std::ffi::CString::new("").expect("the empty gecos stands"), + shell: std::path::PathBuf::new(), + }; + assert_eq!(super::login_name(uid, Ok(Some(user))), "luca"); + assert_eq!( + super::login_name(uid, Ok(None)), + "1000", + "the nameless uid names itself" + ); + assert_eq!( + super::login_name(uid, Err(nix::Error::EACCES)), + "1000", + "the refused lookup names the uid" + ); + } + + #[test] + fn a_denial_outside_a_method_error_reads_the_error_text() { + assert!( + super::is_permission_denied(&zbus::Error::Failure( + "org.freedesktop.DBus.Error.AccessDenied: no access".to_owned(), + )), + "the denial in the text is the denial" + ); + assert!( + !super::is_permission_denied(&zbus::Error::Failure("another failure".to_owned(),)), + "the other failure is not the denial" + ); + } + + #[test] + fn a_dbus_failure_maps_to_the_io_failure() { + let err = super::dbus_error(zbus::Error::Failure("the bus went away".to_owned())); + assert!( + matches!(&err, NmIoError::Failed(text) if text.contains("the bus went away")), + "the failure keeps the reason, {err:?}" + ); + } + + const NEW_PROBE_NAME: &str = "linux::nm::tests::new_reports_an_unreachable_system_bus"; + + /// The constructor reports an unreachable system bus. The `zbus` + /// system bus address reads the process's environment, so the + /// scenario runs in a re-executed child with the address pointed at + /// an absent socket, the child's environment the only one changed. + #[tokio::test] + async fn new_reports_an_unreachable_system_bus() { + if std::env::var_os("CONNETTO_NM_NEW_PROBE").is_some() { + let err = ZbusNm::new() + .await + .err() + .expect("the system bus is unreachable"); + assert!( + matches!(err, NmIoError::Failed(_)), + "the failure is the failed reason, {err:?}" + ); + return; + } + let output = tokio::time::timeout( + Duration::from_secs(60), + tokio::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["--exact", NEW_PROBE_NAME]) + .env("CONNETTO_NM_NEW_PROBE", "1") + .env( + "DBUS_SYSTEM_BUS_ADDRESS", + "unix:path=/run/connetto-proof/absent-socket", + ) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .output(), + ) + .await + .expect("the probe did not end within its bound") + .expect("the probe's output"); + let text = format!( + "{}{}", + String::from_utf8(output.stdout).expect("the child's stdout"), + String::from_utf8(output.stderr).expect("the child's stderr"), + ); + assert!(output.status.success(), "the probe failed:\n{text}"); + } +} diff --git a/crates/connetto-peer/src/node.rs b/crates/connetto-peer/src/node.rs index a0e7fa05..c8507732 100644 --- a/crates/connetto-peer/src/node.rs +++ b/crates/connetto-peer/src/node.rs @@ -361,6 +361,22 @@ impl Node { self.state.listener.read().as_ref().map(|slot| slot.addr) } + /// The identities the node reports linked, the live links' peers and, + /// among them, a duplicate close's peer while it waits for its keeper, + /// whose `PeerUnlinked` has not been emitted yet (R76). + #[must_use] + pub fn linked(&self) -> Vec { + self.state + .links + .lock() + .values() + .map(|slot_state| match slot_state { + SlotState::Live(slot) => slot.peer.clone(), + SlotState::AwaitingKeeper(peer) => peer.clone(), + }) + .collect() + } + /// The fingerprint the live link to `peer` carries, for discovery's /// mapping of the node's events to its instances. #[cfg(feature = "discovery")] diff --git a/crates/connetto-peer/src/tests.rs b/crates/connetto-peer/src/tests.rs index cfabde14..65b8a30f 100644 --- a/crates/connetto-peer/src/tests.rs +++ b/crates/connetto-peer/src/tests.rs @@ -2849,3 +2849,225 @@ fn a_link_error_keeps_its_kind_as_the_exchange_error() { ExchangeError::Tls(_) )); } + +/// The node's `linked` read stands its live peers, and a close drops the +/// peer with the close (R76). +#[tokio::test] +async fn linked_stands_its_live_peers_and_a_close_drops_one() { + let now = whole_second(); + let deployment = Deployment::new(23, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let alpha = deployment.device( + &issuer, + "alpha", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let beta = deployment.device( + &issuer, + "beta", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + let (a_tx, mut a_rx) = events(); + let (b_tx, mut b_rx) = events(); + let a = node(deployment.root_der(), a_tx); + let b = node(deployment.root_der(), b_tx); + + assert!(a.linked().is_empty(), "nothing links before the links"); + assert!(b.linked().is_empty(), "nothing links before the links"); + let a_addr = serve(&a, &alpha); + let b_addr = serve(&b, &beta); + + // The two link both ways, and the duplicate rule keeps one link each. + let (a_to_b, b_to_a) = tokio::join!(a.link(b_addr), b.link(a_addr)); + assert_eq!(a_to_b.as_ref().ok(), Some(&beta.identity)); + assert_eq!(b_to_a.as_ref().ok(), Some(&alpha.identity)); + assert_eq!( + next_events(&mut a_rx, 1).await, + vec![PeerEvent::Linked { + peer: beta.identity.clone() + }] + ); + assert_eq!( + next_events(&mut b_rx, 1).await, + vec![PeerEvent::Linked { + peer: alpha.identity.clone() + }] + ); + assert_eq!(a.linked(), vec![beta.identity.clone()]); + assert_eq!(b.linked(), vec![alpha.identity.clone()]); + + // B stops, the link closes, and A's read drops B with the close. + b.stop(CloseReason::Closed); + await_event(&mut a_rx, Duration::from_secs(10), |event| { + matches!(event, PeerEvent::Unlinked { .. }) + }) + .await; + assert!( + a.linked().is_empty(), + "the closed link's peer drops out of the read" + ); + no_events(&mut a_rx, Duration::from_millis(500)).await; + a.stop(CloseReason::Closed); +} + +/// A duplicate close holds the peer in the node's `linked` read while its +/// keeper registers, and the kept link's close drops the peer (R76). +#[tokio::test] +async fn a_duplicate_close_holds_the_peer_in_the_link_read() { + let now = whole_second(); + let deployment = Deployment::new(24, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let host = deployment.device( + &issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let gone = deployment.device( + &issuer, + "gone", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + let (h_tx, mut h_rx) = events(); + let host_node = node(deployment.root_der(), h_tx); + let host_addr = serve(&host_node, &host); + + // The live link's peer stands in the read. + let mut raw = raw_linked(deployment.root_der(), &gone, host_addr).await; + assert_eq!( + next_events(&mut h_rx, 1).await, + vec![PeerEvent::Linked { + peer: gone.identity.clone() + }] + ); + assert_eq!(host_node.linked(), vec![gone.identity.clone()]); + + // The duplicate close holds the peer in the read while its kept link + // registers, which raises no second event for the same peer. + write_frame( + &mut raw, + &PeerFrame::Close { + reason: CloseReason::Duplicate, + }, + ) + .await + .expect("the close writes"); + drop(raw); + no_events(&mut h_rx, Duration::from_millis(500)).await; + assert_eq!( + host_node.linked(), + vec![gone.identity.clone()], + "the held peer stays in the read" + ); + let mut raw = raw_linked(deployment.root_der(), &gone, host_addr).await; + no_events(&mut h_rx, Duration::from_millis(500)).await; + assert_eq!( + host_node.linked(), + vec![gone.identity.clone()], + "the keeper's link stands the peer live again" + ); + + // The kept link's close drops the peer from the read. + write_frame( + &mut raw, + &PeerFrame::Close { + reason: CloseReason::Closed, + }, + ) + .await + .expect("the close writes"); + drop(raw); + await_event(&mut h_rx, Duration::from_secs(10), |event| { + matches!(event, PeerEvent::Unlinked { .. }) + }) + .await; + assert!( + host_node.linked().is_empty(), + "the closed link's peer drops out of the read" + ); + no_events(&mut h_rx, Duration::from_millis(500)).await; + host_node.stop(CloseReason::Closed); +} + +/// A duplicate close's held peer stays in the node's `linked` read until the +/// hello bound passes without a keeper, and the bound's drop empties the +/// read (R76). +#[tokio::test] +async fn the_hello_bound_drops_a_held_peer_in_the_link_read() { + let now = whole_second(); + let deployment = Deployment::new(25, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let host = deployment.device( + &issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let gone = deployment.device( + &issuer, + "gone", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + let (h_tx, mut h_rx) = events(); + let host_node = node(deployment.root_der(), h_tx); + let host_addr = serve(&host_node, &host); + + // The live link's peer stands in the read, and the pause stands before + // the close, so the keeper's wait runs on the paused clock. + let mut raw = raw_linked(deployment.root_der(), &gone, host_addr).await; + assert_eq!( + next_events(&mut h_rx, 1).await, + vec![PeerEvent::Linked { + peer: gone.identity.clone() + }] + ); + assert_eq!(host_node.linked(), vec![gone.identity.clone()]); + time::pause(); + let started = time::Instant::now(); + write_frame( + &mut raw, + &PeerFrame::Close { + reason: CloseReason::Duplicate, + }, + ) + .await + .expect("the close writes"); + drop(raw); + no_events(&mut h_rx, Duration::from_millis(500)).await; + assert_eq!( + host_node.linked(), + vec![gone.identity.clone()], + "the held peer stays in the read" + ); + await_event(&mut h_rx, Duration::from_secs(60), |event| { + matches!(event, PeerEvent::Unlinked { .. }) + }) + .await; + assert!( + started.elapsed() >= crate::node::HELLO_TIMEOUT, + "the peer stays reported linked for the hello bound" + ); + time::resume(); + assert!( + host_node.linked().is_empty(), + "the bound drops the peer the keeper never kept" + ); + host_node.stop(CloseReason::Closed); +} diff --git a/crates/connetto-test-harness/Cargo.toml b/crates/connetto-test-harness/Cargo.toml index a290f839..866cb282 100644 --- a/crates/connetto-test-harness/Cargo.toml +++ b/crates/connetto-test-harness/Cargo.toml @@ -31,6 +31,10 @@ connetto-ca = { path = "../connetto-ca" } # The browser stack's lose-content route finds a file's chunks and deletes them from the store. connetto-file-core = { path = "../connetto-file-core" } connetto-file-server = { path = "../connetto-file-server" } +# The linux leg of the two-device peer proof (R76 slice 9b) signs in, +# enrols and drives its peer link through the same production paths the +# phone demo drives, in a child process of the proof binary. +connetto-client = { path = "../connetto-client", features = ["peer"] } # The CDC replication source, the async re-execution connector, and the catalog # parser used to build both. pg-streaming brings PgStreamingCdcSource and # PgStreamingConfig, executor-diesel-async-postgres brings PgAsyncDieselConnector. @@ -52,7 +56,7 @@ pg2sqlite = { git = "https://github.com/LucaCappelletti94/pg2sqlite", branch = " ] } anyhow.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["process"] } +tokio = { workspace = true, features = ["process", "io-std"] } # The admin and role pools that reset the fixture and back the write, snapshot, # and connector paths. diesel = { workspace = true, features = ["postgres_backend"] } @@ -82,6 +86,8 @@ reqwest = { version = "0.13", default-features = false } # Reading the test binary path out of cargo's build messages, and the # DevTools protocol the Android proof speaks. serde_json = "1" +# The leg's answers cross the proof's standard streams as JSON. +serde = { version = "1", features = ["derive"] } axum = "0.8" openidconnect = { version = "4.0.1", default-features = false, features = [ "reqwest", diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index 02ee28eb..365ccd4c 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -61,13 +61,33 @@ //! (R76 decision 4). This is the run to make whenever two phones are at hand //! and before a peer phase closes. +//! +//! With `--linux-peer` and `--every-join`, the second device is pippo +//! itself. The run drives a native client in a child process, signed in as +//! its own account and durable under the evidence directory, beside the +//! phone, and makes every join both ways, linux hosting and the phone +//! joining through the beacon, the payload and the form, then the phone +//! hosting and linux joining the same three ways (R76 slice 9b). The phone +//! stays off its network for the walk, since the home LAN would link the +//! two by discovery, and pippo's station holds its own network throughout, +//! the joins taking their link through the hotspots alone. The run ends +//! with the phone back on its usual network and both Bluetooth adapters +//! off. +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; +use connetto_client::BluetoothState; use connetto_test_harness::demo::{order_count, wait_for_count}; use connetto_test_harness::inspector::{PageSession, list_pages}; -use connetto_test_harness::peer_devices::{own_identity, read_hotspot_offer, wait_link_ends}; +use connetto_test_harness::linux_leg::{LegClient, run_leg}; +use connetto_test_harness::linux_proof::{ + ADAPTER_OFF_BOUND, LegOffer, in_hotspot_subnet, parse_station_name, ss_remote_address, +}; +use connetto_test_harness::peer_devices::{ + LINK_END_BOUND, own_identity, read_hotspot_offer, wait_link_ends, +}; use connetto_test_harness::peer_proof::{JoinReport, commit, summary}; use connetto_test_harness::stack::{ demo_has_device_identity, demo_mobile_features, now_millis, repo_path, @@ -148,6 +168,10 @@ const PEER_USER: &str = "bob"; /// the locales the proof phones run, which the driver taps in place of the /// user (R76 decision 16). const CONNECT_BUTTONS: [&str; 3] = ["Connect", "Connetti", "Verbinden"]; +/// The unlock prompt's switch from the fingerprint sensor to the PIN pad in +/// the runner's locales, which a run given the PIN taps on a phone with a +/// fingerprint enrolled. +const PIN_FALLBACK_BUTTONS: [&str; 3] = ["Use PIN", "Usa PIN", "PIN verwenden"]; /// The Bluetooth enable dialog's refusal in the runner's locales, which a /// host whose beacon stays down taps (R76 decision 21). const DENY_BUTTONS: [&str; 6] = [ @@ -170,15 +194,37 @@ const BT_PERMISSIONS: [&str; 3] = [ "android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_ADVERTISE", ]; +/// How long pippo's station gets to be back on its network after a join. +const STATION_BACK_BOUND: Duration = Duration::from_secs(60); +/// The argument lines `--help` shows. +const USAGE: &str = "\ +usage: connetto-android-proof [--serial SERIAL] [--apk PATH] [--unlock-pin PIN] \ +[--peer-serial SECOND] [--linux-peer] [--bluetooth] [--payload] [--every-join] + +--serial the phone the proof runs on +--apk an APK to deploy instead of building one +--unlock-pin the PIN the driver types into the unlock prompt +--peer-serial a second attached phone, which joins the first phone's hotspot +--linux-peer pippo takes the second device's place in the six-join walk +--bluetooth the host-side beacon proof, and the beacon join with --peer-serial +--payload the join through the host's WIFI: payload, with --peer-serial +--every-join every join in one run, with a summary +"; #[tokio::main] async fn main() -> Result<()> { + // The proof starts itself again as its Linux leg, a child serving the + // command stream, which takes none of the runner's arguments. + if std::env::args().nth(1).as_deref() == Some("--linux-leg") { + return run_leg().await; + } let stack = Stack::from_env()?; let Arguments { serial, apk, unlock_pin, peer_serial, + linux_peer, bluetooth, payload, every_join, @@ -219,9 +265,10 @@ async fn main() -> Result<()> { ); // The peer proof runs only on a proved phone, and a failed // proof keeps its own error. - match (bluetooth, peer_serial, outcome) { - (true, None, Ok(())) => bluetooth_proof(&device, &evidence).await, - (_, Some(serial), Ok(())) => { + match (linux_peer, bluetooth, peer_serial, outcome) { + (true, _, _, Ok(())) => linux_joins_with_summary(&device, &evidence, &proved).await, + (false, true, None, Ok(())) => bluetooth_proof(&device, &evidence).await, + (false, _, Some(serial), Ok(())) => { let joins = JoinBy::chosen(every_join, bluetooth, payload); let joiner = Device::new(serial); let run = PeerRun { @@ -232,11 +279,37 @@ async fn main() -> Result<()> { }; joins_with_summary(&device, &joiner, &run, joins, &proved).await } - (_, _, outcome) => outcome, + (_, _, _, outcome) => outcome, } } Err(err) => Err(err), }; + let cleanup = finish( + &device, + &evidence, + previous.as_deref(), + stay_on.as_deref(), + error_dialogs.as_deref(), + &outcome, + ) + .await; + outcome.and(cleanup) +} + +/// The run's cleanup: the device log, the failure's screenshot, the +/// forwards and reverses, the demo's stop, and the device's settings back. +/// +/// # Errors +/// +/// When the log will not be written or a setting will not come back. +async fn finish( + device: &Device, + evidence: &Path, + previous: Option<&str>, + stay_on: Option<&str>, + error_dialogs: Option<&str>, + outcome: &Result<()>, +) -> Result<()> { let log = device.adb(&["logcat", "-d"]).await.unwrap_or_default(); if outcome.is_err() { for line in log.lines().filter(|line| { @@ -250,14 +323,14 @@ async fn main() -> Result<()> { tokio::fs::write(evidence.join("logcat.txt"), log) .await .context("writing the device log")?; - if let Err(err) = &outcome { - let _ = device.screenshot(&evidence, "failure").await; + if let Err(err) = outcome { + let _ = device.screenshot(evidence, "failure").await; eprintln!("proof failed: {err:#}"); } let _ = device.adb(&["forward", "--remove-all"]).await; let _ = device.adb(&["reverse", "--remove-all"]).await; let _ = device.adb(&["shell", "am", "force-stop", package()]).await; - let restored = match previous.as_deref() { + let restored = match previous { Some(holder) => device.set_browser_role_holder(holder).await, None => device .adb(&[ @@ -271,13 +344,11 @@ async fn main() -> Result<()> { .await .map(drop), }; - let screen = device - .restore_global_setting(STAY_ON, stay_on.as_deref()) - .await; + let screen = device.restore_global_setting(STAY_ON, stay_on).await; let dialogs = device - .restore_global_setting(HIDE_ERROR_DIALOGS, error_dialogs.as_deref()) + .restore_global_setting(HIDE_ERROR_DIALOGS, error_dialogs) .await; - outcome.and(restored).and(screen).and(dialogs) + restored.and(screen).and(dialogs) } /// What `connetto-demo-stack` tells its command. @@ -639,6 +710,38 @@ struct PeerOutcome { teardown: Result<()>, } +/// Which side hosts in one join of the linux run. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Direction { + /// The phone hosts and linux joins. + PhoneHosts, + /// Linux hosts and the phone joins. + LinuxHosts, +} + +/// The six joins the linux run makes, linux hosting first, then the phone, +/// each through the beacon, the payload and the form. +const LINUX_JOINS: [(Direction, JoinBy); 6] = [ + (Direction::LinuxHosts, JoinBy::Beacon), + (Direction::LinuxHosts, JoinBy::Payload), + (Direction::LinuxHosts, JoinBy::Form), + (Direction::PhoneHosts, JoinBy::Beacon), + (Direction::PhoneHosts, JoinBy::Payload), + (Direction::PhoneHosts, JoinBy::Form), +]; + +/// A join's name in the summary and the evidence, its side named. +const fn join_name(direction: Direction, join: JoinBy) -> &'static str { + match (direction, join) { + (Direction::PhoneHosts, JoinBy::Beacon) => "beacon (phone hosts)", + (Direction::PhoneHosts, JoinBy::Payload) => "payload (phone hosts)", + (Direction::PhoneHosts, JoinBy::Form) => "form (phone hosts)", + (Direction::LinuxHosts, JoinBy::Beacon) => "beacon (linux hosts)", + (Direction::LinuxHosts, JoinBy::Payload) => "payload (linux hosts)", + (Direction::LinuxHosts, JoinBy::Form) => "form (linux hosts)", + } +} + /// The two-phone peer proof (R76 slice 4): `host` hosts a hotspot from the /// demo and `joiner` signs in as its own account and joins it, the driver /// tapping the system's approval in place of the user (decision 16), until @@ -1307,6 +1410,869 @@ fn check_beacon_dump(dump: &str) -> Result<()> { Ok(()) } +/// The linux-peer run, the six joins with their summary (R76 slice 9b). +/// +/// # Errors +/// +/// As [`joins_with_summary`]. +async fn linux_joins_with_summary( + phone: &Device, + evidence: &Path, + proved: &(Duration, Result<(), String>), +) -> Result<()> { + let PeerOutcome { joins, teardown } = match linux_peer_proof(phone, evidence).await { + Ok(outcome) => outcome, + Err(err) => PeerOutcome { + joins: Err(err), + teardown: Ok(()), + }, + }; + let summary = summary( + &commit().await, + proved, + joins.as_deref(), + teardown.as_ref().copied(), + ); + eprintln!("{summary}"); + tokio::fs::write(evidence.join("summary.txt"), &summary) + .await + .context("writing the summary")?; + let failed: Vec<&str> = joins? + .iter() + .filter(|report| report.outcome.is_err()) + .map(|report| report.name) + .collect(); + if !failed.is_empty() { + bail!("the {} join failed", failed.join(", ")); + } + teardown.context("bringing the machines back to rest after the joins") +} + +/// The linux-peer proof: the phone's single-phone proof, then pippo in the +/// second device's place, a native client driven in a child process. The +/// outcome holds the walk's reports and the machines' return to rest apart. +/// +/// # Errors +/// +/// When the phone cannot be readied before its network leaves. +async fn linux_peer_proof(phone: &Device, evidence: &Path) -> Result { + if !demo_has_device_identity() { + bail!("the linux peer proof needs a demo build with its device identity"); + } + step("prepare the linux leg"); + for permission in BT_PERMISSIONS + .iter() + .copied() + .chain(["android.permission.NEARBY_WIFI_DEVICES"]) + { + phone + .adb(&["shell", "pm", "grant", package(), permission]) + .await?; + } + // The phone's usual network, read while it still holds one, which the + // run's cleanup brings it back to, and pippo's, which the joins must + // not keep down. + let phone_usual = phone.current_ssid().await?; + let pippo_usual = linux_station() + .await? + .context("pippo's station names no connection, the run cannot restore it")?; + eprintln!( + "the phone holds {phone_usual:?} and pippo holds {pippo_usual} until the run restores them" + ); + // The phone leaves the home LAN for the whole section, so the LAN + // never links the two by discovery. Its demo reaches the server over + // adb reverse. + step("take the phone off its network"); + phone.adb(&["shell", "svc", "wifi", "disable"]).await?; + sleep(Duration::from_secs(5)).await; + let steps = RealSteps { + phone, + phone_usual: phone_usual.clone(), + pippo_usual: pippo_usual.clone(), + }; + Ok(linux_section(&steps, evidence).await) +} + +/// The linux section's resource steps, the phone and the leg behind them +/// for the setup-failure unit test. +trait LinuxSteps: Send { + /// The leg's handle, the real client or the test's stand-in. + type Leg: Send; + /// Stand the leg. + fn spawn_leg(&self, evidence: &Path) -> impl Future> + Send; + /// Wait for the leg's ready line. + fn ready(&self, leg: &Self::Leg) -> impl Future> + Send; + /// The walk itself, the phone's identity read and the joins in turn. + fn walk( + &self, + leg: &Self::Leg, + linux_identity: &str, + evidence: &Path, + ) -> impl Future>> + Send; + /// Write the leg's log to the evidence. + fn write_leg_log( + &self, + leg: &Self::Leg, + evidence: &Path, + ) -> impl Future> + Send; + /// Power the leg's adapter off. + fn adapter_off(&self, leg: &Self::Leg) -> impl Future> + Send; + /// Close the leg. + fn close_leg(&self, leg: &Self::Leg) -> impl Future> + Send; + /// Bring the phone back to its usual network. + fn restore_network(&self) -> impl Future> + Send; + /// Turn the phone's Bluetooth off. + fn bluetooth_off(&self) -> impl Future> + Send; +} + +/// The steps over the real phone and leg. +struct RealSteps<'a> { + phone: &'a Device, + phone_usual: Option, + pippo_usual: String, +} + +impl LinuxSteps for RealSteps<'_> { + type Leg = LegClient; + + async fn spawn_leg(&self, evidence: &Path) -> Result { + LegClient::spawn(evidence).await + } + + async fn ready(&self, leg: &LegClient) -> Result<(String, u16)> { + leg.wait_ready().await + } + + async fn walk( + &self, + leg: &LegClient, + linux_identity: &str, + evidence: &Path, + ) -> Result> { + let mut app = self.phone.app().await?; + app.wait_for_text("device: certified until", Duration::from_secs(60)) + .await?; + let phone_identity = own_identity(&mut app).await?; + run_linux_joins( + self.phone, + &mut app, + leg, + &phone_identity, + linux_identity, + &self.pippo_usual, + evidence, + ) + .await + } + + async fn write_leg_log(&self, leg: &LegClient, evidence: &Path) -> Result<()> { + let leg_log = leg.take_log(); + tokio::fs::write(evidence.join("linux-leg.log"), leg_log) + .await + .context("writing the linux leg's log") + } + + async fn adapter_off(&self, leg: &LegClient) -> Result<()> { + linux_adapter_off(leg).await + } + + async fn close_leg(&self, leg: &LegClient) -> Result<()> { + leg.close().await + } + + async fn restore_network(&self) -> Result<()> { + restore_phone_network(self.phone, self.phone_usual.as_ref()).await + } + + async fn bluetooth_off(&self) -> Result<()> { + bluetooth_off(self.phone).await + } +} + +/// The section after the phone's network leaves. Every fallible step +/// stands in the setup, so the restore and the leg cleanup run before the +/// result returns, whatever the step meets. +async fn linux_section(steps: &S, evidence: &Path) -> PeerOutcome { + let (result, leg): (Result>, Option) = + match steps.spawn_leg(evidence).await { + Err(err) => (Err(err), None), + Ok(leg) => { + // The log stands after the setup whatever it meets, so the + // evidence holds it on the failure cases, and the setup's + // error stands over a log-write error. + let setup = async { + let (linux_identity, _) = steps.ready(&leg).await?; + steps.walk(&leg, &linux_identity, evidence).await + } + .await; + let log = steps.write_leg_log(&leg, evidence).await; + (setup.and_then(|reports| log.map(|()| reports)), Some(leg)) + } + }; + // The adapter power-off talks to the leg, so it runs before the close. + let adapter = match &leg { + Some(leg) => steps.adapter_off(leg).await, + None => Ok(()), + }; + let leg_closed = match &leg { + Some(leg) => steps.close_leg(leg).await, + None => Ok(()), + }; + let phone_back = steps.restore_network().await; + let bluetooth = steps.bluetooth_off().await; + // The walk's outcome stands whatever the cleanup meets, and the first + // cleanup failure stands beside it as the teardown's. + let mut teardown = Ok(()); + for (what, outcome) in [ + ("powering the linux adapter off", adapter), + ("closing the linux leg", leg_closed), + ("bringing the phone back to its network", phone_back), + ("turning the phone's Bluetooth off", bluetooth), + ] { + if let Err(err) = outcome { + eprintln!("the cleanup failed {what}: {err:#}"); + if teardown.is_ok() { + teardown = Err(err.context(what)); + } + } + } + PeerOutcome { + joins: result, + teardown, + } +} + +/// The walk itself: each join in turn, every one recorded, with the +/// machines brought back to rest after each. +async fn run_linux_joins( + phone: &Device, + app: &mut PageSession, + leg: &LegClient, + phone_identity: &str, + linux_identity: &str, + pippo_usual: &str, + evidence: &Path, +) -> Result> { + let mut reports = Vec::with_capacity(LINUX_JOINS.len()); + for (direction, join) in LINUX_JOINS.iter().copied() { + let started = Instant::now(); + let name = join_name(direction, join); + let outcome = one_linux_join( + phone, + app, + leg, + phone_identity, + linux_identity, + direction, + join, + pippo_usual, + evidence, + ) + .await; + if let Err(err) = &outcome { + eprintln!("the {name} join failed: {err:#}"); + let _ = phone + .screenshot(evidence, &format!("{name}-failure-phone")) + .await; + let _ = write_linux_state(evidence, &format!("{name}-failure-linux"), leg, None).await; + } + reports.push(JoinReport { + name, + took: Some(started.elapsed()), + outcome, + }); + } + Ok(reports) +} + +/// One join of the linux run, from the offer to the link's end, and the +/// machines back at rest whatever happened. +#[expect( + clippy::too_many_arguments, + reason = "the phone, its page, the leg, both identities, the direction, the join, the usual network and the evidence are each the step's own" +)] +async fn one_linux_join( + phone: &Device, + app: &mut PageSession, + leg: &LegClient, + phone_identity: &str, + linux_identity: &str, + direction: Direction, + join: JoinBy, + pippo_usual: &str, + evidence: &Path, +) -> Result<()> { + let name = join_name(direction, join); + // The hotspot's ssid, recorded as soon as it shows, so the cleanup + // forgets it even when a later step fails. + let mut offer_ssid = None; + let walked = match direction { + Direction::PhoneHosts => { + phone_hosts_walk( + phone, + app, + leg, + phone_identity, + linux_identity, + join, + name, + evidence, + &mut offer_ssid, + ) + .await + } + Direction::LinuxHosts => { + linux_hosts_walk( + phone, + app, + leg, + phone_identity, + linux_identity, + join, + name, + evidence, + &mut offer_ssid, + ) + .await + } + }; + // Back to rest whatever happened, so the next join starts clean: the + // pages' own leave and stop, the phone's network left down until the run + // ends, both adapters off, and pippo's station back, the first failure + // kept. + if walked.is_err() { + // The phone as the failure left it, before the cleanup moves it. + let name = join_name(direction, join); + let _ = phone + .screenshot(evidence, &format!("{name}-failure-moment")) + .await; + if let Ok(page) = app.page_text().await { + let _ = tokio::fs::write(evidence.join(format!("{name}-failure-page.txt")), page).await; + } + let _ = leg.leave().await; + let _ = leg.stop().await; + let _ = app.click("Leave the hotspot").await; + let _ = app.click("Stop the hotspot").await; + // An abandoned join leaves the system's "Couldn't connect" notice up, + // and the next request reuses that dialog in place of its approval, so + // the cleanup closes it with the Settings process that shows it. + let _ = phone + .adb(&["shell", "am", "force-stop", "com.android.settings"]) + .await; + } + let station = wait_linux_station(pippo_usual).await; + let mut bluetooth = bluetooth_off(phone).await; + bluetooth = bluetooth.and(linux_adapter_off(leg).await); + let networks = match &offer_ssid { + Some(ssid) => phone.forget_network(ssid).await.map(drop), + None => Ok(()), + }; + // The phone's network stays down between joins, so the home LAN never + // links the two by discovery. + let lan = phone + .adb(&["shell", "svc", "wifi", "disable"]) + .await + .map(drop); + sleep(Duration::from_secs(5)).await; + walked.and(station).and(bluetooth).and(networks).and(lan)?; + phone + .screenshot(evidence, &format!("{name}-peer-clean")) + .await?; + Ok(()) +} + +/// Turn the beacon's radio on on both sides, which the beacon joins need. +/// +/// # Errors +/// +/// When a side will not power its adapter within the bound. +async fn beacon_on_both_sides(phone: &Device, leg: &LegClient) -> Result<()> { + step("turn Bluetooth on on both sides"); + bluetooth_on(phone).await?; + leg.enable_bluetooth().await?; + Ok(()) +} + +/// The walk while the phone hosts: the offer from its page, the join from +/// the leg, the link named on both sides and proven over the hotspot, and +/// the link's end. +/// +/// # Errors +/// +/// When a step's bound runs or a page never shows its line. +#[expect( + clippy::too_many_arguments, + reason = "the phone, its page, the leg, both identities, the join, its name, the evidence and the ssid the cleanup forgets are each the walk's own" +)] +async fn phone_hosts_walk( + phone: &Device, + app: &mut PageSession, + leg: &LegClient, + phone_identity: &str, + linux_identity: &str, + join: JoinBy, + name: &'static str, + evidence: &Path, + offer_ssid: &mut Option, +) -> Result<()> { + if join == JoinBy::Beacon { + beacon_on_both_sides(phone, leg).await?; + } + // The phone's network stays down from the section's start, which an + // A35 also needs to start an access point. + step(&format!("host a hotspot on the phone for the {name} join")); + app.click("Host a hotspot").await?; + if join != JoinBy::Beacon { + step("decline Bluetooth on the phone"); + phone + .tap_dialog_button(&DENY_BUTTONS, "the Bluetooth enable dialog's refusal") + .await?; + app.wait_for_text("hosting, the beacon is off", Duration::from_secs(30)) + .await?; + } + let offer = read_hotspot_offer(app).await?; + *offer_ssid = Some(offer.ssid.clone()); + phone + .screenshot(evidence, &format!("{name}-host-offer")) + .await?; + assert_no_link(app, leg).await?; + let gateway = match join { + JoinBy::Form => { + step("join the hotspot from linux"); + leg.join_form(&offer.ssid, &offer.passphrase, offer.port) + .await? + } + JoinBy::Payload => { + let payload = offer + .payload + .as_deref() + .context("the phone shows no WIFI: payload")?; + step("join the hotspot from the payload on linux"); + leg.join_payload(payload).await? + } + JoinBy::Beacon => { + let beacon = read_beacon(app).await?; + let prefix = beacon + .strip_prefix("beacon: advertising ") + .context("the beacon line carries no prefix")? + .to_owned(); + step("find the beacon on linux"); + let host = leg.wait_nearby(&prefix).await?; + leg.join_nearby(&host).await?.1 + } + }; + step("link through the hotspot"); + if app + .wait_for_text( + &format!("linked: {linux_identity}"), + Duration::from_secs(90), + ) + .await + .is_err() + { + let linked = leg.linked().await.unwrap_or_default(); + bail!("the phone never named linux as linked, linux names no peer or:\n{linked:?}"); + } + leg.wait_linked(phone_identity, Duration::from_secs(30)) + .await?; + step("prove the link runs over the hotspot"); + let ss_line = ss_link_line(move |remote| remote == gateway).await?; + phone + .screenshot(evidence, &format!("{name}-host-linked")) + .await?; + write_linux_state( + evidence, + &format!("{name}-linux-linked"), + leg, + Some(ss_line.as_str()), + ) + .await?; + step("leave the hotspot on linux"); + leg.leave().await?; + step("stop the hotspot on the phone"); + app.click("Stop the hotspot").await?; + wait_link_ends_leg(app, leg, phone_identity).await?; + phone + .screenshot(evidence, &format!("{name}-host-unlinked")) + .await?; + Ok(()) +} + +/// The walk while linux hosts: the offer stood up on pippo, then the +/// phone's side of it. +/// +/// # Errors +/// +/// As [`linux_hosts_preamble`] and [`linux_hosts_join`]. +#[expect( + clippy::too_many_arguments, + reason = "the phone, its page, the leg, both identities, the join, its name, the evidence and the ssid the cleanup forgets are each the walk's own" +)] +async fn linux_hosts_walk( + phone: &Device, + app: &mut PageSession, + leg: &LegClient, + phone_identity: &str, + linux_identity: &str, + join: JoinBy, + name: &'static str, + evidence: &Path, + offer_ssid: &mut Option, +) -> Result<()> { + let (offer, port) = + linux_hosts_preamble(phone, app, leg, join, name, evidence, offer_ssid).await?; + linux_hosts_join( + phone, + app, + leg, + phone_identity, + linux_identity, + join, + name, + &offer, + port, + evidence, + ) + .await +} + +/// The linux host's start: the beacon's sides, the phone off its network, +/// the offer stood up with its beacon standing asserted, and its port. +/// +/// # Errors +/// +/// When the hotspot will not host, the beacon stands down where the join +/// needs it up, or a step's bound runs. +async fn linux_hosts_preamble( + phone: &Device, + app: &mut PageSession, + leg: &LegClient, + join: JoinBy, + name: &'static str, + evidence: &Path, + offer_ssid: &mut Option, +) -> Result<(LegOffer, u16)> { + // `host_hotspot` powers the adapter on Linux (decision 21), where no + // dialog stands to decline, so the payload and form joins run beside a + // live beacon the phone leaves unused. + if join == JoinBy::Beacon { + beacon_on_both_sides(phone, leg).await?; + } + assert_no_link(app, leg).await?; + step(&format!("host a hotspot on linux for the {name} join")); + let offer = leg.host().await?; + *offer_ssid = Some(offer.ssid.clone()); + if let (JoinBy::Beacon, Some(reason)) = (join, offer.beacon_off.as_deref()) { + bail!("linux hosted the beacon join with its beacon down: {reason}"); + } + write_linux_state(evidence, &format!("{name}-linux-offer"), leg, None).await?; + let port = match offer.port { + Some(port) => port, + None => leg.wait_serving().await?, + }; + Ok((offer, port)) +} + +/// The linux host's link: the phone shown the offer and joined by its way, +/// the system's approval, the link named on both sides and proven over the +/// hotspot, and the link's end. +/// +/// # Errors +/// +/// When the phone will not take the offer, the link never shows, no +/// established connection names the hotspot, or a step's bound runs. +#[expect( + clippy::too_many_arguments, + reason = "the phone, its page, the leg, both identities, the join, its name, the offer, its port and the evidence are each the link's own" +)] +async fn linux_hosts_join( + phone: &Device, + app: &mut PageSession, + leg: &LegClient, + phone_identity: &str, + linux_identity: &str, + join: JoinBy, + name: &'static str, + offer: &LegOffer, + port: u16, + evidence: &Path, +) -> Result<()> { + // The phone's network comes back for the join, which moves it from its + // own network to linux's hotspot. + step("bring the phone's network back for the join"); + phone.adb(&["shell", "svc", "wifi", "enable"]).await?; + sleep(Duration::from_secs(5)).await; + match join { + JoinBy::Form => { + step("fill the join form on the phone"); + app.set_input("ssid", &offer.ssid).await?; + app.set_input("passphrase", &offer.passphrase).await?; + app.set_input("port", &port.to_string()).await?; + app.click("Join the hotspot").await?; + } + JoinBy::Payload => { + step("paste the payload on the phone"); + app.set_input("payload", &offer.payload).await?; + app.click("Join from the payload").await?; + if app + .wait_for_text("joining the hotspot", Duration::from_secs(10)) + .await + .is_err() + { + let page = app.page_text().await.unwrap_or_default(); + bail!("the phone did not take the payload, its page shows:\n{page}"); + } + } + JoinBy::Beacon => { + let prefix = leg.wait_beacon().await?; + step("find the beacon on the phone"); + app.wait_for_text(&format!("nearby: {prefix}"), Duration::from_secs(60)) + .await + .context("the phone never saw linux's beacon")?; + // The join button takes the strongest host, so linux's + // must be the only one in sight. + let page = app.page_text().await?; + let nearby: Vec<&str> = page + .lines() + .filter(|line| line.starts_with("nearby: ")) + .collect(); + if nearby.len() != 1 { + bail!("the phone sees other hosts than linux: {nearby:?}"); + } + phone + .screenshot(evidence, &format!("{name}-peer-nearby")) + .await?; + app.click("Join the nearby host").await?; + } + } + step("approve the network on the phone"); + phone.tap_connect_button().await?; + step("link through the hotspot"); + leg.wait_linked(phone_identity, Duration::from_secs(90)) + .await?; + if app + .wait_for_text( + &format!("linked: {linux_identity}"), + Duration::from_secs(30), + ) + .await + .is_err() + { + let linked = leg.linked().await.unwrap_or_default(); + bail!("linux never named the phone as linked, the phone names:\n{linked:?}"); + } + step("prove the link runs over the hotspot"); + let ss_line = ss_link_line(in_hotspot_subnet).await?; + phone + .screenshot(evidence, &format!("{name}-peer-linked")) + .await?; + write_linux_state( + evidence, + &format!("{name}-linux-linked"), + leg, + Some(ss_line.as_str()), + ) + .await?; + step("leave the hotspot on the phone"); + app.click("Leave the hotspot").await?; + step("stop the hotspot on linux"); + leg.stop().await?; + // Leaving the hotspot hands the phone's station back to its home + // network, where discovery would link it to pippo over the LAN, so the + // phone goes off Wi-Fi before the link's end is awaited. + step("take the phone off its network"); + phone.adb(&["shell", "svc", "wifi", "disable"]).await?; + wait_link_ends_leg(app, leg, phone_identity).await?; + phone + .screenshot(evidence, &format!("{name}-host-unlinked")) + .await?; + Ok(()) +} + +/// No link may stand before a join's join step, so the run proves the join +/// made the link: a stale link closes after 45 s of silence, and the wait +/// fails only past [`LINK_END_BOUND`]. +/// +/// # Errors +/// +/// When a link still stands past the bound, or a side does not answer. +async fn assert_no_link(app: &mut PageSession, leg: &LegClient) -> Result<()> { + let deadline = Instant::now() + LINK_END_BOUND; + loop { + let page = app.page_text().await?; + let phone_linked = page.lines().any(|line| line.starts_with("linked: ")); + let linked = leg.linked().await?; + if !phone_linked && linked.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + bail!( + "a link stands before the join step, the phone names a link, \ + linux names: {linked:?}" + ); + } + sleep(Duration::from_secs(2)).await; + } +} + +/// Wait for the phone's page to drop its linked line and the leg to drop the +/// phone from its linked list, which the leave and the stop end the link. +/// +/// # Errors +/// +/// When a link stands past [`LINK_END_BOUND`], or a side does not answer. +async fn wait_link_ends_leg(app: &mut PageSession, leg: &LegClient, peer: &str) -> Result<()> { + let deadline = Instant::now() + LINK_END_BOUND; + loop { + let page = app.page_text().await?; + let linked = leg.linked().await?; + if !page.contains("linked: ") && !linked.iter().any(|held| held == peer) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the link did not end after the leave and the stop"); + } + sleep(Duration::from_secs(2)).await; + } +} + +/// pippo's station's connection name, which `nmcli dev status` names while +/// the Wi-Fi device holds one. +/// +/// # Errors +/// +/// When `nmcli` fails. +async fn linux_station() -> Result> { + let output = Command::new("nmcli") + .args(["dev", "status"]) + .output() + .await?; + if !output.status.success() { + bail!("nmcli dev status exited with {}", output.status); + } + Ok(parse_station_name(&String::from_utf8_lossy(&output.stdout))) +} + +/// One line of pippo's established TCP connections whose remote endpoint +/// satisfies `expect`, which the run's link proof reads. +/// +/// # Errors +/// +/// When `ss` will not run or no line satisfies `expect`. +async fn ss_link_line(expect: impl Fn(IpAddr) -> bool) -> Result { + let output = Command::new("ss") + .args(["-tnH", "state", "established"]) + .output() + .await?; + if !output.status.success() { + bail!("ss -tnH state established exited with {}", output.status); + } + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + .find(|line| ss_remote_address(line).is_some_and(&expect)) + .map(str::to_owned) + .context("no established connection on pippo matches the hotspot link") +} + +/// Hold until pippo's station is back on `usual`, which `NetworkManager` +/// reconnects on its own once a join's connection is gone. +/// +/// # Errors +/// +/// When the station never comes back within the bound. +async fn wait_linux_station(usual: &str) -> Result<()> { + let deadline = Instant::now() + STATION_BACK_BOUND; + loop { + if linux_station().await?.as_deref() == Some(usual) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("pippo's station never came back on {usual}"); + } + sleep(Duration::from_secs(2)).await; + } +} + +/// Power the linux adapter off where it stands on, and hold until it reports +/// itself off. +/// +/// # Errors +/// +/// When `bluetoothctl` fails or the adapter never reports itself off. +async fn linux_adapter_off(leg: &LegClient) -> Result<()> { + if leg.bluetooth().await? == BluetoothState::Off { + return Ok(()); + } + step("power the linux adapter off"); + let output = Command::new("bluetoothctl") + .args(["power", "off"]) + .output() + .await?; + if !output.status.success() { + bail!("bluetoothctl power off exited with {}", output.status); + } + let deadline = Instant::now() + ADAPTER_OFF_BOUND; + loop { + if leg.bluetooth().await? == BluetoothState::Off { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the linux adapter never reported itself off"); + } + sleep(Duration::from_secs(2)).await; + } +} + +/// Bring the phone back to its usual network at the end of the run, where +/// the joins kept it down. +/// +/// # Errors +/// +/// When the phone never comes back on its usual network within the bound. +async fn restore_phone_network(phone: &Device, usual: Option<&String>) -> Result<()> { + if usual.is_none() { + return Ok(()); + } + let _ = phone.adb(&["shell", "svc", "wifi", "enable"]).await; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let current = phone.current_ssid().await?; + if current.as_deref() == usual.map(String::as_str) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the phone is back on {current:?}, not {usual:?}"); + } + sleep(Duration::from_secs(2)).await; + } +} + +/// The leg's full standing as a text file beside the run's evidence, which a +/// headless machine shows in place of a screenshot. +/// +/// # Errors +/// +/// When the file cannot be written. +async fn write_linux_state( + evidence: &Path, + name: &str, + leg: &LegClient, + ss_line: Option<&str>, +) -> Result<()> { + let mut text = match leg.state().await { + Ok(state) => format!("{state}\n"), + Err(err) => format!("the leg answered no state: {err:#}\n"), + }; + if let Some(line) = ss_line { + text.push_str("ss: "); + text.push_str(line); + text.push('\n'); + } + tokio::fs::write(evidence.join(format!("{name}.txt")), text) + .await + .with_context(|| format!("writing the leg's state file {name}")) +} + /// Who answers the platform's unlock prompt. #[derive(Clone, Copy)] struct Unlock<'a> { @@ -1353,6 +2319,7 @@ impl Unlock<'_> { device.screenshot(evidence, name).await?; match self.pin { Some(pin) => { + device.tap_pin_fallback().await?; // The credential pad needs a moment to take focus. sleep(Duration::from_secs(1)).await; device.adb(&["shell", "input", "text", pin]).await?; @@ -1401,14 +2368,20 @@ fn step(name: &str) { eprintln!("== {name}"); } -/// `--serial` and `--apk`, both optional. /// What the command line names. #[derive(Default)] +#[expect( + clippy::struct_excessive_bools, + reason = "each flag names one run mode the command line takes, and none groups into a mode of its own" +)] struct Arguments { serial: Option, apk: Option, unlock_pin: Option, peer_serial: Option, + /// Pippo takes the second device's place in the six-join walk, which + /// needs `--every-join` (R76 slice 9b). + linux_peer: bool, /// The Bluetooth beacon proof, host-side alone, and the beacon join with /// `--peer-serial`. bluetooth: bool, @@ -1421,6 +2394,10 @@ struct Arguments { } fn cli_arguments() -> Result { + if std::env::args().any(|arg| arg == "--help" || arg == "-h") { + eprintln!("{USAGE}"); + std::process::exit(0); + } let mut arguments = Arguments::default(); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -1436,6 +2413,10 @@ fn cli_arguments() -> Result { arguments.every_join = true; continue; } + if arg == "--linux-peer" { + arguments.linux_peer = true; + continue; + } let value = args.next().ok_or_else(|| anyhow!("{arg} needs a value"))?; match arg.as_str() { "--serial" => arguments.serial = Some(value), @@ -1448,23 +2429,38 @@ fn cli_arguments() -> Result { other => bail!("unknown argument {other}"), } } + validate_arguments(&arguments, demo_app())?; + Ok(arguments) +} + +/// The argument rules, checked before the run starts. +fn validate_arguments(arguments: &Arguments, app: DemoApp) -> Result<()> { + if arguments.linux_peer && arguments.peer_serial.is_some() { + bail!("--linux-peer and --peer-serial name the same side twice, pick one"); + } + if arguments.linux_peer && !arguments.every_join { + bail!("--linux-peer runs the six-join walk, so it needs --every-join"); + } + if arguments.linux_peer && (arguments.bluetooth || arguments.payload) { + bail!("--linux-peer walks every join both ways, so --bluetooth and --payload name none"); + } if arguments.payload && arguments.peer_serial.is_none() { bail!("--payload needs --peer-serial"); } - if arguments.every_join && arguments.peer_serial.is_none() { + if arguments.every_join && arguments.peer_serial.is_none() && !arguments.linux_peer { bail!("--every-join needs --peer-serial"); } if arguments.every_join && (arguments.bluetooth || arguments.payload) { bail!("--every-join runs every join, so --bluetooth and --payload name none"); } - if (arguments.peer_serial.is_some() || arguments.bluetooth) - && matches!(demo_app(), DemoApp::Tauri) + if (arguments.peer_serial.is_some() || arguments.bluetooth || arguments.linux_peer) + && matches!(app, DemoApp::Tauri) { bail!( - "--peer-serial and --bluetooth need the dioxus demo, whose panel holds the peer controls" + "--peer-serial, --linux-peer and --bluetooth need the dioxus demo, whose panel holds the peer controls" ); } - Ok(arguments) + Ok(()) } /// Comma-separated `device:host` port pairs. @@ -1883,6 +2879,33 @@ impl Device { /// The bounds of a system dialog's button, or none while the dialog is /// not up, matched by the localized texts `labels` name. + /// Open the PIN pad a phone with a fingerprint enrolled keeps behind + /// the prompt's own button. + /// + /// # Errors + /// + /// When the tap fails. + async fn tap_pin_fallback(&self) -> Result<()> { + // A dump can crash while the prompt animates in, which reads as no + // button, so the PIN entry goes on. + let fallback = self + .dialog_button_bounds(&PIN_FALLBACK_BUTTONS) + .await + .unwrap_or_else(|err| { + eprintln!( + "{} gave no UI dump for the PIN fallback: {err:#}", + self.serial + ); + None + }); + if let Some((left, top, right, bottom)) = fallback { + let x = i32::midpoint(left, right).to_string(); + let y = i32::midpoint(top, bottom).to_string(); + self.adb(&["shell", "input", "tap", &x, &y]).await?; + } + Ok(()) + } + async fn dialog_button_bounds(&self, labels: &[&str]) -> Result> { self.adb(&["shell", "uiautomator", "dump", "/sdcard/connetto-ui.xml"]) .await?; @@ -2172,6 +3195,47 @@ mod tests { std::fs::read_to_string(dir.join("calls")).unwrap_or_default() } + /// A UI dump that fails while the prompt animates in leaves the PIN + /// entry to go on, with no tap. + #[tokio::test] + async fn a_failed_dump_leaves_the_pin_entry_to_go_on() { + let dir = tempfile::tempdir().expect("tempdir"); + let adb = fake_adb( + dir.path(), + r#"case "$*" in + *"uiautomator dump"*) echo "ERROR: could not get idle state." >&2; exit 1 ;; +esac +exit 0"#, + ); + let device = Device::with_adb("emulator-5554".to_owned(), adb, Duration::from_secs(5)); + device + .tap_pin_fallback() + .await + .expect("a failed dump is no failure"); + let calls = calls(dir.path()); + assert!(!calls.contains("input tap"), "{calls}"); + } + + /// The fallback button a dump shows is tapped at its centre. + #[tokio::test] + async fn the_pin_fallback_button_is_tapped_at_its_centre() { + let dir = tempfile::tempdir().expect("tempdir"); + let adb = fake_adb( + dir.path(), + &format!( + r#"case "$*" in + *"cat /sdcard/connetto-ui.xml"*) echo '' ;; +esac +exit 0"#, + super::PIN_FALLBACK_BUTTONS[0] + ), + ); + let device = Device::with_adb("emulator-5554".to_owned(), adb, Duration::from_secs(5)); + device.tap_pin_fallback().await.expect("the tap stands"); + let calls = calls(dir.path()); + assert!(calls.contains("input tap 200 300"), "{calls}"); + } + #[tokio::test] async fn a_device_that_drops_offline_once_is_reconnected_and_the_command_retried() { let dir = tempfile::tempdir().expect("tempdir"); @@ -2288,4 +3352,205 @@ echo "adb: device offline" >&2; exit 1"#, "; assert!(!rebuilt_in(events, "4447", TAURI_ACTIVITY)); } + + /// The resource step the test refuses. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum FakeRefusal { + /// The leg's launch. + Spawn, + /// The leg's ready line. + Ready, + /// The six-join walk. + Walk, + /// The leg's log write. + Log, + /// The phone's return to its network, a cleanup step. + Restore, + } + + /// The calls the section made, its setup and its cleanup. + #[derive(Clone, Default)] + #[expect( + clippy::struct_excessive_bools, + reason = "the flags record the section's six calls, one per step" + )] + struct FakeFlags { + walk: bool, + log: bool, + adapter_off: bool, + close: bool, + restore: bool, + bluetooth_off: bool, + } + + /// The section's steps, the failures injected and the cleanup calls + /// recorded. + struct FakeSteps { + refusal: FakeRefusal, + flags: parking_lot::Mutex, + } + + impl FakeSteps { + fn new(refusal: FakeRefusal) -> Self { + Self { + refusal, + flags: parking_lot::Mutex::new(FakeFlags::default()), + } + } + + fn flags(&self) -> FakeFlags { + self.flags.lock().clone() + } + } + + struct FakeLeg; + + #[expect( + clippy::unused_async_trait_impl, + reason = "the fake steps answer without a device or bus round trip" + )] + impl super::LinuxSteps for FakeSteps { + type Leg = FakeLeg; + + async fn spawn_leg(&self, _evidence: &Path) -> anyhow::Result { + if self.refusal == FakeRefusal::Spawn { + anyhow::bail!("the injected leg launch failure"); + } + Ok(FakeLeg) + } + + async fn ready(&self, _leg: &FakeLeg) -> anyhow::Result<(String, u16)> { + if self.refusal == FakeRefusal::Ready { + anyhow::bail!("the injected readiness failure"); + } + Ok(("linux".into(), 8080)) + } + + async fn walk( + &self, + _leg: &FakeLeg, + _linux_identity: &str, + _evidence: &Path, + ) -> anyhow::Result> { + self.flags.lock().walk = true; + if self.refusal == FakeRefusal::Walk { + anyhow::bail!("the injected walk failure"); + } + Ok(Vec::new()) + } + + async fn write_leg_log(&self, _leg: &FakeLeg, _evidence: &Path) -> anyhow::Result<()> { + self.flags.lock().log = true; + if self.refusal == FakeRefusal::Log { + anyhow::bail!("the injected evidence write failure"); + } + Ok(()) + } + + async fn adapter_off(&self, _leg: &FakeLeg) -> anyhow::Result<()> { + self.flags.lock().adapter_off = true; + Ok(()) + } + + async fn close_leg(&self, _leg: &FakeLeg) -> anyhow::Result<()> { + self.flags.lock().close = true; + Ok(()) + } + + async fn restore_network(&self) -> anyhow::Result<()> { + self.flags.lock().restore = true; + if self.refusal == FakeRefusal::Restore { + anyhow::bail!("the injected restore failure"); + } + Ok(()) + } + + async fn bluetooth_off(&self) -> anyhow::Result<()> { + self.flags.lock().bluetooth_off = true; + Ok(()) + } + } + + /// A setup failure leaves the phone back on its network, its Bluetooth + /// off, and the leg's cleanup run once the leg stood, and the leg's log + /// stands whatever the setup meets. + #[tokio::test] + async fn setup_failures_leave_the_phone_restored_and_the_leg_released() { + for (refusal, leg_up, walk_ran, log_written) in [ + (FakeRefusal::Spawn, false, false, false), + (FakeRefusal::Ready, true, false, true), + (FakeRefusal::Walk, true, true, true), + (FakeRefusal::Log, true, true, true), + ] { + let steps = FakeSteps::new(refusal); + let outcome = super::linux_section(&steps, Path::new("evidence")).await; + assert!(outcome.joins.is_err(), "the {refusal:?} refusal stands"); + assert!( + outcome.teardown.is_ok(), + "the {refusal:?} refusal's cleanup stood" + ); + let flags = steps.flags(); + assert!( + flags.restore, + "the {refusal:?} refusal left the phone off its network" + ); + assert!( + flags.bluetooth_off, + "the {refusal:?} refusal left the phone's Bluetooth on" + ); + assert_eq!( + flags.adapter_off, leg_up, + "the {refusal:?} refusal mis-ran the adapter power-off" + ); + assert_eq!( + flags.close, leg_up, + "the {refusal:?} refusal mis-ran the leg close" + ); + assert_eq!( + flags.walk, walk_ran, + "the {refusal:?} refusal mis-ran the walk" + ); + assert_eq!( + flags.log, log_written, + "the {refusal:?} refusal mis-wrote the leg's log" + ); + } + } + + /// A cleanup step that fails stands as the teardown's error beside the + /// walk's reports, and the cleanup after it still runs. + #[tokio::test] + async fn a_cleanup_failure_stands_beside_the_walk() { + let steps = FakeSteps::new(FakeRefusal::Restore); + let outcome = super::linux_section(&steps, Path::new("evidence")).await; + assert!(outcome.joins.is_ok(), "the walk's reports stand"); + let err = outcome.teardown.expect_err("the restore's failure stands"); + assert_eq!(err.root_cause().to_string(), "the injected restore failure"); + assert!( + steps.flags().bluetooth_off, + "the cleanup after the failure still ran" + ); + } + + /// The walk's mode needs the dioxus panel, so `--linux-peer` with the + /// Tauri demo is refused before the run starts. + #[test] + fn linux_peer_with_the_tauri_demo_is_refused() { + let arguments = super::Arguments { + linux_peer: true, + every_join: true, + ..Default::default() + }; + assert!( + super::validate_arguments(&arguments, super::DemoApp::Dioxus).is_ok(), + "the dioxus demo carries the peer panel" + ); + let err = super::validate_arguments(&arguments, super::DemoApp::Tauri) + .expect_err("the tauri demo has no peer panel"); + let text = format!("{err:#}"); + assert!( + text.contains("--peer-serial, --linux-peer and --bluetooth"), + "{text}" + ); + } } diff --git a/crates/connetto-test-harness/src/lib.rs b/crates/connetto-test-harness/src/lib.rs index 2975c4cd..b578077e 100644 --- a/crates/connetto-test-harness/src/lib.rs +++ b/crates/connetto-test-harness/src/lib.rs @@ -66,6 +66,8 @@ pub mod demo; pub mod fanout; pub mod inspector; pub mod ios_signing; +pub mod linux_leg; +pub mod linux_proof; pub mod peer_devices; pub mod peer_proof; pub mod relay; diff --git a/crates/connetto-test-harness/src/linux_leg.rs b/crates/connetto-test-harness/src/linux_leg.rs new file mode 100644 index 00000000..b4226f51 --- /dev/null +++ b/crates/connetto-test-harness/src/linux_leg.rs @@ -0,0 +1,1300 @@ +//! The linux leg of the two-device peer proof (R76 slice 9b), the native +//! client the run drives in place of a second phone, so pippo and the phone +//! make every join both ways. +//! +//! The leg runs in a child process of the proof binary, because the +//! client's linux keyring detection reads the process environment, which +//! the proof sets for the child alone. The child signs in as its own +//! account through a fake browser that walks the real OIDC chain, keeps its +//! refresh token and its replica key in sealed files under the evidence +//! directory, and serves a line-based JSON protocol on its standard +//! streams. The proof sends one line per command, each carrying an id, and +//! reads one line per answer, matched by the id, plus a fatal event line +//! when the client stands down. The `linked` and `nearby` answers read the +//! client's own standing, so a missed broadcast event leaves nothing +//! stale, and the pump keeps the one-shot fatal notices. A lag is itself +//! a fatal line, since a missed fatal notice cannot be read back. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::ops::ControlFlow; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use anyhow::{Context as _, Result, anyhow, bail}; +use connetto_client::{ + AccountChoice, Auth, BeaconState, BluetoothError, BluetoothState, BrowserOpener, ClientEvent, + HotspotError, HotspotOffer, HotspotSecurity, JoinError, JoinNearbyError, NativeClient, + NativeClientBuilder, NativeTransport, SyncSchema, WifiPayloadError, +}; +use parking_lot::Mutex as Lock; +use serde_json::json; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin}; +use tokio::sync::Mutex as AsyncLock; +use tokio::sync::broadcast::error::RecvError; +use tokio::time::{Instant, sleep, timeout}; +use uuid::Uuid; + +use crate::linux_proof::{LegOffer, NearbyHost, bluetooth_state, hex_prefix, single_nearby}; +use crate::peer_devices::LINK_END_BOUND; +use crate::stack::DEMO_DEVICE_ROOT_VAR; + +/// How long a state query gets on the leg's command stream. +const QUERY_BOUND: Duration = Duration::from_secs(15); +/// How long a leg action gets, past the backend's own bound. +const ACTION_BOUND: Duration = Duration::from_secs(90); +/// How long the leg gets to sign in, enrol and serve its peer listener. +const READY_BOUND: Duration = Duration::from_secs(120); +/// How long the leg's scan gets to find the host's beacon. +const NEARBY_BOUND: Duration = Duration::from_secs(60); +/// How long the leg gets to close once the proof asks it. +const CLOSE_BOUND: Duration = Duration::from_secs(30); + +/// The data directory the leg keeps its replica in, which the proof names. +const DATA_VAR: &str = "CONNETTO_LINUX_LEG_DATA"; +/// The account the leg signs in as, beside the phone's own. +const LEG_USER: &str = "bob"; +/// The keyring service the leg's sealed records name. +const LEG_SERVICE: &str = "connetto-linux-proof"; +/// The provider name the demo stack registers. +const PROVIDER: &str = "dev-idp"; + +/// The proof's side of the leg, which is the child process, its command +/// stream and the waits the walk drives over it. +pub struct LegClient { + child: AsyncLock, + stdin: AsyncLock>, + next_id: AtomicU64, + answers: Arc, + log: Arc>>, +} + +/// The leg's answer stream as the proof reads it, each answer handed to +/// its waiting command, and the first fatal line kept as the cause every +/// later command fails with. +#[derive(Default)] +struct Answers { + waiters: Lock>>, + fatal: Lock>, +} + +impl Answers { + /// One line of the leg's answer stream. + fn route(&self, line: &str) { + let Ok(value) = serde_json::from_str::(line) else { + eprintln!("[leg] an unparseable line: {line}"); + return; + }; + if let Some(id) = value.get("id").and_then(serde_json::Value::as_u64) { + if let Some(waiter) = self.waiters.lock().remove(&id) { + let _ = waiter.send(value); + } + } else if let Some(error) = value.get("error").and_then(serde_json::Value::as_str) { + eprintln!("[leg] fatal: {error}"); + self.fatal.lock().get_or_insert_with(|| error.to_owned()); + // The dropped senders end every pending wait on the cause. + self.waiters.lock().clear(); + } + } + + /// The fatal line's cause, once the leg stood down. + fn fatal(&self) -> Option { + self.fatal.lock().clone() + } +} + +/// Drive the leg, the child process of the proof binary, which signs in, +/// enrols and serves the command protocol until the proof asks it to close. +/// +/// # Errors +/// +/// When a named environment is missing, the demo schema does not translate, +/// the sign-in or the enrolment fails, or the command stream breaks. +pub async fn run_leg() -> Result<()> { + // The leg's own steps land in `linux-leg.log` beside the evidence, the + // radios' and the link's included. + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new( + "warn,connetto_client::bluetooth=debug,connetto_client::hotspot=debug,connetto_peer=debug", + )) + .with_writer(std::io::stderr) + .try_init(); + let data_dir = + std::env::var(DATA_VAR).map_err(|_| anyhow!("{DATA_VAR} is unset, run under the proof"))?; + let ws_url = std::env::var("CONNETTO_DEMO_WS") + .map_err(|_| anyhow!("CONNETTO_DEMO_WS is unset, run under connetto-demo-stack"))?; + let auth_origin = std::env::var("CONNETTO_DEMO_AUTH_ORIGIN").map_err(|_| { + anyhow!("CONNETTO_DEMO_AUTH_ORIGIN is unset, run under connetto-demo-stack") + })?; + let root_path = std::env::var(DEMO_DEVICE_ROOT_VAR) + .map_err(|_| anyhow!("{DEMO_DEVICE_ROOT_VAR} is unset, run under connetto-demo-stack"))?; + let root = tokio::fs::read(&root_path) + .await + .with_context(|| format!("reading the demo's device root {root_path}"))?; + let demo = crate::stack::repo_path(&["examples", "dioxus-desktop-demo"])?; + let schema_sql = tokio::fs::read_to_string(demo.join("schema.sql")) + .await + .context("reading the demo schema")?; + let policies_sql = tokio::fs::read_to_string(demo.join("policies.sql")) + .await + .context("reading the demo policies")?; + let bundle = + connetto_schema::translate::(&schema_sql, &policies_sql) + .map_err(|err| anyhow!("the demo schema does not translate: {err}"))?; + + tokio::fs::create_dir_all(&data_dir) + .await + .with_context(|| format!("creating the leg's data directory {data_dir}"))?; + let failure = Arc::new(Lock::new(None)); + let sign_in = Auth::new(auth_origin, PROVIDER) + .with_account(AccountChoice::New) + .keyring(LEG_SERVICE) + .with_browser_opener(fake_browser(LEG_USER, Arc::clone(&failure))); + let connect = NativeClientBuilder::new(ws_url, SyncSchema::new(bundle)) + .signed_in(sign_in) + .durable(data_dir.as_str()) + .with_deployment_roots([root]) + // With autolink on, the leg's join dials the gateway at the offer's port. + .with_peer_autolink(true) + .connect(); + let client = Box::pin(connect).await.map_err(|err| { + let detail = failure.lock().take(); + match detail { + Some(detail) => { + anyhow!("the linux leg never signed in: {err:#}, the fake browser: {detail}") + } + None => anyhow!("the linux leg never signed in: {err:#}"), + } + })?; + serve(client).await +} + +/// The command loop, one JSON line per command and one per answer, until +/// the proof asks it to close or its stream ends. +/// +/// # Errors +/// +/// When the command stream breaks or the leg's answer stream closes. +async fn serve(client: NativeClient) -> Result<()> { + let (fatal, mut fatal_rx) = tokio::sync::mpsc::unbounded_channel(); + let events = client.client().events(); + let pump_fatal = fatal.clone(); + let pump = tokio::spawn(async move { + let mut events = events; + while pump_received(events.recv().await, &pump_fatal).is_continue() {} + }); + + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut out = tokio::io::stdout(); + loop { + tokio::select! { + line = lines.next_line() => { + let line = match line { + Ok(Some(line)) => line, + Ok(None) => break, + Err(err) => { + return Err(anyhow::Error::from(err).context("reading the leg's command stream")); + } + }; + if line.trim().is_empty() { + continue; + } + let Ok(command) = serde_json::from_str::(&line) else { + let _ = write_line(&mut out, &json!({"error": "the leg read no JSON line"})).await; + continue; + }; + let reply = handle_command(&client, &command).await; + if write_line(&mut out, &reply).await.is_err() { + return Err(anyhow!("the proof stopped reading the leg's answers")); + } + if command.get("cmd").and_then(serde_json::Value::as_str) == Some("close") { + break; + } + } + Some(error) = fatal_rx.recv() => { + if write_line(&mut out, &json!({"event": "fatal", "error": error})).await.is_err() { + return Err(anyhow!("the proof stopped reading the leg's answers")); + } + // The client stood down, so the leg does too. + break; + } + } + } + client.close().await; + // The client still holds the event sender, so the pump never sees its + // stream end and is stopped here. + pump.abort(); + Ok(()) +} + +/// One line of the leg's answer stream. +/// +/// # Errors +/// +/// When the write fails. +async fn write_line( + out: &mut (impl AsyncWriteExt + Unpin), + value: &serde_json::Value, +) -> Result<()> { + let mut line = value.to_string(); + line.push('\n'); + out.write_all(line.as_bytes()) + .await + .context("writing the leg's answer")?; + out.flush().await.context("flushing the leg's answer") +} + +/// The answer to one command, its refusal carried in the line's `error` +/// field. +async fn handle_command( + client: &NativeClient, + command: &serde_json::Value, +) -> serde_json::Value { + let id = command + .get("id") + .cloned() + .unwrap_or(serde_json::Value::Null); + let cmd = command.get("cmd").and_then(serde_json::Value::as_str); + let answer = match cmd { + Some("identity" | "serving" | "beacon" | "bluetooth" | "linked" | "nearby" | "state") => { + handle_query(client, cmd) + } + _ => handle_action(client, command).await, + }; + answer_line(&id, &answer) +} + +/// The line the command's answer stands, its refusal's text in the line's +/// `error` field. +fn answer_line( + id: &serde_json::Value, + answer: &Result, +) -> serde_json::Value { + match answer { + Ok(value) => json!({ "id": id, "ok": true, "result": value }), + Err(reason) => json!({ "id": id, "ok": false, "error": reason.to_string() }), + } +} + +/// The leg's command refusals, which the answer line's `error` field +/// carries. +#[derive(Debug, thiserror::Error)] +enum LegCommandError { + /// The command is not one the leg answers. + #[error("an unknown leg command")] + Unknown, + /// The command names no field the call reads. + #[error("the {command} command names no {field}")] + MissingField { + /// The command's name. + command: &'static str, + /// The field's name. + field: &'static str, + }, + /// The payload the command names does not parse. + #[error("the payload does not parse: {0}")] + Payload(#[from] WifiPayloadError), + /// The client holds no certificate yet. + #[error("the device holds no certificate yet")] + NoCertificate, + /// The client serves no peer listener yet. + #[error("the device does not serve its peer listener yet")] + NoListener, + /// The client's hotspot host refused. + #[error(transparent)] + Host(#[from] HotspotError), + /// The client's Bluetooth call refused. + #[error(transparent)] + Bluetooth(#[from] BluetoothError), + /// The client's hotspot join refused. + #[error(transparent)] + Join(#[from] JoinError), + /// The client's nearby join refused. + #[error(transparent)] + NearbyJoin(#[from] JoinNearbyError), +} + +/// The answers that read the client's standing. +/// +/// # Errors +/// +/// When the client holds no certificate or serves no listener yet, or the +/// command is unknown. +fn handle_query( + client: &NativeClient, + cmd: Option<&str>, +) -> Result { + match cmd { + Some("identity") => client + .device_certificate() + .map(|cert| json!({ "identity": short_identity(cert.identity()) })) + .ok_or(LegCommandError::NoCertificate), + Some("serving") => client + .peer_address() + .map(|address| json!({ "port": address.port() })) + .ok_or(LegCommandError::NoListener), + Some("beacon") => Ok(beacon_json(&client.beacon_state())), + Some("bluetooth") => Ok(json!({ "state": client.bluetooth_state().to_string() })), + Some("linked") => Ok(json!({ + "linked": client.linked_peers().iter().map(short_identity).collect::>(), + })), + Some("nearby") => Ok(json!({ "nearby": nearby_entries(&client.nearby_hosts()) })), + Some("state") => { + let identity = client + .device_certificate() + .map(|cert| short_identity(cert.identity())); + let port = client.peer_address().map(|address| address.port()); + Ok(json!({ + "identity": identity, + "port": port, + "bluetooth": client.bluetooth_state().to_string(), + "beacon": beacon_json(&client.beacon_state()), + "linked": client + .linked_peers() + .iter() + .map(short_identity) + .collect::>(), + "nearby": nearby_entries(&client.nearby_hosts()), + })) + } + _ => Err(LegCommandError::Unknown), + } +} + +/// The answers that act on the network, the host's side and the joins. +/// +/// # Errors +/// +/// When the client's call refuses, a field or payload is missing or +/// unparseable, or the command is unknown. +async fn handle_action( + client: &NativeClient, + command: &serde_json::Value, +) -> Result { + match command.get("cmd").and_then(serde_json::Value::as_str) { + Some("host") => match client.host_hotspot().await { + Ok(hosted) => Ok(json!({ + "ssid": hosted.offer.ssid, + "passphrase": hosted.offer.passphrase, + "port": hosted.offer.port, + "payload": hosted.offer.to_wifi_payload().to_string(), + "beacon_off": hosted.beacon.map_err(|err| err.to_string()).ok(), + })), + Err(err) => Err(err.into()), + }, + Some("stop") => { + client.stop_hotspot().await; + Ok(json!({})) + } + Some("leave") => { + client.leave_hotspot().await; + Ok(json!({})) + } + Some("enable-bt") => client + .enable_bluetooth() + .await + .map_err(LegCommandError::Bluetooth) + .map(|()| json!({})), + Some("join-form") => { + let ssid = command + .get("ssid") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let passphrase = command + .get("passphrase") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + match command + .get("port") + .and_then(serde_json::Value::as_u64) + .map(u16::try_from) + .and_then(Result::ok) + { + Some(port) => { + let offer = + HotspotOffer::new(ssid, passphrase, HotspotSecurity::Wpa2, Some(port)); + join_gateway(client, &offer).await + } + None => Err(LegCommandError::MissingField { + command: "join-form", + field: "port", + }), + } + } + Some("join-payload") => match command.get("payload").and_then(serde_json::Value::as_str) { + Some(payload) => match HotspotOffer::from_wifi_payload(payload) { + Ok(offer) => join_gateway(client, &offer).await, + Err(err) => Err(err.into()), + }, + None => Err(LegCommandError::MissingField { + command: "join-payload", + field: "payload", + }), + }, + Some("join-nearby") => match command.get("host").and_then(serde_json::Value::as_u64) { + Some(host) => match client.join_nearby(&host).await { + Ok((peer, gateway)) => Ok(json!({ + "identity": short_identity(&peer), + "gateway": gateway.to_string(), + })), + Err(err) => Err(err.into()), + }, + None => Err(LegCommandError::MissingField { + command: "join-nearby", + field: "host", + }), + }, + Some("close") => Ok(json!({})), + _ => Err(LegCommandError::Unknown), + } +} + +/// The join call with its gateway answer, the two join commands share it. +/// +/// # Errors +/// +/// When the hotspot's join will not go. +async fn join_gateway( + client: &NativeClient, + offer: &HotspotOffer, +) -> Result { + client + .join_hotspot(offer) + .await + .map(|gateway| json!({ "gateway": gateway.to_string() })) + .map_err(LegCommandError::Join) +} + +/// One receive of the client's event stream. A lag is a fatal line, since a +/// missed one-shot fatal notice cannot be read back, and a close ends the +/// pump. +fn pump_received( + received: Result, + fatal: &tokio::sync::mpsc::UnboundedSender, +) -> ControlFlow<()> { + match received { + Ok(event) => pump_event(event, fatal), + Err(RecvError::Lagged(n)) => { + let _ = fatal.send(format!( + "the leg missed {n} of the client's events and may have lost a fatal notice" + )); + } + Err(RecvError::Closed) => return ControlFlow::Break(()), + } + ControlFlow::Continue(()) +} + +/// The one-shot fatal notices the leg reports as its fatal lines, the rest +/// of the events read the client's standing and carry nothing here. +fn pump_event(event: ClientEvent, fatal: &tokio::sync::mpsc::UnboundedSender) { + let fatal_line = |line: String| { + let _ = fatal.send(line); + }; + match event { + ClientEvent::PeerListenFailed { address, error } => { + fatal_line(format!( + "the peer listener could not bind {address}: {error}" + )); + } + ClientEvent::CertificateExpired => { + fatal_line("the device certificate is expired".to_owned()); + } + ClientEvent::DeviceRevoked => { + fatal_line("the device was revoked".to_owned()); + } + ClientEvent::CertificateWithdrawn => { + fatal_line("the device certificate was withdrawn".to_owned()); + } + ClientEvent::ClockOutsideWindow { ahead } => { + fatal_line(format!( + "the device clock is outside the certificate window, ahead {ahead}" + )); + } + ClientEvent::AttestationRequired => { + fatal_line("the server requires attestation".to_owned()); + } + _ => {} + } +} + +/// The identity's short form, `account` over the key's first 8 hex digits, +/// which the demo pages show and the proof matches against. +fn short_identity(identity: &connetto_core::device_cert::DeviceIdentity) -> String { + let key = identity.key().to_string(); + let prefix = key.get(..8).unwrap_or_default(); + format!("{}/{prefix}", identity.account()) +} + +/// The beacon's answer fields, advertising with its prefix or off with its +/// reason. +fn beacon_json(state: &BeaconState) -> serde_json::Value { + match state { + BeaconState::Advertising { prefix } => json!({ "prefix": hex_prefix(*prefix) }), + BeaconState::Off { reason } => json!({ "beacon_off": reason }), + } +} + +/// The `nearby` answer's hosts, each with its beacon's prefix in hex and +/// its last signal. +fn nearby_entries(hosts: &[connetto_client::NearbyHost]) -> Vec { + hosts + .iter() + .map(|host| { + json!({ + "host": host.host, + "prefix": host.prefix.map(hex_prefix), + "rssi": host.rssi, + }) + }) + .collect() +} + +/// A browser opener that walks the real OIDC chain for `subject`, posting +/// the login form where the chain names it, until the authenticator's +/// loopback listener takes the code. A broken chain records its failure, +/// which the sign-in's error carries. +fn fake_browser(subject: &str, failure: Arc>>) -> BrowserOpener { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("a plain http client"); + let subject = subject.to_owned(); + Arc::new(move |login_url: &str| { + let http = http.clone(); + let subject = subject.clone(); + let failure = Arc::clone(&failure); + let login_url = login_url.to_owned(); + tokio::spawn(async move { + let mut next_url = login_url; + let mut submitted = false; + for _ in 0..6 { + let resp = match http.get(&next_url).send().await { + Ok(resp) => resp, + Err(err) => { + *failure.lock() = Some(format!("the hop to {next_url} failed: {err}")); + return; + } + }; + if !submitted && resp.url().path().ends_with("/authorize") { + let form_url = resp.url().to_string(); + let posted = match http + .post(form_url.as_str()) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(format!("username={subject}")) + .send() + .await + { + Ok(posted) => posted, + Err(err) => { + *failure.lock() = + Some(format!("the login form to {form_url} failed: {err}")); + return; + } + }; + submitted = true; + let Some(loc) = posted.headers().get(reqwest::header::LOCATION) else { + *failure.lock() = + Some(format!("the login form at {form_url} answered no location")); + return; + }; + let Ok(loc) = loc.to_str() else { + *failure.lock() = Some("the login form's location is not utf8".to_owned()); + return; + }; + loc.clone_into(&mut next_url); + continue; + } + match resp.headers().get(reqwest::header::LOCATION) { + Some(loc) => match loc.to_str() { + Ok(loc) => loc.clone_into(&mut next_url), + Err(err) => { + *failure.lock() = Some(format!("a location is not utf8: {err}")); + return; + } + }, + None => break, + } + } + }); + Ok(()) + }) +} + +impl LegClient { + /// Start the leg under `evidence`, giving it a fresh credential and state + /// directory with a fresh wrap key, the data directory beside them, and + /// the child re-running this binary as its own process. + /// + /// # Errors + /// + /// When the directories cannot be made or the child will not start. + pub async fn spawn(evidence: &Path) -> Result { + let dir = evidence.join("linux-leg"); + let credentials = dir.join("credentials"); + let state = dir.join("state"); + let data = dir.join("data"); + for path in [&credentials, &state, &data] { + tokio::fs::create_dir_all(path) + .await + .with_context(|| format!("creating {}", path.display()))?; + } + let mut wrap_key = Vec::with_capacity(32); + wrap_key.extend_from_slice(Uuid::new_v4().as_bytes()); + wrap_key.extend_from_slice(Uuid::new_v4().as_bytes()); + tokio::fs::write(credentials.join("connetto.wrap-key"), &wrap_key) + .await + .context("writing the leg's wrap key")?; + let program = std::env::current_exe().context("finding the proof binary")?; + let mut child = tokio::process::Command::new(program) + .arg("--linux-leg") + .env("CREDENTIALS_DIRECTORY", &credentials) + .env("STATE_DIRECTORY", &state) + .env(DATA_VAR, &data) + // A portal marker in the proof's environment would send the + // child's keyring detection to the sandbox instead of the + // sealed files. + .env_remove("DESKTOP_PORTAL") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting the linux leg")?; + let stdin = child.stdin.take().context("the leg has no stdin")?; + let stdout = child.stdout.take().context("the leg has no stdout")?; + let stderr = child.stderr.take().context("the leg has no stderr")?; + let child = AsyncLock::new(child); + let log = Arc::new(Lock::new(Vec::new())); + let log_lines = Arc::clone(&log); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("[leg] {line}"); + log_lines.lock().push(line); + } + }); + let answers = Arc::new(Answers::default()); + let read_answers = Arc::clone(&answers); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + read_answers.route(&line); + } + }); + Ok(Self { + child, + stdin: AsyncLock::new(Some(stdin)), + next_id: AtomicU64::new(0), + answers, + log, + }) + } + + /// One command over the stream, answered within `bound` on the + /// monotonic clock. + /// + /// # Errors + /// + /// When the leg will not answer, closes its stream or answers with a + /// refusal. + async fn request( + &self, + command: &'static str, + body: serde_json::Value, + bound: Duration, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1; + let (sender, receiver) = tokio::sync::oneshot::channel(); + self.answers.waiters.lock().insert(id, sender); + // A fatal line that landed before the wait stood ends it here. + if let Some(cause) = self.answers.fatal() { + self.answers.waiters.lock().remove(&id); + bail!("the linux leg stood down before {command}: {cause}"); + } + let mut request = body; + request["id"] = json!(id); + request["cmd"] = json!(command); + let mut line = serde_json::to_string(&request) + .map_err(|err| anyhow!("encoding the {command} command: {err}"))?; + line.push('\n'); + let written = { + let mut guard = self.stdin.lock().await; + match guard.as_mut() { + Some(stdin) => match stdin.write_all(line.as_bytes()).await { + Ok(()) => stdin.flush().await, + Err(err) => Err(err), + }, + None => Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "the leg's stdin is closed", + )), + } + }; + if let Err(err) = written { + self.answers.waiters.lock().remove(&id); + if self.exited().await { + bail!("the linux leg exited before it took {command}"); + } + bail!("writing {command} to the linux leg: {err}"); + } + let answer = match timeout(bound, receiver).await { + Ok(Ok(answer)) => answer, + Ok(Err(_)) => match self.answers.fatal() { + Some(cause) => { + bail!("the linux leg stood down before it answered {command}: {cause}") + } + None => bail!("the linux leg closed its stream without answering {command}"), + }, + Err(_) => { + self.answers.waiters.lock().remove(&id); + if self.exited().await { + bail!("the linux leg exited before it answered {command}"); + } + bail!("the linux leg gave no {command} answer within {bound:?}"); + } + }; + if answer.get("ok") != Some(&json!(true)) { + let reason = answer + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("an unnamed refusal"); + bail!("the linux leg refused {command}: {reason}"); + } + Ok(answer + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null)) + } + + /// Whether the leg's process has exited. + async fn exited(&self) -> bool { + self.child + .lock() + .await + .try_wait() + .is_ok_and(|exit| exit.is_some()) + } + + /// The leg's own peer identity, which its enrolment stands up. + /// + /// # Errors + /// + /// When the leg answers a refusal or gives no answer within the bound. + pub async fn identity(&self) -> Result { + let answer = self.request("identity", json!({}), QUERY_BOUND).await?; + answer + .get("identity") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| anyhow!("the identity answer names no identity")) + } + + /// The port the leg serves its peer listener on. + /// + /// # Errors + /// + /// When the listener does not serve or the leg gives no answer within + /// the bound. + pub async fn serving(&self) -> Result { + let answer = self.request("serving", json!({}), QUERY_BOUND).await?; + answer + .get("port") + .and_then(serde_json::Value::as_u64) + .map(u16::try_from) + .and_then(Result::ok) + .ok_or_else(|| anyhow!("the serving answer names no port")) + } + + /// The beacon's prefix as 16 hex digits, while the beacon advertises. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn beacon(&self) -> Result> { + let answer = self.request("beacon", json!({}), QUERY_BOUND).await?; + Ok(answer + .get("prefix") + .and_then(serde_json::Value::as_str) + .map(str::to_owned)) + } + + /// The leg's Bluetooth standing, as the client reads it. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn bluetooth(&self) -> Result { + let answer = self.request("bluetooth", json!({}), QUERY_BOUND).await?; + let text = answer + .get("state") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("the bluetooth answer names no state"))?; + Ok(bluetooth_state(text)) + } + + /// The peers the leg names as linked, its short identities. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn linked(&self) -> Result> { + let answer = self.request("linked", json!({}), QUERY_BOUND).await?; + let list = answer + .get("linked") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| anyhow!("the linked answer names no list"))?; + Ok(list + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) + .collect()) + } + + /// The hosts the leg's scan stands on, as its standing. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn nearby(&self) -> Result> { + let answer = self.request("nearby", json!({}), QUERY_BOUND).await?; + let list = answer + .get("nearby") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| anyhow!("the nearby answer names no list"))?; + list.iter() + .cloned() + .map(serde_json::from_value) + .collect::, _>>() + .map_err(|err| anyhow!("the nearby answer names no host: {err}")) + } + + /// The leg's full standing, for the run's state files. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn state(&self) -> Result { + self.request("state", json!({}), QUERY_BOUND).await + } + + /// Host a hotspot from the leg and read the offer it stands on. + /// + /// # Errors + /// + /// When the hotspot will not host or the leg gives no answer within + /// the bound. + pub async fn host(&self) -> Result { + let answer = self.request("host", json!({}), ACTION_BOUND).await?; + serde_json::from_value(answer).context("reading the host answer") + } + + /// Stop the hotspot the leg hosts. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn stop(&self) -> Result<()> { + self.request("stop", json!({}), ACTION_BOUND) + .await + .map(drop) + } + + /// Leave the hotspot the leg joined. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn leave(&self) -> Result<()> { + self.request("leave", json!({}), ACTION_BOUND) + .await + .map(drop) + } + + /// Turn the leg's Bluetooth on, the platform's action inside the call. + /// + /// # Errors + /// + /// As [`LegClient::identity`]. + pub async fn enable_bluetooth(&self) -> Result<()> { + self.request("enable-bt", json!({}), ACTION_BOUND) + .await + .map(drop) + } + + /// Join the phone's hotspot by the form's three lines. + /// + /// # Errors + /// + /// When the hotspot's join will not go or the leg gives no answer + /// within the bound. + pub async fn join_form(&self, ssid: &str, passphrase: &str, port: u16) -> Result { + let answer = self + .request( + "join-form", + json!({ "ssid": ssid, "passphrase": passphrase, "port": port }), + ACTION_BOUND, + ) + .await?; + gateway(&answer) + } + + /// Join the phone's hotspot through its `WIFI:` payload. + /// + /// # Errors + /// + /// As [`LegClient::join_form`]. + pub async fn join_payload(&self, payload: &str) -> Result { + let answer = self + .request("join-payload", json!({ "payload": payload }), ACTION_BOUND) + .await?; + gateway(&answer) + } + + /// Join the nearby host the leg's scan stands on, through the exchange + /// behind its beacon. + /// + /// # Errors + /// + /// As [`LegClient::join_form`]. + pub async fn join_nearby(&self, host: &u64) -> Result<(String, IpAddr)> { + let answer = self + .request("join-nearby", json!({ "host": host }), ACTION_BOUND) + .await?; + let identity = answer + .get("identity") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| anyhow!("the join-nearby answer names no identity"))?; + Ok((identity, gateway(&answer)?)) + } + + /// Hold until the leg has signed in, enrolled and served its peer + /// listener, which the walk needs before its first join. + /// + /// # Errors + /// + /// When the leg exits or never stands within the bound. + pub async fn wait_ready(&self) -> Result<(String, u16)> { + let deadline = Instant::now() + READY_BOUND; + loop { + let identity = self.identity().await; + let serving = self.serving().await; + match (identity, serving) { + (Ok(identity), Ok(serving)) => return Ok((identity, serving)), + (Err(err), _) | (_, Err(err)) => { + if self.exited().await { + bail!("the linux leg exited before it stood: {err:#}"); + } + if Instant::now() >= deadline { + bail!("the linux leg never stood within {READY_BOUND:?}: {err:#}"); + } + } + } + sleep(Duration::from_millis(500)).await; + } + } + + /// Hold until the leg names `peer` as linked, polling its standing. + /// + /// # Errors + /// + /// When the leg never names `peer` within `bound`. + pub async fn wait_linked(&self, peer: &str, bound: Duration) -> Result<()> { + let deadline = Instant::now() + bound; + loop { + let linked = self.linked().await?; + if linked.iter().any(|held| held == peer) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the linux leg never named {peer} as linked, it names: {linked:?}"); + } + sleep(Duration::from_secs(1)).await; + } + } + + /// Hold until the leg drops `peer` from its linked list, which the + /// leave and the stop end the link. + /// + /// # Errors + /// + /// When the leg still names `peer` past [`LINK_END_BOUND`]. + pub async fn wait_unlinked(&self, peer: &str) -> Result<()> { + let deadline = Instant::now() + LINK_END_BOUND; + loop { + let linked = self.linked().await?; + if !linked.iter().any(|held| held == peer) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("the linux leg still names {peer} as linked after the leave and the stop"); + } + sleep(Duration::from_secs(2)).await; + } + } + + /// Hold until the leg's scan stands the host whose beacon carries + /// `prefix` and it alone, which the beacon join names to the walk. + /// + /// # Errors + /// + /// When the leg never stands a lone host with the prefix within the + /// bound. + pub async fn wait_nearby(&self, prefix: &str) -> Result { + let deadline = Instant::now() + NEARBY_BOUND; + loop { + let hosts = self.nearby().await?; + if let Some(host) = single_nearby(&hosts, prefix) { + return Ok(host); + } + if Instant::now() >= deadline { + bail!( + "the linux leg never stood a lone host with the beacon prefix {prefix}, \ + it stands: {hosts:?}" + ); + } + sleep(Duration::from_secs(2)).await; + } + } + + /// Hold until the leg's beacon advertises, which the host stands up + /// beside the hotspot. + /// + /// # Errors + /// + /// When the beacon never advertises within the bound. + pub async fn wait_beacon(&self) -> Result { + let deadline = Instant::now() + NEARBY_BOUND; + loop { + if let Some(prefix) = self.beacon().await? { + return Ok(prefix); + } + if Instant::now() >= deadline { + bail!("the linux leg's beacon never advertised within {NEARBY_BOUND:?}"); + } + sleep(Duration::from_secs(2)).await; + } + } + + /// Hold until the leg serves its peer listener. + /// + /// # Errors + /// + /// When the listener never serves within the bound. + pub async fn wait_serving(&self) -> Result { + let deadline = Instant::now() + NEARBY_BOUND; + loop { + if let Ok(port) = self.serving().await { + return Ok(port); + } + if Instant::now() >= deadline { + bail!("the linux leg never served its peer listener within {NEARBY_BOUND:?}"); + } + sleep(Duration::from_secs(2)).await; + } + } + + /// Ask the leg to close and hold until it exits, killing it past the + /// bound. + /// + /// # Errors + /// + /// When the leg does not close within the bound or exits on its own. + pub async fn close(&self) -> Result<()> { + let _ = self.request("close", json!({}), CLOSE_BOUND).await; + // The leg's stdin read sits in the runtime's blocking pool, which + // a shutdown waits for. Dropping the pipe ends it on EOF. + self.stdin.lock().await.take(); + let deadline = Instant::now() + CLOSE_BOUND; + loop { + let exit = self.child.lock().await.try_wait(); + match exit { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + bail!("the linux leg exited with {status} while the proof asked it to close"); + } + Err(err) => bail!("reading the linux leg's exit: {err}"), + Ok(None) => { + if Instant::now() >= deadline { + let mut child = self.child.lock().await; + let _ = child.kill().await; + let _ = child.wait().await; + bail!("the linux leg did not close within {CLOSE_BOUND:?}"); + } + sleep(Duration::from_millis(200)).await; + } + } + } + } + + /// The leg's standard error so far, for the run's evidence. + pub fn take_log(&self) -> String { + let mut lines = self.log.lock(); + std::mem::take(&mut *lines).join("\n") + } +} + +/// The gateway a join answer names, as an address. +/// +/// # Errors +/// +/// When the answer names no gateway or names one that does not parse. +fn gateway(answer: &serde_json::Value) -> Result { + let text = answer + .get("gateway") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("the join answer names no gateway"))?; + text.parse() + .map_err(|err| anyhow!("the join answer names no gateway address: {err}")) +} + +#[cfg(test)] +mod tests { + /// A fatal line ends every pending wait and stands as the cause, the + /// first one kept, while an answer still reaches its command. + #[test] + fn a_fatal_line_ends_the_waits_and_stands_as_the_cause() { + let answers = super::Answers::default(); + let (answered, mut answer) = tokio::sync::oneshot::channel(); + answers.waiters.lock().insert(1, answered); + answers.route(r#"{"id":1,"ok":true}"#); + assert_eq!( + answer.try_recv().ok(), + Some(serde_json::json!({"id": 1, "ok": true})) + ); + let (pending, mut waiting) = tokio::sync::oneshot::channel(); + answers.waiters.lock().insert(2, pending); + answers.route(r#"{"event":"fatal","error":"the device was revoked"}"#); + assert!(matches!( + waiting.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Closed) + )); + answers.route(r#"{"event":"fatal","error":"a later cause"}"#); + assert_eq!(answers.fatal().as_deref(), Some("the device was revoked")); + } + + /// A lagged stream is a fatal line, since a missed one-shot fatal + /// notice cannot be read back, and a closed one ends the pump. + #[test] + fn a_lag_stands_the_leg_down_and_a_close_ends_the_pump() { + use tokio::sync::broadcast::error::RecvError; + let (fatal, mut lines) = tokio::sync::mpsc::unbounded_channel(); + assert!(super::pump_received(Err(RecvError::Lagged(3)), &fatal).is_continue()); + assert_eq!( + lines.try_recv().ok().as_deref(), + Some("the leg missed 3 of the client's events and may have lost a fatal notice") + ); + assert!(super::pump_received(Err(RecvError::Closed), &fatal).is_break()); + assert!(lines.try_recv().is_err()); + } + + /// The answer line's `error` field stands the refusal's text, so each + /// variant's line stays the wire's. + #[test] + fn the_command_errors_stand_their_answer_lines() { + use super::LegCommandError; + + let line = super::answer_line(&serde_json::json!("7"), &Err(LegCommandError::Unknown)); + assert_eq!(line["id"], "7"); + assert_eq!(line["ok"], false); + assert_eq!(line["error"], "an unknown leg command"); + + for (error, text) in [ + ( + LegCommandError::MissingField { + command: "join-form", + field: "port", + }, + "the join-form command names no port", + ), + ( + LegCommandError::MissingField { + command: "join-payload", + field: "payload", + }, + "the join-payload command names no payload", + ), + ( + LegCommandError::MissingField { + command: "join-nearby", + field: "host", + }, + "the join-nearby command names no host", + ), + ( + LegCommandError::NoCertificate, + "the device holds no certificate yet", + ), + ( + LegCommandError::NoListener, + "the device does not serve its peer listener yet", + ), + ( + LegCommandError::Payload(connetto_client::WifiPayloadError::NotWifi), + "the payload does not parse: the string is not a WIFI: payload", + ), + ( + LegCommandError::Host(connetto_client::HotspotError::NoChannel), + "no channel is available for the hotspot", + ), + ( + LegCommandError::Bluetooth(connetto_client::BluetoothError::Unsupported), + "this system has no Bluetooth", + ), + ( + LegCommandError::Join(connetto_client::JoinError::Declined), + "the network is not available to this device", + ), + ( + LegCommandError::NearbyJoin(connetto_client::JoinNearbyError::Bluetooth( + connetto_client::BluetoothError::Busy, + )), + "an exchange is already running", + ), + ] { + let line = super::answer_line(&serde_json::json!("7"), &Err(error)); + assert_eq!(line["ok"], false, "the {text} line"); + assert_eq!(line["error"], text, "the {text} line"); + } + + let line = super::answer_line( + &serde_json::json!("7"), + &Ok(serde_json::json!({ "port": 8080 })), + ); + assert_eq!(line["id"], "7"); + assert_eq!(line["ok"], true); + assert_eq!(line["result"], serde_json::json!({ "port": 8080 })); + } + + /// The nearby answer's hosts carry the beacon's prefix in hex and the + /// signal, and an empty scan stands an empty list. + #[test] + fn the_nearby_answer_stands_the_hosts_with_their_hex_prefix() { + let hosts = [ + connetto_client::NearbyHost { + host: 7, + prefix: Some([9; 8]), + rssi: Some(-55), + }, + connetto_client::NearbyHost { + host: 8, + prefix: None, + rssi: None, + }, + ]; + assert_eq!( + super::nearby_entries(&hosts), + vec![ + serde_json::json!({ "host": 7, "prefix": "0909090909090909", "rssi": -55 }), + serde_json::json!({ "host": 8, "prefix": null, "rssi": null }), + ] + ); + assert_eq!(super::nearby_entries(&[]), Vec::::new()); + } +} diff --git a/crates/connetto-test-harness/src/linux_proof.rs b/crates/connetto-test-harness/src/linux_proof.rs new file mode 100644 index 00000000..03668890 --- /dev/null +++ b/crates/connetto-test-harness/src/linux_proof.rs @@ -0,0 +1,265 @@ +//! The pure standing of the linux leg proof (R76 slice 9b), beside the +//! leg's child process in `linux_leg`. The leg's scan, its beacon's prefix +//! and the phone's network standing the walk reads, and the helpers that +//! name them. + +use std::fmt::Write; +use std::net::IpAddr; +use std::time::Duration; + +use connetto_client::BluetoothState; +use serde::{Deserialize, Serialize}; + +/// How long the adapter gets to report itself off after a power off. +pub const ADAPTER_OFF_BOUND: Duration = Duration::from_secs(30); + +/// One nearby host the leg's scan stands on, as its standing. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NearbyHost { + /// The host's device, the key the scan stands on. + pub host: u64, + /// The beacon's prefix as 16 hex digits, where the beacon carries one. + pub prefix: Option, + /// The signal the beacon carried. + pub rssi: Option, +} + +/// The offer a linux host stands on, as the leg's `host` answer shows it. +#[derive(Clone, Debug, Deserialize)] +pub struct LegOffer { + /// The hotspot's ssid. + pub ssid: String, + /// The hotspot's passphrase. + pub passphrase: String, + /// The host's peer port, while its listener serves. + pub port: Option, + /// The offer as one `WIFI:` payload. + pub payload: String, + /// Why the beacon is down beside the offer, and none while it + /// advertises. + pub beacon_off: Option, +} + +/// The connection a connected Wi-Fi device holds in an `nmcli dev status` +/// listing, absent while no connected Wi-Fi line names one. +pub fn parse_station_name(status: &str) -> Option { + status.lines().find_map(|line| { + // The tokens come off one by one, the rest trimmed after each. + let (_, rest) = line.trim_start().split_once(char::is_whitespace)?; + let (kind, rest) = rest.trim_start().split_once(char::is_whitespace)?; + let (state, rest) = rest.trim_start().split_once(char::is_whitespace)?; + // The connection name may hold spaces, so it is the whole rest. + let name = rest.trim_start(); + (kind == "wifi" && state == "connected" && !name.is_empty()).then(|| name.to_owned()) + }) +} + +/// The first three octets of the hotspot's shared subnet, which +/// `NetworkManager`'s `ipv4.method shared` always brings up at `10.42.0.1/24`. +pub const HOTSPOT_SUBNET_OCTETS: [u8; 3] = [10, 42, 0]; + +/// Whether `remote` stands inside the shared hotspot subnet. +pub fn in_hotspot_subnet(remote: IpAddr) -> bool { + matches!(remote, IpAddr::V4(v4) if v4.octets()[..3] == HOTSPOT_SUBNET_OCTETS) +} + +/// The remote endpoint of one `ss -tnH state established` line, absent +/// while the line names no endpoint or no address. +pub fn ss_remote_address(line: &str) -> Option { + // The endpoint is the line's last field: an address, a port, and + // brackets while the address is IPv6. + let peer = line.split_whitespace().next_back()?; + let address = if let Some(close) = peer.find(']') + && peer.starts_with('[') + { + &peer[1..close] + } else { + peer.rsplit_once(':')?.0 + }; + let ip: IpAddr = address.parse().ok()?; + if let IpAddr::V6(v6) = ip { + let seg = v6.segments(); + // An IPv4 endpoint can stand mapped into an IPv6 address, its + // five zero groups, the all-ones group and two address groups. + if seg[0..6] == [0, 0, 0, 0, 0, 0xffff] { + let high = seg[6].to_be_bytes(); + let low = seg[7].to_be_bytes(); + return Some(IpAddr::from([high[0], high[1], low[0], low[1]])); + } + } + Some(ip) +} + +/// The host to join, where exactly one host stands in sight and its beacon +/// carries `prefix`, and none while another host stands or the prefix does +/// not show. +pub fn single_nearby(hosts: &[NearbyHost], prefix: &str) -> Option { + match hosts { + [host] if host.prefix.as_deref() == Some(prefix) => Some(host.host), + _ => None, + } +} + +/// The beacon's prefix as the pages show it, 16 lowercase hex digits. +pub fn hex_prefix(prefix: [u8; 8]) -> String { + let mut out = String::with_capacity(16); + for byte in prefix { + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// The Bluetooth standing a leg's `bluetooth` answer names. +pub fn bluetooth_state(text: &str) -> BluetoothState { + match text { + "ready" => BluetoothState::Ready, + "off" => BluetoothState::Off, + "not permitted" => BluetoothState::NotPermitted, + _ => BluetoothState::Unsupported, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn station_name_reads_a_connected_wifi_line() { + let status = "\ +DEVICE TYPE STATE CONNECTION +wlp39s0 wifi connected iliadbox-107795 1 +p2p-dev-wlp39s0 wifi-p2p disconnected -- +"; + assert_eq!( + parse_station_name(status).as_deref(), + Some("iliadbox-107795 1") + ); + } + + #[test] + fn station_name_ignores_every_line_but_a_connected_wifi() { + let status = "\ +enp37s0f0 ethernet connected iliadbox-wired +wlp39s0 wifi disconnected -- +"; + assert_eq!(parse_station_name(status), None); + } + + #[test] + fn station_name_reads_a_hosting_ap() { + let status = "wlp39s0 wifi connected connetto-1a2b\n"; + assert_eq!(parse_station_name(status).as_deref(), Some("connetto-1a2b")); + } + + #[test] + fn station_name_refuses_an_empty_name() { + let status = "wlp39s0 wifi connected \n"; + assert_eq!(parse_station_name(status), None); + } + + #[test] + fn single_host_carries_the_prefix() { + let hosts = vec![NearbyHost { + host: 7, + prefix: Some("a1b2c3d4e5f6a7b8".to_owned()), + rssi: Some(-40), + }]; + assert_eq!(single_nearby(&hosts, "a1b2c3d4e5f6a7b8"), Some(7)); + } + + #[test] + fn single_host_refuses_a_wrong_prefix() { + let hosts = vec![NearbyHost { + host: 7, + prefix: Some("a1b2c3d4e5f6a7b8".to_owned()), + rssi: None, + }]; + assert_eq!(single_nearby(&hosts, "ffffffffffffffff"), None); + assert_eq!(single_nearby(&[], "a1b2c3d4e5f6a7b8"), None); + } + + #[test] + fn single_host_refuses_a_second_host() { + let hosts = vec![ + NearbyHost { + host: 7, + prefix: Some("a1b2c3d4e5f6a7b8".to_owned()), + rssi: None, + }, + NearbyHost { + host: 8, + prefix: None, + rssi: None, + }, + ]; + assert_eq!(single_nearby(&hosts, "a1b2c3d4e5f6a7b8"), None); + } + + #[test] + fn beacon_prefix_renders_sixteen_hex_digits() { + assert_eq!( + hex_prefix([0, 1, 16, 127, 255, 0, 9, 42]), + "0001107fff00092a" + ); + } + + #[test] + fn bluetooth_answers_map_to_the_client_states() { + assert_eq!(bluetooth_state("ready"), BluetoothState::Ready); + assert_eq!(bluetooth_state("off"), BluetoothState::Off); + assert_eq!( + bluetooth_state("not permitted"), + BluetoothState::NotPermitted + ); + assert_eq!(bluetooth_state("unsupported"), BluetoothState::Unsupported); + assert_eq!( + bluetooth_state("something else"), + BluetoothState::Unsupported + ); + } + + #[test] + fn ss_remote_reads_an_ipv4_endpoint() { + let line = "tcp ESTAB 0 0 192.168.1.145:51234 10.42.0.2:37105"; + assert_eq!(ss_remote_address(line), Some(IpAddr::from([10, 42, 0, 2]))); + } + + #[test] + fn ss_remote_reads_an_ipv4_mapped_endpoint() { + let line = "tcp ESTAB 0 0 [::1]:4242 ::ffff:10.42.0.2:37105"; + assert_eq!(ss_remote_address(line), Some(IpAddr::from([10, 42, 0, 2]))); + } + + #[test] + fn ss_remote_reads_a_bracketed_ipv6_endpoint() { + let line = "tcp ESTAB 0 0 [::1]:4242 [fd00::1]:37105"; + assert_eq!( + ss_remote_address(line), + Some(IpAddr::V6("fd00::1".parse().unwrap())) + ); + } + + #[test] + fn ss_remote_refuses_a_line_without_an_endpoint() { + assert_eq!(ss_remote_address(""), None); + assert_eq!(ss_remote_address("tcp ESTAB 0 0"), None); + assert_eq!(ss_remote_address("u_str ESTAB 0 0 u_str"), None); + } + + #[test] + fn ss_remote_refuses_an_unparseable_endpoint() { + let line = "tcp ESTAB 0 0 192.168.1.145:51234 999.999.0.2:37105"; + assert_eq!(ss_remote_address(line), None); + assert_eq!( + ss_remote_address("tcp ESTAB 0 0 [::1]:4242 [not-an-address]:37105"), + None + ); + } + + #[test] + fn the_shared_subnet_holds_the_hotspot_endpoints() { + assert!(in_hotspot_subnet(IpAddr::from([10, 42, 0, 2]))); + assert!(!in_hotspot_subnet(IpAddr::from([192, 168, 1, 2]))); + assert!(!in_hotspot_subnet(IpAddr::from([10, 42, 1, 2]))); + } +} diff --git a/docs/architecture/19-device-to-device.md b/docs/architecture/19-device-to-device.md index a0a9787f..cd5dd659 100644 --- a/docs/architecture/19-device-to-device.md +++ b/docs/architecture/19-device-to-device.md @@ -54,6 +54,8 @@ The lifetime is application-requested per case under a server ceiling and refuse **Built (R76, 2026-10-08), hosting and joining on Android.** `NativeClient::host_hotspot` starts Android's local-only hotspot and answers its SSID, passphrase and, while the device serves, its peer port. `join_hotspot` asks the platform for that network, which the user approves, binds only the peer sockets to it so the server connection keeps the phone's usual network, and dials the host at the gateway on the offered port. The hotspot follows the application's calls and the client's life, not the certificate, so an expired host keeps its group's network up. The Kotlin these need ships in `connetto-peer-android`, and every other system answers `Unsupported` until its own slice. On the two A35s the Android 15 phone hosted and the Android 14 phone joined and linked. An A35 started its access point only once its own Wi-Fi was off, measured while that Wi-Fi sat on eduroam's 5 GHz channel 56. +**Built (R76, 2026-10-17), the state reads.** The client's event stream is broadcast, so an application that keeps a list from its events holds a wrong one after any dropped event. The node's read `Node::linked` stands the identities of every peer it reports linked, live slots and a duplicate close's held peer alike, and `NativeClient::linked_peers` hands them to the application, an empty list for a build without a device. `NativeClient::nearby_hosts` stands the scan's hosts as `NearbyHost` values with their host key, beacon prefix and last signal, published whenever a host is seen, refreshed, renamed or gone, and the test harness's Linux leg reads both instead of keeping its own standing. + **Decided (R76, 2026-10-02), making a shared network and joining it.** Android, Windows and Linux start a hotspot from the application (Android's local-only hotspot, a Wi-Fi Direct access point on Windows, NetworkManager's hotspot on Linux). macOS cannot host from an application, and an iPhone or iPad hosts only through Personal Hotspot switched on by hand, whose password the user types into the application once. A host announces itself with a public Bluetooth beacon carrying the protocol version and its certificate fingerprint. A joining device connects to it over Bluetooth, both run the same mutual TLS as the Wi-Fi link with the same checks and exchange revocation lists, and only then does the host send the hotspot's name and password, encrypted for that device, as Apple's Wi-Fi password sharing proves identity before sending a password. With Bluetooth off, the host shows a `WIFI:` QR code and the same details as text. Every platform then asks its own system to join. The decision rests on these field facts. A hotspot host is a member of its own network, Android at `192.168.x.1` through `LocalOnlyHotspot`, application-startable with no SIM service, and iOS at `172.20.10.1` through a manual, carrier-gated toggle whose zero-coverage start stays unverified until R80's field test. Android multicast reception needs a `MulticastLock`, and iOS 14 and later and macOS 15 and later prompt once for local-network permission. A university network can put devices in separate subnets that mDNS cannot cross (measured on eduroam), and a battery travel router removes every hotspot caveat. Bluetooth carries only the hotspot handoff. A full Bluetooth data plane is never promised, the crate landscape being pre-1.0 and single-maintainer, and Noise returns to the cipher table only if that plane ever exists. diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index 98331ec0..65ae741d 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -707,6 +707,35 @@ dependencies = [ "objc2", ] +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "bluez-async" version = "0.9.0" @@ -1385,8 +1414,11 @@ dependencies = [ name = "connetto-peer" version = "0.0.0" dependencies = [ + "bluer", "connetto-core", + "futures-util", "mdns-sd", + "nix", "parking_lot", "ring", "rmp-serde", @@ -1400,6 +1432,7 @@ dependencies = [ "tokio", "tokio-rustls", "tracing", + "zbus", ] [[package]] @@ -1495,6 +1528,7 @@ dependencies = [ "axum", "base64 0.22.1", "connetto-ca", + "connetto-client", "connetto-core", "connetto-file-core", "connetto-file-server", @@ -1512,6 +1546,7 @@ dependencies = [ "ring", "rls2fga", "rustls", + "serde", "serde_json", "sqlite-diff-rs", "sqlparser", @@ -1885,6 +1920,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -1905,6 +1972,20 @@ dependencies = [ "darling_macro 0.24.1", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -1931,6 +2012,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -1986,6 +2078,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + [[package]] name = "dbus-tokio" version = "0.7.6" @@ -4914,6 +5015,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + [[package]] name = "macro-string" version = "0.1.4" @@ -5312,6 +5419,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -8466,6 +8585,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subql" version = "0.1.0" diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 72b76c86..1a404cf9 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5339,12 +5339,17 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 26. **An iPhone or iPad host's network name is typed by default and prefilled where the application holds Apple's device-name entitlement** (2026-10-09). Personal Hotspot names its network after the device and iOS gives no application its password, so `host_personal_hotspot(ssid, passphrase)` takes both. Since iOS 16 `UIDevice`'s name is a generic "iPhone" unless the application holds `com.apple.developer.device-information.user-assigned-device-name`, which Apple grants per application, so connetto answers the real name through `personal_hotspot_name()` where the running application holds it and `None` elsewhere, and the demo prefills it once Apple grants the demo's request. Waiting for the entitlement was rejected, since every application without it still needs the typed name. 27. **An iPhone or iPad host reports an offer no joiner landed on and leaves the retry to the people involved** (2026-10-09). iOS tells an application nothing about Personal Hotspot, whether it is on, who joined or whether the typed password is right. Measured on the iPhone 15 Pro Max on iOS 26, `bridge100` at 172.20.10.1 exists exactly while a device is joined to the hotspot, and switching Personal Hotspot on with nobody joined shows no interface. So when an exchange hands the offer out and no device lands on `bridge100` within the join bound, the host emits `ClientEvent::HotspotUnreached`, and the application tells its user, who checks Personal Hotspot and the password, after which the joiner tries again. The beacon keeps advertising. Stopping the beacon on a silent offer was rejected, since a joiner that declined or was slow would turn a working hotspot off. 28. **An Apple joiner asks the system for the network once more when its first join lands nowhere** (decided with the maintainer 2026-10-12). iOS can refuse to join a Personal Hotspot switched on seconds earlier, showing its own "unable to join" alert while `applyConfiguration` still answers success, so the device stays on its previous network. When the system has answered and the interface has not moved off the address it stood on before the join after 15 seconds, the joiner applies the configuration a second time, once per join, within the join table's 60-second bound. A proof that waits before each join was rejected, since a person scanning a code at once meets the same refusal. +29. **The Linux proof drives a headless client in the runner's own process tree** (decided with the maintainer 2026-10-13). `connetto-android-proof --linux-peer --every-join` starts itself again as a child that signs in, enrols and serves a line-based command stream, while the runner drives the phone's demo as in decision 4. pippo has no desktop session, and the demo's screens are the Dioxus code the phones already prove. The desktop demo under Xvfb was rejected, since it needs a driver for a webkit window without a screen. +30. **A Linux host names its network `connetto-` and four random hex digits** (decided with the maintainer 2026-10-13), new for each hotspot, beside a fresh random 16-character passphrase held in memory only. A name revealing nothing was rejected, since a person typing the three lines picks the network from the system's list by its name. +31. **The Linux peripheral is `bluer`** (decided with the maintainer 2026-10-13), BlueZ's own binding, in `connetto-peer` for Linux only. BlueZ's D-Bus objects written by hand over zbus were rejected, since their calling conventions would be connetto's to own and only a phone run would find their mistakes. +32. **A Linux host tries 5 GHz for 10 seconds, then 2.4 GHz** (decided with the maintainer 2026-10-13), and remembers for the life of the process each device whose 5 GHz attempt failed. Intel's Linux driver refuses some 5 GHz access points, and NetworkManager's own give-up takes 25 seconds. 2.4 GHz always was rejected, since a chip that hosts on 5 GHz should give the group its speed, and waiting for NetworkManager under a longer host bound was rejected, since every start on an Intel card would take about 28 seconds. +33. **One Wi-Fi device refuses the second of hosting and joining** (decided with the maintainer 2026-10-13). A join while the device hosts answers `JoinError::Hosting` and a host while it is joined answers `HotspotError::Joined`, so the application decides and a group never loses its host's network as a side effect. The newest request replacing the other was rejected, since a host joining elsewhere would drop every device joined to it. ### Design - **The Bluetooth exchange.** The host serves through the platform's server role (`BluetoothGattServer` on Android, `GattServiceProvider` on Windows, BlueZ through `bluer` on Linux, `CBPeripheralManager` in the foreground on iPhone and iPad), Android's and Apple's in platform plugins on the `connetto-auth-session` pattern. The joiner writes to one characteristic and reads notifications on another. Frames are length-prefixed and split to the negotiated packet size, and rustls runs over that stream through `read_tls` and `write_tls`, which take any reader and writer. An A35 supports 16 advertising sets and 1,650 bytes of advertising data, measured. -- **Joining.** iPhone and iPad join through `NEHotspotConfigurationManager`, which the user confirms. The Mac uses `CWInterface.associate`, which may ask for an administrator password. Windows saves a profile and calls `WlanConnect`. Linux calls NetworkManager's `AddAndActivateConnection`, and pippo's NetworkManager answers `auth` for `network-control` outside a desktop session, measured. Android uses the platform's network request with the user's approval in the application, in tests too, with the approval tapped by the runner (decision 16). -- **Hosting permissions.** Android needs `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES`, `BLUETOOTH_ADVERTISE` and `BLUETOOTH_CONNECT`. pippo's NetworkManager answers `no` to `wifi.share.protected` outside a desktop session, measured, so a refused hotspot maps to a typed error naming the permission. A host keeps its hotspot credentials in memory only, for the life of the hotspot. +- **Joining.** iPhone and iPad join through `NEHotspotConfigurationManager`, which the user confirms. The Mac uses `CWInterface.associate`, which may ask for an administrator password. Windows saves a profile and calls `WlanConnect`. Linux calls NetworkManager's `AddAndActivateConnection2` with a volatile profile its user owns, which needs `network-control` and `settings.modify.own`. Android uses the platform's network request with the user's approval in the application, in tests too, with the approval tapped by the runner (decision 16). +- **Hosting permissions.** Android needs `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES`, `BLUETOOTH_ADVERTISE` and `BLUETOOTH_CONNECT`. Linux needs NetworkManager's `wifi.share.protected` beside the two joining rights. A desktop user holds all three, and outside a desktop session NetworkManager answers `no` or `auth`, measured on pippo, so a refused hotspot maps to a typed error naming the permission. pippo, which runs the proof headless, grants its user the three through the polkit rule `/etc/polkit-1/rules.d/49-connetto-peer-proof.rules`. A host keeps its hotspot credentials in memory only, for the life of the hotspot. - **Revocation lists on the link.** Each side sends its CRL Numbers per issuer as the first frames inside the established TLS session and then any list the other lacks (R74). A live link whose peer the new list revokes closes with a typed reason. - **Discovery networks.** emi's eduroam put the two A35s in separate subnets, measured, so they reach each other by direct connection and never by mDNS. Devices on such a network meet through a hotspot. @@ -5518,7 +5523,7 @@ One pull request each, in this order, each green and useful on its own (2026-10- 8. **The on-demand run** (step 7's first half, decision 4). `--every-join` on `connetto-android-proof`, every join in one run on the same signed-in phones, with its summary. Proven by one run on the two A35s with every join green. **Built (R76, 2026-10-09)**: one run on the A35s passed the single-phone proof in 106 s and the form, beacon and payload joins in 21 s, 31 s and 21 s, with both phones back at rest after each and the screenshots named by their join. A join that is not the beacon's declines the system's Bluetooth dialog on the host, since hosting with Bluetooth off opens it (decision 21). 9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run), split one pull request per system and ordered handhelds first, since the feature is above all for handheld devices where the network is poor or absent (decided with the maintainer 2026-10-09), each ending with an end-to-end join on its own machine. - 9a. **iPhone and iPad**, one pull request covering both halves, since the devices at hand are an iPhone, an iPad and the Mac in one place, and the only end-to-end pair among them is the iPhone hosting and the iPad joining (decided with the maintainer 2026-10-09). Joining is the btleplug central over CoreBluetooth, `NEHotspotConfiguration` behind the system's join alert, and the gateway dial under iOS's local-network permission. Hosting is the typed Personal Hotspot and the beacon through `CBPeripheralManager`, which only an iPhone or a cellular iPad can do, and the house's iPad is the Wi-Fi model (`iPad15,7`). Apple lets an application's advert carry only a local name and service UUIDs, so the iPhone's beacon shape is settled when the slice starts. The proof drives both devices from the Mac and answers the system's alerts through an XCUITest bundle (decided with the maintainer 2026-10-09), and the iPad joins the iPhone by beacon, payload and form. **Built (R76, 2026-10-12)** as `connetto-peer-apple`, the bridge to `CBPeripheralManager`, `NEHotspotConfiguration` and `UIDevice`, with `AppleBluetoothBackend` and `AppleHotspotBackend` in `connetto-client`, the btleplug central over CoreBluetooth, the demo's Personal Hotspot form, and `connetto-ios-proof --peer-device --every-join` with an XCUITest tapper that answers the system's alerts and switches Personal Hotspot in Settings. On 2026-10-12 the iPhone 15 Pro Max on iOS 26.6 hosted and the iPad A16 joined by the beacon, the pasted payload and the typed form, each linking over the hotspot, in one unattended run. Measured on those devices: the iPhone's hotspot is WPA2 while Maximize Compatibility is on, `NEIPv4Route.defaultRoute` carries no gateway, so the joined network's gateway is read from the routing table through `sysctl`, the iPhone's listener answers at 172.20.10.1, and iOS refuses an application's first connection to a local address with `EHOSTUNREACH` while it asks for local-network access, which decision 11's redial covers. A Personal Hotspot shows its name to devices outside the owner's account only while its pane is open, so the join asks for the network by name as a hidden one. - - 9b. **Linux.** Hosting and joining through NetworkManager, the beacon on BlueZ, and the btleplug central, proven by a native client on pippo against a phone in radio range of it, since emi has neither Wi-Fi nor Bluetooth. + - 9b. **Linux.** Hosting and joining through NetworkManager, the beacon on BlueZ, and the btleplug central, proven by a native client on pippo against a phone in radio range of it, since emi has neither Wi-Fi nor Bluetooth. **Built (R76, 2026-10-13)** with decisions 29 to 33, as `connetto-peer`'s Linux module (NetworkManager over zbus, the peripheral over `bluer`), `LinuxHotspotBackend` and `LinuxBluetoothBackend` in `connetto-client` with every table row under a fake of that module, the btleplug central widened to Linux, and `connetto-android-proof --linux-peer --every-join`. On 2026-10-13 pippo's AX200 and the Galaxy M52 on Android 13 passed all six joins in one unattended run, pippo hosting with the phone joining by the beacon, the pasted payload and the typed form, then the phone hosting with pippo joining the same three ways, each link shown on pippo's established TCP connections inside the hotspot's subnet. Measured on the way: NetworkManager's D-Bus API takes the long group names (`802-11-wireless`, `802-11-wireless-security`) and refuses nmcli's aliases, `AddAndActivateConnection2` answers the saved profile before the active connection, an active connection's states run 2 activated, 3 deactivating and 4 deactivated, the AX200 started some 5 GHz access points in 2 seconds and left others in configuration past 10 seconds, and a phone and pippo sharing a home network link over it by discovery before any hotspot, so the proof keeps the phone off that network for the whole section. The M52 shows its unlock prompt on the fingerprint sensor, so a run given the PIN taps the prompt's PIN button first. The beacon join with pippo hosting stopped before the phone's network approval in two of the four runs that reached it, one of them with the exchange's I/O failing while the phone read pippo's beacon at -87 dBm beside it, and the cause of the other was not captured. - 9c. **macOS.** Joining through CoreWLAN and the btleplug central, the Mac never hosting (decision 1). - 9d. **Windows.** Hosting through a Wi-Fi Direct advertisement in legacy mode, joining through the WLAN API, the beacon through `GattServiceProvider`, and the btleplug central. - 9e. **The recorded home run** across every local machine, with Windows and Linux each hosting once. diff --git a/sonar-project.properties b/sonar-project.properties index a86efe84..738fcaab 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -32,6 +32,7 @@ sonar.rust.cargo.manifestPaths=Cargo.toml # the ignore list in codecov.yml, which the two tools share. sonar.coverage.exclusions=**/*.sql,**/*.swift,**/*.kt,\ crates/connetto-test-harness/src/bin/**,\ + crates/connetto-test-harness/src/linux_leg.rs,\ crates/connetto-test-harness/src/stack.rs,\ crates/connetto-test-harness/src/peer_devices.rs,\ crates/connetto-web/**,\