From cc709a4af9699da2d8b06433f1c30184a850f166 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:10:42 +0800 Subject: [PATCH] Test the NM layer against an in-process D-Bus fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/common/fake_nm.rs` stands up a fake `org.freedesktop.NetworkManager` on a private bus so `cli.rs` and `flow_watch.rs` exercise the real `nm` code paths without a live NetworkManager and without shelling out. Replaces the previous command-capture scaffolding in those two files; scenario coverage (captive portal, exit-node failover, 802.1x, per-network DNS, schedule triggers, Tailscale recovery, SSID verification) is preserved — 139 tests. --- tests/cli.rs | 307 ++++-------- tests/common/fake_nm.rs | 1007 +++++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 10 +- tests/flow_watch.rs | 633 +++++++++--------------- 4 files changed, 1345 insertions(+), 612 deletions(-) create mode 100644 tests/common/fake_nm.rs diff --git a/tests/cli.rs b/tests/cli.rs index c5a0109..2d1a422 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,10 @@ //! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a -//! throwaway tempdir and PATH is emptied so no real `nmcli`/`tailscale`/`date` -//! is ever invoked and the host system is never touched. +//! throwaway tempdir, PATH is emptied so no real `tailscale`/`curl`/`date` is +//! ever invoked, and a private `dbus-daemon` (optionally hosting a fake +//! NetworkManager service — see `tests/common::fake_nm`) stands in for the +//! system bus so the binary's D-Bus calls never touch the host. + +mod common; use std::fs; use std::path::PathBuf; @@ -8,12 +12,15 @@ use std::process::Command; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use common::fake_nm::{self, Security}; + const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs"); static COUNTER: AtomicU32 = AtomicU32::new(0); struct Sandbox { root: PathBuf, + _bus: fake_nm::Daemon, } impl Sandbox { @@ -30,7 +37,16 @@ impl Sandbox { nanos )); fs::create_dir_all(root.join("bin")).unwrap(); - Sandbox { root } + Sandbox { + root, + _bus: fake_nm::launch_daemon(), + } + } + + /// Attach the fake NetworkManager service to this sandbox's bus and + /// return a handle for driving its state. + fn nm(&self) -> fake_nm::FakeNmBus { + fake_nm::serve_on(&self._bus.addr) } /// Binary invocation with an isolated, side-effect-free environment. @@ -48,7 +64,10 @@ impl Sandbox { .env("XDG_CONFIG_HOME", self.root.join("config")) .env("XDG_STATE_HOME", self.root.join("state")) // Empty bin dir => no external commands resolve. - .env("PATH", self.root.join("bin")); + .env("PATH", self.root.join("bin")) + // Point the binary's `Connection::system()` at this test's + // private bus so it never touches a real system bus. + .env("DBUS_SYSTEM_BUS_ADDRESS", &self._bus.addr); for (k, v) in extra { c.env(k, v); } @@ -66,7 +85,7 @@ impl Sandbox { } /// Write an executable shell script into the sandbox's PATH dir so a - /// test can stand in for an external command (e.g. `$EDITOR`). + /// test can stand in for an external command (e.g. `$EDITOR`, `curl`). fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf { let path = self.root.join("bin").join(name); fs::write(&path, script).unwrap(); @@ -151,7 +170,7 @@ fn profile_defaults_to_away_then_persists_set() { assert!(o.status.success()); assert_eq!(stdout(&o).trim(), "away"); - // `set --no-apply` must not touch the network (no nmcli available anyway). + // `set --no-apply` must not touch the network (no NM on the bus anyway). let o = sb.cmd(&["profile", "set", "home", "--no-apply"]); assert!( o.status.success(), @@ -194,9 +213,7 @@ fn profile_list_marks_exactly_the_current_profile() { let out = stdout(&o); assert!(out.contains("* home"), "out: {out}"); assert_eq!( - out.lines() - .filter(|l| l.trim_start().starts_with('*')) - .count(), + out.lines().filter(|l| l.trim_start().starts_with('*')).count(), 1, "expected exactly one marked profile, got: {out}" ); @@ -264,10 +281,7 @@ fn forget_removes_network_from_config() { ); // ...and it should never have been in breadcrumbs.toml to begin with. let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!( - !text.contains("CafeWifi"), - "network leaked into config: {text}" - ); + assert!(!text.contains("CafeWifi"), "network leaked into config: {text}"); } #[test] @@ -275,15 +289,13 @@ fn detect_without_wifi_adapter_errors() { let sb = Sandbox::new(); let o = sb.cmd(&["detect"]); assert!(!o.status.success()); - assert!( - stderr(&o).contains("could not detect"), - "stderr: {}", - stderr(&o) - ); + assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o)); } #[test] -fn doctor_reports_missing_nmcli_in_sandbox() { +fn doctor_reports_missing_network_manager_on_private_bus() { + // The sandbox bus has no NetworkManager service on it, so doctor must + // report it missing rather than assuming presence. let sb = Sandbox::new(); let o = sb.cmd(&["doctor"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); @@ -376,11 +388,7 @@ fn networks_are_stored_separately_from_settings_and_profiles() { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(sb.networks_file()) - .unwrap() - .permissions() - .mode() - & 0o777; + let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "networks.toml should be owner-only"); } } @@ -390,8 +398,8 @@ fn add_with_empty_password_is_stored_as_no_password() { // An explicitly empty password (e.g. `add SSID ""`, or a blank response // at the interactive prompt) means "this is an open network" — it must // round-trip as an absent `password` key, the same as a cleared one, - // not as `password = ""` (which `nm::connect_verbose` would treat as a - // blank secret rather than an open network). + // not as `password = ""` (which `nm::connect_verbose` would send as a + // literal empty PSK and fail against a real open SSID). let sb = Sandbox::new(); let o = sb.cmd(&["add", "OpenCafe", ""]); assert!(o.status.success(), "stderr: {}", stderr(&o)); @@ -408,77 +416,25 @@ fn add_with_empty_password_is_stored_as_no_password() { // NM-owned credentials (item 4): a password is only ever needed once. // ----------------------------------------------------------------------- -/// A fake `nmcli` that behaves statefully enough to exercise the "first -/// connect creates a profile with a password, second connect reuses it -/// without one" path: it records every invocation's argv (one per line) to -/// `$HOME/.nmcli-calls`, and remembers — via a marker file, also under -/// `$HOME` — that "device wifi connect TestNet" has already run, so a -/// following `connection show` reports a saved profile exists. -const FAKE_NMCLI_STATEFUL: &str = r#"#!/bin/sh -record="$HOME/.nmcli-calls" -marker="$HOME/.nmcli-profile-created" -echo "$@" >> "$record" -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID device wifi list ifname wlan0") - echo "TestNet" ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "TestNet:80" ;; - "-t -f ACTIVE,SSID device wifi list ifname wlan0") - echo "yes:TestNet" ;; - "-t -f NAME,TYPE connection show") - if [ -f "$marker" ]; then - echo "TestNet:802-11-wireless" - fi - ;; - *"device wifi connect TestNet"*) - # `: > file` (truncate-or-create via a shell builtin + redirection) — - # not `touch`, which is an external binary and the sandbox's PATH - # deliberately contains nothing but this fake nmcli itself. - : > "$marker" ;; - *"connection up TestNet"*) ;; - *"802-11-wireless-security.psk"*) ;; - "-g GENERAL.CON-UUID device show wlan0") - echo "uuid-1" ;; - *"ipv4.ignore-auto-dns"*) ;; - "device reapply wlan0") ;; - "-t -f DEVICE,STATE device status") - echo "wlan0:connected" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn password_is_cleared_after_first_connect_and_never_sent_again() { + // The first connect creates an NM profile carrying the PSK (over D-Bus, + // never in argv); the second connect reuses that profile and must not + // create a duplicate or resend the password. let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_STATEFUL); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "TestNet", 80, Security::Wpa2); let add = sb.cmd(&["add", "TestNet", "hunter2"]); assert!(add.status.success(), "stderr: {}", stderr(&add)); // "away" is the default profile and defaults to include_all_known, so // TestNet is already a connect candidate with no `--to` needed. - let record = sb.root.join(".nmcli-calls"); - - // First connect: no saved NM profile yet, so breadcrumbs creates one via - // `nmcli --ask device wifi connect ...` with the PSK on stdin, not argv. + // First connect: no saved NM profile yet, so breadcrumbs creates one + // with the password. let first = sb.cmd(&["init"]); assert!(first.status.success(), "stderr: {}", stderr(&first)); - let first_calls = fs::read_to_string(&record).unwrap_or_default(); - assert!( - first_calls.contains("device wifi connect TestNet") && first_calls.contains("--ask"), - "first connect should create a new NM profile via --ask: {first_calls}" - ); - assert!( - !first_calls.contains("hunter2"), - "PSK must not appear on nmcli argv: {first_calls}" - ); - // stdin payload is asserted in-process via FakeRunner (see flow_watch). // The local copy is gone from disk immediately after. let networks = fs::read_to_string(sb.networks_file()).unwrap(); @@ -486,54 +442,43 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() { !networks.contains("hunter2"), "password should have been cleared from networks.toml: {networks}" ); - assert!( - networks.contains("TestNet"), - "network entry itself should remain" - ); + assert!(networks.contains("TestNet"), "network entry itself should remain"); - // Reset the recording so the second run's argv can be checked in isolation. - fs::write(&record, "").unwrap(); + // The NM profile durably holds the secret. + let psk = { + let st = nm.state.lock().unwrap(); + st.connections + .values() + .filter_map(|s| { + let sec = s.get("802-11-wireless-security")?; + sec.get("psk").and_then(|v| v.downcast_ref::().ok()) + }) + .next() + }; + assert_eq!(psk.as_deref(), Some("hunter2"), "NM profile must hold the PSK"); - // Second connect: a saved profile now exists (per the fake nmcli's own - // bookkeeping) and breadcrumbs has no local password anymore, so it must - // reuse the profile via `connection up` and never send a PSK argument. + // Second connect: breadcrumbs has no local password anymore, so it must + // reuse the existing profile without creating a duplicate. let second = sb.cmd(&["init"]); assert!(second.status.success(), "stderr: {}", stderr(&second)); - let second_calls = fs::read_to_string(&record).unwrap_or_default(); - assert!( - second_calls.contains("connection up TestNet"), - "second connect should reuse the existing NM profile: {second_calls}" - ); - assert!( - !second_calls.to_lowercase().contains("hunter2") - && !second_calls.contains("psk") - && !second_calls.contains("password"), - "second connect must never send a password argument: {second_calls}" + assert_eq!( + nm.connection_count(), + 1, + "reuse must not accumulate duplicate NM profiles" ); } // ----------------------------------------------------------------------- -// CLI-level coverage through fake nmcli/tailscale (item 3) +// CLI-level coverage through fake NM + tailscale (item 3) // ----------------------------------------------------------------------- -const FAKE_NMCLI_HEALTHY: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "-t -f ACTIVE,SSID device wifi list ifname wlan0") - echo "yes:HomeWifi" ;; - "-g IP4.ADDRESS device show wlan0") - echo "192.168.1.50/24" ;; - *) ;; -esac -exit 0 -"#; - #[test] -fn status_reports_healthy_through_fake_nmcli_and_curl() { +fn status_reports_healthy_through_fake_nm_and_curl() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + let ap = nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2); + nm.set_active_ap(&dev, &ap); sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n"); let o = sb.cmd(&["status"]); @@ -544,41 +489,27 @@ fn status_reports_healthy_through_fake_nmcli_and_curl() { } #[test] -fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() { +fn doctor_reports_present_when_nm_and_tailscale_are_available() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY); + let _nm = sb.nm(); // attach the fake NM; keep the handle alive for the run sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n"); let o = sb.cmd(&["doctor"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); let out = stdout(&o); assert!( - out.contains("nmcli") && out.contains("present"), + out.contains("network-manager") && out.contains("present"), "out: {out}" ); assert!(!out.contains("MISSING"), "out: {out}"); } -const FAKE_NMCLI_DETECT: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID device wifi list ifname wlan0") - echo "CorpWifi" ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "CorpWifi:80" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn detect_picks_profile_whose_detect_ssids_are_visible() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); sb.cmd(&["list"]); // bootstrap the default config (home/work/away) // Attach a marker SSID to "work" so detection has something to match — @@ -789,7 +720,9 @@ fn status_json_emits_machine_readable_output() { #[test] fn detect_json_emits_machine_readable_output() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); sb.cmd(&["list"]); // bootstrap the default config let text = fs::read_to_string(sb.config_file()).unwrap(); @@ -806,25 +739,13 @@ fn detect_json_emits_machine_readable_output() { assert_eq!(v["profile"].as_str(), Some("work")); } -const FAKE_NMCLI_DETECT_TWO: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "CorpWifi:80" - echo "CafeWifi:70" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn detect_prefers_profile_with_more_matching_markers() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT_TWO); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); + nm.add_ap(&dev, "CafeWifi", 70, Security::Wpa2); sb.cmd(&["list"]); // bootstrap // home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi). @@ -849,23 +770,11 @@ fn detect_prefers_profile_with_more_matching_markers() { ); } -const FAKE_NMCLI_PRUNE: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f NAME,TYPE connection show") - echo "OldCafe:802-11-wireless" ;; - "-g 802-11-wireless.ssid connection show OldCafe") - echo "OldCafe" ;; - "connection delete id OldCafe") ;; - *) ;; -esac -exit 0 -"#; - #[test] fn prune_dry_run_lists_stale_nm_profiles() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE); + let nm = sb.nm(); + nm.save_connection("OldCafe", None); sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale) let o = sb.cmd(&["prune", "--dry-run"]); @@ -880,7 +789,8 @@ fn prune_dry_run_lists_stale_nm_profiles() { #[test] fn prune_removes_stale_nm_profiles() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE); + let nm = sb.nm(); + nm.save_connection("OldCafe", None); sb.cmd(&["list"]); let o = sb.cmd(&["prune"]); @@ -890,53 +800,26 @@ fn prune_removes_stale_nm_profiles() { out.contains("removed") && out.contains("OldCafe"), "out: {out}" ); + assert_eq!( + nm.connection_count(), + 0, + "prune must actually delete the stale profile" + ); } -const FAKE_NMCLI_RETRY: &str = r#"#!/bin/sh -marker="$HOME/.nmcli-connect-ok" -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID device wifi list ifname wlan0") - echo "HomeWifi" ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "HomeWifi:80" ;; - "-t -f ACTIVE,SSID device wifi list ifname wlan0") - echo "yes:HomeWifi" ;; - "-t -f NAME,TYPE connection show") ;; - *"device wifi connect HomeWifi"*) - if [ -f "$marker" ]; then - exit 0 - else - : > "$marker" - exit 1 - fi ;; - *"connection up HomeWifi"*) - exit 0 ;; - "-g GENERAL.CON-UUID device show wlan0") - echo "uuid-1" ;; - *"ipv4.ignore-auto-dns"*) ;; - "device reapply wlan0") ;; - "-t -f DEVICE,STATE device status") - echo "wlan0:connected" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn init_wait_retries_until_connect_succeeds() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_RETRY); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2); // "away" defaults to include_all_known, so HomeWifi is a candidate. let add = sb.cmd(&["add", "HomeWifi", "hunter2"]); assert!(add.status.success(), "stderr: {}", stderr(&add)); - // The fake's first `device wifi connect` fails; the retry succeeds. - // `--wait` must keep going past the first failure rather than bailing. + // The fake's first activation fails; the retry succeeds. `--wait` must + // keep going past the first failure rather than bailing. + nm.fail_next_activations(1); let o = sb.cmd(&["init", "--wait", "5"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o)); diff --git a/tests/common/fake_nm.rs b/tests/common/fake_nm.rs new file mode 100644 index 0000000..bc2ee96 --- /dev/null +++ b/tests/common/fake_nm.rs @@ -0,0 +1,1007 @@ +//! A faithful fake NetworkManager served as a real D-Bus service on a +//! private `dbus-daemon`, so the production `nm` module (a pure zbus client +//! against `org.freedesktop.NetworkManager` on the system bus) can be +//! exercised end-to-end — real D-Bus marshalling, real property reads, real +//! method calls — with zero test-only code paths in `src/`. +//! +//! Two ways to use it: +//! +//! - [`launch_private`] — starts a fresh private daemon + fake NM and +//! returns a [`FakeNmBus`] handle. The caller passes +//! `DBUS_SYSTEM_BUS_ADDRESS=` to any subprocess (the CLI sandbox) so +//! the binary's `Connection::system()` lands on this bus. Independent per +//! test → safe to run in parallel. +//! - [`shared`] — one process-wide fake NM bus pointed at by the process +//! env var (in-process tests can't set per-test env safely). Tests using +//! it must serialize against each other, which the returned guard does. +//! +//! The fake implements the subset of the NetworkManager D-Bus API the +//! client uses: devices, access points (SSID/strength/security flags), +//! connection profiles (list/add/get/delete), activation (which lands the +//! device on the matching AP and marks it ACTIVATED), per-device IP4Config, +//! and the `WirelessEnabled`/`Connectivity` root properties. It also +//! records every call for assertions. + +use std::collections::{BTreeMap, HashMap}; +use std::io::BufRead; +use std::ops::Deref; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use zbus::fdo; +use zbus::interface; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +pub const NM_DEST: &str = "org.freedesktop.NetworkManager"; +pub const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +pub const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const DEVICES_PREFIX: &str = "/org/freedesktop/NetworkManager/Devices"; +const APS_PREFIX: &str = "/org/freedesktop/NetworkManager/AccessPoints"; +const ACTIVE_PREFIX: &str = "/org/freedesktop/NetworkManager/ActiveConnection"; +const IP4_PREFIX: &str = "/org/freedesktop/NetworkManager/IP4Config"; +const CONNS_PREFIX: &str = "/org/freedesktop/NetworkManager/Settings"; + +// Security flag constants (mirrors src/nm.rs). +pub const DEV_TYPE_WIFI: u32 = 2; +pub const DEV_STATE_ACTIVATED: u32 = 100; +const AP_FLAG_PRIVACY: u32 = 0x1; +const SEC_PSK: u32 = 0x100; +const SEC_802_1X: u32 = 0x200; +const SEC_SAE: u32 = 0x400; + +/// Wi-Fi security flavors the fake can advertise for an AP. +#[derive(Debug, Clone, Copy)] +pub enum Security { + Open, + Wpa2, + Wpa3, + Wpa1Wpa2, + Enterprise, + Wep, +} + +impl Security { + fn to_flags(self) -> (u32, u32, u32) { + match self { + Security::Open => (0, 0, 0), + Security::Wpa2 => (AP_FLAG_PRIVACY, 0, SEC_PSK), + Security::Wpa3 => (AP_FLAG_PRIVACY, 0, SEC_SAE), + Security::Wpa1Wpa2 => (AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK), + Security::Enterprise => (AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X), + Security::Wep => (AP_FLAG_PRIVACY, 0, 0), + } + } +} + +pub type SettingsMap = HashMap>; + +#[derive(Debug, Clone)] +pub struct FakeDeviceData { + pub iface: String, + pub dev_type: u32, + pub state: u32, + pub active_ap: Option, + pub ip4: String, +} + +#[derive(Debug, Clone)] +pub struct FakeApData { + pub ssid: Vec, + pub strength: u8, + pub flags: u32, + pub wpa: u32, + pub rsn: u32, +} + +#[derive(Debug, Default)] +pub struct FakeState { + pub devices: BTreeMap, + pub aps: BTreeMap, + pub dev_aps: HashMap>, + pub connections: BTreeMap, + /// active-conn path -> settings-conn path + pub active_conns: BTreeMap, + /// device path -> active-conn path + pub dev_active: HashMap, + pub connectivity: u32, + pub wireless_enabled: bool, + /// When set, activation lands the device on the AP with this SSID + /// (simulates an NM autoconnect race landing elsewhere). + pub land_on: Option, + /// When set, activation creates a matching AP on the fly if none exists + /// (hidden networks appear in the scan only after connecting). + pub connect_any: bool, + /// When > 0, the next activations fail (simulating a transient NM + /// failure); each attempted activation decrements the counter. + pub fail_next_activations: u32, + pub calls: Vec, + pub next_dev: u32, + pub next_ap: u32, + pub next_conn: u32, + pub next_active: u32, +} + +#[derive(Clone)] +struct Shared { + state: Arc>, +} + +pub fn value_bytes(v: &OwnedValue) -> Option> { + match v.deref() { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + if let Value::U8(b) = item { + out.push(*b); + } else { + return None; + } + } + Some(out) + } + _ => None, + } +} + +/// Extract an array-of-strings (e.g. `ipv4.dns`, `802-1x.eap`) from a +/// settings dict value. zvariant has no `TryFrom<&Value>` for `Vec`, +/// so we peel the `Array` ourselves. +pub fn value_str_list(v: &OwnedValue) -> Option> { + match v.deref() { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + match item { + Value::Str(s) => out.push(s.as_str().to_string()), + _ => return None, + } + } + Some(out) + } + _ => None, + } +} + +fn ov(v: Value<'_>) -> OwnedValue { + OwnedValue::try_from(v).expect("settings value is ownable") +} + +/// OwnedValue isn't `Clone` (only `try_clone`), so settings dicts must be +/// copied field-by-field when the fake hands one out. +fn clone_settings(s: &SettingsMap) -> SettingsMap { + s.iter() + .map(|(section, vals)| { + let cloned = vals + .iter() + .map(|(k, v)| v.try_clone().map(|c| (k.clone(), c))) + .collect::, _>>() + .expect("settings values are ownable"); + (section.clone(), cloned) + }) + .collect() +} + +fn conn_ssid(s: &SettingsMap) -> Option { + s.get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(value_bytes) + .map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +fn conn_id(s: &SettingsMap) -> Option { + s.get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) +} + +fn obj(path: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(path).expect("valid object path") +} + +// --------------------------------------------------------------------- +// Root interface: org.freedesktop.NetworkManager +// --------------------------------------------------------------------- + +struct FakeNm { + shared: Shared, +} + +#[interface(name = "org.freedesktop.NetworkManager")] +impl FakeNm { + async fn get_devices(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st.devices.keys().map(|p| obj(p)).collect()) + } + + #[zbus(property)] + fn wireless_enabled(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().wireless_enabled) + } + + // Setters must return `zbus::Error` (not `fdo::Error`): the macro's + // generated setter keeps the fallible arm's error type verbatim, and the + // dispatch future is typed `Result<(), zbus::Error>`. + #[zbus(property)] + fn set_wireless_enabled(&self, v: bool) -> zbus::Result<()> { + self.shared.state.lock().unwrap().wireless_enabled = v; + Ok(()) + } + + #[zbus(property)] + fn connectivity(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().connectivity) + } + + async fn activate_connection( + &self, + conn: OwnedObjectPath, + dev: OwnedObjectPath, + _specific: OwnedObjectPath, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result { + let active = self.register_active(c, conn.as_str(), dev.as_str()).await?; + Ok(active) + } + + async fn add_and_activate_connection2( + &self, + settings: SettingsMap, + dev: OwnedObjectPath, + _specific: OwnedObjectPath, + _options: HashMap, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result<(OwnedObjectPath, OwnedObjectPath)> { + let conn_path = self.save_connection(settings, c).await?; + let active = self.register_active(c, conn_path.as_str(), dev.as_str()).await?; + Ok((conn_path, active)) + } +} + +impl FakeNm { + async fn save_connection(&self, settings: SettingsMap, c: &zbus::Connection) -> fdo::Result { + let path = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("AddAndActivateConnection2".into()); + // Same UUID-exists semantics as real NetworkManager: this call + // creates a *new* profile, so a duplicate UUID is an error, not + // an upsert (updates go through Settings.Connection.Update2). + if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) { + return Err(fdo::Error::Failed( + "A connection with this UUID already exists.".into(), + )); + } + let p = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(p.clone(), settings); + p + }; + c.object_server() + .at(path.as_str(), FakeConn { + shared: self.shared.clone(), + path: path.clone(), + }) + .await?; + Ok(obj(&path)) + } + + async fn register_active( + &self, + c: &zbus::Connection, + conn_path: &str, + dev: &str, + ) -> fdo::Result { + let (active_path, new_ap) = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push(format!("activate {conn_path} -> {dev}")); + if st.fail_next_activations > 0 { + st.fail_next_activations -= 1; + return Err(fdo::Error::Failed("transient activation failure".into())); + } + let ssid = st.connections.get(conn_path).and_then(conn_ssid); + let target = st.land_on.clone().or(ssid); + let mut dev_ap = target.as_ref().and_then(|t| { + let aps = st.dev_aps.get(dev).cloned().unwrap_or_default(); + aps.into_iter().find(|ap| { + st.aps + .get(ap) + .map(|a| String::from_utf8_lossy(&a.ssid).into_owned() == *t) + .unwrap_or(false) + }) + }); + // Hidden networks don't appear in a scan until after they're + // associated; create the AP on the fly in that case. + let mut new_ap = None; + if dev_ap.is_none() && st.connect_any { + if let Some(t) = &target { + let id = st.next_ap; + st.next_ap += 1; + let ap_path = format!("{APS_PREFIX}/{id}"); + st.aps.insert( + ap_path.clone(), + FakeApData { + ssid: t.as_bytes().to_vec(), + strength: 80, + flags: AP_FLAG_PRIVACY, + wpa: 0, + rsn: SEC_PSK, + }, + ); + st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone()); + dev_ap = Some(ap_path.clone()); + new_ap = Some(ap_path); + } + } + if let Some(d) = st.devices.get_mut(dev) { + if let Some(ap) = &dev_ap { + d.active_ap = Some(ap.clone()); + } + d.state = DEV_STATE_ACTIVATED; + } + let p = format!("{ACTIVE_PREFIX}/{}", st.next_active); + st.next_active += 1; + st.active_conns.insert(p.clone(), conn_path.to_string()); + st.dev_active.insert(dev.to_string(), p.clone()); + (p, new_ap) + }; + if let Some(ap) = &new_ap { + c.object_server() + .at(ap.as_str(), FakeAp { + shared: self.shared.clone(), + path: ap.clone(), + }) + .await?; + } + c.object_server() + .at(active_path.as_str(), FakeActive { + shared: self.shared.clone(), + path: active_path.clone(), + }) + .await?; + Ok(obj(&active_path)) + } +} + +// --------------------------------------------------------------------- +// Settings: org.freedesktop.NetworkManager.Settings +// --------------------------------------------------------------------- + +struct FakeSettings { + shared: Shared, +} + +#[interface(name = "org.freedesktop.NetworkManager.Settings")] +impl FakeSettings { + async fn list_connections(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st.connections.keys().map(|p| obj(p)).collect()) + } + + async fn add_connection2( + &self, + settings: SettingsMap, + _flags: u32, + _args: HashMap, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result<(OwnedObjectPath, HashMap)> { + let path = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("AddConnection2".into()); + // Real NetworkManager rejects a duplicate UUID with + // NM_SETTINGS_ERROR_UUID_EXISTS — it does NOT upsert. Existing + // profiles must be edited via Settings.Connection.Update2; model + // that here so a client regression fails loudly. + if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) { + return Err(fdo::Error::Failed( + "A connection with this UUID already exists.".into(), + )); + } + let p = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(p.clone(), settings); + p + }; + c.object_server() + .at(path.as_str(), FakeConn { + shared: self.shared.clone(), + path: path.clone(), + }) + .await?; + Ok((obj(&path), HashMap::new())) + } +} + +// --------------------------------------------------------------------- +// Settings.Connection +// --------------------------------------------------------------------- + +struct FakeConn { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Settings.Connection")] +impl FakeConn { + async fn get_settings(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + let settings = st + .connections + .get(&self.path) + .ok_or_else(|| fdo::Error::UnknownObject(self.path.clone()))?; + Ok(clone_settings(settings)) + } + + async fn delete(&self) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push(format!("delete {}", self.path)); + st.connections.remove(&self.path); + Ok(()) + } + + async fn update2( + &self, + settings: SettingsMap, + _flags: u32, + _args: HashMap, + ) -> fdo::Result> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("Update2".into()); + st.connections.insert(self.path.clone(), settings); + Ok(HashMap::new()) + } +} + +// --------------------------------------------------------------------- +// Access point +// --------------------------------------------------------------------- + +struct FakeAp { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.AccessPoint")] +impl FakeAp { + #[zbus(property)] + fn ssid(&self) -> fdo::Result> { + Ok(self.shared.state.lock().unwrap().aps[&self.path].ssid.clone()) + } + + #[zbus(property)] + fn strength(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].strength) + } + + #[zbus(property)] + fn flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].flags) + } + + #[zbus(property)] + fn wpa_flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].wpa) + } + + #[zbus(property)] + fn rsn_flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].rsn) + } +} + +// --------------------------------------------------------------------- +// Device + Wireless + IP4Config +// --------------------------------------------------------------------- + +struct FakeDevice { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Device")] +impl FakeDevice { + #[zbus(property)] + fn interface(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].iface.clone()) + } + + #[zbus(property)] + fn device_type(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].dev_type) + } + + #[zbus(property)] + fn state(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].state) + } + + #[zbus(property)] + fn active_connection(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(st + .dev_active + .get(&self.path) + .map(|p| obj(p)) + .unwrap_or_else(|| obj("/"))) + } + + #[zbus(property)] + fn ip4_config(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(obj(&st.devices[&self.path].ip4)) + } + + async fn reapply( + &self, + _settings: SettingsMap, + _version: u64, + _flags: u32, + ) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("Reapply".into()); + Ok(()) + } +} + +struct FakeWireless { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] +impl FakeWireless { + #[zbus(property)] + fn active_access_point(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(st.devices[&self.path] + .active_ap + .as_ref() + .map(|p| obj(p)) + .unwrap_or_else(|| obj("/"))) + } + + async fn get_all_access_points(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st + .dev_aps + .get(&self.path) + .map(|aps| aps.iter().map(|p| obj(p)).collect()) + .unwrap_or_default()) + } + + async fn request_scan(&self, _options: HashMap) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("RequestScan".into()); + Ok(()) + } +} + +struct FakeIp4 { + shared: Shared, + /// Device path (so the fake can find the device's IP). + dev: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.IP4Config")] +impl FakeIp4 { + #[zbus(property)] + fn addresses(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + // A fixed, recognizable address: 192.168.1.42/24, gw .1. + let _ = &st.devices[&self.dev]; + Ok(vec![(0xC0A8012A, 24, 0xC0A80101)]) + } +} + +// --------------------------------------------------------------------- +// Connection.Active +// --------------------------------------------------------------------- + +struct FakeActive { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Connection.Active")] +impl FakeActive { + #[zbus(property)] + fn connection(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(obj(&st.active_conns[&self.path])) + } +} + +// --------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------- + +/// A private `dbus-daemon`. Dropping it kills the daemon. +pub struct Daemon { + pub addr: String, + child: Child, +} + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DBUS_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Start a private `dbus-daemon` with permissive policies. Nothing is +/// registered on it — attach the fake NM with [`serve_on`] if the test +/// needs NetworkManager to be present. +pub fn launch_daemon() -> Daemon { + let n = DBUS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "breadcrumbs-dbus-{}-{}", + std::process::id(), + n + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create dbus dir"); + let config = dir.join("bus.conf"); + std::fs::write(&config, BUS_CONFIG).expect("write bus config"); + + let mut child = Command::new("dbus-daemon") + .arg("--nofork") + .arg("--nopidfile") + .arg(format!("--config-file={}", config.display())) + .arg("--print-address=1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("dbus-daemon must be installed to run the NetworkManager fake tests"); + let mut line = String::new(); + std::io::BufReader::new(child.stdout.take().expect("dbus stdout")) + .read_line(&mut line) + .expect("read dbus-daemon address"); + let addr = line + .trim() + .split(';') + .next() + .expect("address") + .to_string(); + Daemon { addr, child } +} + +/// A fake NM service served on an existing (usually private) bus. +pub struct FakeNmBus { + pub addr: String, + pub state: Arc>, + server: zbus::blocking::Connection, +} + +const BUS_CONFIG: &str = r#" + + session + unix:tmpdir=/tmp + + + + + + +"#; + +/// Serve the fake NetworkManager on the bus at `addr` and claim its name. +/// Subprocesses pointed at the same address via `DBUS_SYSTEM_BUS_ADDRESS` +/// will see this as their system NetworkManager. +pub fn serve_on(addr: &str) -> FakeNmBus { + let conn = zbus::blocking::connection::Builder::address(addr) + .expect("connect to private bus") + .build() + .expect("build blocking connection"); + let state = Arc::new(Mutex::new(FakeState { + connectivity: 4, + wireless_enabled: true, + next_dev: 1, + next_ap: 1, + next_conn: 1, + next_active: 1, + ..Default::default() + })); + let shared = Shared { + state: state.clone(), + }; + + conn.object_server() + .at(NM_PATH, FakeNm { + shared: shared.clone(), + }) + .expect("register fake NM"); + conn.object_server() + .at(SETTINGS_PATH, FakeSettings { + shared: shared.clone(), + }) + .expect("register fake settings"); + conn.request_name(NM_DEST).expect("claim NM name"); + + FakeNmBus { + addr: addr.to_string(), + state, + server: conn, + } +} + +impl FakeNmBus { + pub fn addr(&self) -> &str { + &self.addr + } + + pub fn reset(&self) { + let mut st = self.state.lock().unwrap(); + st.devices.clear(); + st.aps.clear(); + st.dev_aps.clear(); + st.connections.clear(); + st.active_conns.clear(); + st.dev_active.clear(); + st.connectivity = 4; + st.wireless_enabled = true; + st.land_on = None; + st.connect_any = false; + st.fail_next_activations = 0; + st.calls.clear(); + st.next_dev = 1; + st.next_ap = 1; + st.next_conn = 1; + st.next_active = 1; + } + + /// Add a Wi-Fi device; returns its object path. `state` is the device + /// state (e.g. 100 = ACTIVATED). + pub fn add_wifi_device(&self, iface: &str, state: u32) -> String { + let (dev_path, ip4_path) = { + let mut st = self.state.lock().unwrap(); + let id = st.next_dev; + st.next_dev += 1; + let dev_path = format!("{DEVICES_PREFIX}/{id}"); + let ip4_path = format!("{IP4_PREFIX}/{id}"); + st.devices.insert( + dev_path.clone(), + FakeDeviceData { + iface: iface.to_string(), + dev_type: DEV_TYPE_WIFI, + state, + active_ap: None, + ip4: ip4_path.clone(), + }, + ); + (dev_path, ip4_path) + }; + self.server + .object_server() + .at( + dev_path.as_str(), + FakeDevice { + shared: self.shared(), + path: dev_path.clone(), + }, + ) + .expect("register device"); + self.server + .object_server() + .at( + dev_path.as_str(), + FakeWireless { + shared: self.shared(), + path: dev_path.clone(), + }, + ) + .expect("register wireless"); + self.server + .object_server() + .at( + ip4_path.as_str(), + FakeIp4 { + shared: self.shared(), + dev: dev_path.clone(), + }, + ) + .expect("register ip4"); + dev_path + } + + fn shared(&self) -> Shared { + Shared { + state: self.state.clone(), + } + } + + /// Add an access point to a device; returns its object path. + pub fn add_ap(&self, dev: &str, ssid: &str, strength: u8, sec: Security) -> String { + let (ap_path, flags, wpa, rsn) = { + let mut st = self.state.lock().unwrap(); + let id = st.next_ap; + st.next_ap += 1; + let ap_path = format!("{APS_PREFIX}/{id}"); + let (flags, wpa, rsn) = sec.to_flags(); + st.aps.insert( + ap_path.clone(), + FakeApData { + ssid: ssid.as_bytes().to_vec(), + strength, + flags, + wpa, + rsn, + }, + ); + st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone()); + (ap_path, flags, wpa, rsn) + }; + let _ = (flags, wpa, rsn); + self.server + .object_server() + .at( + ap_path.as_str(), + FakeAp { + shared: self.shared(), + path: ap_path.clone(), + }, + ) + .expect("register AP"); + ap_path + } + + pub fn set_active_ap(&self, dev: &str, ap: &str) { + let mut st = self.state.lock().unwrap(); + if let Some(d) = st.devices.get_mut(dev) { + d.active_ap = Some(ap.to_string()); + d.state = DEV_STATE_ACTIVATED; + } + } + + /// When set, any activation lands the device on the AP with this SSID + /// (simulating an NM autoconnect race). + pub fn set_land_on(&self, ssid: Option<&str>) { + self.state.lock().unwrap().land_on = ssid.map(str::to_string); + } + + /// When enabled, connecting to a network with no visible AP creates one + /// (hidden-network semantics). + pub fn set_connect_any(&self, on: bool) { + self.state.lock().unwrap().connect_any = on; + } + + /// Make the next `n` activation attempts fail (transient-failure + /// simulation, e.g. for `init --wait` retries). + pub fn fail_next_activations(&self, n: u32) { + self.state.lock().unwrap().fail_next_activations = n; + } + + pub fn set_connectivity(&self, c: u32) { + self.state.lock().unwrap().connectivity = c; + } + + pub fn set_device_state(&self, dev: &str, state: u32) { + let mut st = self.state.lock().unwrap(); + if let Some(d) = st.devices.get_mut(dev) { + d.state = state; + } + } + + /// Save a wireless connection profile (as `nm` would create one) and + /// return its path. + pub fn save_connection(&self, ssid: &str, password: Option<&str>) -> String { + let mut settings: SettingsMap = HashMap::new(); + let mut conn: HashMap = HashMap::new(); + conn.insert("id".into(), ov(Value::from(ssid.to_string()))); + conn.insert("type".into(), ov(Value::from("802-11-wireless"))); + conn.insert( + "uuid".into(), + ov(Value::from("00000000-0000-4000-8000-000000000001")), + ); + settings.insert("connection".into(), conn); + + let mut wifi: HashMap = HashMap::new(); + wifi.insert("ssid".into(), ov(Value::from(ssid.as_bytes().to_vec()))); + wifi.insert("mode".into(), ov(Value::from("infrastructure"))); + settings.insert("802-11-wireless".into(), wifi); + + if let Some(pw) = password { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + sec.insert("psk".into(), ov(Value::from(pw.to_string()))); + settings.insert("802-11-wireless-security".into(), sec); + } + + let mut ipv4: HashMap = HashMap::new(); + ipv4.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv4".into(), ipv4); + + let path = { + let mut st = self.state.lock().unwrap(); + st.calls.push(format!("save {ssid}")); + let path = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(path.clone(), settings); + path + }; + self.server + .object_server() + .at( + path.as_str(), + FakeConn { + shared: self.shared(), + path: path.clone(), + }, + ) + .expect("register saved connection"); + path + } + + pub fn calls(&self) -> Vec { + self.state.lock().unwrap().calls.clone() + } + + pub fn connection_count(&self) -> usize { + self.state.lock().unwrap().connections.len() + } + + pub fn device_state(&self, dev: &str) -> u32 { + self.state.lock().unwrap().devices.get(dev).map(|d| d.state).unwrap_or(0) + } + + pub fn active_ssid(&self, dev: &str) -> Option { + let st = self.state.lock().unwrap(); + let ap = st.devices.get(dev)?.active_ap.clone()?; + st.aps.get(&ap).map(|a| String::from_utf8_lossy(&a.ssid).into_owned()) + } + + /// SSIDs of every connection activated so far, in activation order. + pub fn activated_ssids(&self) -> Vec { + let st = self.state.lock().unwrap(); + st.active_conns + .values() + .filter_map(|p| st.connections.get(p).and_then(conn_ssid)) + .collect() + } +} + +// --------------------------------------------------------------------- +// Shared in-process bus (flow_watch tests) +// --------------------------------------------------------------------- + +struct SharedBus { + _daemon: Daemon, + bus: FakeNmBus, +} + +static SHARED: OnceLock> = OnceLock::new(); + +/// The process-wide fake NM bus for in-process tests. The env var +/// `DBUS_SYSTEM_BUS_ADDRESS` is pointed at it once, so the production +/// `nm` module (which uses `Connection::system()`) reaches it with zero +/// test seams. Tests using this must serialize against each other — the +/// returned guard holds the bus's lock for its whole lifetime. +pub struct SharedNm { + guard: MutexGuard<'static, SharedBus>, +} + +impl std::ops::Deref for SharedNm { + type Target = FakeNmBus; + fn deref(&self) -> &FakeNmBus { + &self.guard.bus + } +} + +pub fn shared() -> SharedNm { + let bus = SHARED.get_or_init(|| { + let daemon = launch_daemon(); + let bus = serve_on(&daemon.addr); + std::env::set_var("DBUS_SYSTEM_BUS_ADDRESS", &daemon.addr); + Mutex::new(SharedBus { _daemon: daemon, bus }) + }); + let guard = bus.lock().unwrap_or_else(|e| e.into_inner()); + SharedNm { guard } +} + +/// Convenience guard used by tests that only need the bus available (no +/// state control) — e.g. classify tests that merely observe "no adapter". +/// Ensures the shared bus is up (and the env var set) before any `nm` +/// call happens. +pub fn ensure_shared() -> SharedNm { + shared() +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 67f5f2c..704a989 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -9,7 +9,7 @@ //! rules ("if the program+args match this predicate, return this canned //! `Output`"), which also records every invocation so a test can assert //! exactly what was — or, just as importantly, was *not* — passed (e.g. -//! that a password argument never reaches a fake `nmcli`). +//! that a password argument never reaches a fake subprocess). //! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does //! its own best-effort file logging via `notify::log`, which resolves a //! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a @@ -18,9 +18,15 @@ //! inherently cross-test-within-this-binary racy, so it's guarded by a //! process-wide mutex — tests using it serialize against each other but //! not against unrelated tests (each `tests/*.rs` file is its own binary). +//! - [`fake_nm`]: a real fake NetworkManager D-Bus service on a private +//! `dbus-daemon`. The production `nm` module talks to it over real D-Bus +//! marshalling (`Connection::system()` honors `DBUS_SYSTEM_BUS_ADDRESS`), +//! replacing the old fake-`nmcli`-argv rules. #![allow(dead_code)] // not every test file uses every helper here +pub mod fake_nm; + use std::cell::RefCell; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -94,7 +100,7 @@ impl FakeRunner { } /// Shorthand for matching on `prog` plus a whitespace-joined view of - /// `args` containing `substr` (handy for `nmcli`/`tailscale` calls, whose + /// `args` containing `substr` (handy for `tailscale`/`curl` calls, whose /// interesting bit is usually a subcommand somewhere in the middle). pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self { self.on( diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs index 9fc207f..eadeb0e 100644 --- a/tests/flow_watch.rs +++ b/tests/flow_watch.rs @@ -1,9 +1,11 @@ //! In-process tests for the actual state machine (`flow::run`) and the watch -//! loop's health classification (`watch::classify`), driven entirely through -//! a faked `breadcrumbs::util::Runner` (see `tests/common`) — no subprocess -//! is ever spawned. This complements `tests/cli.rs`'s black-box coverage -//! (which spawns the real binary against fake-bin shell scripts) with fast, -//! precise coverage of the logic itself: candidate priority order, the +//! loop's health classification (`watch::classify`). NetworkManager is a real +//! fake NM D-Bus service on a private bus (see `tests/common::fake_nm`), so +//! every `nm` call is exercised over genuine D-Bus marshalling. Everything +//! else (tailscale, curl/ping, notify) is faked through a +//! `breadcrumbs::util::Runner` (see `tests/common`). This complements +//! `tests/cli.rs`'s black-box coverage (which spawns the real binary) with +//! fast, precise coverage of the logic itself: candidate priority order, the //! bootstrap+Tailscale gate, and every `watch::Health` transition. mod common; @@ -18,6 +20,7 @@ use breadcrumbs::state::{self, State}; use breadcrumbs::util::with_runner; use breadcrumbs::watch::{classify, Health}; +use common::fake_nm::{self, Security, SharedNm}; use common::{fail, ok, EnvSandbox, FakeRunner}; fn net(ssid: &str, password: Option<&str>) -> NetworkDef { @@ -52,82 +55,52 @@ fn base_config() -> Config { } } -/// Wires up the nmcli plumbing every `flow::run` call needs regardless of -/// scenario: a Wi-Fi interface exists, radio/rescan calls are no-ops, no -/// saved NM connection profiles exist yet (so every connect takes the -/// "create via `device wifi connect`" path), DNS enforcement succeeds, and -/// the device reports connected after any successful connect attempt. -fn base_nm(visible_ssids: &[&str]) -> FakeRunner { - let visible = visible_ssids.join("\n"); - // `-f SSID,SIGNAL` lines: all SSIDs at the same (strong) signal, so - // priority order — not signal — decides between them. - let with_signal = visible_ssids - .iter() - .map(|s| format!("{s}:80")) - .collect::>() - .join("\n"); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "radio wifi on", ok("")) - .on_contains("nmcli", "wifi rescan", ok("")) - // Exact matches: `-f ACTIVE,SSID` queries (which contain the - // substring "SSID device wifi list") must NOT be answered with the - // visible list — they go to the stateful rule below. - .on( - move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", - ok(&visible), - ) - .on( - move |_prog, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", - ok(&with_signal), - ) - .on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")); - let calls = runner.calls_handle(); - runner.on_dynamic( - move |_prog, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", - move |_prog, _args| { - // Stateful: answer with the SSID of the most recently dialed - // connection, so connect_and_verify's post-connect SSID check - // sees the network that was just activated (bootstrap first, - // then the target). - let rec = calls.borrow(); - let ssid = rec.iter().rev().find_map(|call| { - let j = call.args.join(" "); - if j.contains("connect") { - call.args - .iter() - .position(|a| a == "connect") - .map(|i| call.args[i + 1].clone()) - } else if j.contains("connection up") { - call.args - .iter() - .position(|a| a == "up") - .map(|i| call.args[i + 1].clone()) - } else { - None - } - }); - match ssid { - Some(s) => ok(&format!("yes:{s}")), - None => ok(""), - } - }, +/// Reset the shared fake-NM bus and put a Wi-Fi device on it with one AP per +/// SSID at the given signal strength. Returns the bus guard (held for the +/// whole test so tests serialize) and the device path. +fn setup_wifi(ssids: &[(&str, u8)]) -> (SharedNm, String) { + let nm = fake_nm::shared(); + nm.reset(); + let dev = nm.add_wifi_device("wlan0", 100); + for (ssid, strength) in ssids { + nm.add_ap(&dev, ssid, *strength, Security::Wpa2); + } + (nm, dev) +} + +/// A runner that fakes the non-NM subprocesses a successful `flow::run` +/// needs: curl (internet check) and nothing else. +fn healthy_runner() -> FakeRunner { + FakeRunner::new() + .with_command("curl") + .on(|prog, _| prog == "curl", ok("204")) +} + +/// The runner used by `classify` tests: internet check + optional tailscale. +fn classify_runner(curl: &str, tailscale_status: Option<&str>) -> FakeRunner { + let mut r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok(curl)); + if let Some(json) = tailscale_status { + r = r + .with_command("tailscale") + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok(json)); + } + r +} + +/// Make the device report being associated with `ssid` (for classify tests). +fn associate(nm: &SharedNm, dev: &str, ssid: &str) { + let ap = nm.add_ap(dev, ssid, 80, Security::Wpa2); + nm.set_active_ap(dev, &ap); +} + +fn tailscale_json_ok(exit_node: &str) -> String { + format!( + r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"# ) } -/// A successful `device wifi connect ...` for every ssid in `ssids`. -fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner { - ssids.iter().fold(runner, |r, ssid| { - let ssid: &'static str = ssid; - r.on( - move |prog, args| prog == "nmcli" && args.contains(&"connect") && args.contains(&ssid), - ok(""), - ) - }) +fn tailscale_json_missing() -> &'static str { + r#"{"BackendState":"Running","Peer":{}}"# } // --------------------------------------------------------------------- @@ -137,12 +110,10 @@ fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner { #[test] fn flow_run_connects_to_first_visible_candidate_in_priority_order() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("First", 80), ("Second", 80)]); let mut cfg = base_config(); - cfg.networks = vec![ - net("First", Some("pw1")), - net("Second", Some("pw2")), - ]; + cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))]; cfg.profiles.insert( "home".into(), Profile { @@ -151,12 +122,7 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { }, ); - let runner = allow_connects(base_nm(&["First", "Second"]), &["First", "Second"]) - .on(|prog, _| prog == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); match outcome { flow::Outcome::Connected { ssid, note } => { @@ -166,13 +132,13 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { other => panic!("expected Connected, got {other:?}"), } - // Priority order actually mattered: "Second" was never dialed even + // Priority order actually mattered: "Second" was never activated even // though it was visible and would have succeeded too. - let dialed_second = calls - .borrow() - .iter() - .any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second")); - assert!(!dialed_second, "connected to Second when First should win"); + assert_eq!( + nm.activated_ssids(), + vec!["First".to_string()], + "Second must not be dialed when First wins" + ); // The password used for the winning connect is now NM's problem, not // breadcrumbs' — cleared and (via clear_password_if_used) persisted. @@ -187,14 +153,15 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { #[test] fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() { let _env = EnvSandbox::new(); + // Nothing is visible; connecting creates the AP on the fly (hidden + // networks appear only after association). + let (nm, _dev) = setup_wifi(&[]); + nm.set_connect_any(true); let mut cfg = base_config(); // "Ghost" is neither visible nor hidden, so pass 1 *and* pass 2 both // skip it outright — it should never be dialed. - cfg.networks = vec![ - net("Ghost", Some("pw-ghost")), - hidden_net("Shadow", Some("pw-shadow")), - ]; + cfg.networks = vec![net("Ghost", Some("pw-ghost")), hidden_net("Shadow", Some("pw-shadow"))]; cfg.profiles.insert( "away".into(), Profile { @@ -203,44 +170,37 @@ fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() { }, ); - // Neither SSID shows up in the scan — "Shadow" is only reachable via the - // pass-2 "hidden and unseen" path. - let runner = allow_connects(base_nm(&[]), &["Shadow"]) - .on(|prog, _| prog == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - - let outcome = with_runner(runner, || flow::run(&mut cfg, "away")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "away")); match outcome { flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Shadow"), other => panic!("expected Connected to Shadow, got {other:?}"), } - let dialed_ghost = calls - .borrow() - .iter() - .any(|c| c.args.iter().any(|a| a == "Ghost") && c.args.contains(&"connect".to_string())); - assert!(!dialed_ghost, "Ghost should never have been dialed"); + assert_eq!( + nm.activated_ssids(), + vec!["Shadow".to_string()], + "Ghost must never have been dialed" + ); } #[test] fn flow_run_unknown_profile_short_circuits_before_touching_nm() { let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); let mut cfg = base_config(); let runner = FakeRunner::new(); // no rules at all - let calls = runner.calls_handle(); let outcome = with_runner(runner, || flow::run(&mut cfg, "does-not-exist")); assert!(matches!(outcome, flow::Outcome::UnknownProfile(p) if p == "does-not-exist")); - // The only `Runner::run` call on this path is `notify`/`log`'s own - // `date` timestamp lookup — nmcli (or anything network-related) is - // never touched for a profile that doesn't exist. + // The fake NetworkManager must never be touched for a profile that + // doesn't exist (no devices, no scans, no activations). assert!( - calls.borrow().iter().all(|c| c.prog != "nmcli"), - "unknown-profile path should never shell out to nmcli: {:?}", - calls.borrow() + nm.calls().is_empty(), + "unknown-profile path should never call NetworkManager: {:?}", + nm.calls() ); } @@ -248,19 +208,10 @@ fn flow_run_unknown_profile_short_circuits_before_touching_nm() { // flow::run — bootstrap + Tailscale gating // --------------------------------------------------------------------- -fn tailscale_json_ok(exit_node: &str) -> String { - format!( - r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"# - ) -} - -fn tailscale_json_missing() -> &'static str { - r#"{"BackendState":"Running","Peer":{}}"# -} - #[test] fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); @@ -275,13 +226,10 @@ fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { }, ); - let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"]) - .with_command("curl") + let runner = healthy_runner() .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) .on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode"))) .on_contains("tailscale", "set", ok("")); - let calls = runner.calls_handle(); let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); @@ -294,16 +242,17 @@ fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { assert_eq!(cfg.network("Guest").unwrap().password, None); assert_eq!(cfg.network("Corp").unwrap().password, None); - let dialed_guest = calls - .borrow() - .iter() - .any(|c| c.args.iter().any(|a| a == "Guest") && c.args.contains(&"connect".to_string())); - assert!(dialed_guest, "bootstrap should have been dialed first"); + assert_eq!( + nm.activated_ssids(), + vec!["Guest".to_string(), "Corp".to_string()], + "bootstrap must be dialed before the target" + ); } #[test] fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); @@ -318,19 +267,10 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() }, ); - let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"]) - .with_command("curl") + let runner = healthy_runner() .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on( - |prog, args| prog == "tailscale" && args.contains(&"status"), - ok(tailscale_json_missing()), - ) - .on( - |prog, args| prog == "tailscale" && args.contains(&"set"), - ok(""), - ); - let calls = runner.calls_handle(); + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok(tailscale_json_missing())) + .on(|p, args| p == "tailscale" && args.contains(&"set"), ok("")); let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); @@ -342,12 +282,9 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() other => panic!("expected TailscaleError, got {other:?}"), } - let dialed_corp = calls - .borrow() - .iter() - .any(|c| c.args.iter().any(|a| a == "Corp") && c.args.contains(&"connect".to_string())); - assert!( - !dialed_corp, + assert_eq!( + nm.activated_ssids(), + vec!["Guest".to_string()], "target network must never be dialed while Tailscale is unhealthy" ); // The bootstrap connect *did* use a password and succeeded, so it's @@ -362,75 +299,69 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() #[test] fn classify_reports_unknown_profile_without_touching_nm() { let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); let cfg = base_config(); // no profiles at all let runner = FakeRunner::new(); let calls = runner.calls_handle(); let class = with_runner(runner, || classify(&cfg, "ghost")); - let health = class.health; - let ssid = class.ssid; - assert_eq!(health, Health::UnknownProfile); - assert_eq!(ssid, None); + assert_eq!(class.health, Health::UnknownProfile); + assert_eq!(class.ssid, None); assert!(calls.borrow().is_empty()); + assert!( + nm.calls().is_empty(), + "unknown-profile classify must not touch NetworkManager" + ); } #[test] fn classify_reports_no_adapter_when_wifi_interface_absent() { let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); // no devices at all let mut cfg = base_config(); cfg.profiles.insert("away".into(), Profile::default()); - // `device status` succeeds but lists no wifi-type device. - let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet")); - let class = with_runner(runner, || classify(&cfg, "away")); - let health = class.health; - - assert_eq!(health, Health::NoAdapter); + let class = with_runner(FakeRunner::new(), || classify(&cfg, "away")); + assert_eq!(class.health, Health::NoAdapter); } #[test] fn classify_reports_down_no_net_when_internet_check_fails() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); let mut cfg = base_config(); cfg.profiles.insert("away".into(), Profile::default()); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) - .on(|prog, _| prog == "curl" || prog == "ping", fail("")); + let runner = FakeRunner::new().on(|prog, _| prog == "curl" || prog == "ping", fail("")); let class = with_runner(runner, || classify(&cfg, "away")); - let health = class.health; - let ssid = class.ssid; - assert_eq!(health, Health::DownNoNet); - assert_eq!(ssid, Some("HomeWifi".to_string())); + assert_eq!(class.health, Health::DownNoNet); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); } #[test] fn classify_reports_up_when_healthy_and_tailscale_not_required() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); let mut cfg = base_config(); cfg.profiles.insert("home".into(), Profile::default()); // tailscale: false - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) - .with_command("curl") - .on(|prog, _| prog == "curl", ok("204")); - let class = with_runner(runner, || classify(&cfg, "home")); - let health = class.health; - let ssid = class.ssid; + let class = with_runner(healthy_runner(), || classify(&cfg, "home")); - assert_eq!(health, Health::Up); - assert_eq!(ssid, Some("HomeWifi".to_string())); + assert_eq!(class.health, Health::Up); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); } #[test] fn classify_reports_down_tailscale_manual_when_not_installed() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.profiles.insert( "work".into(), @@ -440,23 +371,17 @@ fn classify_reports_down_tailscale_manual_when_not_installed() { }, ); - // No `with_command("tailscale")`, so `tailscale::installed()` is false — - // `status::gather` never even tries to run the `tailscale` binary. - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .on(|prog, _| prog == "curl", ok("204")); - let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; + // No `with_command("tailscale")`, so `tailscale::installed()` is false. + let class = with_runner(healthy_runner(), || classify(&cfg, "work")); - assert_eq!(health, Health::DownTailscaleManual); + assert_eq!(class.health, Health::DownTailscaleManual); } #[test] fn classify_reports_down_tailscale_manual_when_needs_login() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.profiles.insert( "work".into(), @@ -466,26 +391,17 @@ fn classify_reports_down_tailscale_manual_when_needs_login() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on( - |prog, args| prog == "tailscale" && args.contains(&"status"), - ok(r#"{"BackendState":"NeedsLogin"}"#), - ); + let runner = classify_runner("204", Some(r#"{"BackendState":"NeedsLogin"}"#)); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - assert_eq!(health, Health::DownTailscaleManual); + assert_eq!(class.health, Health::DownTailscaleManual); } #[test] fn classify_reports_down_tailscale_other_when_exit_node_offline() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); cfg.profiles.insert( @@ -497,26 +413,17 @@ fn classify_reports_down_tailscale_other_when_exit_node_offline() { ); let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"exitnode","Online":false,"ExitNode":false,"ExitNodeOption":true}}}"#; - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on( - |prog, args| prog == "tailscale" && args.contains(&"status"), - ok(json), - ); + let runner = classify_runner("204", Some(json)); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - assert_eq!(health, Health::DownTailscaleOther); + assert_eq!(class.health, Health::DownTailscaleOther); } #[test] fn classify_reports_up_when_tailscale_healthy() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); cfg.profiles.insert( @@ -527,18 +434,10 @@ fn classify_reports_up_when_tailscale_healthy() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode"))); + let runner = classify_runner("204", Some(&tailscale_json_ok("exitnode"))); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - assert_eq!(health, Health::Up); + assert_eq!(class.health, Health::Up); } // --------------------------------------------------------------------- @@ -662,6 +561,8 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() { // TsHealth::NoExitNode — and must never run `tailscale set --exit-node=` // with an empty value, which would clear the user's current selection. let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Corp", 80)]); + let mut cfg = base_config(); cfg.networks = vec![net("Corp", Some("corp-pw"))]; cfg.profiles.insert( @@ -673,7 +574,7 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() { }, ); - let runner = base_nm(&["Corp"]).with_command("tailscale"); + let runner = FakeRunner::new().with_command("tailscale"); let calls = runner.calls_handle(); let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); @@ -688,6 +589,11 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() { "with no exit node configured, tailscale must not be touched: {:?}", calls.borrow() ); + assert!( + nm.activated_ssids().is_empty(), + "with no exit node configured, no network must be dialed: {:?}", + nm.activated_ssids() + ); } #[test] @@ -696,6 +602,8 @@ fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() { // classify as DownTailscaleManual — not DownTailscaleOther, which would // make the watcher spin auto-recovery forever. let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.profiles.insert( "work".into(), @@ -705,19 +613,11 @@ fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")); + let runner = classify_runner("204", None).with_command("tailscale"); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - let ssid = class.ssid; - assert_eq!(health, Health::DownTailscaleManual); - assert_eq!(ssid, Some("CorpWifi".to_string())); + assert_eq!(class.health, Health::DownTailscaleManual); + assert_eq!(class.ssid, Some("CorpWifi".to_string())); } #[test] @@ -733,10 +633,9 @@ fn ensure_exit_node_attempts_to_start_unreachable_daemon() { .on(|p, args| p == "tailscale" && args.contains(&"status"), ok("")) .on(|p, args| p == "tailscale" && args.contains(&"up"), ok("")); let calls = runner.calls_handle(); - let health = - with_runner(runner, || { - breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()]) - }); + let health = with_runner(runner, || { + breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()]) + }); assert!( matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)), "daemon still unreachable after `up` → Error, got {health:?}" @@ -759,6 +658,9 @@ fn flow_run_fails_when_device_lands_on_wrong_ssid() { // *different* network than requested. flow must not report Connected to // the requested SSID, and must not clear its password. let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("First", 80), ("OtherNet", 90)]); + nm.set_land_on(Some("OtherNet")); + let mut cfg = base_config(); cfg.networks = vec![net("First", Some("pw1"))]; cfg.profiles.insert( @@ -769,31 +671,7 @@ fn flow_run_fails_when_device_lands_on_wrong_ssid() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "radio wifi on", ok("")) - .on_contains("nmcli", "wifi rescan", ok("")) - .on( - |_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", - ok("First"), - ) - .on( - |_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", - ok("First:80"), - ) - .on_contains("nmcli", "NAME,TYPE", ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) - // The connect itself succeeds... - .on_contains("nmcli", "device wifi connect First", ok("")) - // ...but the device reports being on a different network. - .on( - |_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", - ok("yes:OtherNet"), - ); - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); assert!( !matches!(outcome, flow::Outcome::Connected { .. }), @@ -811,6 +689,8 @@ fn run_quiet_suppresses_notifications_that_run_emits() { // The watch loop calls flow::run_quiet so a persistent failure doesn't // re-notify on every retry; the CLI keeps flow::run's notifications. let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); let mut cfg = base_config(); // no profiles → UnknownProfile path notifies let runner = FakeRunner::new().with_command("notify-send"); @@ -860,21 +740,22 @@ fn internet_ok_requires_204_and_falls_back_to_ping() { #[test] fn scan_list_dedups_by_ssid_keeping_strongest_signal() { - // One line per BSSID: the same SSID broadcast by several APs must show - // once, at its strongest signal (not the first, possibly weak, listing). + // One entry per SSID, at its strongest signal (not the first, possibly + // weak, listing). Hidden (empty-SSID) APs are skipped. let _env = EnvSandbox::new(); - let runner = FakeRunner::new().on_contains( - "nmcli", - "SSID,SIGNAL,SECURITY", - ok("Cafe:40:WPA2\nCafe:80:WPA2\nOffice:60:WPA3\nCafe:90 %:WPA2\n:70:WPA2"), - ); - let list = with_runner(runner, || breadcrumbs::nm::scan_list("wlan0")); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Cafe", 40, Security::Wpa2); + nm.add_ap(&dev, "Cafe", 80, Security::Wpa2); + nm.add_ap(&dev, "Office", 60, Security::Wpa3); + nm.add_ap(&dev, "Cafe", 90, Security::Wpa2); - assert_eq!(list.len(), 2, "dedup by SSID, hidden (empty SSID) skipped: {list:?}"); + let list = breadcrumbs::nm::scan_list("wlan0"); + assert_eq!(list.len(), 2, "dedup by SSID: {list:?}"); let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap(); - assert_eq!(cafe.signal, "90 %", "strongest signal wins"); + assert_eq!(cafe.signal, "90", "strongest signal wins"); let office = list.iter().find(|e| e.ssid == "Office").unwrap(); assert_eq!(office.signal, "60"); + assert_eq!(office.security, "WPA3"); } // --------------------------------------------------------------------- @@ -886,6 +767,10 @@ fn scan_list_dedups_by_ssid_keeping_strongest_signal() { #[test] fn flow_run_prefers_strongest_visible_signal_over_priority_order() { let _env = EnvSandbox::new(); + // "Weak" is listed first (higher priority), but "Strong" has the better + // signal — signal-aware selection must dial Strong first. + let (nm, _dev) = setup_wifi(&[("Weak", 40), ("Strong", 90)]); + let mut cfg = base_config(); cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))]; cfg.profiles.insert( @@ -896,58 +781,24 @@ fn flow_run_prefers_strongest_visible_signal_over_priority_order() { }, ); - // "Weak" is listed first (higher priority), but "Strong" has the better - // signal — signal-aware selection must dial Strong first. - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "radio wifi on", ok("")) - .on_contains("nmcli", "wifi rescan", ok("")) - .on( - |_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", - ok("Weak\nStrong"), - ) - .on( - |_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", - ok("Weak:40\nStrong:90"), - ) - .on_contains("nmcli", "NAME,TYPE", ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) - .on( - |_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", - ok("yes:Strong"), - ) - .on( - |p, args| p == "nmcli" && args.contains(&"connect") && args.contains(&"Strong"), - ok(""), - ) - .on(|p, _| p == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); match &outcome { flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Strong"), other => panic!("expected Connected to Strong, got {other:?}"), } - let dialed = |s: &str| { - calls.borrow().iter().any(|c| { - c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == s) - }) - }; - assert!(dialed("Strong"), "the stronger network must be dialed"); - assert!( - !dialed("Weak"), - "the weaker network must not be dialed despite higher priority" + assert_eq!( + nm.activated_ssids(), + vec!["Strong".to_string()], + "the stronger network must be dialed, and only it" ); } #[test] fn flow_run_pins_per_network_dns_override() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Home", 80)]); + let mut cfg = base_config(); cfg.settings.dns = "1.1.1.1".into(); let mut def = net("Home", Some("pw")); @@ -961,24 +812,25 @@ fn flow_run_pins_per_network_dns_override() { }, ); - let runner = allow_connects(base_nm(&["Home"]), &["Home"]) - .on(|p, _| p == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); assert!(matches!(outcome, flow::Outcome::Connected { .. })); - // The DNS-pinning `connection modify` must carry the per-network override, - // not the global 1.1.1.1. - let dns_arg = calls.borrow().iter().any(|c| { - c.args.join(" ").contains("ipv4.dns") && c.args.iter().any(|a| a == "9.9.9.9") - }); - assert!(dns_arg, "per-network DNS override must reach nmcli"); + // The DNS-pinned profile must carry the per-network override, not the + // global 1.1.1.1. + let st = nm.state.lock().unwrap(); + let conn = st.connections.values().next().expect("a profile was saved"); + let dns = conn + .get("ipv4") + .and_then(|m| m.get("dns")) + .and_then(fake_nm::value_str_list); + assert_eq!(dns, Some(vec!["9.9.9.9".to_string()])); } #[test] fn flow_run_appends_learned_ssid_to_detect_ssids() { let _env = EnvSandbox::new(); + let (_nm, _dev) = setup_wifi(&[("Home", 80)]); + let mut cfg = base_config(); cfg.networks = vec![net("Home", Some("pw"))]; cfg.profiles.insert( @@ -990,10 +842,7 @@ fn flow_run_appends_learned_ssid_to_detect_ssids() { }, ); - let runner = allow_connects(base_nm(&["Home"]), &["Home"]) - .on(|p, _| p == "curl", ok("204")) - .with_command("curl"); - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); assert!(matches!(outcome, flow::Outcome::Connected { .. })); assert_eq!( @@ -1006,15 +855,12 @@ fn flow_run_appends_learned_ssid_to_detect_ssids() { #[test] fn classify_reports_captive_portal_when_connectivity_returns_200() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); let mut cfg = base_config(); cfg.profiles.insert("home".into(), Profile::default()); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) - .with_command("curl") - .on(|p, _| p == "curl", ok("200")); + let runner = classify_runner("200", None); let class = with_runner(runner, || classify(&cfg, "home")); assert_eq!(class.health, Health::CaptivePortal); @@ -1023,10 +869,10 @@ fn classify_reports_captive_portal_when_connectivity_returns_200() { #[test] fn ensure_exit_node_failover_tries_nodes_in_priority_order() { - let _env = EnvSandbox::new(); // The status never shows nodeA; it always shows nodeB selected + online. // ensure_exit_node must therefore try nodeA (fail), then nodeB (succeed), // in that exact priority order. + let _env = EnvSandbox::new(); let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"nodeB","DNSName":"nodeB.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}"#; let runner = FakeRunner::new() .with_command("tailscale") @@ -1054,78 +900,69 @@ fn ensure_exit_node_failover_tries_nodes_in_priority_order() { #[test] fn wifi_interface_preferred_picks_named_device_over_first_wifi() { - let runner = FakeRunner::new().on_contains( - "nmcli", - "DEVICE,TYPE", - ok("wlan0:wifi\nwlan1:wifi"), - ); - let iface = with_runner(runner, || { - breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")) - }); - assert_eq!(iface.as_deref(), Some("wlan1")); + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + nm.add_wifi_device("wlan0", 100); + nm.add_wifi_device("wlan1", 100); + + assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")).as_deref(), Some("wlan1")); } #[test] fn wifi_interface_preferred_falls_back_to_first_wifi_when_pref_missing() { - let runner = FakeRunner::new().on_contains( - "nmcli", - "DEVICE,TYPE", - ok("wlan0:wifi\nwlan1:wifi"), - ); - let iface = with_runner(runner, || { - breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")) - }); - assert_eq!(iface.as_deref(), Some("wlan0")); + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + nm.add_wifi_device("wlan0", 100); + nm.add_wifi_device("wlan1", 100); + + assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")).as_deref(), Some("wlan0")); } #[test] fn visible_signals_dedups_by_strongest_signal() { - let runner = FakeRunner::new().on_contains( - "nmcli", - "SSID,SIGNAL", - ok("Cafe:40\nCafe:85\nOffice:60\n:90"), - ); - let map = with_runner(runner, || breadcrumbs::nm::visible_signals("wlan0")); + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Cafe", 40, Security::Wpa2); + nm.add_ap(&dev, "Cafe", 85, Security::Wpa2); + nm.add_ap(&dev, "Office", 60, Security::Wpa2); + + let map = breadcrumbs::nm::visible_signals("wlan0"); assert_eq!(map.get("Cafe"), Some(&85)); assert_eq!(map.get("Office"), Some(&60)); - assert!(!map.contains_key(""), "hidden/empty SSID must be skipped"); } #[test] fn connect_verbose_enterprise_creates_8021x_profile() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Corp", 80, Security::Enterprise); + let mut def = net("Corp", Some("pw")); def.eap = Some("peap".into()); def.identity = Some("user@corp".into()); def.ca_cert = Some("/etc/ca.pem".into()); - // No saved profile (NAME,TYPE empty), so the enterprise create path runs. - let runner = FakeRunner::new() - .on_contains("nmcli", "NAME,TYPE", ok("")) - .on( - |p, args| p == "nmcli" && args.contains(&"add") && args.contains(&"connection"), - ok(""), - ) - .on(|p, args| p == "nmcli" && args.contains(&"up"), ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")); - let calls = runner.calls_handle(); - - let res = with_runner(runner, || { - breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1") - }); + let res = breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1"); assert!(res.is_ok(), "enterprise connect should succeed: {res:?}"); - let calls_ref = calls.borrow(); - let add = calls_ref - .iter() - .find(|c| c.args.contains(&"add".to_string()) && c.args.contains(&"connection".to_string())) - .expect("enterprise path must create a profile via `connection add`"); - let joined = add.args.join(" "); - assert!(joined.contains("wpa-eap")); - assert!(joined.contains("peap")); - assert!(joined.contains("user@corp")); - assert!(joined.contains("/etc/ca.pem")); - assert!(joined.contains("802-1x.password")); + let st = nm.state.lock().unwrap(); + let (_, settings) = st.connections.iter().next().expect("a profile was saved"); + let x1 = settings.get("802-1x").expect("802-1x section"); + assert_eq!( + x1.get("identity").and_then(|v| v.downcast_ref::().ok()).as_deref(), + Some("user@corp") + ); + let eap = x1.get("eap").and_then(fake_nm::value_str_list); + assert_eq!(eap.as_deref(), Some(&["peap".to_string()][..])); + // ca-cert is a GBytes (`ay`) holding the conventional `file://` URI for + // a filesystem path — never a bare string. + let ca = x1.get("ca-cert").and_then(fake_nm::value_bytes); + assert_eq!(ca.as_deref(), Some(b"file:///etc/ca.pem".as_slice())); + let sec = settings.get("802-11-wireless-security").expect("security section"); + assert_eq!( + sec.get("key-mgmt").and_then(|v| v.downcast_ref::().ok()).as_deref(), + Some("wpa-eap") + ); }