From 89fb77a159ccba7f18f7752b896b7678f6261fcd Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 11:17:35 -0400 Subject: [PATCH 1/4] fix(#505): harden tests and correct uncovered network behavior Replace permissive and redundant tests with exact happy-path, failure-path, payload, error, boundary, race, and cleanup assertions. Add isolated NetworkManager integration coverage for settings events, secret agent requests, WireGuard activation, wired DHCP, WiFi authentication, and monitor callbacks. Fix behavioral defects exposed by the audit, including builder serialization, saved WiFi credential handling, monitor readiness, secret-agent concurrency, veth classification, validation, and active-connection enumeration races. --- .github/workflows/ci.yml | 48 +- AGENTS.md | 25 +- CONTRIBUTING.md | 66 +- Dockerfile | 2 + README.md | 9 + docs/src/api/errors.md | 2 +- docs/src/api/network-manager.md | 3 + docs/src/appendix/faq.md | 7 +- docs/src/development/contributing.md | 55 +- docs/src/development/testing.md | 52 +- docs/src/examples/wifi-auto-connect.md | 3 +- docs/src/guide/devices.md | 4 + docs/src/guide/error-handling.md | 2 +- docs/src/guide/ethernet.md | 2 +- docs/src/guide/profiles.md | 34 +- docs/src/guide/wifi-connecting.md | 16 +- docs/src/guide/wifi-hidden.md | 15 +- docs/src/guide/wifi-wpa-psk.md | 22 +- nmrs/CHANGELOG.md | 36 + nmrs/src/agent/builder.rs | 150 +- nmrs/src/agent/iface.rs | 526 +++- nmrs/src/agent/request.rs | 219 +- nmrs/src/api/builders/bluetooth.rs | 132 +- nmrs/src/api/builders/connection_builder.rs | 176 +- nmrs/src/api/builders/openvpn_builder.rs | 160 +- nmrs/src/api/builders/vlan.rs | 172 +- nmrs/src/api/builders/vpn.rs | 644 ++-- nmrs/src/api/builders/wifi_builder.rs | 40 +- nmrs/src/api/builders/wireguard_builder.rs | 513 +++- nmrs/src/api/models/device.rs | 4 +- nmrs/src/api/models/monitor.rs | 90 + nmrs/src/api/models/openvpn.rs | 12 +- nmrs/src/api/models/snapshot.rs | 32 + nmrs/src/api/models/tests.rs | 381 ++- nmrs/src/api/models/vlan.rs | 22 +- nmrs/src/api/models/wifi.rs | 159 +- nmrs/src/api/network_manager.rs | 24 +- nmrs/src/core/active_connection.rs | 156 +- nmrs/src/core/airplane.rs | 72 +- nmrs/src/core/bluetooth.rs | 68 - nmrs/src/core/connection.rs | 250 +- nmrs/src/core/custom_connection.rs | 137 +- nmrs/src/core/device.rs | 40 +- nmrs/src/core/ovpn_parser/parser.rs | 140 +- nmrs/src/core/rfkill.rs | 87 +- nmrs/src/core/saved_connection.rs | 598 +++- nmrs/src/core/state_wait.rs | 824 ++++-- nmrs/src/core/vpn.rs | 348 ++- nmrs/src/monitoring/bluetooth.rs | 15 - nmrs/src/monitoring/device.rs | 153 +- nmrs/src/monitoring/events.rs | 193 +- nmrs/src/monitoring/network.rs | 222 +- nmrs/src/monitoring/settings.rs | 159 +- nmrs/src/types/constants.rs | 2 + nmrs/src/types/device_type_registry.rs | 147 +- nmrs/src/util/cert_store.rs | 257 +- nmrs/src/util/test_utils.rs | 77 +- nmrs/src/util/validation.rs | 833 +++++- nmrs/tests/integration_test.rs | 2937 +++++++++---------- nmrs/tests/validation_test.rs | 263 -- scripts/ci/run-networkmanager-tests.sh | 277 +- 61 files changed, 8531 insertions(+), 3583 deletions(-) delete mode 100644 nmrs/tests/validation_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71926110..62e5b397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,7 @@ jobs: run: cargo test --locked --doc --all-features --workspace integration: - name: NetworkManager WiFi Integration + name: NetworkManager Integration runs-on: [self-hosted, linux, x64] # Pull requests wait for a maintainer to approve this environment. environment: @@ -112,6 +112,52 @@ jobs: sudo -n modprobe mac80211_hwsim radios=2 udevadm settle --timeout=10 + - name: Release virtual WiFi radios from host NetworkManager + run: | + set -euo pipefail + export LC_ALL=C + + diagnose() { + echo "Host hwsim/NetworkManager diagnostics:" >&2 + iw dev >&2 || true + nmcli device status >&2 || true + sudo -n journalctl -u NetworkManager --no-pager -n 100 >&2 || true + } + trap diagnose ERR + + if ! command -v ethtool >/dev/null 2>&1; then + echo "ethtool is required to identify mac80211_hwsim interfaces" >&2 + exit 1 + fi + + mapfile -t hwsim_interfaces < <( + iw dev | awk '$1 == "Interface" { print $2 }' | + while read -r interface; do + if ethtool -i "${interface}" 2>/dev/null | + grep --fixed-strings --quiet 'driver: mac80211_hwsim'; then + printf '%s\n' "${interface}" + fi + done | sort + ) + if (( ${#hwsim_interfaces[@]} != 2 )); then + echo "Expected exactly two host mac80211_hwsim interfaces, found ${#hwsim_interfaces[@]}" >&2 + exit 1 + fi + + for interface in "${hwsim_interfaces[@]}"; do + sudo -n nmcli device set "${interface}" managed no + done + for interface in "${hwsim_interfaces[@]}"; do + managed="$(sudo -n nmcli --get-values GENERAL.NM-MANAGED device show "${interface}")" + if [[ "${managed}" != "no" ]]; then + echo "Host NetworkManager still manages ${interface}: NM-MANAGED=${managed}" >&2 + exit 1 + fi + done + + - name: Run integration tests with NetworkManager and virtual Ethernet + run: docker compose run --build --rm test-integration + - name: Run integration tests with NetworkManager and virtual WiFi run: docker compose run --build --rm test-wifi-integration diff --git a/AGENTS.md b/AGENTS.md index 55c18076..b6b059dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ Internal modules (`core`, `dbus`, `monitoring`, `types`, `util`) are not part of ## Build and test -Requires a running NetworkManager instance (or use the provided Dockerfile). +Library and documentation tests do not require NetworkManager. Environmental +tests must use the isolated Docker harness or an explicit opt-in. ```bash cargo check # quick compile check @@ -35,18 +36,32 @@ cargo fmt --all -- --check # formatting (default cargo clippy --all-targets --all-features -- -D warnings # lints (warnings are errors in CI) cargo test -p nmrs --lib --all-features # unit tests only cargo test --doc --all-features --workspace # doc tests -cargo test --all-features --workspace # unit + integration (needs NM + wifi hardware) -cargo test --test integration_test --all-features # integration only +cargo test --all-features --workspace # unit/docs; environmental tests stay ignored +docker compose run --build --rm test-integration # isolated NM settings/agent/WireGuard/wired lifecycle docker compose run --build --rm test-wifi-integration # CI-equivalent virtual WiFi tests (Linux) ``` -Integration tests require wifi hardware or `mac80211_hwsim`: +Integration tests are `#[ignore]` so normal test commands never touch the host +NetworkManager. The WiFi harness requires two `mac80211_hwsim` radios: ```bash sudo modprobe mac80211_hwsim radios=2 -cargo test --test integration_test --all-features +docker compose run --build --rm test-wifi-integration sudo modprobe -r mac80211_hwsim ``` +For a deliberately selected local NetworkManager, the NM-only opt-in is: +```bash +NMRS_REQUIRE_NETWORKMANAGER=1 \ + cargo test --test integration_test --all-features \ + networkmanager_ -- --ignored --test-threads=1 +``` + +Those tests create, update, and delete saved profiles; exercise a real +NetworkManager-to-agent secret exchange; and activate a native WireGuard +connection. The Docker harness additionally creates an isolated veth pair and +validates wired DHCP activation. Once a capability flag is set, missing +facilities, timeouts, and unexpected errors must fail rather than skip. + ## Toolchain - Edition 2024, resolver 3, stable Rust (MSRV: 1.90.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5ede1f3..e6dec001 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ I'm fairly accepting to all PR's, only with a couple caveats: **To run or develop nmrs you need:** - Rust (stable) via `rustup` -- A running `NetworkManager` instance +- Linux and NetworkManager only for environmental integration tests I also provide a `Dockerfile` you can build if you don't use Linux and use macOS instead. @@ -29,7 +29,8 @@ docker compose run --rm test ``` This starts an isolated system D-Bus and NetworkManager instance, waits for it -to be ready, and requires integration tests to connect to it. +to be ready, runs the workspace tests, and executes the NetworkManager profile +CRUD integration contract. It does not use the host system bus. **To run an interactive shell:** @@ -47,7 +48,7 @@ docker run --rm -it -v $(pwd):/app nmrs-lib # mounts local changes If you decide to run the shell, ensure you run all commands from within the nmrs directory, not root. ```bash -cargo test -p nmrs # run library tests +cargo test -p nmrs --lib # run library unit tests cargo build -p nmrs # build the library cargo check # you get the point... ``` @@ -69,45 +70,68 @@ fix(#24): fixed bug where something was happening ## Tests -All tests must pass before a merge takes place. +All unit, documentation, and applicable environmental tests must pass before a +merge takes place. -### Ensure NetworkManager is running +### Unit and documentation tests ```bash -sudo systemctl start NetworkManager +cargo test --locked --lib --all-features --workspace +cargo test --locked --doc --all-features --workspace ``` -### Test everything (unit + integration) +The integration tests are marked `#[ignore]`. A normal `cargo test` compiles +them but does not contact or mutate any NetworkManager instance. Do not remove +that boundary or add tests which silently return success when a required daemon, +device, or access point is missing. + +### Isolated NetworkManager integration ```bash -cargo test --all-features +docker compose run --build --rm test-integration ``` -### Integration tests +This starts a private system D-Bus and NetworkManager, plus a veth-backed DHCP +network which cannot select Docker's own `eth0`. It validates real saved-profile +creation, decoding, update, deletion, exact direct and unified settings events, +a NetworkManager-routed secret request and reply, native WireGuard activation, +wired discovery, DHCP activation, typed active-connection data, and disconnect +cleanup. The harness sets +`NMRS_REQUIRE_NETWORKMANAGER=1` and `NMRS_REQUIRE_WIRED=1`; once a capability is +declared, missing services and unexpected D-Bus errors fail the test. -These require WiFi hardware. Please make sure you -run this locally before your PR to ensure everything works. +### Deterministic WiFi integration -```bash -cargo test --test integration_test --all-features -``` - -If you do not have access to WiFi hardware (for whatever odd reason that is), you can do something like this: +The WiFi contract requires two `mac80211_hwsim` radios. The container configures +one as a WPA2 access point, supplies DHCP with dnsmasq, and gives only the other +radio to its private NetworkManager. It asserts discovery, WPA authentication, +network and device callback delivery, DHCP, disconnect, saved-credential +reconnect, forget, and the missing-password error after cleanup. ```bash sudo modprobe mac80211_hwsim radios=2 -cargo test --test integration_test --all-features +docker compose run --build --rm test-wifi-integration sudo modprobe -r mac80211_hwsim ``` -For the same virtual-radio setup used in CI, on a Linux host with Docker: +The WiFi runner sets `NMRS_REQUIRE_WIFI=1`, `NMRS_WIFI_INTERFACE`, +`NMRS_EXPECT_WIFI_SSID`, and `NMRS_WIFI_PASSWORD`. If a declared facility is +missing, the test fails rather than being reported as a pass. + +To run the NM-only contracts against a deliberately selected local daemon, opt +in explicitly: ```bash -sudo modprobe mac80211_hwsim radios=2 -docker compose run --build --rm test-wifi-integration -sudo modprobe -r mac80211_hwsim +NMRS_REQUIRE_NETWORKMANAGER=1 \ + cargo test --test integration_test --all-features \ + networkmanager_ -- --ignored --test-threads=1 ``` +These tests create and delete a NetworkManager profile and register a temporary +secret agent. The wired contract is intentionally available only when its +separate capability and private interface are supplied. Prefer the Docker +harness unless modifying the selected daemon is intentional. + > [!NOTE] > > This method only works on linux diff --git a/Dockerfile b/Dockerfile index 44e46556..21de9344 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,8 +6,10 @@ RUN apt-get update && apt-get install -y \ libdbus-1-dev \ pkg-config \ dbus \ + dnsmasq-base \ ethtool \ hostapd \ + iproute2 \ iw \ network-manager \ wpasupplicant \ diff --git a/README.md b/README.md index fef4374c..3f8cd4fb 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,15 @@ If something is missing that you'd like to see, please file a PR or issue, addin Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. +Environmental tests are opt-in and ignored by a normal `cargo test`, so local +test runs never probe the host NetworkManager implicitly. Use +`docker compose run --build --rm test-integration` for isolated settings, +NetworkManager-routed secrets, native WireGuard activation, and veth-backed +wired DHCP lifecycles, or +`test-wifi-integration` with two `mac80211_hwsim` radios for the deterministic +WPA/DHCP and callback-monitor lifecycle. See the contributing guide for the +exact commands. + ## Requirements - **Rust**: 1.90.0+ diff --git a/docs/src/api/errors.md b/docs/src/api/errors.md index c523be79..9b8e30c5 100644 --- a/docs/src/api/errors.md +++ b/docs/src/api/errors.md @@ -95,7 +95,7 @@ These indicate issues the user can fix: |-------|------------| | `NotFound` | Move closer to the network or check SSID spelling | | `AuthFailed` | Check password or credentials | -| `MissingPassword` | Provide a non-empty password | +| `MissingPassword` | Provide a non-empty password, or ensure a saved profile exists before requesting its stored PSK | | `Timeout` | Retry or increase timeout | | `DhcpFailed` | Check network infrastructure | | `NoWifiDevice` | Check that a Wi-Fi adapter is installed | diff --git a/docs/src/api/network-manager.md b/docs/src/api/network-manager.md index 968a1f02..0765bf3a 100644 --- a/docs/src/api/network-manager.md +++ b/docs/src/api/network-manager.md @@ -126,6 +126,9 @@ builder output, prefer |--------|---------|-------------| | `connect_wired()` | `Result<()>` | Connect first available Ethernet device | +For this method and the wired device-listing methods below, Ethernet includes +devices that NetworkManager reports as `veth`. + ## VPN Methods | Method | Returns | Description | diff --git a/docs/src/appendix/faq.md b/docs/src/appendix/faq.md index a0c181bd..30b442a4 100644 --- a/docs/src/appendix/faq.md +++ b/docs/src/appendix/faq.md @@ -53,7 +53,12 @@ No. Concurrent connection operations (calling `connect()` from multiple tasks) a ### How do I handle saved connections? -When nmrs connects to a network, NetworkManager saves the profile. On subsequent connections, the saved profile is reused automatically. You don't need to provide credentials again. Use `forget()` to delete a saved profile. +When nmrs connects to a network, NetworkManager saves the profile. To reconnect +with its stored settings, pass `WifiSecurity::Open` or an empty +`WifiSecurity::WpaPsk` password. A non-empty PSK or an EAP configuration is an +explicit fresh-credential request, so nmrs builds a fresh profile instead of +ignoring it. If activation with a stored PSK fails, nmrs returns the error but +keeps the saved profile. Use `forget()` to delete a saved profile intentionally. ## VPN diff --git a/docs/src/development/contributing.md b/docs/src/development/contributing.md index a0ae7af2..98bf5857 100644 --- a/docs/src/development/contributing.md +++ b/docs/src/development/contributing.md @@ -12,7 +12,7 @@ I'm fairly accepting to all PRs, only with a couple caveats: ### To run or develop nmrs you need: - Rust (stable) via `rustup` -- A running `NetworkManager` instance +- Linux and NetworkManager only for environmental integration tests I also provide a `Dockerfile` you can build if you don't use Linux and use MacOS instead. @@ -21,8 +21,9 @@ I also provide a `Dockerfile` you can build if you don't use Linux and use MacOS docker compose run --rm test ``` -This starts an isolated system D-Bus and NetworkManager instance before running -the test suite. +This starts an isolated system D-Bus and NetworkManager instance, runs the +workspace tests, and executes the NM-only integration contract. It does not use +the host system bus. ### To run an interactive shell: ```bash @@ -31,7 +32,7 @@ docker compose run shell If you decide to run the shell, ensure you run all commands from within the nmrs directory, not root. ```bash -cargo test -p nmrs # run library tests +cargo test -p nmrs --lib # run library unit tests cargo build -p nmrs # build the library cargo check # you get the point... ``` @@ -61,35 +62,55 @@ All issues are acceptable. If a situation arises where a request or concern is n ## Tests -All tests must pass before a merge takes place. +All unit, documentation, and applicable environmental tests must pass before a +merge takes place. -### Ensure NetworkManager is running +### Unit and documentation tests ```bash -sudo systemctl start NetworkManager +cargo test --locked --lib --all-features --workspace +cargo test --locked --doc --all-features --workspace ``` -### Test everything (unit + integration) +Integration tests are `#[ignore]`. A normal `cargo test` compiles them without +contacting or mutating the host NetworkManager. + +### Isolated NetworkManager integration ```bash -cargo test --all-features +docker compose run --build --rm test-integration ``` -### Integration tests +This provisions private D-Bus and NetworkManager processes plus a veth-backed +DHCP network. It validates saved-profile CRUD, schema decoding, exact settings +events on both event APIs, an actual NetworkManager-to-agent secret exchange, +native WireGuard activation and classification, wired discovery, activation +details, disconnect, and cleanup. + +### Deterministic WiFi integration -These require WiFi hardware. Please make sure you run this locally before your PR to ensure everything works. +The WiFi contract uses one hwsim radio for a WPA2 access point with DHCP and a +second radio as NetworkManager's station: ```bash -cargo test --test integration_test --all-features +sudo modprobe mac80211_hwsim radios=2 +docker compose run --build --rm test-wifi-integration +sudo modprobe -r mac80211_hwsim ``` -If you do not have access to WiFi hardware (for whatever odd reason that is), you can do something like this: +Missing facilities and unexpected operations fail once the harness declares its +capabilities. The lifecycle also drives both network and device callback +monitors. Tests never accept an arbitrary error or silently skip. + +To run the NM-only contracts against a deliberately selected local daemon: ```bash -sudo modprobe mac80211_hwsim radios=2 -cargo test --test integration_test --all-features -sudo modprobe -r mac80211_hwsim +NMRS_REQUIRE_NETWORKMANAGER=1 \ + cargo test --test integration_test --all-features \ + networkmanager_ -- --ignored --test-threads=1 ``` -> **Note**: This method only works on Linux +These create and delete a NetworkManager profile and register a temporary +secret agent. Prefer the isolated Docker harness unless those operations on the +selected daemon are intentional. ## Documentation diff --git a/docs/src/development/testing.md b/docs/src/development/testing.md index 68af533c..940144de 100644 --- a/docs/src/development/testing.md +++ b/docs/src/development/testing.md @@ -10,7 +10,7 @@ Unit tests cover validation, model construction, and builder logic. They run wit ```bash cd nmrs -cargo test +cargo test --lib --all-features ``` ### Specific Test Modules @@ -28,14 +28,32 @@ cargo test --lib util::validation ### Integration Tests -Integration tests require a running NetworkManager instance: +Environmental integration tests are `#[ignore]` so a normal `cargo test` never +contacts or mutates the host NetworkManager. Run the NM-only contract through +the isolated Docker harness: ```bash -cargo test --test integration_test -cargo test --test validation_test +docker compose run --build --rm test-integration ``` -> **Note:** Integration tests that interact with real hardware may fail in CI or on systems without Wi-Fi adapters. +The harness sets `NMRS_REQUIRE_NETWORKMANAGER=1` and provisions a private veth +pair with DHCP before setting `NMRS_REQUIRE_WIRED=1`. It covers saved settings, +exact direct and unified settings events, a NetworkManager-routed secret request +and reply, native WireGuard activation, wired discovery, typed active-connection +data, DHCP activation, disconnect, and cleanup. Once a capability is declared, +an unavailable daemon, a D-Bus error, a missing event, or a timeout is a test +failure. There are no skip-as-pass branches. + +To target a deliberately selected local daemon instead, opt in explicitly. The +NM-only contracts create, update, and delete a saved profile and register a +temporary secret agent, so prefer Docker unless those operations are +intentional: + +```bash +NMRS_REQUIRE_NETWORKMANAGER=1 \ + cargo test --test integration_test --all-features \ + networkmanager_ -- --ignored --test-threads=1 +``` ## Test Categories @@ -111,15 +129,17 @@ For reproducible testing with a real NetworkManager instance: docker compose run --build --rm test-integration ``` -This starts a private system D-Bus and NetworkManager instance, waits for it to -be ready, and fails if tests cannot connect to the daemon. Wi-Fi-specific tests -continue to skip until the test environment has a Wi-Fi device. +This starts a private system D-Bus and NetworkManager instance, provisions a +veth-backed DHCP network, waits for both to be ready, and runs the settings, +secret-agent, native WireGuard, and wired lifecycle contracts. It fails if any +declared facility is unavailable. ### Virtual Wi-Fi Integration -On a Linux host, the CI-equivalent test target creates two virtual radios with -`mac80211_hwsim`. One radio advertises a WPA-PSK test network using `hostapd`; -NetworkManager manages the other radio and scans for that access point. +On a Linux host, the CI-equivalent test target uses two virtual radios created +with `mac80211_hwsim`. One radio advertises a WPA2-PSK test network using +`hostapd` and serves DHCP using dnsmasq. The isolated NetworkManager manages the +other radio. ```bash sudo modprobe mac80211_hwsim radios=2 @@ -130,6 +150,12 @@ sudo modprobe -r mac80211_hwsim This service uses host networking and is therefore intended for Linux hosts and the GitHub Actions runner, not Docker Desktop. +The harness provides `NMRS_REQUIRE_WIFI=1`, the exact interface, SSID, and +password, then asserts AP discovery, WPA authentication, DHCP activation, +network and device callback delivery, disconnect, saved-credential reconnect, +forget, and the exact missing-password error after cleanup. Missing declared +capabilities and unexpected errors fail. + It also mounts the host's `/run/udev` read-only so NetworkManager can manage the newly created hwsim links. @@ -156,8 +182,8 @@ Tests run automatically via GitHub Actions on every push and pull request. The C 1. Checks formatting (`cargo fmt --check`) 2. Runs clippy (`cargo clippy`) -3. Runs unit tests (`cargo test`) -4. Runs integration tests against NetworkManager in Docker +3. Runs unit tests (`cargo test --lib`) +4. Runs the ignored integration contracts against isolated NetworkManager and virtual Wi-Fi harnesses 5. Builds documentation (`mdbook build`) ## Next Steps diff --git a/docs/src/examples/wifi-auto-connect.md b/docs/src/examples/wifi-auto-connect.md index 991a3c52..fe6816bb 100644 --- a/docs/src/examples/wifi-auto-connect.md +++ b/docs/src/examples/wifi-auto-connect.md @@ -112,5 +112,6 @@ HOME_WIFI_PSK="my_home_password" OFFICE_WIFI_PSK="office_pass" cargo run --examp - **Persistent loop:** Wrap in a loop with a timer to continuously monitor and reconnect - **Signal threshold:** Skip networks below a minimum signal strength -- **Saved profiles:** Check `has_saved_connection()` first to avoid needing passwords +- **Saved profiles:** After `has_saved_connection()` succeeds, pass + `WifiSecurity::Open` or an empty PSK to request the profile's stored settings - **Monitoring:** Use `monitor_network_changes()` to react to new networks appearing diff --git a/docs/src/guide/devices.md b/docs/src/guide/devices.md index a1f5fc97..85fff4d9 100644 --- a/docs/src/guide/devices.md +++ b/docs/src/guide/devices.md @@ -57,6 +57,10 @@ use nmrs::DeviceType; | `DeviceType::Vlan` | 802.1Q virtual VLAN | | `DeviceType::Other(u32)` | Unknown type with raw code | +Devices that NetworkManager reports as `veth` are normalized to +`DeviceType::Ethernet`. They return `true` from `is_wired()` and appear in +`list_wired_devices()` and `list_wired_device_details()`. + ### Type Helper Methods ```rust diff --git a/docs/src/guide/error-handling.md b/docs/src/guide/error-handling.md index c0cc6619..6167d892 100644 --- a/docs/src/guide/error-handling.md +++ b/docs/src/guide/error-handling.md @@ -26,7 +26,7 @@ most commonly handled variants by category. | `ApBssidNotFound { ssid, bssid }` | No AP matching both the SSID and BSSID | | `InvalidBssid(String)` | Invalid BSSID format | | `AuthFailed` | Wrong password or rejected credentials | -| `MissingPassword` | Empty password provided | +| `MissingPassword` | Empty PSK provided without a saved profile to reuse | | `NoWifiDevice` | No Wi-Fi adapter found | | `WifiNotReady` | Wi-Fi device not ready in time | | `WifiInterfaceNotFound { interface }` | Specified Wi-Fi interface doesn't exist | diff --git a/docs/src/guide/ethernet.md b/docs/src/guide/ethernet.md index 17fb8d20..119b9494 100644 --- a/docs/src/guide/ethernet.md +++ b/docs/src/guide/ethernet.md @@ -1,6 +1,6 @@ # Ethernet Management -nmrs supports wired (Ethernet) connections through NetworkManager. Ethernet connections are simpler than Wi-Fi since they don't require authentication in most cases. +nmrs supports wired (Ethernet) connections through NetworkManager. Ethernet connections are simpler than Wi-Fi since they don't require authentication in most cases. NetworkManager `veth` devices are included in these wired APIs and treated as Ethernet devices. ## Connecting diff --git a/docs/src/guide/profiles.md b/docs/src/guide/profiles.md index d7895536..e5836b22 100644 --- a/docs/src/guide/profiles.md +++ b/docs/src/guide/profiles.md @@ -52,11 +52,19 @@ if nm.has_saved_connection("HomeWiFi").await? { ## How Saved Profiles Affect Connection -When you call `connect()` with an SSID that has a saved profile, nmrs activates the saved profile directly. This means: +When you call `connect()` with an SSID that has a saved profile, the supplied +`WifiSecurity` selects reuse or a fresh credential path: -- **Credentials are already stored** — the `WifiSecurity` value you pass is ignored -- **Connection is faster** — no need to create a new profile -- **Settings are preserved** — autoconnect, priority, and IP configuration are retained +- `WifiSecurity::Open` activates the saved profile. +- `WifiSecurity::WpaPsk` with an empty PSK activates the saved profile using its + stored secret. +- `WifiSecurity::WpaPsk` with a non-empty PSK builds a fresh profile using that + password. +- `WifiSecurity::WpaEap` and `WifiSecurity::Wpa3Eap192bit` build a fresh profile + using the supplied EAP configuration. + +Reusing a profile preserves its autoconnect, priority, IP configuration, and +other saved settings. Explicit fresh credentials are never silently ignored. ```rust let nm = NetworkManager::new().await?; @@ -66,10 +74,17 @@ nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await?; -// Later reconnection — saved profile is used, security parameter is ignored -nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; +// Later reconnection — request the PSK stored in the saved profile +nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { + psk: String::new(), +}).await?; ``` +If activation with an empty-PSK stored-secret request fails, nmrs returns the +activation error without deleting the saved profile. The caller can retry, +inspect the profile, supply a non-empty replacement PSK, or remove it explicitly +with `forget()`. + If a saved profile is missing or has stale secrets, NetworkManager may ask a registered secret agent for credentials during activation. GUI apps should register one long-lived agent at startup, keep the returned handle alive for @@ -237,9 +252,10 @@ if let Some(path) = nm.get_saved_connection_path("HomeWiFi").await? { 1. **Created** — when you first connect to a network, NetworkManager creates a profile 2. **Persisted** — profiles are saved to `/etc/NetworkManager/system-connections/` -3. **Reused** — subsequent connections to the same SSID use the saved profile -4. **Updated** — if you connect with different credentials, the profile may be updated -5. **Deleted** — calling `forget()`, `forget_vpn()`, or `forget_bluetooth()` removes it +3. **Reused** — `WifiSecurity::Open` or an empty PSK activates the saved profile +4. **Rebuilt** — a non-empty PSK or EAP configuration creates a fresh profile +5. **Preserved on stored-secret failure** — a failed empty-PSK activation does not delete the saved profile +6. **Deleted** — calling `forget()`, `forget_vpn()`, or `forget_bluetooth()` removes it ## Next Steps diff --git a/docs/src/guide/wifi-connecting.md b/docs/src/guide/wifi-connecting.md index cc85c90b..ecc15974 100644 --- a/docs/src/guide/wifi-connecting.md +++ b/docs/src/guide/wifi-connecting.md @@ -32,7 +32,8 @@ When you call `connect()`, nmrs performs the following steps: 1. **Validates** the SSID and credentials 2. **Searches** for the network among visible access points 3. **Checks** for a saved connection profile matching the SSID -4. **Creates** a new connection profile if none exists, or **reuses** the saved one +4. **Reuses** the saved profile for `Open` or empty-PSK requests, or **builds** a + fresh profile when explicit PSK or EAP credentials are supplied 5. **Activates** the connection via NetworkManager 6. **Waits** for the device to reach the `Activated` state 7. **Returns** `Ok(())` on success, or a specific error on failure @@ -101,20 +102,27 @@ nm.disconnect(None).await?; ## Saved Connections -When nmrs connects to a network, NetworkManager saves a connection profile. On subsequent connections to the same SSID, the saved profile is reused automatically. +When nmrs connects to a network, NetworkManager saves a connection profile. A +later call reuses that profile when `security` is `WifiSecurity::Open` or an +empty `WifiSecurity::WpaPsk`. Passing a non-empty PSK or an EAP configuration +instead tells nmrs to build a fresh profile with those credentials. ```rust let nm = NetworkManager::new().await?; // Check if a profile exists if nm.has_saved_connection("HomeWiFi").await? { - println!("Profile exists — will reconnect without needing credentials"); + println!("Profile exists — stored settings can be reused"); } -// Connect using saved profile (WifiSecurity value is ignored if profile exists) +// WifiSecurity::Open requests reuse when a saved profile exists. nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; ``` +For a saved WPA-PSK network, an empty PSK also explicitly requests the stored +secret. If that activation fails, nmrs preserves the saved profile so it can be +retried, inspected, or removed with `forget()`. + See [Connection Profiles](./profiles.md) for more on managing saved connections. ## Error Handling diff --git a/docs/src/guide/wifi-hidden.md b/docs/src/guide/wifi-hidden.md index 1daa263b..353da412 100644 --- a/docs/src/guide/wifi-hidden.md +++ b/docs/src/guide/wifi-hidden.md @@ -29,9 +29,13 @@ async fn main() -> nmrs::Result<()> { When you call `connect()` with an SSID: -1. nmrs first checks if there is a **saved connection profile** for that SSID — if so, it activates the saved profile directly -2. If no saved profile exists, it searches the **visible access point list** -3. If the network is not visible (hidden), NetworkManager creates a connection profile with the hidden flag set and performs a **directed probe request** for the specific SSID +1. nmrs first checks if there is a **saved connection profile** for that SSID +2. With a saved profile, `WifiSecurity::Open` or an empty PSK reuses it; a + non-empty PSK or EAP configuration builds a fresh profile with those credentials +3. If no saved profile exists, nmrs searches the **visible access point list** +4. If the network is not visible (hidden), NetworkManager creates a connection + profile with the hidden flag set and performs a **directed probe request** for + the specific SSID This means hidden networks work transparently. The first connection may take slightly longer as NetworkManager performs the directed scan. @@ -56,7 +60,10 @@ nm.connect("HiddenCorpNet", None, WifiSecurity::WpaEap { ## Reconnecting -After the first successful connection, NetworkManager saves the profile with the hidden flag. Subsequent connections to the same SSID will reconnect automatically using the saved profile, even though the network doesn't appear in scan results. +After the first successful connection, NetworkManager saves the profile with the +hidden flag. A later `WifiSecurity::Open` or empty-PSK request can reuse that +profile even though the network does not appear in scan results. Explicit +non-empty PSK or EAP credentials build a fresh profile instead. ## Considerations diff --git a/docs/src/guide/wifi-wpa-psk.md b/docs/src/guide/wifi-wpa-psk.md index 829ff1dc..ada513a4 100644 --- a/docs/src/guide/wifi-wpa-psk.md +++ b/docs/src/guide/wifi-wpa-psk.md @@ -24,7 +24,9 @@ The `WifiSecurity::WpaPsk` variant works with WPA, WPA2, and WPA3 Personal netwo ## Password Requirements -- Must not be empty — `ConnectionError::MissingPassword` is returned for empty strings +- A new profile requires a non-empty password. An empty PSK requests the stored + password only when a saved profile already exists; otherwise nmrs returns + `ConnectionError::MissingPassword`. - WPA-PSK passwords are typically 8–63 characters (ASCII passphrase) or exactly 64 hex characters (raw PSK) - nmrs passes the password directly to NetworkManager, which handles validation @@ -52,18 +54,24 @@ async fn main() -> nmrs::Result<()> { ## Reconnecting to Saved Networks -After the first successful connection, NetworkManager saves the credentials in a connection profile. Subsequent connections to the same SSID will reuse the saved profile automatically — you don't need to provide the password again: +After the first successful connection, NetworkManager saves the credentials in +a connection profile. Request its stored password with an empty PSK: ```rust let nm = NetworkManager::new().await?; if nm.has_saved_connection("HomeWiFi").await? { - // Saved profile exists; password is stored in it. - // The WifiSecurity value is ignored when a saved profile exists. - nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; + nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { + psk: String::new(), + }).await?; } ``` +`WifiSecurity::Open` also reuses an existing saved profile. A non-empty PSK is +not ignored: it asks nmrs to build a fresh profile with that password. If +activation with the empty-PSK stored-secret request fails, the original saved +profile is preserved. + ## Error Handling The most common errors for WPA-PSK connections: @@ -71,7 +79,7 @@ The most common errors for WPA-PSK connections: | Error | Meaning | |-------|---------| | `ConnectionError::AuthFailed` | Wrong password | -| `ConnectionError::MissingPassword` | Empty password string | +| `ConnectionError::MissingPassword` | Empty password string and no saved profile to reuse | | `ConnectionError::NotFound` | Network not in range | | `ConnectionError::Timeout` | Connection took too long | | `ConnectionError::DhcpFailed` | Connected to AP but DHCP failed | @@ -89,7 +97,7 @@ match nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { eprintln!("Wrong password — check and try again"); } Err(ConnectionError::MissingPassword) => { - eprintln!("Password cannot be empty"); + eprintln!("No saved password is available; provide a non-empty PSK"); } Err(e) => eprintln!("Error: {}", e), } diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 39d5d24d..db11819d 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,6 +4,42 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] +### Added + +- Isolated NetworkManager integration contracts now cover saved-profile events, + secret-agent registration, wired DHCP activation, and virtual WPA Wi-Fi + discovery/authentication/reconnection without touching developer profiles. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Expanded unit coverage for exact D-Bus settings payloads, activation races, + monitor lifecycle behavior, secret-agent concurrency, saved-profile decoding, + validation boundaries, OpenVPN parsing, and certificate storage cleanup. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) + +### Changed + +- Network, device, and settings monitors now return only after their initial + D-Bus subscriptions are installed, so a mutation immediately after startup + cannot race the subscription task. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Supplying a non-empty PSK or EAP configuration for an existing Wi-Fi profile + now applies the fresh credentials; an empty PSK continues to request the + stored secret. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) + +### Fixed + +- Preserve complete OpenVPN, VLAN, WireGuard, Bluetooth, Wi-Fi, and access-point + settings when constructing or decoding NetworkManager payloads. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Preserve saved Wi-Fi profiles when stored-secret activation fails, while + removing newly created profiles whose fresh authentication fails. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Recheck active-connection state at timeout boundaries and retain typed + NetworkManager failure reasons instead of reporting false timeouts. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Keep active-connection snapshots usable when NetworkManager removes an + enumerated connection object while its properties are being read. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Map secret-agent registration conflicts to the documented typed errors and + handle concurrent same-key requests, cancellation, closed responders, and + bounded-queue backpressure without false cancellation or hangs. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Ignore unmanaged interfaces during automatic device selection and recognize + NetworkManager veth devices as wired Ethernet for selection and reporting. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Harden IPv6, WireGuard, OpenVPN, rfkill, and certificate-storage validation, + decoding, error reporting, and temporary-file cleanup. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) + ## [3.4.0] - 2026-07-08 ### Added - Expose existing secrets on SecretRequest for re-auth prefill ([#460](https://github.com/freedesktop-rs/nmrs/pull/460)) diff --git a/nmrs/src/agent/builder.rs b/nmrs/src/agent/builder.rs index 7ac94108..d0117585 100644 --- a/nmrs/src/agent/builder.rs +++ b/nmrs/src/agent/builder.rs @@ -1,6 +1,7 @@ //! Secret agent builder, handle, and lifecycle management. use std::collections::HashMap; +use std::sync::atomic::AtomicU64; use std::sync::{Arc, Mutex}; use futures::channel::mpsc; @@ -16,6 +17,68 @@ use super::request::{CancelReason, SecretAgentCapabilities, SecretRequest, Secre const DEFAULT_IDENTIFIER: &str = "com.system76.CosmicApplets.nmrs.secret_agent"; const DEFAULT_OBJECT_PATH: &str = "/org/freedesktop/NetworkManager/SecretAgent"; const DEFAULT_QUEUE_DEPTH: usize = 32; +const AGENT_MANAGER_ERROR_PREFIX: &str = "org.freedesktop.NetworkManager.AgentManager."; + +#[derive(Debug, PartialEq, Eq)] +enum AgentManagerFailure { + AlreadyRegistered, + Registration, + NotRegistered, + Other, +} + +fn classify_agent_manager_failure(name: &str, detail: Option<&str>) -> AgentManagerFailure { + if !name.starts_with(AGENT_MANAGER_ERROR_PREFIX) { + return AgentManagerFailure::Other; + } + + match name.strip_prefix(AGENT_MANAGER_ERROR_PREFIX) { + Some("PermissionDenied") + if detail.is_some_and(|detail| { + detail.to_ascii_lowercase().contains("already registered") + }) => + { + AgentManagerFailure::AlreadyRegistered + } + Some("NotRegistered") => AgentManagerFailure::NotRegistered, + _ => AgentManagerFailure::Registration, + } +} + +fn classify_zbus_agent_manager_failure(error: &zbus::Error) -> AgentManagerFailure { + match error { + zbus::Error::MethodError(name, detail, _) => { + classify_agent_manager_failure(name.as_str(), detail.as_deref()) + } + _ => AgentManagerFailure::Other, + } +} + +fn registration_error(error: zbus::Error, operation: &str) -> ConnectionError { + match classify_zbus_agent_manager_failure(&error) { + AgentManagerFailure::AlreadyRegistered => ConnectionError::AgentAlreadyRegistered, + AgentManagerFailure::Registration | AgentManagerFailure::NotRegistered => { + ConnectionError::AgentRegistration { + context: format!("{operation}: {error}"), + } + } + AgentManagerFailure::Other => ConnectionError::DbusOperation { + context: operation.into(), + source: error, + }, + } +} + +fn unregistration_error(error: zbus::Error) -> ConnectionError { + if classify_zbus_agent_manager_failure(&error) == AgentManagerFailure::NotRegistered { + ConnectionError::AgentNotRegistered + } else { + ConnectionError::DbusOperation { + context: "unregistering secret agent".into(), + source: error, + } + } +} /// Entry point for creating a NetworkManager secret agent. /// @@ -151,6 +214,8 @@ impl SecretAgentBuilder { cancel_tx, store_tx, pending: Arc::new(Mutex::new(HashMap::new())), + next_request_id: AtomicU64::new(1), + response_timeout: crate::types::constants::timeouts::secret_agent_response_timeout(), }; let conn = Connection::system() @@ -184,9 +249,8 @@ impl SecretAgentBuilder { agent_proxy .register_with_capabilities(&self.identifier, self.capabilities.bits()) .await - .map_err(|e| ConnectionError::DbusOperation { - context: "registering secret agent with NetworkManager".into(), - source: e, + .map_err(|error| { + registration_error(error, "registering secret agent with NetworkManager") })?; debug!( @@ -237,9 +301,9 @@ impl SecretAgentHandle { /// Re-registers the agent with NetworkManager. /// /// Call this after detecting that NetworkManager restarted (e.g. its - /// D-Bus name owner changed). The call is idempotent while the bus - /// connection is healthy. The same long-lived handle can be kept for the - /// process lifetime and re-registered whenever NetworkManager comes back. + /// D-Bus name owner changed). Calling it while the agent is still registered + /// returns [`ConnectionError::AgentAlreadyRegistered`]. The same long-lived + /// handle can be kept and re-registered whenever NetworkManager comes back. /// /// # Errors /// @@ -254,9 +318,8 @@ impl SecretAgentHandle { proxy .register_with_capabilities(&self.identifier, self.capabilities.bits()) .await - .map_err(|e| ConnectionError::DbusOperation { - context: "re-registering secret agent with NetworkManager".into(), - source: e, + .map_err(|error| { + registration_error(error, "re-registering secret agent with NetworkManager") })?; debug!("Re-registered secret agent '{}'", self.identifier); Ok(()) @@ -277,13 +340,7 @@ impl SecretAgentHandle { source: e, } })?; - proxy - .unregister() - .await - .map_err(|e| ConnectionError::DbusOperation { - context: "unregistering secret agent".into(), - source: e, - })?; + proxy.unregister().await.map_err(unregistration_error)?; debug!("Unregistered secret agent '{}'", self.identifier); Ok(()) } @@ -320,17 +377,62 @@ impl SecretAgentHandle { mod tests { use super::*; + #[test] + fn agent_manager_errors_map_to_public_lifecycle_failures() { + let cases = [ + ( + "org.freedesktop.NetworkManager.AgentManager.PermissionDenied", + Some("An agent with this ID is already registered for this user."), + AgentManagerFailure::AlreadyRegistered, + ), + ( + "org.freedesktop.NetworkManager.AgentManager.PermissionDenied", + Some("Not authorized"), + AgentManagerFailure::Registration, + ), + ( + "org.freedesktop.NetworkManager.AgentManager.InvalidIdentifier", + Some("Identifier contains invalid character ':'"), + AgentManagerFailure::Registration, + ), + ( + "org.freedesktop.NetworkManager.AgentManager.NotRegistered", + None, + AgentManagerFailure::NotRegistered, + ), + ( + "org.freedesktop.DBus.Error.NoReply", + Some("timed out"), + AgentManagerFailure::Other, + ), + ]; + + for (name, detail, expected) in cases { + assert_eq!(classify_agent_manager_failure(name, detail), expected); + } + } + #[test] fn defaults_match_networkmanager_secret_agent_contract() { let builder = SecretAgentBuilder::default(); - assert_eq!( - builder.object_path, - "/org/freedesktop/NetworkManager/SecretAgent" - ); - assert_eq!( - builder.identifier, - "com.system76.CosmicApplets.nmrs.secret_agent" - ); + assert_eq!(builder.object_path, DEFAULT_OBJECT_PATH); + assert_eq!(builder.identifier, DEFAULT_IDENTIFIER); + assert_eq!(builder.capabilities, SecretAgentCapabilities::VPN_HINTS); + assert_eq!(builder.queue_depth, DEFAULT_QUEUE_DEPTH); + } + + #[test] + fn builder_methods_replace_every_registration_option() { + let builder = SecretAgent::builder() + .with_identifier("org.example.nmrs.agent") + .with_capabilities(SecretAgentCapabilities::empty()) + .with_object_path("/org/example/nmrs/Agent") + .with_queue_depth(7); + + assert_eq!(builder.identifier, "org.example.nmrs.agent"); + assert_eq!(builder.capabilities, SecretAgentCapabilities::empty()); + assert_eq!(builder.object_path, "/org/example/nmrs/Agent"); + assert_eq!(builder.queue_depth, 7); } } diff --git a/nmrs/src/agent/iface.rs b/nmrs/src/agent/iface.rs index 31d0debb..a0313208 100644 --- a/nmrs/src/agent/iface.rs +++ b/nmrs/src/agent/iface.rs @@ -5,7 +5,9 @@ //! into the channel-based API exposed by [`super::agent`]. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use futures::SinkExt; use futures::channel::{mpsc, oneshot}; @@ -13,8 +15,6 @@ use futures::future::{self, Either}; use log::{debug, trace, warn}; use zvariant::{ObjectPath, OwnedObjectPath}; -use crate::types::constants::timeouts; - use super::request::{ CancelReason, ConnectionDict, SecretAgentFlags, SecretReply, SecretRequest, SecretResponder, SecretStoreEvent, extract_existing_secrets, extract_setting_string, parse_secret_setting, @@ -33,7 +33,19 @@ pub(crate) enum SecretAgentDBusError { NoSecrets(String), } -type PendingMap = Arc>>>; +type PendingKey = (String, String); +type PendingMap = Arc)>>>>; + +fn remove_pending_request(pending: &PendingMap, key: &PendingKey, request_id: u64) { + let mut pending = pending.lock().unwrap_or_else(|error| error.into_inner()); + let remove_key = pending.get_mut(key).is_some_and(|requests| { + requests.retain(|(id, _)| *id != request_id); + requests.is_empty() + }); + if remove_key { + pending.remove(key); + } +} /// The object served at the agent's D-Bus path. Not part of the public API — /// consumers interact through [`SecretRequest`] / [`SecretResponder`]. @@ -42,6 +54,8 @@ pub(crate) struct SecretAgentInterface { pub(crate) cancel_tx: mpsc::UnboundedSender, pub(crate) store_tx: mpsc::UnboundedSender, pub(crate) pending: PendingMap, + pub(crate) next_request_id: AtomicU64, + pub(crate) response_timeout: Duration, } #[zbus::interface(name = "org.freedesktop.NetworkManager.SecretAgent")] @@ -69,12 +83,15 @@ impl SecretAgentInterface { let (reply_tx, reply_rx) = oneshot::channel::(); let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); // Track this pending request so CancelGetSecrets can find it. self.pending .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(key.clone(), cancel_tx); + .entry(key.clone()) + .or_default() + .push((request_id, cancel_tx)); let setting = parse_secret_setting(&connection, setting_name); let request = SecretRequest { @@ -92,43 +109,50 @@ impl SecretAgentInterface { existing_secrets: extract_existing_secrets(&connection, setting_name), }; - // Send to the consumer stream. If the channel is full or closed, - // reply NoSecrets immediately. - if self.request_tx.clone().send(request).await.is_err() { - self.pending - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&key); - return Err(SecretAgentDBusError::NoSecrets( - "agent request channel closed".into(), - )); - } + // Cancellation must remain responsive while a bounded request queue is + // applying back-pressure. + let mut request_tx = self.request_tx.clone(); + let send_request = request_tx.send(request); + let cancel_rx = match future::select(cancel_rx, send_request).await { + Either::Left((_cancel, _send_request)) => { + remove_pending_request(&self.pending, &key, request_id); + return Err(SecretAgentDBusError::UserCanceled( + "canceled by NetworkManager".into(), + )); + } + Either::Right((Ok(()), cancel_rx)) => cancel_rx, + Either::Right((Err(_), _cancel_rx)) => { + remove_pending_request(&self.pending, &key, request_id); + return Err(SecretAgentDBusError::NoSecrets( + "agent request channel closed".into(), + )); + } + }; - let timeout = futures_timer::Delay::new(timeouts::secret_agent_response_timeout()); + let timeout = futures_timer::Delay::new(self.response_timeout); // Wait for: consumer response, NM cancellation, or timeout. - let result = future::select(reply_rx, future::select(cancel_rx, timeout)).await; + // Cancellation takes precedence if the consumer drops its responder at + // the same time NetworkManager cancels the request. + let result = future::select(cancel_rx, future::select(reply_rx, timeout)).await; - self.pending - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&key); + remove_pending_request(&self.pending, &key, request_id); match result { - Either::Left((Ok(SecretReply::Secrets(map)), _)) => Ok(map), - Either::Left((Ok(SecretReply::UserCanceled), _)) => { - Err(SecretAgentDBusError::UserCanceled("user canceled".into())) - } - Either::Left((Ok(SecretReply::NoSecrets) | Err(_), _)) => Err( - SecretAgentDBusError::NoSecrets("no secrets available".into()), - ), - Either::Right((Either::Left(_cancel), _)) => { + Either::Left((_cancel, _)) => { debug!("GetSecrets cancelled by NetworkManager for {}", key.1); Err(SecretAgentDBusError::UserCanceled( "canceled by NetworkManager".into(), )) } - Either::Right((Either::Right(_timeout), _)) => { + Either::Right((Either::Left((Ok(SecretReply::Secrets(map)), _)), _)) => Ok(map), + Either::Right((Either::Left((Ok(SecretReply::UserCanceled), _)), _)) => { + Err(SecretAgentDBusError::UserCanceled("user canceled".into())) + } + Either::Right((Either::Left((Ok(SecretReply::NoSecrets) | Err(_), _)), _)) => Err( + SecretAgentDBusError::NoSecrets("no secrets available".into()), + ), + Either::Right((Either::Right((_timeout, _)), _)) => { warn!("GetSecrets timed out for setting {}", key.1); Err(SecretAgentDBusError::NoSecrets( "timeout waiting for consumer response".into(), @@ -147,13 +171,15 @@ impl SecretAgentInterface { debug!("CancelGetSecrets: path={} setting={}", key.0, key.1); - if let Some(cancel_tx) = self + if let Some(requests) = self .pending .lock() .unwrap_or_else(|e| e.into_inner()) .remove(&key) { - let _ = cancel_tx.send(()); + for (_, cancel_tx) in requests { + let _ = cancel_tx.send(()); + } } let _ = self.cancel_tx.unbounded_send(CancelReason { @@ -190,3 +216,439 @@ impl SecretAgentInterface { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use futures::StreamExt; + use zvariant::{ObjectPath, OwnedValue, Str}; + + use super::*; + + const CONNECTION_PATH: &str = "/org/freedesktop/NetworkManager/Settings/42"; + + struct Harness { + iface: SecretAgentInterface, + requests: mpsc::Receiver, + cancellations: mpsc::UnboundedReceiver, + store_events: mpsc::UnboundedReceiver, + } + + fn harness_with_queue_depth(response_timeout: Duration, queue_depth: usize) -> Harness { + let (request_tx, requests) = mpsc::channel(queue_depth); + let (cancel_tx, cancellations) = mpsc::unbounded(); + let (store_tx, store_events) = mpsc::unbounded(); + Harness { + iface: SecretAgentInterface { + request_tx, + cancel_tx, + store_tx, + pending: Arc::new(Mutex::new(HashMap::new())), + next_request_id: AtomicU64::new(1), + response_timeout, + }, + requests, + cancellations, + store_events, + } + } + + fn harness(response_timeout: Duration) -> Harness { + harness_with_queue_depth(response_timeout, 4) + } + + fn connection() -> ConnectionDict { + let mut metadata = HashMap::new(); + metadata.insert("uuid".into(), OwnedValue::from(Str::from("test-uuid"))); + metadata.insert("id".into(), OwnedValue::from(Str::from("test-id"))); + metadata.insert( + "type".into(), + OwnedValue::from(Str::from("802-11-wireless")), + ); + + let mut wireless = HashMap::new(); + wireless.insert( + "ssid".into(), + OwnedValue::try_from(zvariant::Array::from(b"test-ssid".to_vec())) + .expect("owned byte array"), + ); + + HashMap::from([ + ("connection".into(), metadata), + ("802-11-wireless".into(), wireless), + ]) + } + + fn path() -> ObjectPath<'static> { + ObjectPath::try_from(CONNECTION_PATH).expect("valid object path") + } + + fn assert_no_pending(iface: &SecretAgentInterface) { + assert!( + iface + .pending + .lock() + .unwrap_or_else(|err| err.into_inner()) + .is_empty() + ); + } + + fn pending_count(iface: &SecretAgentInterface) -> usize { + iface + .pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .values() + .map(Vec::len) + .sum() + } + + fn returned_psk(result: std::result::Result) -> String { + let settings = result.expect("consumer secrets should be returned"); + let security = settings + .get("802-11-wireless-security") + .expect("security section"); + <&str>::try_from(security.get("psk").expect("PSK value")) + .expect("string PSK") + .to_owned() + } + + #[tokio::test] + async fn get_secrets_forwards_context_and_returns_consumer_reply() { + let mut harness = harness(Duration::from_secs(1)); + + let get = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + vec!["psk".into()], + (SecretAgentFlags::ALLOW_INTERACTION | SecretAgentFlags::USER_REQUESTED).bits(), + ); + let consume = async { + let request = harness.requests.next().await.expect("secret request"); + assert_eq!(request.connection_uuid, "test-uuid"); + assert_eq!(request.connection_id, "test-id"); + assert_eq!(request.connection_type, "802-11-wireless"); + assert_eq!(request.connection_path.as_str(), CONNECTION_PATH); + assert_eq!(request.hints, ["psk"]); + assert!(request.flags.contains(SecretAgentFlags::ALLOW_INTERACTION)); + assert!(request.flags.contains(SecretAgentFlags::USER_REQUESTED)); + match request.setting { + super::super::request::SecretSetting::WifiPsk { ssid } => { + assert_eq!(ssid, "test-ssid") + } + other => panic!("expected Wi-Fi PSK request, got {other:?}"), + } + request.responder.wifi_psk("test-password").await.unwrap(); + }; + + let (result, ()) = tokio::join!(get, consume); + let settings = result.expect("consumer secrets should be returned"); + let security = settings + .get("802-11-wireless-security") + .expect("security section"); + assert_eq!( + <&str>::try_from(security.get("psk").expect("PSK value")).unwrap(), + "test-password" + ); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn get_secrets_maps_consumer_refusals_to_dbus_errors() { + for user_cancelled in [false, true] { + let mut harness = harness(Duration::from_secs(1)); + let get = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let consume = async { + let request = harness.requests.next().await.expect("secret request"); + if user_cancelled { + request.responder.cancel().await.unwrap(); + } else { + request.responder.no_secrets().await.unwrap(); + } + }; + + let (result, ()) = tokio::join!(get, consume); + if user_cancelled { + assert!(matches!( + result, + Err(SecretAgentDBusError::UserCanceled(message)) + if message == "user canceled" + )); + } else { + assert!(matches!( + result, + Err(SecretAgentDBusError::NoSecrets(message)) + if message == "no secrets available" + )); + } + assert_no_pending(&harness.iface); + } + } + + #[tokio::test] + async fn cancel_get_secrets_aborts_pending_request_and_emits_reason() { + let mut harness = harness(Duration::from_secs(1)); + let get = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let cancel = async { + let request = harness.requests.next().await.expect("secret request"); + harness + .iface + .cancel_get_secrets(path(), "802-11-wireless-security") + .await + .unwrap(); + drop(request); + }; + + let (result, ()) = tokio::join!(get, cancel); + assert!(matches!( + result, + Err(SecretAgentDBusError::UserCanceled(message)) + if message == "canceled by NetworkManager" + )); + let reason = harness + .cancellations + .next() + .await + .expect("cancellation event"); + assert_eq!(reason.connection_path.as_str(), CONNECTION_PATH); + assert_eq!(reason.setting_name, "802-11-wireless-security"); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn concurrent_same_key_requests_keep_independent_responders() { + let mut harness = harness(Duration::from_secs(1)); + let first = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let second = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let respond = async { + let first_request = harness.requests.next().await.expect("first request"); + let second_request = harness.requests.next().await.expect("second request"); + assert_eq!(pending_count(&harness.iface), 2); + + first_request + .responder + .wifi_psk("first-password") + .await + .expect("first request remains live"); + second_request + .responder + .wifi_psk("second-password") + .await + .expect("second request remains live"); + }; + + let (first_result, second_result, ()) = tokio::join!(first, second, respond); + assert_eq!(returned_psk(first_result), "first-password"); + assert_eq!(returned_psk(second_result), "second-password"); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn cancellation_aborts_all_concurrent_requests_with_the_same_key() { + let mut harness = harness(Duration::from_secs(1)); + let first = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let second = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let cancel = async { + let first_request = harness.requests.next().await.expect("first request"); + let second_request = harness.requests.next().await.expect("second request"); + assert_eq!(pending_count(&harness.iface), 2); + + harness + .iface + .cancel_get_secrets(path(), "802-11-wireless-security") + .await + .unwrap(); + drop((first_request, second_request)); + }; + + let (first_result, second_result, ()) = tokio::join!(first, second, cancel); + for result in [first_result, second_result] { + assert!(matches!( + result, + Err(SecretAgentDBusError::UserCanceled(message)) + if message == "canceled by NetworkManager" + )); + } + let reason = harness + .cancellations + .next() + .await + .expect("cancellation event"); + assert_eq!(reason.connection_path.as_str(), CONNECTION_PATH); + assert_eq!(reason.setting_name, "802-11-wireless-security"); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn cancellation_interrupts_backpressure_before_request_delivery() { + let harness = harness_with_queue_depth(Duration::from_secs(1), 0); + let get = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let cancel = async { + while pending_count(&harness.iface) == 0 { + tokio::task::yield_now().await; + } + harness + .iface + .cancel_get_secrets(path(), "802-11-wireless-security") + .await + .unwrap(); + }; + + let (result, ()) = tokio::time::timeout(Duration::from_millis(100), async { + tokio::join!(get, cancel) + }) + .await + .expect("cancellation must not wait for request queue capacity"); + + assert!(matches!( + result, + Err(SecretAgentDBusError::UserCanceled(message)) + if message == "canceled by NetworkManager" + )); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn get_secrets_fails_immediately_when_request_stream_is_closed() { + let harness = harness(Duration::from_secs(1)); + drop(harness.requests); + + let result = harness + .iface + .get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ) + .await; + + assert!(matches!( + result, + Err(SecretAgentDBusError::NoSecrets(message)) + if message == "agent request channel closed" + )); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn get_secrets_times_out_and_cleans_up_pending_request() { + let mut harness = harness(Duration::from_millis(1)); + let get = harness.iface.get_secrets( + connection(), + path(), + "802-11-wireless-security", + Vec::new(), + 0, + ); + let hold_request = async { + let _request = harness.requests.next().await.expect("secret request"); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + + let (result, ()) = tokio::join!(get, hold_request); + assert!(matches!( + result, + Err(SecretAgentDBusError::NoSecrets(message)) + if message == "timeout waiting for consumer response" + )); + assert_no_pending(&harness.iface); + } + + #[tokio::test] + async fn save_and_delete_secrets_emit_exact_store_events() { + let mut harness = harness(Duration::from_secs(1)); + + harness + .iface + .save_secrets(ConnectionDict::new(), path()) + .await + .unwrap(); + harness + .iface + .delete_secrets(ConnectionDict::new(), path()) + .await + .unwrap(); + + match harness.store_events.next().await.expect("save event") { + SecretStoreEvent::Save { connection_path } => { + assert_eq!(connection_path.as_str(), CONNECTION_PATH) + } + other => panic!("expected save event, got {other:?}"), + } + match harness.store_events.next().await.expect("delete event") { + SecretStoreEvent::Delete { connection_path } => { + assert_eq!(connection_path.as_str(), CONNECTION_PATH) + } + other => panic!("expected delete event, got {other:?}"), + } + } + + #[tokio::test] + async fn informational_methods_acknowledge_when_consumers_are_gone() { + let harness = harness(Duration::from_secs(1)); + drop(harness.cancellations); + drop(harness.store_events); + + harness + .iface + .cancel_get_secrets(path(), "vpn") + .await + .expect("cancellation notification is best-effort"); + harness + .iface + .save_secrets(ConnectionDict::new(), path()) + .await + .expect("save notification is best-effort"); + harness + .iface + .delete_secrets(ConnectionDict::new(), path()) + .await + .expect("delete notification is best-effort"); + } +} diff --git a/nmrs/src/agent/request.rs b/nmrs/src/agent/request.rs index 5c77b373..ff89c3e1 100644 --- a/nmrs/src/agent/request.rs +++ b/nmrs/src/agent/request.rs @@ -323,8 +323,8 @@ impl SecretResponder { .reply_tx .take() .ok_or(ConnectionError::AgentNotRegistered)?; - let _ = tx.send(reply); - Ok(()) + tx.send(reply) + .map_err(|_| ConnectionError::AgentNotRegistered) } } @@ -415,6 +415,19 @@ pub(crate) fn extract_existing_secrets( mod tests { use super::*; + fn responder() -> ( + SecretResponder, + futures::channel::oneshot::Receiver, + ) { + let (tx, rx) = futures::channel::oneshot::channel(); + (SecretResponder::new(tx, "requested-setting".into()), rx) + } + + fn string_value<'a>(dict: &'a HashMap, key: &str) -> &'a str { + <&str>::try_from(dict.get(key).expect("missing secret value")) + .expect("secret value should be a string") + } + #[test] fn flags_from_bits() { let flags = SecretAgentFlags::from_bits_truncate(0x5); @@ -431,16 +444,63 @@ mod tests { #[test] fn parse_wifi_psk_setting() { - let connection = HashMap::new(); + let mut wireless = HashMap::new(); + wireless.insert( + "ssid".to_owned(), + OwnedValue::try_from(zvariant::Array::from(vec![b'n', b'm', b'r', b's'])) + .expect("owned byte array"), + ); + let mut connection = HashMap::new(); + connection.insert("802-11-wireless".to_owned(), wireless); let setting = parse_secret_setting(&connection, "802-11-wireless-security"); - assert!(matches!(setting, SecretSetting::WifiPsk { .. })); + + match setting { + SecretSetting::WifiPsk { ssid } => assert_eq!(ssid, "nmrs"), + other => panic!("expected Wi-Fi PSK setting, got {other:?}"), + } } #[test] - fn parse_vpn_setting() { - let connection = HashMap::new(); - let setting = parse_secret_setting(&connection, "vpn"); - assert!(matches!(setting, SecretSetting::Vpn { .. })); + fn parse_eap_setting_preserves_context() { + let mut eap = HashMap::new(); + eap.insert( + "identity".to_owned(), + OwnedValue::from(Str::from("alice@example.com")), + ); + eap.insert("eap".to_owned(), OwnedValue::from(Str::from("peap"))); + let mut connection = HashMap::new(); + connection.insert("802-1x".to_owned(), eap); + + match parse_secret_setting(&connection, "802-1x") { + SecretSetting::WifiEap { identity, method } => { + assert_eq!(identity.as_deref(), Some("alice@example.com")); + assert_eq!(method.as_deref(), Some("peap")); + } + other => panic!("expected Wi-Fi EAP setting, got {other:?}"), + } + } + + #[test] + fn parse_vpn_setting_preserves_context() { + let mut vpn = HashMap::new(); + vpn.insert( + "service-type".to_owned(), + OwnedValue::from(Str::from("org.freedesktop.NetworkManager.openvpn")), + ); + vpn.insert("user-name".to_owned(), OwnedValue::from(Str::from("alice"))); + let mut connection = HashMap::new(); + connection.insert("vpn".to_owned(), vpn); + + match parse_secret_setting(&connection, "vpn") { + SecretSetting::Vpn { + service_type, + user_name, + } => { + assert_eq!(service_type, "org.freedesktop.NetworkManager.openvpn"); + assert_eq!(user_name.as_deref(), Some("alice")); + } + other => panic!("expected VPN setting, got {other:?}"), + } } #[test] @@ -450,6 +510,50 @@ mod tests { assert!(matches!(setting, SecretSetting::Other(s) if s == "some-custom-thing")); } + #[test] + fn parse_simple_secret_settings_maps_each_networkmanager_name() { + let connection = ConnectionDict::new(); + + assert!(matches!( + parse_secret_setting(&connection, "gsm"), + SecretSetting::Gsm + )); + assert!(matches!( + parse_secret_setting(&connection, "cdma"), + SecretSetting::Cdma + )); + assert!(matches!( + parse_secret_setting(&connection, "pppoe"), + SecretSetting::Pppoe + )); + } + + #[test] + fn extract_ssid_accepts_string_fallback_and_lossy_byte_arrays() { + let mut connection = ConnectionDict::new(); + connection.insert( + "802-11-wireless".into(), + HashMap::from([( + "ssid".into(), + OwnedValue::from(Str::from("legacy-string-ssid")), + )]), + ); + assert_eq!( + extract_ssid(&connection).as_deref(), + Some("legacy-string-ssid") + ); + + connection.insert( + "802-11-wireless".into(), + HashMap::from([( + "ssid".into(), + OwnedValue::try_from(zvariant::Array::from(vec![b'n', 0xff, b'm'])) + .expect("owned byte array"), + )]), + ); + assert_eq!(extract_ssid(&connection).as_deref(), Some("n\u{fffd}m")); + } + #[test] fn extract_existing_secrets_reads_vpn_secrets() { let mut secrets = HashMap::new(); @@ -509,4 +613,103 @@ mod tests { assert!(reply.is_some(), "drop should have sent a reply"); assert!(matches!(reply.unwrap(), SecretReply::NoSecrets)); } + + #[tokio::test] + async fn responder_wifi_psk_sends_expected_dictionary() { + let (responder, rx) = responder(); + + responder.wifi_psk("correct horse").await.unwrap(); + let SecretReply::Secrets(settings) = rx.await.expect("reply channel closed") else { + panic!("expected secrets reply"); + }; + let security = settings + .get("802-11-wireless-security") + .expect("missing wireless security setting"); + assert_eq!(string_value(security, "psk"), "correct horse"); + } + + #[tokio::test] + async fn responder_wifi_eap_includes_optional_identity() { + let (responder, rx) = responder(); + + responder + .wifi_eap(Some("alice".into()), "secret".into()) + .await + .unwrap(); + let SecretReply::Secrets(settings) = rx.await.expect("reply channel closed") else { + panic!("expected secrets reply"); + }; + let eap = settings.get("802-1x").expect("missing 802.1X setting"); + assert_eq!(string_value(eap, "identity"), "alice"); + assert_eq!(string_value(eap, "password"), "secret"); + } + + #[tokio::test] + async fn responder_wifi_eap_omits_absent_identity() { + let (responder, rx) = responder(); + + responder.wifi_eap(None, "secret".into()).await.unwrap(); + let SecretReply::Secrets(settings) = rx.await.expect("reply channel closed") else { + panic!("expected secrets reply"); + }; + let eap = settings.get("802-1x").expect("missing 802.1X setting"); + assert!(!eap.contains_key("identity")); + assert_eq!(string_value(eap, "password"), "secret"); + } + + #[tokio::test] + async fn responder_vpn_secrets_nests_plugin_dictionary() { + let (responder, rx) = responder(); + let secrets = HashMap::from([ + ("password".to_owned(), "hunter2".to_owned()), + ("otp".to_owned(), "123456".to_owned()), + ]); + + responder.vpn_secrets(secrets.clone()).await.unwrap(); + let SecretReply::Secrets(settings) = rx.await.expect("reply channel closed") else { + panic!("expected secrets reply"); + }; + let nested = settings + .get("vpn") + .and_then(|vpn| vpn.get("secrets")) + .cloned() + .and_then(|value| HashMap::::try_from(value).ok()) + .expect("missing VPN secrets dictionary"); + assert_eq!(nested, secrets); + } + + #[tokio::test] + async fn responder_raw_preserves_setting_and_values() { + let (responder, rx) = responder(); + let data = HashMap::from([("pin".to_owned(), OwnedValue::from(Str::from("1234")))]); + + responder.raw("gsm", data).await.unwrap(); + let SecretReply::Secrets(settings) = rx.await.expect("reply channel closed") else { + panic!("expected secrets reply"); + }; + assert_eq!(string_value(settings.get("gsm").unwrap(), "pin"), "1234"); + } + + #[tokio::test] + async fn responder_cancel_sends_user_canceled() { + let (responder, rx) = responder(); + responder.cancel().await.unwrap(); + assert!(matches!(rx.await.unwrap(), SecretReply::UserCanceled)); + } + + #[tokio::test] + async fn responder_no_secrets_sends_no_secrets() { + let (responder, rx) = responder(); + responder.no_secrets().await.unwrap(); + assert!(matches!(rx.await.unwrap(), SecretReply::NoSecrets)); + } + + #[tokio::test] + async fn responder_reports_closed_reply_channel() { + let (responder, rx) = responder(); + drop(rx); + + let result = responder.wifi_psk("secret").await; + assert!(matches!(result, Err(ConnectionError::AgentNotRegistered))); + } } diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index fa263830..2985edfa 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -119,28 +119,14 @@ mod tests { assert!(section.contains_key("uuid")); assert!(section.contains_key("autoconnect")); - // Verify values - if let Some(Value::Str(conn_type)) = section.get("type") { - assert_eq!(conn_type.as_str(), "bluetooth"); - } else { - panic!("type field not found or wrong type"); - } - - if let Some(Value::Str(id)) = section.get("id") { - assert_eq!(id.as_str(), "TestBluetooth"); - } else { - panic!("id field not found or wrong type"); - } - - if let Some(Value::Bool(autoconnect)) = section.get("autoconnect") { - assert!(*autoconnect, "{}", true); - } else { - panic!("autoconnect field not found or wrong type"); - } - - // Check optional fields - assert!(section.contains_key("autoconnect-priority")); - assert!(section.contains_key("autoconnect-retries")); + assert_eq!(section.get("type"), Some(&Value::from("bluetooth"))); + assert_eq!(section.get("id"), Some(&Value::from("TestBluetooth"))); + assert_eq!(section.get("autoconnect"), Some(&Value::from(true))); + assert_eq!( + section.get("autoconnect-priority"), + Some(&Value::from(10i32)) + ); + assert_eq!(section.get("autoconnect-retries"), Some(&Value::from(3i32))); } #[test] @@ -170,17 +156,11 @@ mod tests { assert!(section.contains_key("bdaddr")); assert!(section.contains_key("type")); - if let Some(Value::Str(bdaddr)) = section.get("bdaddr") { - assert_eq!(bdaddr.as_str(), "00:1A:7D:DA:71:13"); - } else { - panic!("bdaddr field not found or wrong type"); - } - - if let Some(Value::Str(bt_type)) = section.get("type") { - assert_eq!(bt_type.as_str(), "panu"); - } else { - panic!("type field not found or wrong type"); - } + assert_eq!( + section.get("bdaddr"), + Some(&Value::from("00:1A:7D:DA:71:13")) + ); + assert_eq!(section.get("type"), Some(&Value::from("panu"))); } #[test] @@ -191,17 +171,11 @@ mod tests { assert!(section.contains_key("bdaddr")); assert!(section.contains_key("type")); - if let Some(Value::Str(bdaddr)) = section.get("bdaddr") { - assert_eq!(bdaddr.as_str(), "C8:1F:E8:F0:51:57"); - } else { - panic!("bdaddr field not found or wrong type"); - } - - if let Some(Value::Str(bt_type)) = section.get("type") { - assert_eq!(bt_type.as_str(), "dun"); - } else { - panic!("type field not found or wrong type"); - } + assert_eq!( + section.get("bdaddr"), + Some(&Value::from("C8:1F:E8:F0:51:57")) + ); + assert_eq!(section.get("type"), Some(&Value::from("dun"))); } #[test] @@ -216,31 +190,21 @@ mod tests { assert!(conn.contains_key("ipv4")); assert!(conn.contains_key("ipv6")); - // Verify connection section let connection_section = conn.get("connection").unwrap(); - if let Some(Value::Str(id)) = connection_section.get("id") { - assert_eq!(id.as_str(), "MyPhone"); - } + assert_eq!(connection_section.get("id"), Some(&Value::from("MyPhone"))); - // Verify bluetooth section let bt_section = conn.get("bluetooth").unwrap(); - if let Some(Value::Str(bdaddr)) = bt_section.get("bdaddr") { - assert_eq!(bdaddr.as_str(), "00:1A:7D:DA:71:13"); - } - if let Some(Value::Str(bt_type)) = bt_section.get("type") { - assert_eq!(bt_type.as_str(), "panu"); - } + assert_eq!( + bt_section.get("bdaddr"), + Some(&Value::from("00:1A:7D:DA:71:13")) + ); + assert_eq!(bt_section.get("type"), Some(&Value::from("panu"))); - // Verify IP sections let ipv4_section = conn.get("ipv4").unwrap(); - if let Some(Value::Str(method)) = ipv4_section.get("method") { - assert_eq!(method.as_str(), "auto"); - } + assert_eq!(ipv4_section.get("method"), Some(&Value::from("auto"))); let ipv6_section = conn.get("ipv6").unwrap(); - if let Some(Value::Str(method)) = ipv6_section.get("method") { - assert_eq!(method.as_str(), "auto"); - } + assert_eq!(ipv6_section.get("method"), Some(&Value::from("auto"))); } #[test] @@ -258,11 +222,16 @@ mod tests { assert!(conn.contains_key("ipv4")); assert!(conn.contains_key("ipv6")); - // Verify DUN type let bt_section = conn.get("bluetooth").unwrap(); - if let Some(Value::Str(bt_type)) = bt_section.get("type") { - assert_eq!(bt_type.as_str(), "dun"); - } + assert_eq!( + conn["connection"].get("autoconnect"), + Some(&Value::from(false)) + ); + assert_eq!(bt_section.get("type"), Some(&Value::from("dun"))); + assert_eq!( + bt_section.get("bdaddr"), + Some(&Value::from("C8:1F:E8:F0:51:57")) + ); } #[test] @@ -273,27 +242,15 @@ mod tests { let conn1 = build_bluetooth_connection("BT1", &identity, &opts); let conn2 = build_bluetooth_connection("BT2", &identity, &opts); - let uuid1 = if let Some(section) = conn1.get("connection") { - if let Some(Value::Str(uuid)) = section.get("uuid") { - uuid.as_str() - } else { - panic!("uuid not found in conn1"); - } - } else { - panic!("connection section not found in conn1"); + let Value::Str(uuid1) = &conn1["connection"]["uuid"] else { + panic!("conn1 UUID must be a string"); }; - - let uuid2 = if let Some(section) = conn2.get("connection") { - if let Some(Value::Str(uuid)) = section.get("uuid") { - uuid.as_str() - } else { - panic!("uuid not found in conn2"); - } - } else { - panic!("connection section not found in conn2"); + let Value::Str(uuid2) = &conn2["connection"]["uuid"] else { + panic!("conn2 UUID must be a string"); }; + let uuid1 = uuid::Uuid::parse_str(uuid1.as_str()).expect("conn1 must contain a valid UUID"); + let uuid2 = uuid::Uuid::parse_str(uuid2.as_str()).expect("conn2 must contain a valid UUID"); - // UUIDs should be different assert_ne!(uuid1, uuid2, "UUIDs should be unique"); } @@ -305,8 +262,9 @@ mod tests { let conn = build_bluetooth_connection("Test", &identity, &opts); let bt_section = conn.get("bluetooth").unwrap(); - if let Some(Value::Str(bdaddr)) = bt_section.get("bdaddr") { - assert_eq!(bdaddr.as_str(), "AA:BB:CC:DD:EE:FF"); - } + assert_eq!( + bt_section.get("bdaddr"), + Some(&Value::from("AA:BB:CC:DD:EE:FF")) + ); } } diff --git a/nmrs/src/api/builders/connection_builder.rs b/nmrs/src/api/builders/connection_builder.rs index 7bf72203..6b635e88 100644 --- a/nmrs/src/api/builders/connection_builder.rs +++ b/nmrs/src/api/builders/connection_builder.rs @@ -396,10 +396,13 @@ impl ConnectionBuilder { /// Sets IPv6 DNS servers. #[must_use] pub fn ipv6_dns(mut self, servers: Vec) -> Self { - let dns_strings: Vec = servers.into_iter().map(|s| s.to_string()).collect(); + let dns_bytes: Vec> = servers + .into_iter() + .map(|server| server.octets().to_vec()) + .collect(); if let Some(ipv6) = self.settings.get_mut("ipv6") { - ipv6.insert("dns", Value::from(dns_strings)); + ipv6.insert("dns", Value::from(dns_bytes)); } self } @@ -471,6 +474,11 @@ impl ConnectionBuilder { self } + pub(crate) fn without_section(mut self, name: &'static str) -> Self { + self.settings.remove(name); + self + } + /// Updates an existing section using a closure. /// /// This allows modifying a section after it's been created, which is useful @@ -514,6 +522,31 @@ impl ConnectionBuilder { mod tests { use super::*; + fn address_data(address: &str, prefix: u32) -> Value<'static> { + let mut entry = HashMap::new(); + entry.insert("address".to_string(), Value::from(address.to_string())); + entry.insert("prefix".to_string(), Value::from(prefix)); + Value::from(vec![entry]) + } + + fn route_data( + dest: &str, + prefix: u32, + next_hop: Option<&str>, + metric: Option, + ) -> Value<'static> { + let mut entry = HashMap::new(); + entry.insert("dest".to_string(), Value::from(dest.to_string())); + entry.insert("prefix".to_string(), Value::from(prefix)); + if let Some(next_hop) = next_hop { + entry.insert("next-hop".to_string(), Value::from(next_hop.to_string())); + } + if let Some(metric) = metric { + entry.insert("metric".to_string(), Value::from(metric)); + } + Value::from(vec![entry]) + } + #[test] fn creates_basic_connection() { let settings = ConnectionBuilder::new("802-11-wireless", "TestNetwork").build(); @@ -578,6 +611,18 @@ mod tests { assert_eq!(conn.get("autoconnect-retries"), Some(&Value::from(2i32))); } + #[test] + fn omits_unset_optional_connection_options() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .options(&ConnectionOptions::new(false)) + .build(); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("autoconnect"), Some(&Value::from(false))); + assert!(!conn.contains_key("autoconnect-priority")); + assert!(!conn.contains_key("autoconnect-retries")); + } + #[test] fn configures_ipv4_auto() { let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") @@ -596,7 +641,9 @@ mod tests { let ipv4 = settings.get("ipv4").unwrap(); assert_eq!(ipv4.get("method"), Some(&Value::from("manual"))); - assert!(ipv4.contains_key("address-data")); + let addresses = ipv4.get("address-data").unwrap(); + assert_eq!(addresses.value_signature().to_string(), "aa{sv}"); + assert_eq!(addresses, &address_data("192.168.1.100", 24)); } #[test] @@ -609,16 +656,59 @@ mod tests { assert_eq!(ipv4.get("method"), Some(&Value::from("disabled"))); } + #[test] + fn configures_ipv4_link_local_and_shared() { + let link_local = ConnectionBuilder::new("802-3-ethernet", "local") + .ipv4_link_local() + .build(); + let shared = ConnectionBuilder::new("802-3-ethernet", "shared") + .ipv4_shared() + .build(); + + assert_eq!( + link_local["ipv4"].get("method"), + Some(&Value::from("link-local")) + ); + assert_eq!(shared["ipv4"].get("method"), Some(&Value::from("shared"))); + } + #[test] fn configures_ipv4_dns() { - let dns = vec!["8.8.8.8".parse().unwrap(), "1.1.1.1".parse().unwrap()]; + let dns: Vec = vec!["8.8.8.8".parse().unwrap(), "1.1.1.1".parse().unwrap()]; let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") .ipv4_auto() - .ipv4_dns(dns) + .ipv4_dns(dns.clone()) + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + let value = ipv4.get("dns").unwrap(); + assert_eq!(value.value_signature().to_string(), "au"); + assert_eq!( + value, + &Value::from(dns.into_iter().map(u32::from).collect::>()) + ); + } + + #[test] + fn configures_ipv4_gateway_and_routes() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_manual(vec![IpConfig::new("192.168.1.100", 24)]) + .ipv4_gateway("192.168.1.1".parse().unwrap()) + .ipv4_routes(vec![ + Route::new("10.0.0.0", 8) + .next_hop("192.168.1.254") + .metric(25), + ]) .build(); let ipv4 = settings.get("ipv4").unwrap(); - assert!(ipv4.contains_key("dns")); + assert_eq!(ipv4.get("gateway"), Some(&Value::from("192.168.1.1"))); + let routes = ipv4.get("route-data").unwrap(); + assert_eq!(routes.value_signature().to_string(), "aa{sv}"); + assert_eq!( + routes, + &route_data("10.0.0.0", 8, Some("192.168.1.254"), Some(25)) + ); } #[test] @@ -631,6 +721,19 @@ mod tests { assert_eq!(ipv6.get("method"), Some(&Value::from("auto"))); } + #[test] + fn configures_ipv6_manual() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv6_manual(vec![IpConfig::new("2001:db8::10", 64)]) + .build(); + + let ipv6 = settings.get("ipv6").unwrap(); + assert_eq!(ipv6.get("method"), Some(&Value::from("manual"))); + let addresses = ipv6.get("address-data").unwrap(); + assert_eq!(addresses.value_signature().to_string(), "aa{sv}"); + assert_eq!(addresses, &address_data("2001:db8::10", 64)); + } + #[test] fn configures_ipv6_ignore() { let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") @@ -641,6 +744,55 @@ mod tests { assert_eq!(ipv6.get("method"), Some(&Value::from("ignore"))); } + #[test] + fn configures_ipv6_link_local() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv6_link_local() + .build(); + + assert_eq!( + settings["ipv6"].get("method"), + Some(&Value::from("link-local")) + ); + } + + #[test] + fn configures_ipv6_dns_gateway_and_routes() { + let dns: Vec = vec![ + "2001:4860:4860::8888".parse().unwrap(), + "2606:4700:4700::1111".parse().unwrap(), + ]; + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv6_manual(vec![IpConfig::new("2001:db8::10", 64)]) + .ipv6_dns(dns.clone()) + .ipv6_gateway("2001:db8::1".parse().unwrap()) + .ipv6_routes(vec![ + Route::new("2001:db8:1::", 64) + .next_hop("2001:db8::2") + .metric(50), + ]) + .build(); + + let ipv6 = settings.get("ipv6").unwrap(); + let dns_value = ipv6.get("dns").unwrap(); + assert_eq!(dns_value.value_signature().to_string(), "aay"); + assert_eq!( + dns_value, + &Value::from( + dns.into_iter() + .map(|server| server.octets().to_vec()) + .collect::>() + ) + ); + assert_eq!(ipv6.get("gateway"), Some(&Value::from("2001:db8::1"))); + let routes = ipv6.get("route-data").unwrap(); + assert_eq!(routes.value_signature().to_string(), "aa{sv}"); + assert_eq!( + routes, + &route_data("2001:db8:1::", 64, Some("2001:db8::2"), Some(50)) + ); + } + #[test] fn adds_custom_section() { let mut bridge = HashMap::new(); @@ -678,8 +830,14 @@ mod tests { let ipv4 = settings.get("ipv4").unwrap(); assert_eq!(ipv4.get("method"), Some(&Value::from("manual"))); - assert!(ipv4.contains_key("address-data")); - assert!(ipv4.contains_key("gateway")); - assert!(ipv4.contains_key("dns")); + assert_eq!( + ipv4.get("address-data"), + Some(&address_data("192.168.1.100", 24)) + ); + assert_eq!(ipv4.get("gateway"), Some(&Value::from("192.168.1.1"))); + assert_eq!( + ipv4.get("dns"), + Some(&Value::from(vec![u32::from(Ipv4Addr::new(8, 8, 8, 8))])) + ); } } diff --git a/nmrs/src/api/builders/openvpn_builder.rs b/nmrs/src/api/builders/openvpn_builder.rs index b0d179d8..2f10ab3b 100644 --- a/nmrs/src/api/builders/openvpn_builder.rs +++ b/nmrs/src/api/builders/openvpn_builder.rs @@ -702,21 +702,54 @@ mod tests { .username("user") } + fn assert_stored_material( + path: Option, + connection_name: &str, + filename: &str, + expected: &str, + ) { + let path = std::path::PathBuf::from(path.expect("stored material path")); + assert!(path.is_absolute()); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some(filename) + ); + assert_eq!( + path.parent() + .and_then(std::path::Path::file_name) + .and_then(|name| name.to_str()), + Some(connection_name) + ); + assert_eq!(std::fs::read_to_string(path).unwrap(), expected); + } + #[test] fn builds_tls_connection() { - let config = tls_builder().build(); - assert!(config.is_ok()); - let config = config.unwrap(); + let config = tls_builder().build().unwrap(); assert_eq!(config.name, "TestVPN"); assert_eq!(config.remote, "vpn.example.com"); assert_eq!(config.port, 1194); assert!(!config.tcp); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + assert_eq!(config.ca_cert.as_deref(), Some("/etc/openvpn/ca.crt")); + assert_eq!( + config.client_cert.as_deref(), + Some("/etc/openvpn/client.crt") + ); + assert_eq!( + config.client_key.as_deref(), + Some("/etc/openvpn/client.key") + ); } #[test] fn builds_password_connection() { - let config = password_builder().build(); - assert!(config.is_ok()); + let config = password_builder().build().unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + assert_eq!(config.username.as_deref(), Some("user")); + assert!(config.ca_cert.is_none()); + assert!(config.client_cert.is_none()); + assert!(config.client_key.is_none()); } #[test] @@ -728,8 +761,19 @@ mod tests { .ca_cert("/etc/openvpn/ca.crt") .client_cert("/etc/openvpn/client.crt") .client_key("/etc/openvpn/client.key") - .build(); - assert!(config.is_ok()); + .build() + .unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::PasswordTls)); + assert_eq!(config.username.as_deref(), Some("user")); + assert_eq!(config.ca_cert.as_deref(), Some("/etc/openvpn/ca.crt")); + assert_eq!( + config.client_cert.as_deref(), + Some("/etc/openvpn/client.crt") + ); + assert_eq!( + config.client_key.as_deref(), + Some("/etc/openvpn/client.key") + ); } #[test] @@ -738,7 +782,11 @@ mod tests { .remote("vpn.example.com") .auth_type(OpenVpnAuthType::StaticKey) .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "StaticKey auth validation is not yet implemented" + )); } #[test] @@ -765,7 +813,7 @@ mod tests { assert_eq!(config.auth, Some("SHA256".into())); assert_eq!(config.cipher, Some("AES-256-GCM".into())); assert_eq!(config.mtu, Some(1400)); - assert!(config.dns.is_some()); + assert_eq!(config.dns, Some(vec!["1.1.1.1".to_string()])); } #[test] @@ -789,7 +837,16 @@ mod tests { }) .build() .unwrap(); - assert!(config.proxy.is_some()); + assert_eq!( + config.proxy, + Some(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 8080, + username: None, + password: None, + retry: false, + }) + ); } #[test] @@ -801,7 +858,11 @@ mod tests { .client_cert("/etc/openvpn/client.crt") .client_key("/etc/openvpn/client.key") .build(); - assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidAddress(message) + if message == "Connection name cannot be empty" + )); } #[test] @@ -814,7 +875,7 @@ mod tests { .build(); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidGateway(_) + ConnectionError::InvalidGateway(message) if message == "remote must be set" )); } @@ -829,7 +890,7 @@ mod tests { .build(); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidGateway(_) + ConnectionError::InvalidGateway(message) if message == "remote must not be empty" )); } @@ -838,7 +899,8 @@ mod tests { let result = tls_builder().port(0).build(); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidGateway(_) + ConnectionError::InvalidGateway(message) + if message == "port must be between 1 and 65535" )); } @@ -847,7 +909,10 @@ mod tests { let result = OpenVpnBuilder::new("TestVPN") .remote("vpn.example.com") .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) if message == "auth_type must be set" + )); } #[test] @@ -856,7 +921,11 @@ mod tests { .remote("vpn.example.com") .auth_type(OpenVpnAuthType::Password) .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "username is required for Password and PasswordTls auth" + )); } #[test] @@ -868,7 +937,11 @@ mod tests { .client_cert("/etc/openvpn/client.crt") .client_key("/etc/openvpn/client.key") .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "username is required for Password and PasswordTls auth" + )); } #[test] @@ -879,7 +952,11 @@ mod tests { .client_cert("/etc/openvpn/client.crt") .client_key("/etc/openvpn/client.key") .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "ca_cert is required for Tls and PasswordTls auth" + )); } #[test] @@ -890,7 +967,11 @@ mod tests { .ca_cert("/etc/openvpn/ca.crt") .client_key("/etc/openvpn/client.key") .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "client_cert is required for Tls and PasswordTls auth" + )); } #[test] @@ -901,7 +982,11 @@ mod tests { .ca_cert("/etc/openvpn/ca.crt") .client_cert("/etc/openvpn/client.crt") .build(); - assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "client_key is required for Tls and PasswordTls auth" + )); } // --- from_ovpn_str tests --- @@ -963,14 +1048,24 @@ FAKEKEY let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "inline-test").unwrap(); let config = builder.build().unwrap(); assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); - - let ca = config.ca_cert.unwrap(); - assert!( - std::path::Path::new(&ca).exists(), - "CA cert should be written to disk: {ca}" + assert_stored_material( + config.ca_cert, + "inline-test", + "ca.pem", + "-----BEGIN CERTIFICATE-----\nFAKECA\n-----END CERTIFICATE-----\n", + ); + assert_stored_material( + config.client_cert, + "inline-test", + "cert.pem", + "-----BEGIN CERTIFICATE-----\nFAKECERT\n-----END CERTIFICATE-----\n", + ); + assert_stored_material( + config.client_key, + "inline-test", + "key.pem", + "-----BEGIN PRIVATE KEY-----\nFAKEKEY\n-----END PRIVATE KEY-----\n", ); - assert!(config.client_cert.is_some()); - assert!(config.client_key.is_some()); }); } @@ -1006,7 +1101,12 @@ FAKEKEY "; let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "inline-ta").unwrap(); let config = builder.build().unwrap(); - assert!(config.tls_auth_key.is_some()); + assert_stored_material( + config.tls_auth_key, + "inline-ta", + "ta.key", + "-----BEGIN OpenVPN Static key V1-----\nFAKEKEY\n-----END OpenVPN Static key V1-----\n", + ); assert_eq!(config.tls_auth_direction, Some(0)); }); } @@ -1058,7 +1158,7 @@ key /etc/openvpn/client.key .unwrap(); assert_eq!(config.port, 443); assert!(config.tcp); - assert!(config.dns.is_some()); + assert_eq!(config.dns, Some(vec!["1.1.1.1".to_string()])); } #[test] @@ -1067,7 +1167,7 @@ key /etc/openvpn/client.key let result = OpenVpnBuilder::from_ovpn_str(ovpn, "test-fail"); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidGateway(_) + ConnectionError::InvalidGateway(message) if message == "no remote in .ovpn file" )); } diff --git a/nmrs/src/api/builders/vlan.rs b/nmrs/src/api/builders/vlan.rs index 76a796b9..37b003d5 100644 --- a/nmrs/src/api/builders/vlan.rs +++ b/nmrs/src/api/builders/vlan.rs @@ -50,6 +50,12 @@ pub fn build_vlan_connection( // VLAN section conn.insert("vlan", vlan_section(config)); + if let Some(mtu) = config.mtu { + let mut wired = HashMap::new(); + wired.insert("mtu", Value::from(mtu)); + conn.insert("802-3-ethernet", wired); + } + // IPv4 section (auto by default) let mut ipv4 = HashMap::new(); ipv4.insert("method", Value::from("auto")); @@ -100,13 +106,11 @@ fn vlan_section(config: &VlanConfig) -> HashMap<&'static str, Value<'static>> { } if let Some(ref map) = config.ingress_priority_map { - let entries: Vec> = map.iter().map(|e| Value::from(e.clone())).collect(); - s.insert("ingress-priority-map", Value::Array(entries.into())); + s.insert("ingress-priority-map", Value::from(map.clone())); } if let Some(ref map) = config.egress_priority_map { - let entries: Vec> = map.iter().map(|e| Value::from(e.clone())).collect(); - s.insert("egress-priority-map", Value::Array(entries.into())); + s.insert("egress-priority-map", Value::from(map.clone())); } s @@ -130,11 +134,26 @@ mod tests { let opts = test_opts(); let conn = build_vlan_connection(&config, &opts).unwrap(); - - assert!(conn.contains_key("connection")); - assert!(conn.contains_key("vlan")); - assert!(conn.contains_key("ipv4")); - assert!(conn.contains_key("ipv6")); + let connection = conn.get("connection").unwrap(); + assert_eq!(connection.get("type"), Some(&Value::from("vlan"))); + assert_eq!(connection.get("id"), Some(&Value::from("VLAN 100 on eth0"))); + assert_eq!( + connection.get("interface-name"), + Some(&Value::from("eth0.100")) + ); + assert_eq!(connection.get("autoconnect"), Some(&Value::from(true))); + assert_eq!( + connection.get("autoconnect-priority"), + Some(&Value::from(10i32)) + ); + assert_eq!( + connection.get("autoconnect-retries"), + Some(&Value::from(3i32)) + ); + assert_eq!(conn["vlan"].get("parent"), Some(&Value::from("eth0"))); + assert_eq!(conn["vlan"].get("id"), Some(&Value::from(100u32))); + assert_eq!(conn["ipv4"].get("method"), Some(&Value::from("auto"))); + assert_eq!(conn["ipv6"].get("method"), Some(&Value::from("auto"))); } #[test] @@ -145,11 +164,7 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let connection = conn.get("connection").unwrap(); - if let Some(Value::Str(t)) = connection.get("type") { - assert_eq!(t.as_str(), "vlan"); - } else { - panic!("type field missing or wrong type"); - } + assert_eq!(connection.get("type"), Some(&Value::from("vlan"))); } #[test] @@ -160,17 +175,8 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let vlan = conn.get("vlan").unwrap(); - if let Some(Value::Str(parent)) = vlan.get("parent") { - assert_eq!(parent.as_str(), "enp3s0"); - } else { - panic!("parent field missing or wrong type"); - } - - if let Some(Value::U32(id)) = vlan.get("id") { - assert_eq!(*id, 200); - } else { - panic!("id field missing or wrong type"); - } + assert_eq!(vlan.get("parent"), Some(&Value::from("enp3s0"))); + assert_eq!(vlan.get("id"), Some(&Value::from(200u32))); } #[test] @@ -181,11 +187,10 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let connection = conn.get("connection").unwrap(); - if let Some(Value::Str(name)) = connection.get("interface-name") { - assert_eq!(name.as_str(), "eth0.100"); - } else { - panic!("interface-name field missing or wrong type"); - } + assert_eq!( + connection.get("interface-name"), + Some(&Value::from("eth0.100")) + ); } #[test] @@ -196,11 +201,10 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let connection = conn.get("connection").unwrap(); - if let Some(Value::Str(name)) = connection.get("interface-name") { - assert_eq!(name.as_str(), "office-vlan"); - } else { - panic!("interface-name field missing or wrong type"); - } + assert_eq!( + connection.get("interface-name"), + Some(&Value::from("office-vlan")) + ); } #[test] @@ -211,11 +215,7 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let connection = conn.get("connection").unwrap(); - if let Some(Value::Str(name)) = connection.get("id") { - assert_eq!(name.as_str(), "VLAN 100 on eth0"); - } else { - panic!("id field missing or wrong type"); - } + assert_eq!(connection.get("id"), Some(&Value::from("VLAN 100 on eth0"))); } #[test] @@ -226,11 +226,7 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let connection = conn.get("connection").unwrap(); - if let Some(Value::Str(name)) = connection.get("id") { - assert_eq!(name.as_str(), "Office Network"); - } else { - panic!("id field missing or wrong type"); - } + assert_eq!(connection.get("id"), Some(&Value::from("Office Network"))); } #[test] @@ -241,38 +237,63 @@ mod tests { let conn = build_vlan_connection(&config, &opts).unwrap(); let vlan = conn.get("vlan").unwrap(); - if let Some(Value::U32(flags)) = vlan.get("flags") { - assert_eq!(*flags, 0x5); - } else { - panic!("flags field missing or wrong type"); - } + assert_eq!(vlan.get("flags"), Some(&Value::from(0x5u32))); } #[test] - fn rejects_invalid_vlan_id_zero() { - let config = VlanConfig::new("eth0", 0); - let opts = test_opts(); + fn serializes_mtu_in_wired_setting() { + let config = VlanConfig::new("eth0", 100).with_mtu(1496); - let result = build_vlan_connection(&config, &opts); - assert!(result.is_err()); + let conn = build_vlan_connection(&config, &test_opts()).unwrap(); + let wired = conn + .get("802-3-ethernet") + .expect("MTU requires an 802-3-ethernet setting"); + + assert_eq!(wired.get("mtu"), Some(&Value::from(1496u32))); + assert_eq!(wired["mtu"].value_signature().to_string(), "u"); + assert!(!conn["vlan"].contains_key("mtu")); } #[test] - fn rejects_invalid_vlan_id_too_high() { - let config = VlanConfig::new("eth0", 4095); - let opts = test_opts(); + fn omits_wired_setting_without_mtu() { + let conn = build_vlan_connection(&VlanConfig::new("eth0", 100), &test_opts()).unwrap(); - let result = build_vlan_connection(&config, &opts); - assert!(result.is_err()); + assert!(!conn.contains_key("802-3-ethernet")); } #[test] - fn rejects_empty_parent() { - let config = VlanConfig::new("", 100); + fn serializes_priority_maps_as_string_arrays() { + let config = VlanConfig::new("eth0", 100) + .with_ingress_priority_map(vec!["0:0", "7:3"]) + .with_egress_priority_map(vec!["0:1", "4:7"]); + + let conn = build_vlan_connection(&config, &test_opts()).unwrap(); + let vlan = conn.get("vlan").unwrap(); + let ingress = vlan.get("ingress-priority-map").unwrap(); + let egress = vlan.get("egress-priority-map").unwrap(); + + assert_eq!(ingress.value_signature().to_string(), "as"); + assert_eq!(egress.value_signature().to_string(), "as"); + assert_eq!( + ingress, + &Value::from(vec!["0:0".to_string(), "7:3".to_string()]) + ); + assert_eq!( + egress, + &Value::from(vec!["0:1".to_string(), "4:7".to_string()]) + ); + } + + #[test] + fn propagates_vlan_model_validation_errors() { + let config = VlanConfig::new("eth0", 0); let opts = test_opts(); let result = build_vlan_connection(&config, &opts); - assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidVlanId { id: 0 } + )); } #[test] @@ -283,23 +304,14 @@ mod tests { let conn1 = build_vlan_connection(&config, &opts).unwrap(); let conn2 = build_vlan_connection(&config, &opts).unwrap(); - let uuid1 = conn1 - .get("connection") - .and_then(|c| c.get("uuid")) - .map(|v| match v { - Value::Str(s) => s.as_str(), - _ => "", - }) - .unwrap_or(""); - - let uuid2 = conn2 - .get("connection") - .and_then(|c| c.get("uuid")) - .map(|v| match v { - Value::Str(s) => s.as_str(), - _ => "", - }) - .unwrap_or(""); + let Value::Str(uuid1) = &conn1["connection"]["uuid"] else { + panic!("conn1 UUID must be a string"); + }; + let Value::Str(uuid2) = &conn2["connection"]["uuid"] else { + panic!("conn2 UUID must be a string"); + }; + let uuid1 = uuid::Uuid::parse_str(uuid1.as_str()).expect("conn1 UUID must be valid"); + let uuid2 = uuid::Uuid::parse_str(uuid2.as_str()).expect("conn2 UUID must be valid"); assert_ne!(uuid1, uuid2, "UUIDs should be unique"); } diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index f297dcf8..1c803ef1 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -187,6 +187,9 @@ pub fn build_openvpn_connection( if let Some(p) = opts.autoconnect_priority { connection.insert("autoconnect-priority", Value::from(p)); } + if let Some(retries) = opts.autoconnect_retries { + connection.insert("autoconnect-retries", Value::from(retries)); + } let mut vpn_data: Vec<(String, String)> = Vec::new(); @@ -363,8 +366,7 @@ pub fn build_openvpn_connection( ipv4.insert("route-data", Value::from(route_data)); } if let Some(dns) = &config.dns { - let dns_array: Vec = dns.iter().map(|s| Value::from(s.clone())).collect(); - ipv4.insert("dns", Value::from(dns_array)); + ipv4.insert("dns-data", Value::from(dns.clone())); } let mut ipv6: HashMap<&'static str, Value<'static>> = HashMap::new(); @@ -409,19 +411,34 @@ mod tests { ConnectionOptions::new(false) } - #[test] - fn builds_wireguard_connection() { - let creds = create_test_credentials(); - let opts = create_test_options(); - - let settings = build_wireguard_connection(&creds, &opts); - assert!(settings.is_ok()); + fn address_data(address: &str, prefix: u32) -> Value<'static> { + let mut entry = HashMap::new(); + entry.insert("address".to_string(), Value::from(address.to_string())); + entry.insert("prefix".to_string(), Value::from(prefix)); + Value::from(vec![entry]) + } - let settings = settings.unwrap(); - assert!(settings.contains_key("connection")); - assert!(settings.contains_key("wireguard")); - assert!(settings.contains_key("ipv4")); - assert!(settings.contains_key("ipv6")); + fn peer_string_array(peer: &Dict<'_, '_>, key: &str) -> Vec { + let value = peer + .iter() + .find_map(|(candidate, value)| { + matches!(candidate, Value::Str(candidate) if candidate.as_str() == key) + .then_some(value) + }) + .unwrap_or_else(|| panic!("missing peer property {key}")); + let Value::Value(value) = value else { + panic!("peer property {key} must be stored as a variant"); + }; + let Value::Array(values) = value.as_ref() else { + panic!("peer property {key} must be an array"); + }; + values + .iter() + .map(|value| match value { + Value::Str(value) => value.as_str().to_string(), + _ => panic!("peer property {key} entries must be strings"), + }) + .collect() } #[test] @@ -477,56 +494,23 @@ mod tests { assert_eq!(method, &Value::from("ignore")); } - #[test] - fn rejects_empty_peers() { - let mut creds = create_test_credentials(); - creds.peers = vec![]; - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidPeers(_) - )); - } - - #[test] - fn rejects_invalid_address_format() { - let mut creds = create_test_credentials(); - creds.address = "invalid".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidAddress(_) - )); - } - - #[test] - fn rejects_address_without_cidr() { - let mut creds = create_test_credentials(); - creds.address = "10.0.0.2".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidAddress(_) - )); - } - #[test] fn accepts_ipv6_address() { let mut creds = create_test_credentials(); creds.address = "fd00::2/64".into(); let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + assert_eq!( + settings["ipv4"].get("method"), + Some(&Value::from("disabled")) + ); + assert!(!settings["ipv4"].contains_key("address-data")); + assert_eq!(settings["ipv6"].get("method"), Some(&Value::from("manual"))); + assert_eq!( + settings["ipv6"].get("address-data"), + Some(&address_data("fd00::2", 64)) + ); } #[test] @@ -542,8 +526,85 @@ mod tests { creds.peers.push(extra_peer); let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let peers = settings["wireguard"].get("peers").unwrap(); + assert_eq!(peers.value_signature().to_string(), "aa{sv}"); + let Value::Array(peers) = peers else { + panic!("wireguard.peers must be an array"); + }; + let peers = peers.iter().collect::>(); + assert_eq!(peers.len(), 2); + assert_eq!(peers[0].value_signature().to_string(), "a{sv}"); + assert_eq!(peers[1].value_signature().to_string(), "a{sv}"); + + let Value::Dict(first) = peers[0] else { + panic!("first wireguard peer must be a dictionary"); + }; + assert_eq!( + first + .get::(&Value::from("public-key")) + .unwrap() + .as_deref(), + Some("HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=") + ); + assert_eq!( + first + .get::(&Value::from("endpoint")) + .unwrap() + .as_deref(), + Some("vpn.example.com:51820") + ); + assert_eq!( + peer_string_array(first, "allowed-ips"), + vec!["0.0.0.0/0".to_string()] + ); + assert!( + first + .get::(&Value::from("preshared-key")) + .unwrap() + .is_none() + ); + assert_eq!( + first + .get::(&Value::from("persistent-keepalive")) + .unwrap(), + Some(25) + ); + + let Value::Dict(second) = peers[1] else { + panic!("second wireguard peer must be a dictionary"); + }; + assert_eq!( + second + .get::(&Value::from("public-key")) + .unwrap() + .as_deref(), + Some("xScVkH3fUGUVRvGLFcjkx+GGD7cf5eBVyN3Gh4FLjmI=") + ); + assert_eq!( + second + .get::(&Value::from("endpoint")) + .unwrap() + .as_deref(), + Some("peer2.example.com:51821") + ); + assert_eq!( + peer_string_array(second, "allowed-ips"), + vec!["192.168.0.0/16".to_string()] + ); + assert_eq!( + second + .get::(&Value::from("preshared-key")) + .unwrap() + .as_deref(), + Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=") + ); + assert!( + second + .get::(&Value::from("persistent-keepalive")) + .unwrap() + .is_none() + ); } #[test] @@ -552,8 +613,9 @@ mod tests { creds.dns = None; let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + assert!(!settings["ipv4"].contains_key("dns")); + assert!(!settings["ipv6"].contains_key("dns")); } #[test] @@ -562,8 +624,8 @@ mod tests { creds.mtu = None; let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + assert!(!settings["wireguard"].contains_key("mtu")); } #[test] @@ -574,7 +636,14 @@ mod tests { let settings = build_wireguard_connection(&creds, &opts).unwrap(); let ipv4 = settings.get("ipv4").unwrap(); - assert!(ipv4.contains_key("dns")); + assert_eq!( + ipv4.get("dns"), + Some(&Value::from(vec![ + u32::from(std::net::Ipv4Addr::new(1, 1, 1, 1)), + u32::from(std::net::Ipv4Addr::new(8, 8, 8, 8)), + ])) + ); + assert_eq!(ipv4["dns"].value_signature().to_string(), "au"); } #[test] @@ -583,9 +652,11 @@ mod tests { let opts = create_test_options(); let settings = build_wireguard_connection(&creds, &opts).unwrap(); - let ipv4 = settings.get("ipv4").unwrap(); - - assert!(ipv4.contains_key("mtu")); + assert_eq!( + settings["wireguard"].get("mtu"), + Some(&Value::from(1420u32)) + ); + assert!(!settings["ipv4"].contains_key("mtu")); } #[test] @@ -610,7 +681,10 @@ mod tests { let settings = build_wireguard_connection(&creds, &opts).unwrap(); let connection = settings.get("connection").unwrap(); - assert!(connection.contains_key("autoconnect-priority")); + assert_eq!( + connection.get("autoconnect-priority"), + Some(&Value::from(10i32)) + ); } #[test] @@ -644,8 +718,19 @@ mod tests { creds.peers[0].preshared_key = Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=".into()); let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let Value::Array(peers) = &settings["wireguard"]["peers"] else { + panic!("wireguard.peers must be an array"); + }; + let Value::Dict(peer) = peers.iter().next().unwrap() else { + panic!("wireguard peer must be a dictionary"); + }; + assert_eq!( + peer.get::(&Value::from("preshared-key")) + .unwrap() + .as_deref(), + Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=") + ); } #[test] @@ -654,8 +739,18 @@ mod tests { creds.peers[0].persistent_keepalive = None; let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let Value::Array(peers) = &settings["wireguard"]["peers"] else { + panic!("wireguard.peers must be an array"); + }; + let Value::Dict(peer) = peers.iter().next().unwrap() else { + panic!("wireguard peer must be a dictionary"); + }; + assert!( + peer.get::(&Value::from("persistent-keepalive")) + .unwrap() + .is_none() + ); } #[test] @@ -665,167 +760,46 @@ mod tests { vec!["0.0.0.0/0".into(), "::/0".into(), "192.168.1.0/24".into()]; let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok()); - } - - // Validation tests - - #[test] - fn rejects_empty_private_key() { - let mut creds = create_test_credentials(); - creds.private_key = "".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidPrivateKey(_) - )); - } - - #[test] - fn rejects_short_private_key() { - let mut creds = create_test_credentials(); - creds.private_key = "tooshort".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidPrivateKey(_) - )); - } - - #[test] - fn rejects_invalid_private_key_characters() { - let mut creds = create_test_credentials(); - creds.private_key = "this is not base64 encoded!!!!!!!!!!!!!!!!!!".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidPrivateKey(_) - )); - } - - // Gateway validation tests for peer gateways - // These test that validation is properly delegated to WireGuardBuilder - - #[test] - fn rejects_peer_with_empty_gateway() { - let mut creds = create_test_credentials(); - creds.peers[0].gateway = "".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidGateway(_) - )); - } - - #[test] - fn rejects_peer_gateway_without_port() { - let mut creds = create_test_credentials(); - creds.peers[0].gateway = "vpn.example.com".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidGateway(_) - )); - } - - #[test] - fn rejects_peer_gateway_with_invalid_port() { - let mut creds = create_test_credentials(); - creds.peers[0].gateway = "vpn.example.com:99999".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidGateway(_) - )); - } - - #[test] - fn rejects_peer_gateway_with_zero_port() { - let mut creds = create_test_credentials(); - creds.peers[0].gateway = "vpn.example.com:0".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidGateway(_) - )); - } - - #[test] - fn rejects_invalid_ipv4_address() { - let mut creds = create_test_credentials(); - creds.address = "999.999.999.999/24".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidAddress(_) - )); - } - - #[test] - fn rejects_ipv4_with_invalid_prefix() { - let mut creds = create_test_credentials(); - creds.address = "10.0.0.2/999".into(); - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidAddress(_) - )); - } - - #[test] - fn rejects_peer_with_empty_allowed_ips() { - let mut creds = create_test_credentials(); - creds.peers[0].allowed_ips = vec![]; - let opts = create_test_options(); - - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConnectionError::InvalidPeers(_) - )); + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let Value::Array(peers) = &settings["wireguard"]["peers"] else { + panic!("wireguard.peers must be an array"); + }; + let Value::Dict(peer) = peers.iter().next().unwrap() else { + panic!("wireguard peer must be a dictionary"); + }; + let allowed_ips = peer + .iter() + .find_map(|(key, value)| { + matches!(key, Value::Str(key) if key.as_str() == "allowed-ips").then_some(value) + }) + .expect("allowed-ips peer property"); + let Value::Value(allowed_ips) = allowed_ips else { + panic!("allowed-ips must be stored as a variant"); + }; + let Value::Array(allowed_ips) = allowed_ips.as_ref() else { + panic!("allowed-ips must be an array"); + }; + let allowed_ips = allowed_ips + .iter() + .map(|value| match value { + Value::Str(value) => value.as_str(), + _ => panic!("allowed-ips entries must be strings"), + }) + .collect::>(); + assert_eq!(allowed_ips, vec!["0.0.0.0/0", "::/0", "192.168.1.0/24"]); } #[test] - fn rejects_peer_with_invalid_public_key() { + fn legacy_builder_propagates_wireguard_validation_errors() { let mut creds = create_test_credentials(); - creds.peers[0].public_key = "invalid!@#$key".into(); + creds.peers[0].public_key = "!".repeat(44); let opts = create_test_options(); let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_err()); - // Should get InvalidPrivateKey error (we use same validation for both) assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidPrivateKey(_) + ConnectionError::InvalidPublicKey(message) + if message == "Peer 0 public key contains invalid base64 characters" )); } @@ -843,11 +817,12 @@ mod tests { creds.address = address.into(); let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!( - result.is_ok(), - "Should accept valid IPv4 address: {}", - address + let settings = build_wireguard_connection(&creds, &opts) + .unwrap_or_else(|error| panic!("valid address {address} failed: {error}")); + let (ip, prefix) = address.split_once('/').unwrap(); + assert_eq!( + settings["ipv4"].get("address-data"), + Some(&address_data(ip, prefix.parse().unwrap())) ); } } @@ -862,11 +837,23 @@ mod tests { for gateway in test_cases { let mut creds = create_test_credentials(); - creds.gateway = gateway.into(); + creds.peers[0].gateway = gateway.into(); let opts = create_test_options(); - let result = build_wireguard_connection(&creds, &opts); - assert!(result.is_ok(), "Should accept valid gateway: {}", gateway); + let settings = build_wireguard_connection(&creds, &opts) + .unwrap_or_else(|error| panic!("valid gateway {gateway} failed: {error}")); + let Value::Array(peers) = &settings["wireguard"]["peers"] else { + panic!("wireguard.peers must be an array"); + }; + let Value::Dict(peer) = peers.iter().next().unwrap() else { + panic!("wireguard peer must be a dictionary"); + }; + assert_eq!( + peer.get::(&Value::from("endpoint")) + .unwrap() + .as_deref(), + Some(gateway) + ); } } @@ -878,19 +865,6 @@ mod tests { .with_client_key("/etc/openvpn/client.key") } - #[test] - fn builds_openvpn_connection() { - let config = create_openvpn_config(); - let opts = create_test_options(); - let result = build_openvpn_connection(&config, &opts); - assert!(result.is_ok()); - let settings = result.unwrap(); - assert!(settings.contains_key("connection")); - assert!(settings.contains_key("vpn")); - assert!(settings.contains_key("ipv4")); - assert!(settings.contains_key("ipv6")); - } - #[test] fn openvpn_connection_type_is_vpn() { let config = create_openvpn_config(); @@ -920,46 +894,31 @@ mod tests { let result = build_openvpn_connection(&config, &opts); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidGateway(_) + ConnectionError::InvalidGateway(message) + if message == "OpenVPN remote must not be empty" )); } - #[test] - fn openvpn_compression_no() { - let config = create_openvpn_config().with_compression(OpenVpnCompression::No); - let opts = create_test_options(); - let settings = build_openvpn_connection(&config, &opts).unwrap(); - let vpn = settings.get("vpn").unwrap(); - // vpn.data is packed — just assert the section exists and no error - assert!(vpn.contains_key("data")); - } #[allow(deprecated)] #[test] - fn openvpn_compression_lzo() { - let config = create_openvpn_config().with_compression(OpenVpnCompression::Lzo); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); - } - - #[test] - fn openvpn_compression_lz4() { - let config = create_openvpn_config().with_compression(OpenVpnCompression::Lz4); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); - } - - #[test] - fn openvpn_compression_lz4v2() { - let config = create_openvpn_config().with_compression(OpenVpnCompression::Lz4V2); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); - } + fn openvpn_serializes_each_compression_mode() { + let cases = [ + (OpenVpnCompression::No, "compress", "no"), + (OpenVpnCompression::Lzo, "comp-lzo", "yes"), + (OpenVpnCompression::Lz4, "compress", "lz4"), + (OpenVpnCompression::Lz4V2, "compress", "lz4-v2"), + (OpenVpnCompression::Yes, "compress", "yes"), + ]; - #[test] - fn openvpn_compression_yes() { - let config = create_openvpn_config().with_compression(OpenVpnCompression::Yes); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); + for (compression, key, expected) in cases { + let config = create_openvpn_config().with_compression(compression.clone()); + let settings = build_openvpn_connection(&config, &create_test_options()).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, key).as_deref(), + Some(expected), + "wrong serialized value for {compression:?}" + ); + } } #[test] @@ -971,8 +930,32 @@ mod tests { password: Some("pass".into()), retry: true, }); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); + let settings = build_openvpn_connection(&config, &create_test_options()).unwrap(); + + assert_eq!( + get_vpn_data_value(&settings, "proxy-type").as_deref(), + Some("http") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-server").as_deref(), + Some("proxy.example.com") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-port").as_deref(), + Some("8080") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-retry").as_deref(), + Some("yes") + ); + assert_eq!( + get_vpn_data_value(&settings, "http-proxy-username").as_deref(), + Some("user") + ); + assert_eq!( + get_vpn_data_value(&settings, "http-proxy-password").as_deref(), + Some("pass") + ); } #[test] @@ -984,8 +967,22 @@ mod tests { password: None, retry: false, }); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); + let settings = build_openvpn_connection(&config, &create_test_options()).unwrap(); + + assert_eq!( + get_vpn_data_value(&settings, "proxy-type").as_deref(), + Some("http") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-port").as_deref(), + Some("3128") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-retry").as_deref(), + Some("no") + ); + assert!(get_vpn_data_value(&settings, "http-proxy-username").is_none()); + assert!(get_vpn_data_value(&settings, "http-proxy-password").is_none()); } #[test] @@ -995,8 +992,24 @@ mod tests { port: 1080, retry: false, }); - let opts = create_test_options(); - assert!(build_openvpn_connection(&config, &opts).is_ok()); + let settings = build_openvpn_connection(&config, &create_test_options()).unwrap(); + + assert_eq!( + get_vpn_data_value(&settings, "proxy-type").as_deref(), + Some("socks") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-server").as_deref(), + Some("socks.example.com") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-port").as_deref(), + Some("1080") + ); + assert_eq!( + get_vpn_data_value(&settings, "proxy-retry").as_deref(), + Some("no") + ); } #[test] @@ -1011,7 +1024,8 @@ mod tests { let opts = create_test_options(); assert!(matches!( build_openvpn_connection(&config, &opts).unwrap_err(), - ConnectionError::InvalidAddress(_) + ConnectionError::InvalidAddress(message) + if message == "proxy port must not be zero" )); } @@ -1025,7 +1039,8 @@ mod tests { let opts = create_test_options(); assert!(matches!( build_openvpn_connection(&config, &opts).unwrap_err(), - ConnectionError::InvalidAddress(_) + ConnectionError::InvalidAddress(message) + if message == "proxy port must not be zero" )); } @@ -1035,7 +1050,12 @@ mod tests { let opts = create_test_options(); let settings = build_openvpn_connection(&config, &opts).unwrap(); let ipv4 = settings.get("ipv4").unwrap(); - assert!(ipv4.contains_key("dns")); + let dns = ipv4.get("dns-data").unwrap(); + assert_eq!(dns.value_signature().to_string(), "as"); + assert_eq!( + dns, + &Value::from(vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()]) + ); } #[test] @@ -1043,8 +1063,29 @@ mod tests { let config = OpenVpnConfig::new("TcpVPN", "vpn.example.com", 443, true); let opts = create_test_options(); let settings = build_openvpn_connection(&config, &opts).unwrap(); - let vpn = settings.get("vpn").unwrap(); - assert!(vpn.contains_key("data")); + assert_eq!( + get_vpn_data_value(&settings, "proto-tcp").as_deref(), + Some("yes") + ); + } + + #[test] + fn openvpn_serializes_all_autoconnect_options() { + let opts = ConnectionOptions::new(true) + .with_priority(12) + .with_retries(7); + let settings = build_openvpn_connection(&create_openvpn_config(), &opts).unwrap(); + let connection = settings.get("connection").unwrap(); + + assert_eq!(connection.get("autoconnect"), Some(&Value::from(true))); + assert_eq!( + connection.get("autoconnect-priority"), + Some(&Value::from(12i32)) + ); + assert_eq!( + connection.get("autoconnect-retries"), + Some(&Value::from(7i32)) + ); } #[test] @@ -1282,16 +1323,25 @@ mod tests { #[test] fn openvpn_ipv4_route_data() { use crate::api::models::VpnRoute; - let config = create_openvpn_config() - .with_routes(vec![VpnRoute::new("10.0.0.0", 24).next_hop("192.168.1.1")]); + let config = create_openvpn_config().with_routes(vec![ + VpnRoute::new("10.0.0.0", 24) + .next_hop("192.168.1.1") + .metric(75), + ]); let opts = create_test_options(); let settings = build_openvpn_connection(&config, &opts).unwrap(); let ipv4 = settings.get("ipv4").unwrap(); let rd = ipv4.get("route-data").unwrap(); - let Value::Array(arr) = rd else { - panic!("route-data must be an array"); - }; - assert_eq!(arr.iter().count(), 1, "expected one static route"); + assert_eq!(rd.value_signature().to_string(), "aa{sv}"); + let mut expected = HashMap::new(); + expected.insert("dest".to_string(), Value::from("10.0.0.0".to_string())); + expected.insert("prefix".to_string(), Value::from(24u32)); + expected.insert( + "next-hop".to_string(), + Value::from("192.168.1.1".to_string()), + ); + expected.insert("metric".to_string(), Value::from(75u32)); + assert_eq!(rd, &Value::from(vec![expected])); } #[test] diff --git a/nmrs/src/api/builders/wifi_builder.rs b/nmrs/src/api/builders/wifi_builder.rs index c9520ec7..97cb8991 100644 --- a/nmrs/src/api/builders/wifi_builder.rs +++ b/nmrs/src/api/builders/wifi_builder.rs @@ -143,12 +143,13 @@ impl WifiConnectionBuilder { /// /// This is the default, but can be called explicitly for clarity. #[must_use] - pub fn open(self) -> Self { - // Open networks don't need a security section - Self { - security_configured: true, - ..self - } + pub fn open(mut self) -> Self { + self.inner = self + .inner + .without_section("802-11-wireless-security") + .without_section("802-1x"); + self.security_configured = false; + self } /// Configures WPA-PSK (Personal) security with the given passphrase. @@ -166,6 +167,7 @@ impl WifiConnectionBuilder { self.inner = self .inner + .without_section("802-1x") .with_section("802-11-wireless-security", security); self.security_configured = true; self @@ -390,10 +392,7 @@ impl WifiConnectionBuilder { wireless.insert("bssid", Value::from(bssid)); } - // Link to security section if security is configured (not open) - if self.security_configured && !self.ssid.is_empty() { - // Check if we actually have a security section (not just open) - // Open networks don't have the security section + if self.security_configured { wireless.insert("security", Value::from("802-11-wireless-security")); } @@ -453,12 +452,33 @@ mod tests { assert!(settings.contains_key("ipv4")); assert!(settings.contains_key("ipv6")); assert!(!settings.contains_key("802-11-wireless-security")); + assert!(!settings.contains_key("802-1x")); let wireless = settings.get("802-11-wireless").unwrap(); assert_eq!( wireless.get("ssid"), Some(&Value::from(b"OpenNetwork".to_vec())) ); + assert_eq!(wireless.get("mode"), Some(&Value::from("infrastructure"))); + assert!( + !wireless.contains_key("security"), + "open Wi-Fi must not reference a security section" + ); + } + + #[test] + fn open_overrides_previously_configured_security() { + let settings = WifiConnectionBuilder::new("OpenNetwork") + .wpa_psk("password123") + .open() + .build(); + + assert!(!settings.contains_key("802-11-wireless-security")); + assert!(!settings.contains_key("802-1x")); + assert!( + !settings["802-11-wireless"].contains_key("security"), + "open() must remove the security link as well as its sections" + ); } #[test] diff --git a/nmrs/src/api/builders/wireguard_builder.rs b/nmrs/src/api/builders/wireguard_builder.rs index a59354c2..b8930a06 100644 --- a/nmrs/src/api/builders/wireguard_builder.rs +++ b/nmrs/src/api/builders/wireguard_builder.rs @@ -4,7 +4,7 @@ //! with comprehensive validation of keys, addresses, and peer configurations. use std::collections::HashMap; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use uuid::Uuid; use zvariant::Value; @@ -276,36 +276,43 @@ impl WireGuardBuilder { self.inner = self.inner.with_section("wireguard", wireguard); - // Configure IPv4 with manual addressing - self.inner = self.inner.ipv4_manual(vec![IpConfig::new(ip, prefix)]); + match ip { + IpAddr::V4(ip) => { + self.inner = self + .inner + .ipv4_manual(vec![IpConfig::new(ip.to_string(), prefix)]) + .ipv6_ignore(); + } + IpAddr::V6(ip) => { + self.inner = self + .inner + .ipv4_disabled() + .ipv6_manual(vec![IpConfig::new(ip.to_string(), prefix)]); + } + } - // Add DNS if configured if let Some(dns) = self.dns { - let dns_addrs: Result, _> = - dns.iter().map(|s| s.parse::()).collect(); - - match dns_addrs { - Ok(addrs) => { - self.inner = self.inner.ipv4_dns(addrs); - } - Err(_) => { - return Err(ConnectionError::VpnFailed( - "Invalid DNS server address".into(), - )); + let mut ipv4_dns = Vec::::new(); + let mut ipv6_dns = Vec::::new(); + for server in dns { + match server.parse::() { + Ok(IpAddr::V4(address)) => ipv4_dns.push(address), + Ok(IpAddr::V6(address)) => ipv6_dns.push(address), + Err(_) => { + return Err(ConnectionError::VpnFailed(format!( + "Invalid DNS server address: {server}" + ))); + } } } + if !ipv4_dns.is_empty() { + self.inner = self.inner.ipv4_dns(ipv4_dns); + } + if !ipv6_dns.is_empty() { + self.inner = self.inner.ipv6_dns(ipv6_dns); + } } - // Add MTU to IPv4 if configured - if let Some(mtu) = self.mtu { - self.inner = self.inner.update_section("ipv4", |ipv4| { - ipv4.insert("mtu", Value::from(mtu)); - }); - } - - // Set IPv6 to ignore - self.inner = self.inner.ipv6_ignore(); - Ok(self.inner.build()) } } @@ -314,18 +321,21 @@ impl WireGuardBuilder { fn validate_wireguard_key(key: &str, key_type: &str) -> Result<(), ConnectionError> { if key.trim().is_empty() { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} cannot be empty", - key_type - ))); + return Err(invalid_wireguard_key( + key_type, + format!("{} cannot be empty", key_type), + )); } let len = key.trim().len(); if !(40..=50).contains(&len) { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} has invalid length: {} (expected ~44 characters)", - key_type, len - ))); + return Err(invalid_wireguard_key( + key_type, + format!( + "{} has invalid length: {} (expected ~44 characters)", + key_type, len + ), + )); } let is_valid_base64 = key @@ -334,16 +344,24 @@ fn validate_wireguard_key(key: &str, key_type: &str) -> Result<(), ConnectionErr .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='); if !is_valid_base64 { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} contains invalid base64 characters", - key_type - ))); + return Err(invalid_wireguard_key( + key_type, + format!("{} contains invalid base64 characters", key_type), + )); } Ok(()) } -fn validate_address(address: &str) -> Result<(String, u32), ConnectionError> { +fn invalid_wireguard_key(key_type: &str, message: String) -> ConnectionError { + if key_type.contains("public key") { + ConnectionError::InvalidPublicKey(message) + } else { + ConnectionError::InvalidPrivateKey(message) + } +} + +fn validate_address(address: &str) -> Result<(IpAddr, u32), ConnectionError> { let (ip, prefix) = address.split_once('/').ok_or_else(|| { ConnectionError::InvalidAddress(format!( "missing CIDR prefix (e.g., '10.0.0.2/24'): {}", @@ -351,54 +369,22 @@ fn validate_address(address: &str) -> Result<(String, u32), ConnectionError> { )) })?; - if ip.trim().is_empty() { - return Err(ConnectionError::InvalidAddress( - "IP address cannot be empty".into(), - )); - } + let ip = ip.trim().parse::().map_err(|_| { + ConnectionError::InvalidAddress(format!("invalid IP address: {}", ip.trim())) + })?; let prefix: u32 = prefix .parse() .map_err(|_| ConnectionError::InvalidAddress(format!("invalid CIDR prefix: {}", prefix)))?; - if prefix > 128 { + let max_prefix = if ip.is_ipv4() { 32 } else { 128 }; + if prefix > max_prefix { return Err(ConnectionError::InvalidAddress(format!( - "CIDR prefix too large: {} (max 128)", - prefix + "CIDR prefix too large: {prefix} (max {max_prefix})" ))); } - // Basic IPv4 validation - if ip.contains('.') { - let octets: Vec<&str> = ip.split('.').collect(); - if octets.len() != 4 { - return Err(ConnectionError::InvalidAddress(format!( - "invalid IPv4 address: {}", - ip - ))); - } - - for octet in octets { - let num: u32 = octet.parse().map_err(|_| { - ConnectionError::InvalidAddress(format!("invalid IPv4 octet: {}", octet)) - })?; - if num > 255 { - return Err(ConnectionError::InvalidAddress(format!( - "IPv4 octet out of range: {}", - num - ))); - } - } - - if prefix > 32 { - return Err(ConnectionError::InvalidAddress(format!( - "IPv4 CIDR prefix too large: {} (max 32)", - prefix - ))); - } - } - - Ok((ip.to_string(), prefix)) + Ok((ip, prefix)) } fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> { @@ -408,25 +394,27 @@ fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> { )); } - if !gateway.contains(':') { - return Err(ConnectionError::InvalidGateway(format!( - "gateway must be in 'host:port' format: {}", - gateway - ))); + let (host, port_str) = gateway.rsplit_once(':').ok_or_else(|| { + ConnectionError::InvalidGateway(format!("gateway must be in 'host:port' format: {gateway}")) + })?; + if host.trim().is_empty() { + return Err(ConnectionError::InvalidGateway( + "gateway host cannot be empty".into(), + )); } - - let parts: Vec<&str> = gateway.rsplitn(2, ':').collect(); - if parts.len() != 2 { + if host.contains(':') + && !(host.starts_with('[') + && host.ends_with(']') + && host[1..host.len() - 1].parse::().is_ok()) + { return Err(ConnectionError::InvalidGateway(format!( - "invalid gateway format: {}", - gateway + "IPv6 gateway must use '[address]:port' format: {gateway}" ))); } - let port_str = parts[0]; - let port: u16 = port_str.parse().map_err(|_| { - ConnectionError::InvalidGateway(format!("invalid port number: {}", port_str)) - })?; + let port: u16 = port_str + .parse() + .map_err(|_| ConnectionError::InvalidGateway(format!("invalid port number: {port_str}")))?; if port == 0 { return Err(ConnectionError::InvalidGateway("port cannot be 0".into())); @@ -439,9 +427,42 @@ fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> { mod tests { use super::*; + const PRIVATE_KEY: &str = "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM="; + const PUBLIC_KEY: &str = "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc="; + + fn address_data(address: &str, prefix: u32) -> Value<'static> { + let mut entry = HashMap::new(); + entry.insert("address".to_string(), Value::from(address.to_string())); + entry.insert("prefix".to_string(), Value::from(prefix)); + Value::from(vec![entry]) + } + + fn peer_string_array(peer: &zvariant::Dict<'_, '_>, key: &str) -> Vec { + let value = peer + .iter() + .find_map(|(candidate, value)| { + matches!(candidate, Value::Str(candidate) if candidate.as_str() == key) + .then_some(value) + }) + .unwrap_or_else(|| panic!("missing peer property {key}")); + let Value::Value(value) = value else { + panic!("peer property {key} must be stored as a variant"); + }; + let Value::Array(values) = value.as_ref() else { + panic!("peer property {key} must be an array"); + }; + values + .iter() + .map(|value| match value { + Value::Str(value) => value.as_str().to_string(), + _ => panic!("peer property {key} entries must be strings"), + }) + .collect() + } + fn create_test_peer() -> WireGuardPeer { WireGuardPeer { - public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), + public_key: PUBLIC_KEY.into(), gateway: "vpn.example.com:51820".into(), allowed_ips: vec!["0.0.0.0/0".into()], preshared_key: None, @@ -449,23 +470,95 @@ mod tests { } } - #[test] - fn builds_basic_wireguard_connection() { - let settings = WireGuardBuilder::new("TestVPN") - .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") - .address("10.0.0.2/24") + fn build_test_connection( + address: &str, + ) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address(address) .add_peer(create_test_peer()) .autoconnect(false) .build() - .expect("Failed to build"); + .expect("valid WireGuard settings") + } - assert!(settings.contains_key("connection")); - assert!(settings.contains_key("wireguard")); - assert!(settings.contains_key("ipv4")); - assert!(settings.contains_key("ipv6")); + #[test] + fn builds_basic_wireguard_connection() { + let settings = build_test_connection("10.0.0.2/24"); let conn = settings.get("connection").unwrap(); assert_eq!(conn.get("type"), Some(&Value::from("wireguard"))); + assert_eq!(conn.get("id"), Some(&Value::from("TestVPN"))); + assert_eq!(conn.get("interface-name"), Some(&Value::from("wg-testvpn"))); + assert_eq!(conn.get("autoconnect"), Some(&Value::from(false))); + assert_eq!( + conn.get("uuid"), + Some(&Value::from( + Uuid::new_v5(&Uuid::NAMESPACE_DNS, b"wg:TestVPN").to_string() + )) + ); + + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("method"), Some(&Value::from("manual"))); + assert_eq!( + ipv4.get("address-data"), + Some(&address_data("10.0.0.2", 24)) + ); + assert_eq!(settings["ipv6"].get("method"), Some(&Value::from("ignore"))); + } + + #[test] + fn serializes_complete_peer_payload() { + let peer = + create_test_peer().with_preshared_key("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm="); + let settings = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("10.0.0.2/24") + .add_peer(peer) + .build() + .unwrap(); + + let wireguard = settings.get("wireguard").unwrap(); + assert_eq!( + wireguard.get("private-key"), + Some(&Value::from(PRIVATE_KEY)) + ); + let peers = wireguard.get("peers").unwrap(); + assert_eq!(peers.value_signature().to_string(), "aa{sv}"); + let Value::Array(peers) = peers else { + panic!("wireguard.peers must be an array"); + }; + assert_eq!(peers.iter().count(), 1); + let Value::Dict(peer) = peers.iter().next().unwrap() else { + panic!("each wireguard peer must be a dictionary"); + }; + assert_eq!( + peer.get::(&Value::from("public-key")) + .unwrap() + .as_deref(), + Some(PUBLIC_KEY) + ); + assert_eq!( + peer.get::(&Value::from("endpoint")) + .unwrap() + .as_deref(), + Some("vpn.example.com:51820") + ); + assert_eq!( + peer_string_array(peer, "allowed-ips"), + vec!["0.0.0.0/0".to_string()] + ); + assert_eq!( + peer.get::(&Value::from("preshared-key")) + .unwrap() + .as_deref(), + Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=") + ); + assert_eq!( + peer.get::(&Value::from("persistent-keepalive")) + .unwrap(), + Some(25) + ); } #[test] @@ -475,59 +568,69 @@ mod tests { .add_peer(create_test_peer()) .build(); - assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidPrivateKey(_) + ConnectionError::InvalidPrivateKey(message) if message == "Private key not set" )); } #[test] fn requires_address() { let result = WireGuardBuilder::new("TestVPN") - .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .private_key(PRIVATE_KEY) .add_peer(create_test_peer()) .build(); - assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidAddress(_) + ConnectionError::InvalidAddress(message) if message == "Address not set" )); } #[test] fn requires_at_least_one_peer() { let result = WireGuardBuilder::new("TestVPN") - .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .private_key(PRIVATE_KEY) .address("10.0.0.2/24") .build(); - assert!(result.is_err()); assert!(matches!( result.unwrap_err(), - ConnectionError::InvalidPeers(_) + ConnectionError::InvalidPeers(message) if message == "No peers configured" )); } #[test] fn adds_dns_servers() { let settings = WireGuardBuilder::new("TestVPN") - .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .private_key(PRIVATE_KEY) .address("10.0.0.2/24") .add_peer(create_test_peer()) - .dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .dns(vec!["1.1.1.1".into(), "2001:4860:4860::8888".into()]) .build() - .expect("Failed to build"); + .expect("valid mixed-family DNS settings"); let ipv4 = settings.get("ipv4").unwrap(); - assert!(ipv4.contains_key("dns")); + assert_eq!( + ipv4.get("dns"), + Some(&Value::from(vec![u32::from(Ipv4Addr::new(1, 1, 1, 1))])) + ); + assert_eq!(ipv4["dns"].value_signature().to_string(), "au"); + + let ipv6 = settings.get("ipv6").unwrap(); + let expected_v6 = "2001:4860:4860::8888" + .parse::() + .unwrap() + .octets() + .to_vec(); + assert_eq!(ipv6.get("dns"), Some(&Value::from(vec![expected_v6]))); + assert_eq!(ipv6["dns"].value_signature().to_string(), "aay"); } #[test] fn sets_mtu() { let settings = WireGuardBuilder::new("TestVPN") - .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .private_key(PRIVATE_KEY) .address("10.0.0.2/24") .add_peer(create_test_peer()) .mtu(1420) @@ -536,6 +639,7 @@ mod tests { let wireguard = settings.get("wireguard").unwrap(); assert_eq!(wireguard.get("mtu"), Some(&Value::from(1420u32))); + assert!(!settings["ipv4"].contains_key("mtu")); } #[test] @@ -550,12 +654,187 @@ mod tests { }; let settings = WireGuardBuilder::new("TestVPN") - .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .private_key(PRIVATE_KEY) .address("10.0.0.2/24") .add_peers(vec![peer1, peer2]) .build() .expect("Failed to build"); - assert!(settings.contains_key("wireguard")); + let Value::Array(peers) = &settings["wireguard"]["peers"] else { + panic!("wireguard.peers must be an array"); + }; + assert_eq!(peers.signature().to_string(), "aa{sv}"); + let peers = peers.iter().collect::>(); + assert_eq!(peers.len(), 2); + assert_eq!(peers[0].value_signature().to_string(), "a{sv}"); + assert_eq!(peers[1].value_signature().to_string(), "a{sv}"); + + let Value::Dict(first) = peers[0] else { + panic!("first wireguard peer must be a dictionary"); + }; + assert_eq!( + first + .get::(&Value::from("public-key")) + .unwrap() + .as_deref(), + Some(PUBLIC_KEY) + ); + assert_eq!( + first + .get::(&Value::from("endpoint")) + .unwrap() + .as_deref(), + Some("vpn.example.com:51820") + ); + assert_eq!( + peer_string_array(first, "allowed-ips"), + vec!["0.0.0.0/0".to_string()] + ); + assert_eq!( + first + .get::(&Value::from("persistent-keepalive")) + .unwrap(), + Some(25) + ); + + let Value::Dict(second) = peers[1] else { + panic!("second wireguard peer must be a dictionary"); + }; + assert_eq!( + second + .get::(&Value::from("public-key")) + .unwrap() + .as_deref(), + Some("xScVkH3fUGUVRvGLFcjkx+GGD7cf5eBVyN3Gh4FLjmI=") + ); + assert_eq!( + second + .get::(&Value::from("endpoint")) + .unwrap() + .as_deref(), + Some("peer2.example.com:51821") + ); + assert_eq!( + peer_string_array(second, "allowed-ips"), + vec!["192.168.0.0/16".to_string()] + ); + assert!( + second + .get::(&Value::from("preshared-key")) + .unwrap() + .is_none() + ); + assert!( + second + .get::(&Value::from("persistent-keepalive")) + .unwrap() + .is_none() + ); + } + + #[test] + fn serializes_ipv6_address_in_ipv6_section() { + let settings = build_test_connection("fd00::2/64"); + + assert_eq!( + settings["ipv4"].get("method"), + Some(&Value::from("disabled")) + ); + assert!(!settings["ipv4"].contains_key("address-data")); + assert_eq!(settings["ipv6"].get("method"), Some(&Value::from("manual"))); + assert_eq!( + settings["ipv6"].get("address-data"), + Some(&address_data("fd00::2", 64)) + ); + } + + #[test] + fn rejects_invalid_ip_and_dns_addresses() { + let invalid_address = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("fd00::xyz/64") + .add_peer(create_test_peer()) + .build(); + assert!(matches!( + invalid_address.unwrap_err(), + ConnectionError::InvalidAddress(message) + if message == "invalid IP address: fd00::xyz" + )); + + let invalid_dns = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("10.0.0.2/24") + .add_peer(create_test_peer()) + .dns(vec!["not-an-address".into()]) + .build(); + assert!(matches!( + invalid_dns.unwrap_err(), + ConnectionError::VpnFailed(message) + if message == "Invalid DNS server address: not-an-address" + )); + } + + #[test] + fn validates_gateway_host_and_ipv6_brackets() { + for (gateway, expected) in [ + (":51820", "gateway host cannot be empty"), + ( + "2001:db8::1:51820", + "IPv6 gateway must use '[address]:port' format: 2001:db8::1:51820", + ), + ] { + let mut peer = create_test_peer(); + peer.gateway = gateway.into(); + let result = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + assert!( + matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(message) if message == expected + ), + "gateway {gateway} should be rejected" + ); + } + + let mut peer = create_test_peer(); + peer.public_key = "!".repeat(44); + let result = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPublicKey(message) + if message == "Peer 0 public key contains invalid base64 characters" + )); + + let mut peer = create_test_peer(); + peer.allowed_ips.clear(); + let result = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPeers(message) if message == "Peer 0 has no allowed IPs" + )); + + let mut peer = create_test_peer(); + peer.gateway = "[2001:db8::1]:51820".into(); + let settings = WireGuardBuilder::new("TestVPN") + .private_key(PRIVATE_KEY) + .address("fd00::2/64") + .add_peer(peer) + .build() + .unwrap(); + assert_eq!( + settings["ipv6"].get("address-data"), + Some(&address_data("fd00::2", 64)) + ); } } diff --git a/nmrs/src/api/models/device.rs b/nmrs/src/api/models/device.rs index 5a581097..12e51778 100644 --- a/nmrs/src/api/models/device.rs +++ b/nmrs/src/api/models/device.rs @@ -265,7 +265,7 @@ impl DeviceType { Self::Wifi => 2, Self::WifiP2P => 30, Self::Loopback => 32, - Self::Bluetooth => 6, + Self::Bluetooth => 5, Self::Vlan => 11, Self::Other(code) => *code, } @@ -352,7 +352,7 @@ impl Device { /// Returns `true` if this is a wired (Ethernet) device. #[must_use] pub fn is_wired(&self) -> bool { - matches!(self.device_type, DeviceType::Ethernet) + crate::types::device_type_registry::is_wired(self.device_type.to_code()) } /// Returns `true` if this is a wireless (Wi-Fi) device. diff --git a/nmrs/src/api/models/monitor.rs b/nmrs/src/api/models/monitor.rs index 00255ce1..172c1975 100644 --- a/nmrs/src/api/models/monitor.rs +++ b/nmrs/src/api/models/monitor.rs @@ -71,3 +71,93 @@ impl Drop for MonitorHandle { let _ = self.shutdown_tx.send(()); } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn task_waiting_for_shutdown(mut shutdown_rx: watch::Receiver<()>) -> JoinHandle> { + tokio::spawn(async move { + shutdown_rx + .changed() + .await + .map_err(|error| ConnectionError::Stuck(error.to_string()))?; + Ok(()) + }) + } + + async fn expect_shutdown(shutdown_rx: &mut watch::Receiver<()>) { + tokio::time::timeout(Duration::from_secs(1), shutdown_rx.changed()) + .await + .expect("shutdown signal timed out") + .expect("shutdown sender dropped without signaling"); + } + + #[tokio::test] + async fn stop_signals_and_waits_for_clean_task_exit() { + let (shutdown_tx, shutdown_rx) = watch::channel(()); + let handle = MonitorHandle::new(shutdown_tx, task_waiting_for_shutdown(shutdown_rx)); + + handle.stop().await.unwrap(); + } + + #[tokio::test] + async fn stop_propagates_monitor_task_error() { + let (shutdown_tx, _shutdown_rx) = watch::channel(()); + let task = tokio::spawn(async { + Err(ConnectionError::Stuck( + "monitor returned its own error".into(), + )) + }); + let handle = MonitorHandle::new(shutdown_tx, task); + + let error = handle.stop().await.unwrap_err(); + assert!(matches!( + error, + ConnectionError::Stuck(message) if message == "monitor returned its own error" + )); + } + + #[tokio::test] + async fn stop_maps_panicked_task_to_stuck_error() { + let (shutdown_tx, _shutdown_rx) = watch::channel(()); + let task = tokio::spawn(async { + panic!("monitor task test panic"); + #[allow(unreachable_code)] + Ok(()) + }); + let handle = MonitorHandle::new(shutdown_tx, task); + + let error = handle.stop().await.unwrap_err(); + assert!(matches!( + error, + ConnectionError::Stuck(message) + if message.contains("monitor task panicked") + && message.contains("monitor task test panic") + )); + } + + #[tokio::test] + async fn shutdown_sends_signal_without_consuming_handle() { + let (shutdown_tx, shutdown_rx) = watch::channel(()); + let mut observer = shutdown_rx.clone(); + let handle = MonitorHandle::new(shutdown_tx, task_waiting_for_shutdown(shutdown_rx)); + + handle.shutdown(); + expect_shutdown(&mut observer).await; + handle.stop().await.unwrap(); + } + + #[tokio::test] + async fn drop_sends_shutdown_signal() { + let (shutdown_tx, shutdown_rx) = watch::channel(()); + let mut observer = shutdown_rx.clone(); + let handle = MonitorHandle::new(shutdown_tx, task_waiting_for_shutdown(shutdown_rx)); + + drop(handle); + + expect_shutdown(&mut observer).await; + } +} diff --git a/nmrs/src/api/models/openvpn.rs b/nmrs/src/api/models/openvpn.rs index 76cc41a6..7250f003 100644 --- a/nmrs/src/api/models/openvpn.rs +++ b/nmrs/src/api/models/openvpn.rs @@ -766,11 +766,13 @@ mod tests { fn try_from_inline_cert_returns_error() { let input = ovpn_with_remote("\nCERTPEM\n\n\nKEYPEM\n"); let ovpn = parse_ovpn(&input).unwrap(); - let result = OpenVpnConfig::try_from(ovpn); - assert!( - result.is_err(), - "inline certs should be rejected by TryFrom" - ); + let error = OpenVpnConfig::try_from(ovpn).unwrap_err(); + assert!(matches!( + error, + ConnectionError::VpnFailed(message) + if message.contains("inline blocks") + && message.contains("TryFrom cannot handle inline certs") + )); } #[test] diff --git a/nmrs/src/api/models/snapshot.rs b/nmrs/src/api/models/snapshot.rs index 3fb25815..e4684e4e 100644 --- a/nmrs/src/api/models/snapshot.rs +++ b/nmrs/src/api/models/snapshot.rs @@ -605,6 +605,38 @@ mod tests { assert!(groups.iter().any(|group| group.interface == "wlan1")); } + #[test] + fn attaches_interface_bound_profile_only_to_matching_group() { + let snapshot = snapshot( + vec![ + ap("wlan0", "Cafe", "AA:AA:AA:AA:AA:01", 70), + ap("wlan1", "Cafe", "BB:BB:BB:BB:BB:01", 90), + ], + vec![ + saved_wifi(10, "wlan1-profile", "Cafe", Some("wlan1"), None), + saved_wifi(11, "missing-interface", "Cafe", Some("wlan9"), None), + ], + Vec::new(), + Vec::new(), + ); + + let groups = snapshot.wifi_groups(); + let wlan0 = groups + .iter() + .find(|group| group.interface == "wlan0") + .unwrap(); + let wlan1 = groups + .iter() + .find(|group| group.interface == "wlan1") + .unwrap(); + + assert!(!wlan0.known); + assert!(wlan0.saved_profiles.is_empty()); + assert!(wlan1.known); + assert_eq!(wlan1.saved_profiles.len(), 1); + assert_eq!(wlan1.saved_profiles[0].uuid, "wlan1-profile"); + } + #[test] fn matches_bssid_pinned_saved_profiles() { let snapshot = snapshot( diff --git a/nmrs/src/api/models/tests.rs b/nmrs/src/api/models/tests.rs index f61ce82e..344c906b 100644 --- a/nmrs/src/api/models/tests.rs +++ b/nmrs/src/api/models/tests.rs @@ -15,14 +15,23 @@ use super::wireguard::*; use crate::api::models::DeviceType; #[test] -fn device_type_from_u32_all_variants() { - assert_eq!(DeviceType::from(1), DeviceType::Ethernet); - assert_eq!(DeviceType::from(2), DeviceType::Wifi); - assert_eq!(DeviceType::from(11), DeviceType::Vlan); - assert_eq!(DeviceType::from(30), DeviceType::WifiP2P); - assert_eq!(DeviceType::from(32), DeviceType::Loopback); - assert_eq!(DeviceType::from(999), DeviceType::Other(999)); - assert_eq!(DeviceType::from(0), DeviceType::Other(0)); +fn device_type_code_round_trips_all_variants() { + let cases = [ + (1, DeviceType::Ethernet), + (2, DeviceType::Wifi), + (5, DeviceType::Bluetooth), + (11, DeviceType::Vlan), + (30, DeviceType::WifiP2P), + (32, DeviceType::Loopback), + (999, DeviceType::Other(999)), + (0, DeviceType::Other(0)), + ]; + + for (code, expected) in cases { + let actual = DeviceType::from(code); + assert_eq!(actual, expected); + assert_eq!(actual.to_code(), code); + } } #[test] @@ -33,6 +42,7 @@ fn device_type_from_u32_registry_types() { assert_eq!(DeviceType::from(12), DeviceType::Other(12)); assert_eq!(DeviceType::from(13), DeviceType::Other(13)); assert_eq!(DeviceType::from(16), DeviceType::Other(16)); + assert_eq!(DeviceType::from(20), DeviceType::Other(20)); assert_eq!(DeviceType::from(29), DeviceType::Other(29)); } @@ -42,6 +52,7 @@ fn device_type_display() { assert_eq!(format!("{}", DeviceType::Wifi), "Wi-Fi"); assert_eq!(format!("{}", DeviceType::WifiP2P), "Wi-Fi P2P"); assert_eq!(format!("{}", DeviceType::Loopback), "Loopback"); + assert_eq!(format!("{}", DeviceType::Bluetooth), "Bluetooth"); assert_eq!(format!("{}", DeviceType::Vlan), "VLAN"); assert_eq!(format!("{}", DeviceType::Other(42)), "Other(42)"); } @@ -52,6 +63,7 @@ fn device_type_display_registry() { assert_eq!(format!("{}", DeviceType::Other(12)), "Bond"); assert_eq!(format!("{}", DeviceType::Other(11)), "VLAN"); assert_eq!(format!("{}", DeviceType::Other(16)), "TUN"); + assert_eq!(format!("{}", DeviceType::Other(20)), "Veth"); assert_eq!(format!("{}", DeviceType::Other(29)), "WireGuard"); } @@ -104,6 +116,7 @@ fn device_type_connection_type_str() { assert_eq!(DeviceType::Wifi.connection_type_str(), "802-11-wireless"); assert_eq!(DeviceType::WifiP2P.connection_type_str(), "wifi-p2p"); assert_eq!(DeviceType::Loopback.connection_type_str(), "loopback"); + assert_eq!(DeviceType::Bluetooth.connection_type_str(), "bluetooth"); assert_eq!(DeviceType::Vlan.connection_type_str(), "vlan"); } @@ -112,25 +125,20 @@ fn device_type_connection_type_str_registry() { assert_eq!(DeviceType::Other(13).connection_type_str(), "bridge"); assert_eq!(DeviceType::Other(12).connection_type_str(), "bond"); assert_eq!(DeviceType::Other(11).connection_type_str(), "vlan"); + assert_eq!( + DeviceType::Other(20).connection_type_str(), + "802-3-ethernet" + ); assert_eq!(DeviceType::Other(29).connection_type_str(), "wireguard"); } -#[test] -fn device_type_to_code() { - assert_eq!(DeviceType::Ethernet.to_code(), 1); - assert_eq!(DeviceType::Wifi.to_code(), 2); - assert_eq!(DeviceType::Vlan.to_code(), 11); - assert_eq!(DeviceType::WifiP2P.to_code(), 30); - assert_eq!(DeviceType::Loopback.to_code(), 32); - assert_eq!(DeviceType::Other(999).to_code(), 999); -} - #[test] fn device_type_to_code_registry() { assert_eq!(DeviceType::Other(11).to_code(), 11); assert_eq!(DeviceType::Other(12).to_code(), 12); assert_eq!(DeviceType::Other(13).to_code(), 13); assert_eq!(DeviceType::Other(16).to_code(), 16); + assert_eq!(DeviceType::Other(20).to_code(), 20); assert_eq!(DeviceType::Other(29).to_code(), 29); } @@ -535,8 +543,14 @@ fn test_bluetooth_identity_dun() { #[test] fn test_bluetooth_identity_creation_error() { - let res = BluetoothIdentity::new("SomeInvalidAddress".into(), BluetoothNetworkRole::Dun); - assert!(res.is_err()); + let error = + BluetoothIdentity::new("SomeInvalidAddress".into(), BluetoothNetworkRole::Dun).unwrap_err(); + assert!(matches!( + error, + ConnectionError::InvalidAddress(message) + if message + == "Invalid Bluetooth Address 'SomeInvalidAddress' (must have 6 segments)" + )); } #[test] @@ -553,7 +567,7 @@ fn test_bluetooth_device_creation() { assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); assert_eq!(device.name, Some("MyPhone".into())); assert_eq!(device.alias, Some("Phone".into())); - assert!(matches!(device.bt_caps, _role)); + assert_eq!(device.bt_caps, role); assert_eq!(device.state, DeviceState::Activated); } @@ -591,77 +605,43 @@ fn test_bluetooth_device_display_no_alias() { assert!(display_str.contains("DUN")); } -#[test] -fn test_device_is_bluetooth() { - let bt_device = Device { +fn device_with_type(device_type: DeviceType) -> Device { + Device { path: "/org/freedesktop/NetworkManager/Devices/1".into(), - interface: "bt0".into(), - identity: DeviceIdentity::new("00:1A:7D:DA:71:13".into(), "00:1A:7D:DA:71:13".into()), - device_type: DeviceType::Bluetooth, + interface: "test0".into(), + identity: DeviceIdentity::new("00:1A:7D:DA:71:13".into(), "test0".into()), + device_type, state: DeviceState::Activated, managed: Some(true), - driver: Some("btusb".into()), + driver: Some("test".into()), ip4_address: None, ip6_address: None, frequency: None, speed_mbps: None, - }; - - assert!(bt_device.is_bluetooth()); - assert!(!bt_device.is_wireless()); - assert!(!bt_device.is_wired()); -} - -#[test] -fn test_device_wired_speed_construction() { - let device = Device { - path: "/org/freedesktop/NetworkManager/Devices/2".into(), - interface: "eth0".into(), - identity: DeviceIdentity::new("00:11:22:33:44:55".into(), "00:11:22:33:44:55".into()), - device_type: DeviceType::Ethernet, - state: DeviceState::Activated, - managed: Some(true), - driver: Some("e1000e".into()), - ip4_address: Some("192.0.2.10/24".into()), - ip6_address: None, - frequency: None, - speed_mbps: Some(1000), - }; - - assert!(device.is_wired()); - assert_eq!(device.speed_mbps, Some(1000)); -} - -#[test] -fn test_wired_device_construction() { - let device = WiredDevice { - path: "/org/freedesktop/NetworkManager/Devices/2".into(), - interface: "eth0".into(), - hw_address: "00:11:22:33:44:55".into(), - permanent_hw_address: Some("00:11:22:33:44:55".into()), - speed_mbps: Some(0), - active_connection_id: Some("Wired connection 1".into()), - state: DeviceState::Disconnected, - ip4_address: None, - ip6_address: None, - }; - - assert_eq!(device.interface, "eth0"); - assert_eq!(device.speed_mbps, Some(0)); - assert_eq!( - device.active_connection_id.as_deref(), - Some("Wired connection 1") - ); + } } #[test] -fn test_device_type_bluetooth() { - assert_eq!(DeviceType::from(5), DeviceType::Bluetooth); -} +fn device_transport_predicates_are_mutually_exclusive() { + let cases = [ + (DeviceType::Ethernet, (true, false, false)), + (DeviceType::Other(20), (true, false, false)), + (DeviceType::Wifi, (false, true, false)), + (DeviceType::Bluetooth, (false, false, true)), + (DeviceType::Loopback, (false, false, false)), + ]; -#[test] -fn test_device_type_bluetooth_display() { - assert_eq!(format!("{}", DeviceType::Bluetooth), "Bluetooth"); + for (device_type, expected) in cases { + let device = device_with_type(device_type); + assert_eq!( + ( + device.is_wired(), + device.is_wireless(), + device.is_bluetooth(), + ), + expected + ); + } } #[test] @@ -670,6 +650,27 @@ fn test_connection_error_no_bluetooth_device() { assert_eq!(format!("{}", err), "Bluetooth device not found"); } +fn assert_wireguard_peer( + peer: &WireGuardPeer, + public_key: &str, + gateway: &str, + allowed_ips: &[&str], + preshared_key: Option<&str>, + persistent_keepalive: Option, +) { + assert_eq!(peer.public_key, public_key); + assert_eq!(peer.gateway, gateway); + assert_eq!( + peer.allowed_ips, + allowed_ips + .iter() + .map(|address| (*address).to_string()) + .collect::>() + ); + assert_eq!(peer.preshared_key.as_deref(), preshared_key); + assert_eq!(peer.persistent_keepalive, persistent_keepalive); +} + #[test] fn test_vpn_credentials_builder_basic() { let peer = WireGuardPeer::new( @@ -697,6 +698,14 @@ fn test_vpn_credentials_builder_basic() { ); assert_eq!(creds.address, "10.0.0.2/24"); assert_eq!(creds.peers.len(), 1); + assert_wireguard_peer( + &creds.peers[0], + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + &["0.0.0.0/0"], + None, + None, + ); assert!(creds.dns.is_none()); assert!(creds.mtu.is_none()); } @@ -725,6 +734,14 @@ fn test_wireguard_config_basic() { ); assert_eq!(config.address, "10.0.0.2/24"); assert_eq!(config.peers.len(), 1); + assert_wireguard_peer( + &config.peers[0], + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + &["0.0.0.0/0"], + None, + None, + ); assert!(config.dns.is_none()); assert!(config.mtu.is_none()); } @@ -761,19 +778,25 @@ fn test_wireguard_config_implements_vpn_config() { #[test] fn test_wireguard_config_roundtrips_through_vpn_credentials() { + let uuid = Uuid::new_v4(); let config = WireGuardConfig::new( "TestVPN", "vpn.example.com:51820", "private_key", "10.0.0.2/24", - vec![WireGuardPeer::new( - "public_key", - "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], - )], + vec![ + WireGuardPeer::new( + "public_key", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into(), "10.0.0.0/8".into()], + ) + .with_preshared_key("preshared_key") + .with_persistent_keepalive(25), + ], ) .with_dns(vec!["1.1.1.1".into()]) - .with_mtu(1420); + .with_mtu(1420) + .with_uuid(uuid); let legacy: VpnCredentials = config.clone().into(); let roundtrip = WireGuardConfig::from(legacy); @@ -782,9 +805,18 @@ fn test_wireguard_config_roundtrips_through_vpn_credentials() { assert_eq!(roundtrip.gateway, config.gateway); assert_eq!(roundtrip.private_key, config.private_key); assert_eq!(roundtrip.address, config.address); - assert_eq!(roundtrip.peers.len(), config.peers.len()); + assert_eq!(roundtrip.peers.len(), 1); + assert_wireguard_peer( + &roundtrip.peers[0], + "public_key", + "vpn.example.com:51820", + &["0.0.0.0/0", "10.0.0.0/8"], + Some("preshared_key"), + Some(25), + ); assert_eq!(roundtrip.dns, config.dns); assert_eq!(roundtrip.mtu, config.mtu); + assert_eq!(roundtrip.uuid, Some(uuid)); } #[test] @@ -816,12 +848,18 @@ fn test_vpn_credentials_builder_with_optionals() { #[test] fn test_vpn_credentials_builder_multiple_peers() { - let peer1 = WireGuardPeer::new("key1", "vpn1.example.com:51820", vec!["10.0.0.0/24".into()]); + let peer1 = WireGuardPeer::new( + "key1", + "vpn1.example.com:51820", + vec!["10.0.0.0/24".into(), "10.0.1.0/24".into()], + ) + .with_persistent_keepalive(15); let peer2 = WireGuardPeer::new( "key2", "vpn2.example.com:51820", vec!["192.168.0.0/24".into()], - ); + ) + .with_preshared_key("peer2-psk"); let creds = VpnCredentials::builder() .name("MultiPeerVPN") @@ -835,13 +873,31 @@ fn test_vpn_credentials_builder_multiple_peers() { .unwrap(); assert_eq!(creds.peers.len(), 2); + assert_wireguard_peer( + &creds.peers[0], + "key1", + "vpn1.example.com:51820", + &["10.0.0.0/24", "10.0.1.0/24"], + None, + Some(15), + ); + assert_wireguard_peer( + &creds.peers[1], + "key2", + "vpn2.example.com:51820", + &["192.168.0.0/24"], + Some("peer2-psk"), + None, + ); } #[test] fn test_vpn_credentials_builder_peers_method() { let peers = vec![ - WireGuardPeer::new("key1", "vpn1.example.com:51820", vec!["0.0.0.0/0".into()]), - WireGuardPeer::new("key2", "vpn2.example.com:51820", vec!["0.0.0.0/0".into()]), + WireGuardPeer::new("key1", "vpn1.example.com:51820", vec!["0.0.0.0/0".into()]) + .with_persistent_keepalive(20), + WireGuardPeer::new("key2", "vpn2.example.com:51821", vec!["::/0".into()]) + .with_preshared_key("key2-psk"), ]; let creds = VpnCredentials::builder() @@ -855,6 +911,22 @@ fn test_vpn_credentials_builder_peers_method() { .unwrap(); assert_eq!(creds.peers.len(), 2); + assert_wireguard_peer( + &creds.peers[0], + "key1", + "vpn1.example.com:51820", + &["0.0.0.0/0"], + None, + Some(20), + ); + assert_wireguard_peer( + &creds.peers[1], + "key2", + "vpn2.example.com:51821", + &["::/0"], + Some("key2-psk"), + None, + ); } #[test] @@ -869,7 +941,11 @@ fn test_vpn_credentials_builder_missing_name() { .add_peer(peer) .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); + assert!(matches!( + err, + ConnectionError::IncompleteBuilder(message) + if message == "connection name is required (use .name())" + )); } #[test] @@ -884,7 +960,11 @@ fn test_vpn_credentials_builder_missing_vpn_type() { .add_peer(peer) .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); + assert!(matches!( + err, + ConnectionError::IncompleteBuilder(message) + if message == "VPN type is required (use .wireguard())" + )); } #[test] @@ -897,7 +977,11 @@ fn test_vpn_credentials_builder_missing_peers() { .address("10.0.0.2/24") .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::InvalidPeers(_))); + assert!(matches!( + err, + ConnectionError::InvalidPeers(message) + if message == "at least one peer is required (use .add_peer())" + )); } #[test] @@ -1046,25 +1130,7 @@ fn test_eap_options_builder_tls_missing_client_cert() { } #[test] -fn test_eap_options_builder_path_blob_ca_cert_path() { - let opts = EapOptions::builder() - .identity("student@university.edu") - .method(EapMethod::Tls) - .ca_cert_path("file:///etc/ssl/certs/ca.pem") - .ca_cert_blob(vec![1]) - .private_key_path("file:///etc/ssl/private/client.key") - .private_key_password("password") - .client_cert_path("file:///etc/ssl/certs/client.pem") - .build() - .unwrap(); - - assert_eq!(opts.method, EapMethod::Tls); - assert_eq!(opts.ca_cert_path, None); - assert_eq!(opts.ca_cert_blob, Some(vec![1])); -} - -#[test] -fn test_eap_options_builder_path_blob_ca_cert() { +fn test_eap_options_builder_ca_cert_blob_overrides_path() { let opts = EapOptions::builder() .identity("student@university.edu") .method(EapMethod::Tls) @@ -1125,7 +1191,11 @@ fn test_eap_options_builder_missing_identity() { .phase2(Phase2::Mschapv2) .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); + assert!(matches!( + err, + ConnectionError::IncompleteBuilder(message) + if message == "EAP identity is required (use .identity())" + )); } #[test] @@ -1136,7 +1206,11 @@ fn test_eap_options_builder_missing_password() { .phase2(Phase2::Mschapv2) .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); + assert!(matches!( + err, + ConnectionError::IncompleteBuilder(message) + if message == "EAP password is required (use .password())" + )); } #[test] @@ -1147,7 +1221,11 @@ fn test_eap_options_builder_missing_method() { .phase2(Phase2::Mschapv2) .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); + assert!(matches!( + err, + ConnectionError::IncompleteBuilder(message) + if message == "EAP method is required (use .method())" + )); } #[test] @@ -1158,7 +1236,11 @@ fn test_eap_options_builder_missing_phase2() { .method(EapMethod::Peap) .build() .unwrap_err(); - assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); + assert!(matches!( + err, + ConnectionError::IncompleteBuilder(message) + if message == "EAP phase 2 method is required (use .phase2())" + )); } #[test] @@ -1213,7 +1295,27 @@ fn test_vpn_credentials_builder_equivalence_to_new() { assert_eq!(creds_new.gateway, creds_builder.gateway); assert_eq!(creds_new.private_key, creds_builder.private_key); assert_eq!(creds_new.address, creds_builder.address); - assert_eq!(creds_new.peers.len(), creds_builder.peers.len()); + assert_eq!(creds_new.peers.len(), 1); + assert_eq!(creds_builder.peers.len(), 1); + assert_wireguard_peer( + &creds_new.peers[0], + "public_key", + "vpn.example.com:51820", + &["0.0.0.0/0"], + None, + None, + ); + assert_wireguard_peer( + &creds_builder.peers[0], + "public_key", + "vpn.example.com:51820", + &["0.0.0.0/0"], + None, + None, + ); + assert_eq!(creds_new.dns, creds_builder.dns); + assert_eq!(creds_new.mtu, creds_builder.mtu); + assert_eq!(creds_new.uuid, creds_builder.uuid); } #[test] @@ -1224,38 +1326,8 @@ fn test_timeout_config_default() { } #[test] -fn test_timeout_config_new() { - let config = TimeoutConfig::new(); - assert_eq!(config.connection_timeout, Duration::from_secs(30)); - assert_eq!(config.disconnect_timeout, Duration::from_secs(10)); -} - -#[test] -fn test_timeout_config_with_connection_timeout() { - let config = TimeoutConfig::new().with_connection_timeout(Duration::from_secs(60)); - assert_eq!(config.connection_timeout, Duration::from_secs(60)); - assert_eq!(config.disconnect_timeout, Duration::from_secs(10)); -} - -#[test] -fn test_timeout_config_with_disconnect_timeout() { - let config = TimeoutConfig::new().with_disconnect_timeout(Duration::from_secs(20)); - assert_eq!(config.connection_timeout, Duration::from_secs(30)); - assert_eq!(config.disconnect_timeout, Duration::from_secs(20)); -} - -#[test] -fn test_timeout_config_with_both_timeouts() { +fn test_timeout_config_setters_compose_and_last_value_wins() { let config = TimeoutConfig::new() - .with_connection_timeout(Duration::from_secs(90)) - .with_disconnect_timeout(Duration::from_secs(30)); - assert_eq!(config.connection_timeout, Duration::from_secs(90)); - assert_eq!(config.disconnect_timeout, Duration::from_secs(30)); -} - -#[test] -fn test_timeout_config_chaining() { - let config = TimeoutConfig::default() .with_connection_timeout(Duration::from_secs(45)) .with_disconnect_timeout(Duration::from_secs(15)) .with_connection_timeout(Duration::from_secs(60)); @@ -1264,15 +1336,6 @@ fn test_timeout_config_chaining() { assert_eq!(config.disconnect_timeout, Duration::from_secs(15)); } -#[test] -fn test_timeout_config_copy() { - let config1 = TimeoutConfig::new().with_connection_timeout(Duration::from_secs(120)); - let config2 = config1; - - assert_eq!(config1.connection_timeout, Duration::from_secs(120)); - assert_eq!(config2.connection_timeout, Duration::from_secs(120)); -} - #[test] fn test_device_state_is_transitional() { let transitional = [ diff --git a/nmrs/src/api/models/vlan.rs b/nmrs/src/api/models/vlan.rs index 752cce2e..4ac3ae46 100644 --- a/nmrs/src/api/models/vlan.rs +++ b/nmrs/src/api/models/vlan.rs @@ -305,30 +305,40 @@ mod tests { #[test] fn validate_rejects_zero_id() { let config = VlanConfig::new("eth0", 0); - assert!(config.validate().is_err()); + assert!(matches!( + config.validate().unwrap_err(), + ConnectionError::InvalidVlanId { id: 0 } + )); } #[test] fn validate_rejects_id_over_4094() { let config = VlanConfig::new("eth0", 4095); - assert!(config.validate().is_err()); + assert!(matches!( + config.validate().unwrap_err(), + ConnectionError::InvalidVlanId { id: 4095 } + )); } #[test] fn validate_rejects_empty_parent() { let config = VlanConfig::new("", 100); - assert!(config.validate().is_err()); + assert!(matches!( + config.validate().unwrap_err(), + ConnectionError::InvalidInput { field, reason } + if field == "parent" && reason == "parent interface name cannot be empty" + )); } #[test] fn validate_accepts_valid_config() { let config = VlanConfig::new("eth0", 100); - assert!(config.validate().is_ok()); + config.validate().unwrap(); let config = VlanConfig::new("eth0", 1); - assert!(config.validate().is_ok()); + config.validate().unwrap(); let config = VlanConfig::new("eth0", 4094); - assert!(config.validate().is_ok()); + config.validate().unwrap(); } } diff --git a/nmrs/src/api/models/wifi.rs b/nmrs/src/api/models/wifi.rs index 371b31ca..f4bd4c79 100644 --- a/nmrs/src/api/models/wifi.rs +++ b/nmrs/src/api/models/wifi.rs @@ -959,10 +959,15 @@ impl Network { /// this method keeps the strongest signal and combines security flags. /// Used internally during network scanning to deduplicate results. pub fn merge_ap(&mut self, other: &Network) { - if let Some(ref b) = other.bssid - && !self.bssids.contains(b) - { - self.bssids.push(b.clone()); + let mut bssids = self.bssids.clone(); + if let Some(bssid) = &self.bssid { + push_unique_bssid(&mut bssids, bssid); + } + for bssid in &other.bssids { + push_unique_bssid(&mut bssids, bssid); + } + if let Some(bssid) = &other.bssid { + push_unique_bssid(&mut bssids, bssid); } if other.strength.unwrap_or(0) > self.strength.unwrap_or(0) { @@ -970,9 +975,16 @@ impl Network { self.frequency = other.frequency; self.bssid = other.bssid.clone(); self.best_bssid = other.best_bssid.clone(); - self.security_features = other.security_features; } + if let Some(best_bssid) = &self.bssid { + bssids.retain(|bssid| !bssid.eq_ignore_ascii_case(best_bssid)); + bssids.insert(0, best_bssid.clone()); + } + self.bssids = bssids; + + merge_security_features(&mut self.security_features, other.security_features); + self.secured |= other.secured; self.is_psk |= other.is_psk; self.is_eap |= other.is_eap; @@ -992,9 +1004,54 @@ impl Network { } } +fn push_unique_bssid(bssids: &mut Vec, candidate: &str) { + if !bssids + .iter() + .any(|bssid| bssid.eq_ignore_ascii_case(candidate)) + { + bssids.push(candidate.to_string()); + } +} + +fn merge_security_features(current: &mut SecurityFeatures, other: SecurityFeatures) { + current.privacy |= other.privacy; + current.wps |= other.wps; + current.psk |= other.psk; + current.eap |= other.eap; + current.sae |= other.sae; + current.owe |= other.owe; + current.owe_transition_mode |= other.owe_transition_mode; + current.eap_suite_b_192 |= other.eap_suite_b_192; + current.wep40 |= other.wep40; + current.wep104 |= other.wep104; + current.tkip |= other.tkip; + current.ccmp |= other.ccmp; +} + #[cfg(test)] mod network_merge_tests { - use super::Network; + use super::{Network, SecurityFeatures}; + + fn network(bssid: &str, strength: u8) -> Network { + Network { + device: "wlan0".into(), + ssid: "net".into(), + bssid: Some(bssid.into()), + strength: Some(strength), + frequency: Some(2412), + secured: false, + is_psk: false, + is_eap: false, + is_hotspot: false, + ip4_address: None, + ip6_address: None, + best_bssid: bssid.into(), + bssids: vec![bssid.into()], + is_active: false, + known: false, + security_features: SecurityFeatures::default(), + } + } #[test] fn merge_ap_keeps_ip_and_device_when_stronger_ap_has_none() { @@ -1016,7 +1073,9 @@ mod network_merge_tests { known: false, security_features: Default::default(), }; - let stronger = Network { + weaker_connected.security_features.psk = true; + weaker_connected.security_features.ccmp = true; + let mut stronger = Network { device: String::new(), ssid: "net".into(), bssid: Some("bb:bb:bb:bb:bb:bb".into()), @@ -1034,6 +1093,8 @@ mod network_merge_tests { known: false, security_features: Default::default(), }; + stronger.security_features.eap = true; + stronger.security_features.sae = true; weaker_connected.merge_ap(&stronger); assert_eq!(weaker_connected.strength, Some(90)); assert_eq!(weaker_connected.bssid, Some("bb:bb:bb:bb:bb:bb".into())); @@ -1042,6 +1103,88 @@ mod network_merge_tests { assert_eq!(weaker_connected.ip6_address, Some("fe80::1/64".into())); assert_eq!(weaker_connected.device, "wlan0"); assert!(weaker_connected.is_active); - assert_eq!(weaker_connected.bssids.len(), 2); + assert!(weaker_connected.security_features.psk); + assert!(weaker_connected.security_features.ccmp); + assert!(weaker_connected.security_features.eap); + assert!(weaker_connected.security_features.sae); + assert_eq!( + weaker_connected.bssids, + vec![ + "bb:bb:bb:bb:bb:bb".to_string(), + "aa:aa:aa:aa:aa:aa".to_string() + ] + ); + } + + #[test] + fn merge_ap_combines_flags_security_and_all_unique_bssids() { + let mut strongest = network("AA:AA:AA:AA:AA:01", 90); + strongest.frequency = Some(5180); + strongest.secured = true; + strongest.is_psk = true; + strongest.security_features.psk = true; + strongest.security_features.ccmp = true; + + let mut weaker = network("BB:BB:BB:BB:BB:01", 30); + weaker.frequency = Some(2412); + weaker.is_eap = true; + weaker.is_hotspot = true; + weaker.known = true; + weaker.bssids = vec![ + "BB:BB:BB:BB:BB:01".into(), + "CC:CC:CC:CC:CC:01".into(), + "aa:aa:aa:aa:aa:01".into(), + ]; + weaker.security_features.eap = true; + weaker.security_features.sae = true; + weaker.security_features.wps = true; + + strongest.merge_ap(&weaker); + + assert_eq!(strongest.strength, Some(90)); + assert_eq!(strongest.frequency, Some(5180)); + assert_eq!(strongest.bssid.as_deref(), Some("AA:AA:AA:AA:AA:01")); + assert_eq!( + strongest.bssids, + vec![ + "AA:AA:AA:AA:AA:01".to_string(), + "BB:BB:BB:BB:BB:01".to_string(), + "CC:CC:CC:CC:CC:01".to_string(), + ] + ); + assert!(strongest.secured); + assert!(strongest.is_psk); + assert!(strongest.is_eap); + assert!(strongest.is_hotspot); + assert!(strongest.known); + assert!(strongest.security_features.psk); + assert!(strongest.security_features.eap); + assert!(strongest.security_features.sae); + assert!(strongest.security_features.wps); + assert!(strongest.security_features.ccmp); + } + + #[test] + fn merge_ap_fills_missing_connection_context() { + let mut network_without_context = network("AA:AA:AA:AA:AA:01", 80); + network_without_context.device.clear(); + let mut connected = network("BB:BB:BB:BB:BB:01", 40); + connected.device = "wlan1".into(); + connected.ip4_address = Some("192.168.50.5/24".into()); + connected.ip6_address = Some("2001:db8::5/64".into()); + connected.is_active = true; + + network_without_context.merge_ap(&connected); + + assert_eq!(network_without_context.device, "wlan1"); + assert_eq!( + network_without_context.ip4_address.as_deref(), + Some("192.168.50.5/24") + ); + assert_eq!( + network_without_context.ip6_address.as_deref(), + Some("2001:db8::5/64") + ); + assert!(network_without_context.is_active); } } diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 9e43da96..9b88df48 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::{Mutex, watch}; +use tokio::sync::{Mutex, oneshot, watch}; use zbus::Connection; use zvariant::OwnedValue; @@ -12,9 +12,9 @@ use crate::api::models::snapshot::{ saved_wifi_profiles as filter_saved_wifi_profiles, }; use crate::api::models::{ - ActiveConnection, AirplaneModeState, Device, MonitorHandle, Network, NetworkInfo, - NetworkSnapshot, RadioState, SavedConnection, SavedConnectionBrief, SettingsPatch, WifiDevice, - WifiSecurity, WiredDevice, + ActiveConnection, AirplaneModeState, ConnectionError, Device, MonitorHandle, Network, + NetworkInfo, NetworkSnapshot, RadioState, SavedConnection, SavedConnectionBrief, SettingsPatch, + WifiDevice, WifiSecurity, WiredDevice, }; use crate::api::wifi_scope::WifiScope; use crate::core::active_connection as active_connections; @@ -1541,10 +1541,16 @@ impl NetworkManager { F: Fn() + Send + 'static, { let (tx, rx) = watch::channel(()); + let (ready_tx, ready_rx) = oneshot::channel(); let conn = self.conn.clone(); let task = tokio::spawn(async move { - network_monitor::monitor_network_changes(&conn, rx, callback).await + network_monitor::monitor_network_changes(&conn, rx, callback, ready_tx).await }); + + ready_rx.await.map_err(|_| { + ConnectionError::Stuck("network monitor task ended before becoming ready".into()) + })??; + Ok(MonitorHandle::new(tx, task)) } @@ -1602,10 +1608,16 @@ impl NetworkManager { F: Fn() + Send + 'static, { let (tx, rx) = watch::channel(()); + let (ready_tx, ready_rx) = oneshot::channel(); let conn = self.conn.clone(); let task = tokio::spawn(async move { - device_monitor::monitor_device_changes(&conn, rx, callback).await + device_monitor::monitor_device_changes(&conn, rx, callback, ready_tx).await }); + + ready_rx.await.map_err(|_| { + ConnectionError::Stuck("device monitor task ended before becoming ready".into()) + })??; + Ok(MonitorHandle::new(tx, task)) } } diff --git a/nmrs/src/core/active_connection.rs b/nmrs/src/core/active_connection.rs index 31451fdc..25b7143d 100644 --- a/nmrs/src/core/active_connection.rs +++ b/nmrs/src/core/active_connection.rs @@ -2,10 +2,11 @@ use std::collections::HashMap; +use log::debug; use zbus::Connection; +use zbus::proxy::CacheProperties; use zvariant::{OwnedObjectPath, OwnedValue, Str}; -use crate::Result; use crate::api::models::{ ActiveConnection, ActiveConnectionState, ActiveOtherConnection, ActiveVpnConnection, ActiveWifiConnection, ActiveWiredConnection, @@ -15,7 +16,9 @@ use crate::dbus::{ NMWiredProxy, NMWirelessProxy, }; use crate::types::constants::device_type; +use crate::types::device_type_registry; use crate::util::utils::{decode_ssid_or_hidden, get_ip_addresses_from_active_connection}; +use crate::{ConnectionError, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ActiveConnectionKind { @@ -38,22 +41,90 @@ struct ActiveConnectionBase { /// Lists active NetworkManager connections classified into public model types. pub(crate) async fn list_active_connections(conn: &Connection) -> Result> { - let nm = NMProxy::new(conn).await?; + // The second read confirms disappeared paths, so ActiveConnections must not come from cache. + let nm = NMProxy::builder(conn) + .cache_properties(CacheProperties::No) + .build() + .await?; let active_paths = nm.active_connections().await?; let mut active_connections = Vec::new(); for active_path in active_paths { - let active = NMActiveConnectionProxy::builder(conn) - .path(active_path.clone())? - .build() - .await?; - let base = active_connection_base(conn, &active_path, &active).await?; - active_connections.push(classify_active_connection(conn, base).await?); + match active_connection_for_path(conn, &active_path).await { + Ok(active_connection) => active_connections.push(active_connection), + Err(error) => { + if failed_active_path_vanished(&nm, &active_path, &error).await? { + debug!("active connection {active_path} vanished while it was being read"); + continue; + } + + return Err(error); + } + } } Ok(active_connections) } +async fn active_connection_for_path( + conn: &Connection, + active_path: &OwnedObjectPath, +) -> Result { + let active = NMActiveConnectionProxy::builder(conn) + .path(active_path.clone())? + .build() + .await?; + let base = active_connection_base(conn, active_path, &active).await?; + classify_active_connection(conn, base).await +} + +async fn failed_active_path_vanished( + nm: &NMProxy<'_>, + active_path: &OwnedObjectPath, + error: &ConnectionError, +) -> Result { + if !is_missing_dbus_object_error(error) { + return Ok(false); + } + + let current_paths = nm.active_connections().await?; + Ok(active_path_is_absent(active_path, ¤t_paths)) +} + +fn is_missing_dbus_object_error(error: &ConnectionError) -> bool { + let dbus_error = match error { + ConnectionError::Dbus(error) => error, + ConnectionError::DbusOperation { source, .. } => source, + _ => return false, + }; + + match dbus_error { + zbus::Error::MethodError(name, _, _) => is_missing_dbus_object_error_name(name.as_str()), + zbus::Error::FDO(error) => matches!( + error.as_ref(), + zbus::fdo::Error::UnknownMethod(_) + | zbus::fdo::Error::UnknownObject(_) + | zbus::fdo::Error::UnknownInterface(_) + | zbus::fdo::Error::UnknownProperty(_) + ), + _ => false, + } +} + +fn is_missing_dbus_object_error_name(name: &str) -> bool { + matches!( + name, + "org.freedesktop.DBus.Error.UnknownMethod" + | "org.freedesktop.DBus.Error.UnknownObject" + | "org.freedesktop.DBus.Error.UnknownInterface" + | "org.freedesktop.DBus.Error.UnknownProperty" + ) +} + +fn active_path_is_absent(active_path: &OwnedObjectPath, current_paths: &[OwnedObjectPath]) -> bool { + !current_paths.contains(active_path) +} + async fn active_connection_base( conn: &Connection, active_path: &OwnedObjectPath, @@ -297,7 +368,7 @@ fn active_connection_kind( connection_type: Option<&str>, ) -> ActiveConnectionKind { match raw_device_type { - Some(device_type::ETHERNET) => ActiveConnectionKind::Wired, + Some(raw_type) if device_type_registry::is_wired(raw_type) => ActiveConnectionKind::Wired, Some(device_type::WIFI) => ActiveConnectionKind::Wifi, _ if matches!(connection_type, Some("vpn" | "wireguard")) => ActiveConnectionKind::Vpn, _ => ActiveConnectionKind::Other, @@ -315,6 +386,10 @@ mod tests { active_connection_kind(Some(device_type::ETHERNET), Some("vpn")), ActiveConnectionKind::Wired ); + assert_eq!( + active_connection_kind(Some(device_type::VETH), Some("802-3-ethernet")), + ActiveConnectionKind::Wired + ); assert_eq!( active_connection_kind(Some(device_type::WIFI), Some("vpn")), ActiveConnectionKind::Wifi @@ -357,4 +432,67 @@ mod tests { Some("vpn") ); } + + #[test] + fn recognizes_only_dbus_errors_that_can_report_a_vanished_object() { + for name in [ + "org.freedesktop.DBus.Error.UnknownMethod", + "org.freedesktop.DBus.Error.UnknownObject", + "org.freedesktop.DBus.Error.UnknownInterface", + "org.freedesktop.DBus.Error.UnknownProperty", + ] { + assert!(is_missing_dbus_object_error_name(name), "{name}"); + } + + for name in [ + "org.freedesktop.DBus.Error.AccessDenied", + "org.freedesktop.DBus.Error.NoReply", + "org.freedesktop.NetworkManager.UnknownConnection", + "UnknownMethod", + ] { + assert!(!is_missing_dbus_object_error_name(name), "{name}"); + } + } + + #[test] + fn recognizes_fdo_missing_object_errors_in_both_dbus_wrappers() { + for error in [ + zbus::fdo::Error::UnknownMethod("gone".into()), + zbus::fdo::Error::UnknownObject("gone".into()), + zbus::fdo::Error::UnknownInterface("gone".into()), + zbus::fdo::Error::UnknownProperty("gone".into()), + ] { + let error = ConnectionError::Dbus(zbus::Error::FDO(Box::new(error))); + assert!(is_missing_dbus_object_error(&error)); + } + + let operation_error = ConnectionError::DbusOperation { + context: "reading active connection".into(), + source: zbus::Error::FDO(Box::new(zbus::fdo::Error::UnknownObject("gone".into()))), + }; + assert!(is_missing_dbus_object_error(&operation_error)); + + let permission_error = ConnectionError::Dbus(zbus::Error::FDO(Box::new( + zbus::fdo::Error::AccessDenied("denied".into()), + ))); + assert!(!is_missing_dbus_object_error(&permission_error)); + assert!(!is_missing_dbus_object_error(&ConnectionError::Timeout)); + } + + #[test] + fn vanished_path_requires_absence_from_the_refreshed_snapshot() { + let failed_path = + OwnedObjectPath::try_from("/org/freedesktop/NetworkManager/ActiveConnection/7") + .expect("valid object path"); + let other_path = + OwnedObjectPath::try_from("/org/freedesktop/NetworkManager/ActiveConnection/8") + .expect("valid object path"); + + assert!(!active_path_is_absent( + &failed_path, + &[failed_path.clone(), other_path.clone()] + )); + assert!(active_path_is_absent(&failed_path, &[other_path])); + assert!(active_path_is_absent(&failed_path, &[])); + } } diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs index 7ba1a7c2..322ee09b 100644 --- a/nmrs/src/core/airplane.rs +++ b/nmrs/src/core/airplane.rs @@ -419,7 +419,7 @@ async fn wait_for_powered_no_timeout(proxy: &BluezAdapterProxy<'_>, target: bool #[cfg(test)] mod tests { - use super::finalize_airplane_toggle_results; + use super::{finalize_airplane_toggle_results, reconcile_hardware}; use crate::ConnectionError; #[test] @@ -433,7 +433,7 @@ mod tests { true, ); - assert!(result.is_ok()); + assert!(matches!(result, Ok(()))); } #[test] @@ -449,7 +449,8 @@ mod tests { assert!(matches!( result, - Err(ConnectionError::BluetoothToggleFailed(_)) + Err(ConnectionError::BluetoothToggleFailed(message)) + if message == "adapter did not settle" )); } @@ -464,7 +465,7 @@ mod tests { false, ); - assert!(result.is_ok()); + assert!(matches!(result, Ok(()))); } #[test] @@ -479,6 +480,67 @@ mod tests { true, ); - assert!(matches!(result, Err(ConnectionError::InvalidInput { .. }))); + assert!(matches!( + result, + Err(ConnectionError::InvalidInput { field, reason }) + if field == "bluetooth" && reason == "unexpected failure" + )); + } + + #[test] + fn aggregate_toggle_propagates_wifi_before_other_results() { + let result = finalize_airplane_toggle_results( + Err(ConnectionError::InvalidInput { + field: "wifi".into(), + reason: "write failed".into(), + }), + Err(ConnectionError::InvalidInput { + field: "wwan".into(), + reason: "write failed".into(), + }), + Ok(()), + true, + ); + + assert!(matches!( + result, + Err(ConnectionError::InvalidInput { field, reason }) + if field == "wifi" && reason == "write failed" + )); + } + + #[test] + fn aggregate_toggle_propagates_wwan_when_wifi_succeeds() { + let result = finalize_airplane_toggle_results( + Ok(()), + Err(ConnectionError::InvalidInput { + field: "wwan".into(), + reason: "write failed".into(), + }), + Ok(()), + true, + ); + + assert!(matches!( + result, + Err(ConnectionError::InvalidInput { field, reason }) + if field == "wwan" && reason == "write failed" + )); + } + + #[test] + fn aggregate_toggle_succeeds_when_every_toggle_succeeds() { + assert!(matches!( + finalize_airplane_toggle_results(Ok(()), Ok(()), Ok(()), false), + Ok(()) + )); + } + + #[test] + fn hardware_reconciliation_trusts_any_disabled_source() { + assert!(reconcile_hardware(true, false, "wifi")); + assert!(!reconcile_hardware(true, true, "wifi")); + assert!(!reconcile_hardware(false, false, "wifi")); + assert!(!reconcile_hardware(false, true, "wifi")); } } diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 781a4fea..f39ab4f4 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -235,71 +235,3 @@ pub(crate) async fn disconnect_bluetooth_and_wait( Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::BluetoothNetworkRole; - - #[test] - fn test_bluez_path_format_default_adapter() { - assert_eq!( - bluez_device_path("00:1A:7D:DA:71:13", None), - "/org/bluez/hci0/dev_00_1A_7D_DA_71_13" - ); - } - - #[test] - fn test_bluez_path_format_specific_adapter() { - assert_eq!( - bluez_device_path("00:1A:7D:DA:71:13", Some("hci1")), - "/org/bluez/hci1/dev_00_1A_7D_DA_71_13" - ); - } - - #[test] - fn test_bluez_path_format_various_addresses() { - let test_cases = [ - ("AA:BB:CC:DD:EE:FF", "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), - ("00:00:00:00:00:00", "/org/bluez/hci0/dev_00_00_00_00_00_00"), - ("C8:1F:E8:F0:51:57", "/org/bluez/hci0/dev_C8_1F_E8_F0_51_57"), - ]; - - for (bdaddr, expected) in test_cases { - assert_eq!( - bluez_device_path(bdaddr, None), - expected, - "Failed for bdaddr: {bdaddr}" - ); - } - } - - #[test] - fn test_bluetooth_identity_structure() { - let identity = - BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU).unwrap(); - - assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); - assert_eq!(identity.adapter, None); - assert!(matches!( - identity.bt_device_type, - BluetoothNetworkRole::PanU - )); - } - - #[test] - fn test_bluetooth_identity_with_adapter() { - let identity = BluetoothIdentity::with_adapter( - "00:1A:7D:DA:71:13".into(), - BluetoothNetworkRole::PanU, - "hci1".into(), - ) - .unwrap(); - - assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); - assert_eq!(identity.adapter, Some("hci1".into())); - } - - // Note: Most of the core connection functions require a real D-Bus connection - // and NetworkManager running, so they are better suited for integration tests. -} diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index f4afe7cb..bc393155 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -14,14 +14,16 @@ use crate::monitoring::info::current_ssid; use crate::monitoring::transport::ActiveTransport; use crate::monitoring::wifi::Wifi; use crate::types::constants::{device_state, device_type, timeouts}; +use crate::types::device_type_registry; use crate::util::utils::{decode_ssid_or_empty, nm_proxy}; use crate::util::validation::{validate_bssid, validate_ssid, validate_wifi_security}; /// Decision on whether to reuse a saved connection or create a fresh one. +#[derive(Debug, PartialEq, Eq)] enum SavedDecision { /// Reuse the saved connection at this path. UseSaved(OwnedObjectPath), - /// Delete any saved connection and create a new one with fresh credentials. + /// Create a new connection profile using the supplied credentials. RebuildFresh, } @@ -34,8 +36,10 @@ enum SavedDecision { /// 4. Either activate the saved connection or create and activate a new one /// 5. Wait for the connection to reach the activated state /// -/// If a saved connection exists but fails, it will be deleted and a fresh -/// connection will be attempted with the provided credentials. +/// If a saved connection exists but fails, it is deleted and a fresh +/// connection is attempted only when the caller supplied usable fallback +/// settings. An empty PSK requests stored credentials, so a failed saved +/// profile is preserved and its activation error is returned. pub(crate) async fn connect( conn: &Connection, ssid: &str, @@ -89,6 +93,7 @@ pub(crate) async fn connect( &nm, &wifi_device, &specific_object, + ssid, &creds, saved, timeout_config, @@ -477,7 +482,11 @@ async fn find_device_by_type( .path(dp.clone())? .build() .await?; - if dev.device_type().await? == device_type_id { + if device_matches_type( + dev.device_type().await?, + dev.managed().await?, + device_type_id, + ) { return Ok(dp); } } @@ -489,6 +498,13 @@ async fn find_device_by_type( } } +fn device_matches_type(actual_type: u32, managed: bool, expected_type: u32) -> bool { + managed + && (actual_type == expected_type + || (expected_type == device_type::ETHERNET + && device_type_registry::is_wired(actual_type))) +} + pub(crate) async fn find_wired_device( conn: &Connection, nm: &NMProxy<'_>, @@ -659,6 +675,7 @@ pub(crate) async fn connect_to_bssid( &nm, &wifi_device, &specific_object, + ssid, &creds, saved, timeout_config, @@ -701,8 +718,9 @@ async fn ensure_disconnected( /// /// Activates the saved connection and monitors the activation state using /// D-Bus signals. If activation fails (device disconnects or enters failed -/// state), deletes the saved connection and creates a fresh one with the -/// provided credentials. +/// state), deletes the saved connection and creates a fresh one when the +/// provided settings can stand alone. A request to use a stored PSK has no +/// usable fallback, so the profile is preserved and the failure is returned. /// /// This handles cases where saved passwords are outdated or corrupted. async fn connect_via_saved( @@ -710,6 +728,7 @@ async fn connect_via_saved( nm: &NMProxy<'_>, wifi_device: &OwnedObjectPath, ap: &OwnedObjectPath, + ssid: &str, creds: &WifiSecurity, saved: OwnedObjectPath, timeout_config: Option, @@ -734,6 +753,12 @@ async fn connect_via_saved( } Err(e) => { warn!("Saved connection activation failed: {e}"); + + if !can_rebuild_after_saved_failure(creds) { + warn!("No fresh credentials were supplied; preserving the saved profile"); + return Err(e); + } + warn!("Deleting saved connection and retrying with fresh credentials"); match nm.deactivate_connection(active_conn.clone()).await { @@ -751,10 +776,10 @@ async fn connect_via_saved( autoconnect_retries: None, }; - let settings = build_wifi_connection(ap.as_str(), creds, &opts); + let settings = build_wifi_connection(ssid, creds, &opts); debug!("Creating fresh connection with corrected settings"); - let (_, new_active_conn) = nm + let (new_connection, new_active_conn) = nm .add_and_activate_connection(settings, wifi_device.clone(), ap.clone()) .await .map_err(|e| { @@ -764,13 +789,20 @@ async fn connect_via_saved( // Wait for the fresh connection to activate let timeout = timeout_config.map(|c| c.connection_timeout); - wait_for_connection_activation(conn, &new_active_conn, timeout).await?; + wait_for_fresh_activation(conn, nm, &new_connection, &new_active_conn, timeout) + .await?; } } } Err(e) => { warn!("activate_connection() failed: {e}"); + + if !can_rebuild_after_saved_failure(creds) { + warn!("No fresh credentials were supplied; preserving the saved profile"); + return Err(e.into()); + } + warn!("Saved connection may be corrupted, deleting and retrying with fresh connection"); match delete_connection(conn, saved.clone()).await { @@ -784,9 +816,9 @@ async fn connect_via_saved( autoconnect_retries: None, }; - let settings = build_wifi_connection(ap.as_str(), creds, &opts); + let settings = build_wifi_connection(ssid, creds, &opts); - let (_, active_conn) = nm + let (new_connection, active_conn) = nm .add_and_activate_connection(settings, wifi_device.clone(), ap.clone()) .await .map_err(|e| { @@ -796,13 +828,37 @@ async fn connect_via_saved( // Wait for the fresh connection to activate let timeout = timeout_config.map(|c| c.connection_timeout); - wait_for_connection_activation(conn, &active_conn, timeout).await?; + wait_for_fresh_activation(conn, nm, &new_connection, &active_conn, timeout).await?; } } Ok(()) } +async fn wait_for_fresh_activation( + conn: &Connection, + nm: &NMProxy<'_>, + connection_path: &OwnedObjectPath, + active_connection_path: &OwnedObjectPath, + timeout: Option, +) -> Result<()> { + if let Err(error) = wait_for_connection_activation(conn, active_connection_path, timeout).await + { + if let Err(cleanup_error) = nm + .deactivate_connection(active_connection_path.clone()) + .await + { + warn!("Failed to deactivate rejected fresh connection: {cleanup_error}"); + } + if let Err(cleanup_error) = delete_connection(conn, connection_path.clone()).await { + warn!("Failed to delete rejected fresh connection profile: {cleanup_error}"); + } + return Err(error); + } + + Ok(()) +} + /// Creates a new connection profile and activates it. /// /// Builds connection settings from the provided credentials, ensures the @@ -830,7 +886,7 @@ async fn build_and_activate_new( ensure_disconnected(conn, wifi_device, timeout_config).await?; - let (_, active_conn) = match nm + let (connection_path, active_conn) = match nm .add_and_activate_connection(settings, wifi_device.clone(), ap.clone()) .await { @@ -851,7 +907,7 @@ async fn build_and_activate_new( // Wait for connection activation using the ActiveConnection signals let timeout = timeout_config.map(|c| c.connection_timeout); - wait_for_connection_activation(conn, &active_conn, timeout).await?; + wait_for_fresh_activation(conn, nm, &connection_path, &active_conn, timeout).await?; info!("Connection to '{ssid}' activated successfully"); @@ -888,8 +944,9 @@ async fn scan_and_resolve_ap( /// Decision logic: /// - If a saved connection exists and credentials are empty PSK, use saved /// (user wants to connect with stored password) -/// - If a saved connection exists but new PSK credentials provided, rebuild -/// (user is updating the password) +/// - If a saved connection exists for an open network, use saved +/// - If a saved connection exists but fresh PSK or EAP credentials were +/// provided, create a fresh profile so those credentials are not ignored /// - If no saved connection and PSK is empty, error (can't connect without password) /// - Otherwise, create a fresh connection fn decide_saved_connection( @@ -897,20 +954,26 @@ fn decide_saved_connection( creds: &WifiSecurity, ) -> Result { match saved { - Some(_) if matches!(creds, WifiSecurity::WpaPsk { psk } if !psk.trim().is_empty()) => { - Ok(SavedDecision::RebuildFresh) + Some(path) + if matches!(creds, WifiSecurity::Open) + || matches!(creds, WifiSecurity::WpaPsk { psk } if psk.is_empty()) => + { + Ok(SavedDecision::UseSaved(path)) } - - Some(path) => Ok(SavedDecision::UseSaved(path)), - - None if matches!(creds, WifiSecurity::WpaPsk { psk } if psk.trim().is_empty()) => { + Some(_) => Ok(SavedDecision::RebuildFresh), + None if matches!(creds, WifiSecurity::WpaPsk { psk } if psk.is_empty()) => { Err(ConnectionError::MissingPassword) } - None => Ok(SavedDecision::RebuildFresh), } } +/// Whether a failed saved-profile activation can be retried without relying on +/// that profile's stored secret. +fn can_rebuild_after_saved_failure(creds: &WifiSecurity) -> bool { + !matches!(creds, WifiSecurity::WpaPsk { psk } if psk.is_empty()) +} + /// Checks if currently connected to the specified SSID. /// /// If already connected, returns true. Otherwise, returns false. @@ -1020,3 +1083,144 @@ pub(crate) async fn get_device_by_interface( Err(ConnectionError::NotFound) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::models::EapOptions; + + fn saved_path() -> OwnedObjectPath { + OwnedObjectPath::try_from("/org/freedesktop/NetworkManager/Settings/1") + .expect("valid object path") + } + + #[test] + fn automatic_device_selection_requires_matching_type_and_managed_state() { + assert!(device_matches_type( + device_type::ETHERNET, + true, + device_type::ETHERNET + )); + assert!(device_matches_type( + device_type::VETH, + true, + device_type::ETHERNET + )); + assert!(!device_matches_type( + device_type::ETHERNET, + false, + device_type::ETHERNET + )); + assert!(!device_matches_type( + device_type::WIFI, + true, + device_type::ETHERNET + )); + assert!(!device_matches_type( + device_type::VETH, + false, + device_type::ETHERNET + )); + } + + fn enterprise_credentials() -> WifiSecurity { + WifiSecurity::WpaEap { + opts: EapOptions::new("user", "password"), + } + } + + fn wpa3_enterprise_credentials() -> WifiSecurity { + WifiSecurity::Wpa3Eap192bit { + opts: EapOptions::new_tls_blob("user", vec![1], vec![2]), + } + } + + #[test] + fn saved_profile_is_reused_only_without_fresh_credentials() { + let path = saved_path(); + + assert_eq!( + decide_saved_connection(Some(path.clone()), &WifiSecurity::Open).unwrap(), + SavedDecision::UseSaved(path.clone()) + ); + assert_eq!( + decide_saved_connection( + Some(path.clone()), + &WifiSecurity::WpaPsk { psk: String::new() }, + ) + .unwrap(), + SavedDecision::UseSaved(path) + ); + } + + #[test] + fn saved_profile_is_rebuilt_for_every_supplied_credential_kind() { + let cases = [ + WifiSecurity::WpaPsk { + psk: "new password".into(), + }, + WifiSecurity::WpaPsk { + psk: " ".into(), + }, + enterprise_credentials(), + wpa3_enterprise_credentials(), + ]; + + for creds in cases { + validate_wifi_security(&creds).expect("test credential should be valid"); + assert_eq!( + decide_saved_connection(Some(saved_path()), &creds).unwrap(), + SavedDecision::RebuildFresh, + "fresh credentials must not be ignored: {creds:?}" + ); + } + } + + #[test] + fn absent_profile_rejects_only_the_empty_stored_secret_sentinel() { + assert!(matches!( + decide_saved_connection(None, &WifiSecurity::WpaPsk { psk: String::new() }), + Err(ConnectionError::MissingPassword) + )); + + let whitespace_psk = WifiSecurity::WpaPsk { + psk: " ".into(), + }; + validate_wifi_security(&whitespace_psk).expect("eight spaces is a valid-length PSK"); + assert_eq!( + decide_saved_connection(None, &whitespace_psk).unwrap(), + SavedDecision::RebuildFresh + ); + } + + #[test] + fn absent_profile_builds_open_and_enterprise_connections() { + assert_eq!( + decide_saved_connection(None, &WifiSecurity::Open).unwrap(), + SavedDecision::RebuildFresh + ); + assert_eq!( + decide_saved_connection(None, &enterprise_credentials()).unwrap(), + SavedDecision::RebuildFresh + ); + assert_eq!( + decide_saved_connection(None, &wpa3_enterprise_credentials()).unwrap(), + SavedDecision::RebuildFresh + ); + } + + #[test] + fn saved_failure_recovery_requires_usable_fresh_settings() { + assert!(!can_rebuild_after_saved_failure(&WifiSecurity::WpaPsk { + psk: String::new(), + })); + assert!(can_rebuild_after_saved_failure(&WifiSecurity::Open)); + assert!(can_rebuild_after_saved_failure(&WifiSecurity::WpaPsk { + psk: "password".into(), + })); + assert!(can_rebuild_after_saved_failure(&enterprise_credentials())); + assert!(can_rebuild_after_saved_failure( + &wpa3_enterprise_credentials() + )); + } +} diff --git a/nmrs/src/core/custom_connection.rs b/nmrs/src/core/custom_connection.rs index 0a56145a..4122a90d 100644 --- a/nmrs/src/core/custom_connection.rs +++ b/nmrs/src/core/custom_connection.rs @@ -190,6 +190,30 @@ mod tests { settings } + fn sample_bluetooth_settings( + bdaddr: Option>, + ) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + let mut connection = HashMap::new(); + connection.insert("type", Value::from("bluetooth")); + + let mut bluetooth = HashMap::new(); + if let Some(bdaddr) = bdaddr { + bluetooth.insert("bdaddr", bdaddr); + } + + HashMap::from([("connection", connection), ("bluetooth", bluetooth)]) + } + + fn assert_invalid_input(error: ConnectionError, expected_field: &str, expected_reason: &str) { + match error { + ConnectionError::InvalidInput { field, reason } => { + assert_eq!(field, expected_field); + assert_eq!(reason, expected_reason); + } + other => panic!("expected InvalidInput, got {other:?}"), + } + } + #[test] fn connection_type_from_settings_reads_type_field() { let settings = sample_wifi_settings(); @@ -200,10 +224,80 @@ mod tests { } #[test] - fn connection_type_from_settings_requires_type_field() { - let settings = HashMap::new(); - let err = connection_type_from_settings(&settings).unwrap_err(); - assert!(matches!(err, ConnectionError::InvalidInput { .. })); + fn connection_type_from_settings_rejects_every_missing_or_wrong_type_shape() { + let no_connection = HashMap::new(); + assert_invalid_input( + connection_type_from_settings(&no_connection).unwrap_err(), + "connection.type", + "settings dictionary is missing connection.type", + ); + + let no_type = HashMap::from([("connection", HashMap::new())]); + assert_invalid_input( + connection_type_from_settings(&no_type).unwrap_err(), + "connection.type", + "settings dictionary is missing connection.type", + ); + + let wrong_type = + HashMap::from([("connection", HashMap::from([("type", Value::from(42u32))]))]); + assert_invalid_input( + connection_type_from_settings(&wrong_type).unwrap_err(), + "connection.type", + "settings dictionary is missing connection.type", + ); + } + + #[test] + fn expected_device_type_maps_supported_and_virtual_connection_types() { + assert_eq!( + expected_device_type("802-11-wireless"), + Some(device_type::WIFI) + ); + assert_eq!( + expected_device_type("802-3-ethernet"), + Some(device_type::ETHERNET) + ); + assert_eq!( + expected_device_type("bluetooth"), + Some(device_type::BLUETOOTH) + ); + assert_eq!(expected_device_type("vpn"), None); + assert_eq!(expected_device_type("wireguard"), None); + assert_eq!(expected_device_type("unknown"), None); + } + + #[test] + fn bluetooth_bdaddr_from_settings_reads_address() { + let settings = sample_bluetooth_settings(Some(Value::from("00:1A:7D:DA:71:13"))); + assert_eq!( + bluetooth_bdaddr_from_settings(&settings).unwrap(), + "00:1A:7D:DA:71:13" + ); + } + + #[test] + fn bluetooth_bdaddr_from_settings_rejects_missing_and_wrong_type_values() { + let no_section = sample_wifi_settings(); + assert_invalid_input( + bluetooth_bdaddr_from_settings(&no_section).unwrap_err(), + "bluetooth.bdaddr", + "bluetooth settings are missing bdaddr", + ); + + let no_address = sample_bluetooth_settings(None); + assert_invalid_input( + bluetooth_bdaddr_from_settings(&no_address).unwrap_err(), + "bluetooth.bdaddr", + "bluetooth settings are missing bdaddr", + ); + + let wrong_type = sample_bluetooth_settings(Some(Value::from(42u32))); + assert_invalid_input( + bluetooth_bdaddr_from_settings(&wrong_type).unwrap_err(), + "bluetooth.bdaddr", + "bluetooth settings are missing bdaddr", + ); } #[test] @@ -221,4 +315,39 @@ mod tests { .unwrap(); assert_eq!(path.as_str(), "/org/freedesktop/NetworkManager/Devices/3"); } + + #[test] + fn resolve_specific_object_rejects_invalid_explicit_path() { + let settings = sample_wifi_settings(); + let error = resolve_specific_object(&settings, Some("not/an/object/path")).unwrap_err(); + + match error { + ConnectionError::InvalidInput { field, reason } => { + assert_eq!(field, "specific_object"); + assert!( + !reason.is_empty(), + "zvariant should explain the invalid path" + ); + } + other => panic!("expected InvalidInput, got {other:?}"), + } + } + + #[test] + fn resolve_specific_object_derives_bluez_device_path() { + let settings = sample_bluetooth_settings(Some(Value::from("00:1A:7D:DA:71:13"))); + let path = resolve_specific_object(&settings, None).unwrap(); + + assert_eq!(path.as_str(), "/org/bluez/hci0/dev_00_1A_7D_DA_71_13"); + } + + #[test] + fn resolve_specific_object_requires_bluetooth_address() { + let settings = sample_bluetooth_settings(None); + assert_invalid_input( + resolve_specific_object(&settings, None).unwrap_err(), + "bluetooth.bdaddr", + "bluetooth settings are missing bdaddr", + ); + } } diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 116e2581..767267fc 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -19,6 +19,7 @@ use crate::dbus::{ NMWiredProxy, NMWirelessProxy, }; use crate::types::constants::device_type; +use crate::types::device_type_registry; use crate::util::utils::get_ip_addresses_from_active_connection; /// Lists all network devices managed by NetworkManager. @@ -146,7 +147,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { (None, None) }; - let speed_mbps = if raw_type == device_type::ETHERNET { + let speed_mbps = if device_type_registry::is_wired(raw_type) { async { let wired = NMWiredProxy::builder(conn).path(p.clone())?.build().await?; wired.speed().await @@ -192,7 +193,7 @@ pub(crate) async fn list_wired_device_details(conn: &Connection) -> Result Result<()> { Err(ConnectionError::NoWifiDevice) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::BluetoothNetworkRole; - - #[test] - fn test_default_bluetooth_address() { - // Test that the default address used for devices without hardware address is valid - let default_addr = "00:00:00:00:00:00"; - assert_eq!(default_addr.len(), 17); - assert_eq!(default_addr.matches(':').count(), 5); - } - - #[test] - fn test_bluetooth_device_construction() { - let panu = BluetoothNetworkRole::PanU as u32; - let device = BluetoothDevice::new( - "00:1A:7D:DA:71:13".into(), - Some("TestDevice".into()), - Some("Test".into()), - panu, - DeviceState::Activated, - ); - - assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); - assert_eq!(device.name, Some("TestDevice".into())); - assert_eq!(device.alias, Some("Test".into())); - assert!(matches!(device.bt_caps, _panu)); - assert_eq!(device.state, DeviceState::Activated); - } - - // Note: Most device listing functions require a real D-Bus connection - // and NetworkManager running, so they are better suited for integration tests. -} diff --git a/nmrs/src/core/ovpn_parser/parser.rs b/nmrs/src/core/ovpn_parser/parser.rs index 2ec1d40f..5a85fae0 100644 --- a/nmrs/src/core/ovpn_parser/parser.rs +++ b/nmrs/src/core/ovpn_parser/parser.rs @@ -348,6 +348,15 @@ pub fn parse_ovpn(content: &str) -> Result { "remote" => { // remote [PORT] [PROTO] + if args.len() > 3 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: format!("{args:?}"), + line, + } + .into()); + } + let host = args .first() .ok_or(OvpnParseError::MissingArgument { @@ -406,6 +415,14 @@ pub fn parse_ovpn(content: &str) -> Result { b.proto = Some(value.clone()); } "ca" => { + if args.len() > 1 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: format!("{args:?}"), + line, + } + .into()); + } let path = args .first() .ok_or(OvpnParseError::MissingArgument { @@ -416,6 +433,14 @@ pub fn parse_ovpn(content: &str) -> Result { b.ca = Some(CertSource::File(path)); } "cert" => { + if args.len() > 1 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: format!("{args:?}"), + line, + } + .into()); + } let path = args .first() .ok_or(OvpnParseError::MissingArgument { @@ -426,6 +451,14 @@ pub fn parse_ovpn(content: &str) -> Result { b.cert = Some(CertSource::File(path)); } "key" => { + if args.len() > 1 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: format!("{args:?}"), + line, + } + .into()); + } let path = args .first() .ok_or(OvpnParseError::MissingArgument { @@ -436,6 +469,14 @@ pub fn parse_ovpn(content: &str) -> Result { b.key = Some(CertSource::File(path)); } "tls-crypt" => { + if args.len() > 1 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: format!("{args:?}"), + line, + } + .into()); + } let path = args .first() .ok_or(OvpnParseError::MissingArgument { @@ -448,6 +489,15 @@ pub fn parse_ovpn(content: &str) -> Result { "tls-auth" => { // tls-auth [DIRECTION] + if args.len() > 2 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: format!("{args:?}"), + line, + } + .into()); + } + let path = args .first() .ok_or(OvpnParseError::MissingArgument { @@ -456,17 +506,27 @@ pub fn parse_ovpn(content: &str) -> Result { })? .clone(); - let kd = args - .get(1) - .map(|v| { - v.parse::().map_err(|_| OvpnParseError::InvalidNumber { - key: key.clone(), - value: v.clone(), + let kd = if let Some(value) = args.get(1) { + let direction = + value + .parse::() + .map_err(|_| OvpnParseError::InvalidNumber { + key: key.clone(), + value: value.clone(), + line, + })?; + if direction > 1 { + return Err(OvpnParseError::InvalidArgument { + key, + arg: value.clone(), line, - }) - }) - .transpose()? - .filter(|&d| d <= 1); + } + .into()); + } + Some(direction) + } else { + None + }; b.tls_auth = Some(TlsAuth { source: CertSource::File(path), @@ -965,6 +1025,20 @@ mod tests { ); } + #[test] + fn route_invalid_optional_addresses_identify_the_bad_value() { + assert_parse_err!( + "route 10.0.0.0 bad-netmask", + OvpnParseError::InvalidNumber { key, value, line } + if key == "route" && value == "bad-netmask" && line == 1 + ); + assert_parse_err!( + "route 10.0.0.0 255.255.255.0 bad-gateway", + OvpnParseError::InvalidNumber { key, value, line } + if key == "route" && value == "bad-gateway" && line == 1 + ); + } + #[test] fn parse_redirect_gateway_directive() { let result = parse_ok("redirect-gateway def1 bypass-dhcp bypass-dns local ipv6"); @@ -1074,6 +1148,52 @@ mod tests { ); } + #[test] + fn file_certificate_directives_require_exactly_one_path() { + for directive in ["ca", "cert", "key", "tls-crypt"] { + let error = parse_ovpn(directive).unwrap_err(); + assert!(matches!( + error, + ConnectionError::ParseError(OvpnParseError::MissingArgument { key, line }) + if key == directive && line == 1 + )); + + let input = format!("{directive} first.pem second.pem"); + let error = parse_ovpn(&input).unwrap_err(); + assert!(matches!( + error, + ConnectionError::ParseError(OvpnParseError::InvalidArgument { key, line, .. }) + if key == directive && line == 1 + )); + } + } + + #[test] + fn tls_auth_directive_rejects_invalid_direction() { + assert_parse_err!( + "tls-auth /etc/openvpn/ta.key 2", + OvpnParseError::InvalidArgument { key, arg, line } + if key == "tls-auth" && arg == "2" && line == 1 + ); + assert_parse_err!( + "tls-auth /etc/openvpn/ta.key client", + OvpnParseError::InvalidNumber { key, value, line } + if key == "tls-auth" && value == "client" && line == 1 + ); + } + + #[test] + fn tls_auth_and_remote_reject_extra_arguments() { + assert_parse_err!( + "tls-auth ta.key 1 extra", + OvpnParseError::InvalidArgument { key, .. } if key == "tls-auth" + ); + assert_parse_err!( + "remote vpn.example.com 1194 udp extra", + OvpnParseError::InvalidArgument { key, .. } if key == "remote" + ); + } + #[test] fn key_direction_standalone_passes() { let result = parse_ok("\nAUTHKEY\n\nkey-direction 0"); diff --git a/nmrs/src/core/rfkill.rs b/nmrs/src/core/rfkill.rs index 4f51ab32..4280dede 100644 --- a/nmrs/src/core/rfkill.rs +++ b/nmrs/src/core/rfkill.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; /// Snapshot of hardware (hard-block) rfkill state for each radio type. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct RfkillSnapshot { /// `true` if any WLAN rfkill entry reports a hard block. pub wlan_hard_block: bool, @@ -23,8 +23,10 @@ pub(crate) struct RfkillSnapshot { /// Returns an all-false snapshot if `/sys/class/rfkill` is unreadable /// (common in containers and CI environments). pub(crate) fn read_rfkill() -> RfkillSnapshot { - let rfkill_dir = Path::new("/sys/class/rfkill"); + read_rfkill_from(Path::new("/sys/class/rfkill")) +} +fn read_rfkill_from(rfkill_dir: &Path) -> RfkillSnapshot { let entries = match fs::read_dir(rfkill_dir) { Ok(e) => e, Err(_) => return RfkillSnapshot::default(), @@ -57,3 +59,84 @@ pub(crate) fn read_rfkill() -> RfkillSnapshot { snapshot } + +#[cfg(test)] +mod tests { + use super::*; + + struct RfkillFixture { + root: std::path::PathBuf, + } + + impl RfkillFixture { + fn new() -> Self { + let root = + std::env::temp_dir().join(format!("nmrs-rfkill-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + Self { root } + } + + fn add_entry(&self, name: &str, radio_type: Option<&str>, hard: Option<&str>) { + let entry = self.root.join(name); + fs::create_dir_all(&entry).unwrap(); + if let Some(radio_type) = radio_type { + fs::write(entry.join("type"), radio_type).unwrap(); + } + if let Some(hard) = hard { + fs::write(entry.join("hard"), hard).unwrap(); + } + } + } + + impl Drop for RfkillFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + #[test] + fn unreadable_rfkill_directory_returns_unblocked_snapshot() { + let missing = + std::env::temp_dir().join(format!("nmrs-rfkill-missing-{}", uuid::Uuid::new_v4())); + + assert_eq!(read_rfkill_from(&missing), RfkillSnapshot::default()); + } + + #[test] + fn parses_hard_blocks_for_each_supported_radio_type() { + let fixture = RfkillFixture::new(); + fixture.add_entry("rfkill0", Some("wlan\n"), Some("1\n")); + fixture.add_entry("rfkill1", Some("wwan"), Some(" 1 ")); + fixture.add_entry("rfkill2", Some("bluetooth\n"), Some("1")); + + assert_eq!( + read_rfkill_from(&fixture.root), + RfkillSnapshot { + wlan_hard_block: true, + wwan_hard_block: true, + bluetooth_hard_block: true, + } + ); + } + + #[test] + fn ignores_soft_unblocked_unknown_and_incomplete_entries() { + let fixture = RfkillFixture::new(); + fixture.add_entry("rfkill0", Some("wlan"), Some("0")); + fixture.add_entry("rfkill1", Some("wwan"), Some("not-a-bit")); + fixture.add_entry("rfkill2", Some("nfc"), Some("1")); + fixture.add_entry("rfkill3", Some("bluetooth"), None); + fixture.add_entry("rfkill4", None, Some("1")); + + assert_eq!(read_rfkill_from(&fixture.root), RfkillSnapshot::default()); + } + + #[test] + fn any_hard_blocked_entry_wins_for_a_radio_type() { + let fixture = RfkillFixture::new(); + fixture.add_entry("rfkill0", Some("wlan"), Some("0")); + fixture.add_entry("rfkill1", Some("wlan"), Some("1")); + + assert!(read_rfkill_from(&fixture.root).wlan_hard_block); + } +} diff --git a/nmrs/src/core/saved_connection.rs b/nmrs/src/core/saved_connection.rs index d89a6da8..a49e5527 100644 --- a/nmrs/src/core/saved_connection.rs +++ b/nmrs/src/core/saved_connection.rs @@ -100,6 +100,13 @@ fn owned_to_bytes(v: &OwnedValue) -> Option> { Vec::::try_from(v.clone()).ok() } +fn unbox_value<'a, 'v>(mut value: &'a zvariant::Value<'v>) -> &'a zvariant::Value<'v> { + while let zvariant::Value::Value(inner) = value { + value = inner; + } + value +} + fn take_str(m: &HashMap, key: &str) -> Option { m.get(key).and_then(owned_to_str) } @@ -129,8 +136,8 @@ fn take_str_vec(m: &HashMap, key: &str) -> Vec { }; let mut out = Vec::new(); for item in arr.iter() { - if let Ok(s) = Str::try_from(item.clone()) { - out.push(s.to_string()); + if let zvariant::Value::Str(value) = unbox_value(item) { + out.push(value.to_string()); } } out @@ -290,7 +297,9 @@ fn decode_wifi_security( let key_mgmt_str = take_str(&ws, "key-mgmt").unwrap_or_default(); let key_mgmt = match key_mgmt_str.as_str() { - "none" | "" => WifiKeyMgmt::None, + "none" => WifiKeyMgmt::None, + "" if !eap.is_empty() => WifiKeyMgmt::WpaEap, + "" => WifiKeyMgmt::None, "ieee8021x" => WifiKeyMgmt::WpaEap, "wpa-none" => WifiKeyMgmt::Wep, "wpa-psk" | "wpa-psk-sha256" => WifiKeyMgmt::WpaPsk, @@ -357,7 +366,7 @@ fn decode_vpn(settings: &HashMap>) -> Settin fn decode_wireguard(settings: &HashMap>) -> SettingsSummary { let wg = settings.get("wireguard").cloned().unwrap_or_default(); - let listen_port = take_u32(&wg, "listen-port").map(|p| p as u16); + let listen_port = take_u32(&wg, "listen-port").and_then(|p| u16::try_from(p).ok()); let mtu = take_u32(&wg, "mtu"); let fwmark = take_u32(&wg, "fwmark"); @@ -369,14 +378,14 @@ fn decode_wireguard(settings: &HashMap>) -> { peer_count = arr.len(); if let Some(first) = arr.iter().next() - && let Ok(dict) = zvariant::Dict::try_from(first.clone()) + && let zvariant::Value::Dict(dict) = unbox_value(first) { for (k, val) in dict.iter() { - if let Ok(key) = Str::try_from(k.clone()) + if let zvariant::Value::Str(key) = unbox_value(k) && key.as_str() == "endpoint" - && let Ok(ov) = OwnedValue::try_from(val.clone()) + && let zvariant::Value::Str(endpoint) = unbox_value(val) { - first_peer_endpoint = owned_to_str(&ov); + first_peer_endpoint = Some(endpoint.to_string()); break; } } @@ -759,6 +768,21 @@ mod tests { use super::*; use zvariant::Str; + fn path(suffix: u32) -> OwnedObjectPath { + OwnedObjectPath::try_from(format!("/org/freedesktop/NetworkManager/Settings/{suffix}")) + .expect("valid object path") + } + + fn owned_string_array(values: &[&str]) -> OwnedValue { + OwnedValue::try_from(zvariant::Value::from( + values + .iter() + .map(|value| (*value).to_string()) + .collect::>(), + )) + .expect("owned string array") + } + fn conn_section(uuid: &str, id: &str, ty: &str) -> HashMap { let mut m = HashMap::new(); m.insert("uuid".into(), OwnedValue::from(Str::from(uuid))); @@ -771,26 +795,68 @@ mod tests { } #[test] - fn decode_malformed_missing_uuid() { - let mut settings = HashMap::new(); - let mut c = HashMap::new(); - c.insert("id".into(), OwnedValue::from(Str::from("x"))); - c.insert( - "type".into(), - OwnedValue::from(Str::from("802-11-wireless")), - ); - settings.insert("connection".into(), c); - - let r = decode_saved( - OwnedObjectPath::try_from("/o").unwrap(), - false, - None, - settings, - ); - assert!(matches!( - r, - Err(ConnectionError::MalformedSavedConnection(_)) - )); + fn decode_malformed_required_identity_fields() { + let cases = [ + (HashMap::new(), "missing 'connection' section"), + ( + HashMap::from([( + "connection".into(), + HashMap::from([ + ("id".into(), OwnedValue::from(Str::from("x"))), + ( + "type".into(), + OwnedValue::from(Str::from("802-11-wireless")), + ), + ]), + )]), + "missing connection.uuid", + ), + ( + HashMap::from([( + "connection".into(), + HashMap::from([ + ("uuid".into(), OwnedValue::from(Str::from("u"))), + ( + "type".into(), + OwnedValue::from(Str::from("802-11-wireless")), + ), + ]), + )]), + "missing connection.id", + ), + ( + HashMap::from([( + "connection".into(), + HashMap::from([ + ("uuid".into(), OwnedValue::from(Str::from("u"))), + ("id".into(), OwnedValue::from(Str::from("x"))), + ]), + )]), + "missing connection.type", + ), + ( + HashMap::from([( + "connection".into(), + HashMap::from([ + ("uuid".into(), OwnedValue::from(42u32)), + ("id".into(), OwnedValue::from(Str::from("x"))), + ( + "type".into(), + OwnedValue::from(Str::from("802-11-wireless")), + ), + ]), + )]), + "missing connection.uuid", + ), + ]; + + for (settings, expected) in cases { + let result = decode_saved(path(1), false, None, settings); + assert!(matches!( + result, + Err(ConnectionError::MalformedSavedConnection(message)) if message == expected + )); + } } #[test] @@ -810,7 +876,7 @@ mod tests { settings.insert("802-11-wireless".into(), w); let c = decode_saved( - OwnedObjectPath::try_from("/o").unwrap(), + path(2), false, Some("/etc/NetworkManager/system-connections/coffee.nmconnection".into()), settings, @@ -860,13 +926,7 @@ mod tests { ); settings.insert("802-11-wireless-security".into(), sec); - let c = decode_saved( - OwnedObjectPath::try_from("/o2").unwrap(), - false, - None, - settings, - ) - .unwrap(); + let c = decode_saved(path(3), false, None, settings).unwrap(); match c.summary { SettingsSummary::Wifi { @@ -880,6 +940,68 @@ mod tests { } } + #[test] + fn decode_wifi_eap_security_and_optional_fields() { + let wireless = HashMap::from([ + ( + "ssid".into(), + OwnedValue::try_from(zvariant::Value::from(b"Enterprise".to_vec())) + .expect("owned SSID"), + ), + ("mode".into(), OwnedValue::from(Str::from("infrastructure"))), + ("band".into(), OwnedValue::from(Str::from("a"))), + ("channel".into(), OwnedValue::from(36u32)), + ( + "bssid".into(), + OwnedValue::from(Str::from("00:11:22:33:44:55")), + ), + ("hidden".into(), OwnedValue::from(true)), + ( + "mac-address-randomization".into(), + OwnedValue::from(Str::from("always")), + ), + ]); + let eap = HashMap::from([("eap".into(), owned_string_array(&["peap", "ttls"]))]); + let saved = decode_saved( + path(11), + false, + None, + HashMap::from([ + ( + "connection".into(), + conn_section("eap-u", "Enterprise", "802-11-wireless"), + ), + ("802-11-wireless".into(), wireless), + ("802-1x".into(), eap), + ]), + ) + .unwrap(); + + let SettingsSummary::Wifi { + ssid, + mode, + security, + band, + channel, + bssid, + hidden, + mac_randomization, + } = saved.summary + else { + panic!("expected Wi-Fi summary") + }; + assert_eq!(ssid, "Enterprise"); + assert_eq!(mode.as_deref(), Some("infrastructure")); + assert_eq!(band.as_deref(), Some("a")); + assert_eq!(channel, Some(36)); + assert_eq!(bssid.as_deref(), Some("00:11:22:33:44:55")); + assert!(hidden); + assert_eq!(mac_randomization.as_deref(), Some("always")); + let security = security.expect("EAP security summary"); + assert_eq!(security.key_mgmt, WifiKeyMgmt::WpaEap); + assert_eq!(security.eap_methods, ["peap", "ttls"]); + } + #[test] fn decode_vpn_wireguard_service() { let mut settings = HashMap::new(); @@ -897,13 +1019,7 @@ mod tests { wg.insert("listen-port".into(), OwnedValue::from(51820u32)); settings.insert("wireguard".into(), wg); - let c = decode_saved( - OwnedObjectPath::try_from("/o3").unwrap(), - false, - None, - settings, - ) - .unwrap(); + let c = decode_saved(path(4), false, None, settings).unwrap(); match c.summary { SettingsSummary::WireGuard { @@ -921,13 +1037,7 @@ mod tests { let mut settings = HashMap::new(); settings.insert("connection".into(), conn_section("u4", "tun", "tun")); - let c = decode_saved( - OwnedObjectPath::try_from("/o4").unwrap(), - false, - None, - settings, - ) - .unwrap(); + let c = decode_saved(path(5), false, None, settings).unwrap(); match c.summary { SettingsSummary::Other { sections } => { @@ -990,4 +1100,392 @@ mod tests { Some("bar") ); } + + #[test] + fn decode_saved_brief_reads_identity_and_path() { + let settings = HashMap::from([( + "connection".into(), + conn_section("brief-uuid", "Brief Name", "802-3-ethernet"), + )]); + let expected_path = path(6); + + let brief = decode_saved_brief(expected_path.clone(), &settings).unwrap(); + + assert_eq!(brief.path, expected_path); + assert_eq!(brief.uuid, "brief-uuid"); + assert_eq!(brief.id, "Brief Name"); + assert_eq!(brief.connection_type, "802-3-ethernet"); + } + + #[test] + fn decode_saved_brief_rejects_each_missing_identity_field() { + for (missing, expected) in [ + ("uuid", "missing connection.uuid"), + ("id", "missing connection.id"), + ("type", "missing connection.type"), + ] { + let mut connection = conn_section("u", "id", "vpn"); + connection.remove(missing); + let settings = HashMap::from([("connection".into(), connection)]); + assert!(matches!( + decode_saved_brief(path(7), &settings), + Err(ConnectionError::MalformedSavedConnection(message)) if message == expected + )); + } + + assert!(matches!( + decode_saved_brief(path(7), &HashMap::new()), + Err(ConnectionError::MalformedSavedConnection(message)) + if message == "missing 'connection' section" + )); + } + + #[test] + fn decode_ethernet_summary_and_connection_metadata() { + let mut connection = conn_section("eth-u", "Wired", "802-3-ethernet"); + connection.insert( + "interface-name".into(), + OwnedValue::from(Str::from("enp1s0")), + ); + connection.insert("autoconnect".into(), OwnedValue::from(false)); + connection.insert("autoconnect-priority".into(), OwnedValue::from(-10i32)); + connection.insert("timestamp".into(), OwnedValue::from(1234u64)); + connection.insert( + "permissions".into(), + owned_string_array(&["user:alice:", "user:bob:"]), + ); + let ethernet = HashMap::from([ + ( + "mac-address".into(), + OwnedValue::from(Str::from("00:11:22:33:44:55")), + ), + ("auto-negotiate".into(), OwnedValue::from(true)), + ("speed".into(), OwnedValue::from(1000u32)), + ("mtu".into(), OwnedValue::from(9000u32)), + ]); + let saved = decode_saved( + path(8), + true, + Some("/tmp/wired.nmconnection".into()), + HashMap::from([ + ("connection".into(), connection), + ("802-3-ethernet".into(), ethernet), + ]), + ) + .unwrap(); + + assert_eq!(saved.interface_name.as_deref(), Some("enp1s0")); + assert!(!saved.autoconnect); + assert_eq!(saved.autoconnect_priority, -10); + assert_eq!(saved.timestamp_unix, 1234); + assert_eq!(saved.permissions, ["user:alice:", "user:bob:"]); + assert!(saved.unsaved); + assert_eq!(saved.filename.as_deref(), Some("/tmp/wired.nmconnection")); + assert!(matches!( + saved.summary, + SettingsSummary::Ethernet { + mac_address: Some(ref mac), + auto_negotiate: Some(true), + speed_mbps: Some(1000), + mtu: Some(9000), + } if mac == "00:11:22:33:44:55" + )); + } + + #[test] + fn decode_connection_metadata_uses_documented_defaults_for_wrong_types() { + let mut connection = conn_section("defaults-u", "Defaults", "802-3-ethernet"); + connection.insert("interface-name".into(), OwnedValue::from(Str::from(""))); + connection.insert("autoconnect".into(), OwnedValue::from(1u32)); + connection.insert( + "autoconnect-priority".into(), + OwnedValue::from(Str::from("high")), + ); + connection.insert("timestamp".into(), OwnedValue::from(false)); + connection.insert("permissions".into(), OwnedValue::from(7u32)); + + let saved = decode_saved( + path(13), + false, + None, + HashMap::from([("connection".into(), connection)]), + ) + .unwrap(); + + assert_eq!(saved.interface_name, None); + assert!(saved.autoconnect); + assert_eq!(saved.autoconnect_priority, 0); + assert_eq!(saved.timestamp_unix, 0); + assert!(saved.permissions.is_empty()); + } + + #[test] + fn decode_generic_vpn_summary_omits_secret_values() { + let data = zvariant::Dict::from(HashMap::from([ + ("remote".to_string(), "vpn.example.com".to_string()), + ("cipher".to_string(), "AES-256-GCM".to_string()), + ])); + let vpn = HashMap::from([ + ( + "service-type".into(), + OwnedValue::from(Str::from("org.freedesktop.NetworkManager.openvpn")), + ), + ("user-name".into(), OwnedValue::from(Str::from("alice"))), + ("password-flags".into(), OwnedValue::from(1u32)), + ("persistent".into(), OwnedValue::from(true)), + ( + "data".into(), + OwnedValue::try_from(zvariant::Value::Dict(data)).expect("owned dict"), + ), + ]); + let saved = decode_saved( + path(9), + false, + None, + HashMap::from([ + ("connection".into(), conn_section("vpn-u", "VPN", "vpn")), + ("vpn".into(), vpn), + ]), + ) + .unwrap(); + + assert!(matches!( + saved.summary, + SettingsSummary::Vpn { + ref service_type, + user_name: Some(ref user), + password_flags, + ref data_keys, + persistent: true, + } if service_type == "org.freedesktop.NetworkManager.openvpn" + && user == "alice" + && password_flags.agent_owned() + && data_keys == &["cipher".to_string(), "remote".to_string()] + )); + } + + #[test] + fn decode_native_wireguard_summary_checks_port_range() { + for (port, expected) in [(51820, Some(51820)), (u32::from(u16::MAX) + 1, None)] { + let wireguard = HashMap::from([ + ("listen-port".into(), OwnedValue::from(port)), + ("mtu".into(), OwnedValue::from(1420u32)), + ("fwmark".into(), OwnedValue::from(7u32)), + ]); + let saved = decode_saved( + path(10), + false, + None, + HashMap::from([ + ( + "connection".into(), + conn_section("wg-u", "WireGuard", "wireguard"), + ), + ("wireguard".into(), wireguard), + ]), + ) + .unwrap(); + + assert!(matches!( + saved.summary, + SettingsSummary::WireGuard { + listen_port, + mtu: Some(1420), + fwmark: Some(7), + peer_count: 0, + first_peer_endpoint: None, + } if listen_port == expected + )); + } + } + + #[test] + fn decode_native_wireguard_peer_array() { + let peer: HashMap> = HashMap::from([ + ( + "public-key".into(), + zvariant::Value::Str("peer-public-key".into()), + ), + ( + "endpoint".into(), + zvariant::Value::Str("vpn.example.com:51820".into()), + ), + ]); + let peers = + OwnedValue::try_from(zvariant::Value::from(vec![peer])).expect("owned peer array"); + let saved = decode_saved( + path(12), + false, + None, + HashMap::from([ + ( + "connection".into(), + conn_section("wg-peer-u", "WireGuard peer", "wireguard"), + ), + ("wireguard".into(), HashMap::from([("peers".into(), peers)])), + ]), + ) + .unwrap(); + + let SettingsSummary::WireGuard { + peer_count, + first_peer_endpoint, + .. + } = saved.summary + else { + panic!("expected WireGuard summary") + }; + assert_eq!(peer_count, 1); + assert_eq!( + first_peer_endpoint.as_deref(), + Some("vpn.example.com:51820") + ); + } + + #[test] + fn decode_mobile_and_bluetooth_summaries() { + let gsm = decode_summary( + "gsm", + &HashMap::from([( + "gsm".into(), + HashMap::from([ + ("apn".into(), OwnedValue::from(Str::from("internet"))), + ("username".into(), OwnedValue::from(Str::from("mobile"))), + ("password-flags".into(), OwnedValue::from(1u32)), + ("pin-flags".into(), OwnedValue::from(2u32)), + ]), + )]), + ); + assert!(matches!( + gsm, + SettingsSummary::Gsm { + apn: Some(ref apn), + user_name: Some(ref user), + password_flags: 1, + pin_flags: 2, + } if apn == "internet" && user == "mobile" + )); + + let cdma = decode_summary( + "cdma", + &HashMap::from([( + "cdma".into(), + HashMap::from([ + ("number".into(), OwnedValue::from(Str::from("#777"))), + ("username".into(), OwnedValue::from(Str::from("carrier"))), + ("password-flags".into(), OwnedValue::from(1u32)), + ]), + )]), + ); + assert!(matches!( + cdma, + SettingsSummary::Cdma { + number: Some(ref number), + user_name: Some(ref user), + password_flags: 1, + } if number == "#777" && user == "carrier" + )); + + let bluetooth = decode_summary( + "bluetooth", + &HashMap::from([( + "bluetooth".into(), + HashMap::from([( + "bdaddr".into(), + OwnedValue::from(Str::from("00:11:22:33:44:55")), + )]), + )]), + ); + assert!(matches!( + bluetooth, + SettingsSummary::Bluetooth { ref bdaddr, ref bt_type } + if bdaddr == "00:11:22:33:44:55" && bt_type == "panu" + )); + } + + #[test] + fn patch_delta_empty_patch_is_empty() { + assert!(build_settings_patch_delta(&SettingsPatch::default()).is_empty()); + } + + #[test] + fn patch_delta_serializes_all_typed_fields_and_interface_clear() { + let patch = SettingsPatch { + autoconnect: Some(false), + autoconnect_priority: Some(-42), + id: Some("Renamed".into()), + interface_name: Some(None), + raw_overlay: None, + }; + let delta = build_settings_patch_delta(&patch); + let connection = delta.get("connection").unwrap(); + + assert_eq!( + owned_to_bool(connection.get("autoconnect").unwrap()), + Some(false) + ); + assert_eq!( + owned_to_i32(connection.get("autoconnect-priority").unwrap()), + Some(-42) + ); + assert_eq!( + owned_to_str(connection.get("id").unwrap()).as_deref(), + Some("Renamed") + ); + assert_eq!( + owned_to_str(connection.get("interface-name").unwrap()).as_deref(), + Some("") + ); + } + + #[test] + fn raw_overlay_has_documented_precedence_over_typed_fields() { + let overlay = HashMap::from([( + "connection".into(), + HashMap::from([ + ("autoconnect".into(), OwnedValue::from(true)), + ("id".into(), OwnedValue::from(Str::from("Overlay Name"))), + ]), + )]); + let patch = SettingsPatch { + autoconnect: Some(false), + id: Some("Typed Name".into()), + raw_overlay: Some(overlay), + ..Default::default() + }; + let delta = build_settings_patch_delta(&patch); + let connection = delta.get("connection").unwrap(); + + assert_eq!( + owned_to_bool(connection.get("autoconnect").unwrap()), + Some(true) + ); + assert_eq!( + owned_to_str(connection.get("id").unwrap()).as_deref(), + Some("Overlay Name") + ); + } + + #[test] + fn merge_patch_creates_missing_sections_without_losing_existing_values() { + let mut settings = HashMap::from([( + "connection".into(), + conn_section("merge-u", "Original", "802-3-ethernet"), + )]); + let delta = HashMap::from([( + "ipv4".into(), + HashMap::from([("method".into(), OwnedValue::from(Str::from("manual")))]), + )]); + + merge_settings_patch_delta(&mut settings, delta); + + assert_eq!( + owned_to_str(settings["connection"].get("id").unwrap()).as_deref(), + Some("Original") + ); + assert_eq!( + owned_to_str(settings["ipv4"].get("method").unwrap()).as_deref(), + Some("manual") + ); + } } diff --git a/nmrs/src/core/state_wait.rs b/nmrs/src/core/state_wait.rs index 6f6410be..8aa81933 100644 --- a/nmrs/src/core/state_wait.rs +++ b/nmrs/src/core/state_wait.rs @@ -18,10 +18,11 @@ //! - More reliable; at least in the sense that we won't miss rapid state transitions. //! - Better error messages with specific failure reasons -use futures::{FutureExt, StreamExt, select}; +use futures::{FutureExt, Stream, StreamExt, select}; use futures_timer::Delay; use log::{debug, trace, warn}; -use std::pin::pin; +use std::future::Future; +use std::pin::{Pin, pin}; use std::time::Duration; use zbus::Connection; @@ -36,6 +37,207 @@ use crate::types::constants::{device_state, timeouts}; /// Default timeout for connection activation (30 seconds). const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Debug)] +enum ActivationDecision { + Pending, + Activated, + RefineDeviceError, + Failed(ConnectionError), +} + +#[derive(Clone, Copy)] +enum WaitTarget { + Activation, + Disconnect, + WifiReady, +} + +fn signal_stream_ended_error(target: WaitTarget) -> ConnectionError { + match target { + WaitTarget::Activation | WaitTarget::Disconnect => { + ConnectionError::Stuck("signal stream ended".into()) + } + WaitTarget::WifiReady => ConnectionError::WifiNotReady, + } +} + +fn classify_activation_state( + state: ActiveConnectionState, + reason_code: Option, +) -> ActivationDecision { + match state { + ActiveConnectionState::Activated => ActivationDecision::Activated, + ActiveConnectionState::Deactivated => match reason_code { + Some(code) + if ConnectionStateReason::from(code) + == ConnectionStateReason::DeviceDisconnected => + { + ActivationDecision::RefineDeviceError + } + Some(code) => ActivationDecision::Failed(connection_state_reason_to_error(code)), + None => ActivationDecision::RefineDeviceError, + }, + _ => ActivationDecision::Pending, + } +} + +async fn activation_decision_result( + decision: ActivationDecision, + refine_error: Pin<&mut RefineFuture>, +) -> Option> +where + RefineFuture: Future, +{ + match decision { + ActivationDecision::Pending => None, + ActivationDecision::Activated => Some(Ok(())), + ActivationDecision::RefineDeviceError => Some(Err(refine_error.await)), + ActivationDecision::Failed(error) => Some(Err(error)), + } +} + +async fn wait_for_activation_state( + stream: S, + mut read_state: Read, + refine_error: RefineFuture, + timeout_duration: Duration, +) -> Result<()> +where + S: Stream>, + Read: FnMut() -> ReadFuture, + ReadFuture: Future>, + RefineFuture: Future, +{ + let mut stream = pin!(stream); + let mut refine_error = pin!(refine_error); + + let current_state = ActiveConnectionState::from(read_state().await?); + trace!("Current active connection state: {current_state}"); + if let Some(result) = activation_decision_result( + classify_activation_state(current_state, None), + refine_error.as_mut(), + ) + .await + { + return result; + } + + let mut timeout_delay = pin!(Delay::new(timeout_duration).fuse()); + loop { + // A transition may race with signal subscription. Re-read before waiting. + let current_state = ActiveConnectionState::from(read_state().await?); + if let Some(result) = activation_decision_result( + classify_activation_state(current_state, None), + refine_error.as_mut(), + ) + .await + { + return result; + } + + select! { + _ = timeout_delay => { + // The target transition can race with the timer becoming ready. + let final_state = ActiveConnectionState::from(read_state().await?); + if let Some(result) = activation_decision_result( + classify_activation_state(final_state, None), + refine_error.as_mut(), + ).await { + return result; + } + + warn!("Connection activation timed out after {timeout_duration:?}"); + return Err(ConnectionError::Timeout); + } + signal = stream.next().fuse() => { + match signal { + Some(Some((state_code, reason_code))) => { + let state = ActiveConnectionState::from(state_code); + let reason = ConnectionStateReason::from(reason_code); + trace!("Active connection state changed to: {state} (reason: {reason})"); + + if let Some(result) = activation_decision_result( + classify_activation_state(state, Some(reason_code)), + refine_error.as_mut(), + ).await { + return result; + } + } + Some(None) => {} + None => return Err(signal_stream_ended_error(WaitTarget::Activation)), + } + } + } + } +} + +fn is_disconnected_state(state: u32) -> bool { + state == device_state::DISCONNECTED || state == device_state::UNAVAILABLE +} + +fn is_wifi_ready_state(state: u32) -> bool { + state == device_state::DISCONNECTED || state == device_state::ACTIVATED +} + +fn disconnect_timeout_result(final_state: u32) -> Result<()> { + if is_disconnected_state(final_state) { + Ok(()) + } else { + Err(ConnectionError::Stuck(format!("state {final_state}"))) + } +} + +fn wifi_ready_timeout_result(final_state: u32) -> Result<()> { + if is_wifi_ready_state(final_state) { + Ok(()) + } else { + Err(ConnectionError::WifiNotReady) + } +} + +async fn wait_for_device_state( + stream: S, + mut read_state: Read, + is_target: Target, + timeout_duration: Duration, + timeout_result: TimeoutResult, + wait_target: WaitTarget, +) -> Result<()> +where + S: Stream>, + Read: FnMut() -> ReadFuture, + ReadFuture: Future>, + Target: Fn(u32) -> bool, + TimeoutResult: Fn(u32) -> Result<()>, +{ + let mut stream = pin!(stream); + + if is_target(read_state().await?) { + return Ok(()); + } + + let mut timeout_delay = pin!(Delay::new(timeout_duration).fuse()); + loop { + // A transition may race with signal subscription. Re-read before waiting. + if is_target(read_state().await?) { + return Ok(()); + } + + select! { + _ = timeout_delay => { + return timeout_result(read_state().await?); + } + state = stream.next().fuse() => { + match state { + Some(Some(state)) if is_target(state) => return Ok(()), + Some(_) => {} + None => return Err(signal_stream_ended_error(wait_target)), + } + } + } + } +} + /// When the active connection reports `DeviceDisconnected`, the real failure /// reason lives on the device itself. Query it and return a more specific error. async fn refine_device_disconnected_error( @@ -84,88 +286,26 @@ pub(crate) async fn wait_for_connection_activation( .await?; // Subscribe to signals FIRST to avoid race condition - let mut stream = active_conn.receive_activation_state_changed().await?; + let stream = active_conn + .receive_activation_state_changed() + .await? + .map(|signal| { + signal + .args() + .map(|args| (args.state, args.reason)) + .map_err(|error| warn!("Failed to parse StateChanged signal args: {error}")) + .ok() + }); trace!("Subscribed to ActiveConnection StateChanged signal"); - // Check current state - if already terminal, return immediately - let current_state = active_conn.state().await?; - let state = ActiveConnectionState::from(current_state); - trace!("Current active connection state: {state}"); - - match state { - ActiveConnectionState::Activated => { - debug!("Connection already activated"); - return Ok(()); - } - ActiveConnectionState::Deactivated => { - warn!("Connection already deactivated"); - return Err(refine_device_disconnected_error(conn, &active_conn).await); - } - _ => {} - } - - // Wait for state change with timeout (runtime-agnostic) let timeout_duration = timeout.unwrap_or(CONNECTION_TIMEOUT); - let mut timeout_delay = pin!(Delay::new(timeout_duration).fuse()); - - loop { - // Re-check state to catch any changes that occurred during subscription - let current_state = active_conn.state().await?; - let state = ActiveConnectionState::from(current_state); - - match state { - ActiveConnectionState::Activated => { - debug!("Connection activated during loop"); - return Ok(()); - } - ActiveConnectionState::Deactivated => { - warn!("Connection deactivated during loop"); - return Err(refine_device_disconnected_error(conn, &active_conn).await); - } - _ => {} - } - - select! { - _ = timeout_delay => { - warn!("Connection activation timed out after {:?}", timeout_duration); - return Err(ConnectionError::Timeout); - } - signal_opt = stream.next() => { - match signal_opt { - Some(signal) => { - match signal.args() { - Ok(args) => { - let new_state = ActiveConnectionState::from(args.state); - let reason = ConnectionStateReason::from(args.reason); - trace!("Active connection state changed to: {new_state} (reason: {reason})"); - - match new_state { - ActiveConnectionState::Activated => { - trace!("Connection activation successful"); - return Ok(()); - } - ActiveConnectionState::Deactivated => { - debug!("Connection activation failed: {reason}"); - if reason == ConnectionStateReason::DeviceDisconnected { - return Err(refine_device_disconnected_error(conn, &active_conn).await); - } - return Err(connection_state_reason_to_error(args.reason)); - } - _ => {} - } - } - Err(e) => { - warn!("Failed to parse StateChanged signal args: {e}"); - } - } - } - None => { - return Err(ConnectionError::Stuck("signal stream ended".into())); - } - } - } - } - } + wait_for_activation_state( + stream, + || active_conn.state(), + refine_device_disconnected_error(conn, &active_conn), + timeout_duration, + ) + .await } /// Waits for a device to reach the disconnected state using D-Bus signals. @@ -179,133 +319,437 @@ pub(crate) async fn wait_for_device_disconnect( timeout: Option, ) -> Result<()> { // Subscribe to signals FIRST to avoid race condition - let mut stream = dev.receive_device_state_changed().await?; + let stream = dev.receive_device_state_changed().await?.map(|signal| { + signal + .args() + .map(|args| args.new_state) + .map_err(|error| warn!("Failed to parse StateChanged signal args: {error}")) + .ok() + }); trace!("Subscribed to device StateChanged signal for disconnect"); + let timeout_duration = timeout.unwrap_or(DISCONNECT_TIMEOUT); + wait_for_device_state( + stream, + || dev.state(), + is_disconnected_state, + timeout_duration, + disconnect_timeout_result, + WaitTarget::Disconnect, + ) + .await +} - let current_state = dev.state().await?; - trace!("Current device state for disconnect: {current_state}"); +/// Waits for a Wi-Fi device to be ready (Disconnected or Activated state). +pub(crate) async fn wait_for_wifi_device_ready(dev: &NMDeviceProxy<'_>) -> Result<()> { + // Subscribe to signals FIRST to avoid race condition + let stream = dev.receive_device_state_changed().await?.map(|signal| { + signal + .args() + .map(|args| args.new_state) + .map_err(|error| warn!("Failed to parse StateChanged signal args: {error}")) + .ok() + }); + trace!("Subscribed to device StateChanged signal for ready check"); + let ready_timeout = timeouts::wifi_ready_timeout(); + wait_for_device_state( + stream, + || dev.state(), + is_wifi_ready_state, + ready_timeout, + wifi_ready_timeout_result, + WaitTarget::WifiReady, + ) + .await +} - if current_state == device_state::DISCONNECTED || current_state == device_state::UNAVAILABLE { - debug!("Device already disconnected"); - return Ok(()); +#[cfg(test)] +mod tests { + use super::*; + + const ACTIVATING_STATE: u32 = 1; + const ACTIVATED_STATE: u32 = 2; + const DEACTIVATED_STATE: u32 = 4; + const NO_SPECIFIC_REASON: u32 = 1; + const DEVICE_DISCONNECTED_REASON: u32 = 3; + const NO_SECRETS_REASON: u32 = 9; + + #[test] + fn activation_states_classify_pending_and_success() { + for state in [ + ActiveConnectionState::Unknown, + ActiveConnectionState::Activating, + ActiveConnectionState::Deactivating, + ActiveConnectionState::Other(99), + ] { + assert!(matches!( + classify_activation_state(state, Some(9)), + ActivationDecision::Pending + )); + } + + assert!(matches!( + classify_activation_state(ActiveConnectionState::Activated, None), + ActivationDecision::Activated + )); } - // Wait for disconnect with timeout (runtime-agnostic) - let timeout_duration = timeout.unwrap_or(DISCONNECT_TIMEOUT); - let mut timeout_delay = pin!(Delay::new(timeout_duration).fuse()); + #[test] + fn deactivated_state_refines_device_disconnection() { + assert!(matches!( + classify_activation_state(ActiveConnectionState::Deactivated, None), + ActivationDecision::RefineDeviceError + )); + assert!(matches!( + classify_activation_state(ActiveConnectionState::Deactivated, Some(3)), + ActivationDecision::RefineDeviceError + )); + } - loop { - // Re-check state to catch any changes that occurred during subscription - let current_state = dev.state().await?; + #[test] + fn deactivated_state_maps_signal_reason_to_typed_error() { + assert!(matches!( + classify_activation_state(ActiveConnectionState::Deactivated, Some(9)), + ActivationDecision::Failed(ConnectionError::AuthFailed) + )); + assert!(matches!( + classify_activation_state(ActiveConnectionState::Deactivated, Some(5)), + ActivationDecision::Failed(ConnectionError::DhcpFailed) + )); + assert!(matches!( + classify_activation_state(ActiveConnectionState::Deactivated, Some(6)), + ActivationDecision::Failed(ConnectionError::Timeout) + )); + assert!(matches!( + classify_activation_state(ActiveConnectionState::Deactivated, Some(14)), + ActivationDecision::Failed(ConnectionError::ActivationFailed( + ConnectionStateReason::DeviceRemoved + )) + )); + } - if current_state == device_state::DISCONNECTED || current_state == device_state::UNAVAILABLE - { - debug!("Device disconnected during loop"); - return Ok(()); + #[test] + fn disconnect_target_states_are_exact() { + assert!(is_disconnected_state(device_state::DISCONNECTED)); + assert!(is_disconnected_state(device_state::UNAVAILABLE)); + assert!(!is_disconnected_state(device_state::ACTIVATED)); + assert!(!is_disconnected_state(0)); + } + + #[test] + fn wifi_ready_target_states_are_exact() { + assert!(is_wifi_ready_state(device_state::DISCONNECTED)); + assert!(is_wifi_ready_state(device_state::ACTIVATED)); + assert!(!is_wifi_ready_state(device_state::UNAVAILABLE)); + assert!(!is_wifi_ready_state(50)); + } + + #[test] + fn disconnect_timeout_rechecks_final_state() { + assert!(disconnect_timeout_result(device_state::DISCONNECTED).is_ok()); + assert!(disconnect_timeout_result(device_state::UNAVAILABLE).is_ok()); + assert!(matches!( + disconnect_timeout_result(110), + Err(ConnectionError::Stuck(state)) if state == "state 110" + )); + } + + #[test] + fn wifi_ready_timeout_rechecks_final_state() { + assert!(wifi_ready_timeout_result(device_state::ACTIVATED).is_ok()); + assert!(wifi_ready_timeout_result(device_state::DISCONNECTED).is_ok()); + assert!(matches!( + wifi_ready_timeout_result(device_state::UNAVAILABLE), + Err(ConnectionError::WifiNotReady) + )); + } + + #[test] + fn closed_signal_stream_maps_to_target_specific_error() { + for target in [WaitTarget::Activation, WaitTarget::Disconnect] { + assert!(matches!( + signal_stream_ended_error(target), + ConnectionError::Stuck(message) if message == "signal stream ended" + )); } + assert!(matches!( + signal_stream_ended_error(WaitTarget::WifiReady), + ConnectionError::WifiNotReady + )); + } - select! { - _ = timeout_delay => { - // Check final state - might have reached target during the last moments - let final_state = dev.state().await?; - if final_state == device_state::DISCONNECTED || final_state == device_state::UNAVAILABLE { - return Ok(()); - } else { - warn!("Disconnect timed out, device still in state: {final_state}"); - return Err(ConnectionError::Stuck(format!("state {final_state}"))); - } - } - signal_opt = stream.next() => { - match signal_opt { - Some(signal) => { - match signal.args() { - Ok(args) => { - let new_state = args.new_state; - trace!("Device state during disconnect: {new_state}"); - - if new_state == device_state::DISCONNECTED - || new_state == device_state::UNAVAILABLE - { - trace!("Device reached disconnected state"); - return Ok(()); - } - } - Err(e) => { - warn!("Failed to parse StateChanged signal args: {e}"); - } - } - } - None => { - return Err(ConnectionError::Stuck("signal stream ended".into())); - } - } - } + fn state_reader( + states: std::rc::Rc>>, + ) -> impl FnMut() -> futures::future::Ready> { + move || { + futures::future::ready(Ok(states + .borrow_mut() + .pop_front() + .expect("test provided enough state reads"))) } } -} -/// Waits for a Wi-Fi device to be ready (Disconnected or Activated state). -pub(crate) async fn wait_for_wifi_device_ready(dev: &NMDeviceProxy<'_>) -> Result<()> { - // Subscribe to signals FIRST to avoid race condition - let mut stream = dev.receive_device_state_changed().await?; - trace!("Subscribed to device StateChanged signal for ready check"); + fn run_activation_wait( + states: impl IntoIterator, + stream: S, + refined_error: ConnectionError, + timeout: Duration, + ) -> Result<()> + where + S: Stream>, + { + let states = std::rc::Rc::new(std::cell::RefCell::new(states.into_iter().collect())); + futures::executor::block_on(wait_for_activation_state( + stream, + state_reader(states), + futures::future::ready(refined_error), + timeout, + )) + } - let current_state = dev.state().await?; - trace!("Current device state for ready check: {current_state}"); + #[test] + fn activation_wait_accepts_initial_activated_state() { + let result = run_activation_wait( + [ACTIVATED_STATE], + futures::stream::pending(), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceDisconnected), + Duration::from_secs(1), + ); - if current_state == device_state::DISCONNECTED || current_state == device_state::ACTIVATED { - debug!("Device already ready"); - return Ok(()); + assert!(matches!(result, Ok(()))); } - let ready_timeout = timeouts::wifi_ready_timeout(); - let mut timeout_delay = pin!(Delay::new(ready_timeout).fuse()); + #[test] + fn activation_wait_observes_state_that_raced_with_subscription() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATED_STATE], + futures::stream::pending(), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceDisconnected), + Duration::from_secs(1), + ); - loop { - // Re-check state to catch any changes that occurred during subscription - let current_state = dev.state().await?; + assert!(matches!(result, Ok(()))); + } - if current_state == device_state::DISCONNECTED || current_state == device_state::ACTIVATED { - debug!("Device ready during loop"); - return Ok(()); - } + #[test] + fn activation_wait_maps_signal_reason_to_typed_error() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATING_STATE], + futures::stream::iter([Some((DEACTIVATED_STATE, NO_SECRETS_REASON))]), + ConnectionError::DhcpFailed, + Duration::from_secs(1), + ); - select! { - _ = timeout_delay => { - // Check final state - let final_state = dev.state().await?; - if final_state == device_state::DISCONNECTED || final_state == device_state::ACTIVATED { - return Ok(()); - } else { - warn!("Wi-Fi device not ready after timeout, state: {final_state}"); - return Err(ConnectionError::WifiNotReady); - } - } - signal_opt = stream.next() => { - match signal_opt { - Some(signal) => { - match signal.args() { - Ok(args) => { - let new_state = args.new_state; - trace!("Device state during ready wait: {new_state}"); - - if new_state == device_state::DISCONNECTED - || new_state == device_state::ACTIVATED - { - trace!("Device is now ready"); - return Ok(()); - } - } - Err(e) => { - warn!("Failed to parse StateChanged signal args: {e}"); - } - } - } - None => { - return Err(ConnectionError::WifiNotReady); - } - } - } - } + assert!(matches!(result, Err(ConnectionError::AuthFailed))); + } + + #[test] + fn activation_wait_uses_refined_device_error() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATING_STATE], + futures::stream::iter([Some((DEACTIVATED_STATE, DEVICE_DISCONNECTED_REASON))]), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceRemoved), + Duration::from_secs(1), + ); + + assert!(matches!( + result, + Err(ConnectionError::ActivationFailed( + ConnectionStateReason::DeviceRemoved + )) + )); + } + + #[test] + fn activation_wait_refines_initial_deactivated_state() { + let result = run_activation_wait( + [DEACTIVATED_STATE], + futures::stream::pending(), + ConnectionError::DhcpFailed, + Duration::from_secs(1), + ); + + assert!(matches!(result, Err(ConnectionError::DhcpFailed))); + } + + #[test] + fn activation_wait_ignores_malformed_signal_then_accepts_success() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATING_STATE, ACTIVATING_STATE], + futures::stream::iter([None, Some((ACTIVATED_STATE, NO_SPECIFIC_REASON))]), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceDisconnected), + Duration::from_secs(1), + ); + + assert!(matches!(result, Ok(()))); + } + + #[test] + fn activation_wait_reports_closed_signal_stream() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATING_STATE], + futures::stream::empty(), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceDisconnected), + Duration::from_secs(1), + ); + + assert!(matches!( + result, + Err(ConnectionError::Stuck(message)) if message == "signal stream ended" + )); + } + + #[test] + fn activation_wait_timeout_rechecks_final_activated_state() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATING_STATE, ACTIVATED_STATE], + futures::stream::pending(), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceDisconnected), + Duration::ZERO, + ); + + assert!(matches!(result, Ok(()))); + } + + #[test] + fn activation_wait_timeout_reports_final_pending_state() { + let result = run_activation_wait( + [ACTIVATING_STATE, ACTIVATING_STATE, ACTIVATING_STATE], + futures::stream::pending(), + ConnectionError::ActivationFailed(ConnectionStateReason::DeviceDisconnected), + Duration::ZERO, + ); + + assert!(matches!(result, Err(ConnectionError::Timeout))); + } + + #[test] + fn activation_wait_propagates_state_read_error() { + let result = futures::executor::block_on(wait_for_activation_state( + futures::stream::pending::>(), + || futures::future::ready(Err(zbus::Error::Failure("state read failed".into()))), + futures::future::ready(ConnectionError::DhcpFailed), + Duration::from_secs(1), + )); + + assert!(matches!( + result, + Err(ConnectionError::Dbus(zbus::Error::Failure(message))) + if message == "state read failed" + )); + } + + #[test] + fn device_wait_observes_state_that_raced_with_subscription() { + let states = std::rc::Rc::new(std::cell::RefCell::new( + [50, device_state::DISCONNECTED].into(), + )); + let stream = futures::stream::pending::>(); + + let result = futures::executor::block_on(wait_for_device_state( + stream, + state_reader(states), + is_disconnected_state, + Duration::from_secs(1), + disconnect_timeout_result, + WaitTarget::Disconnect, + )); + + assert!(matches!(result, Ok(()))); + } + + #[test] + fn device_wait_handles_malformed_then_terminal_signal() { + let states = std::rc::Rc::new(std::cell::RefCell::new([50, 50, 50].into())); + let stream = futures::stream::iter([None, Some(device_state::DISCONNECTED)]); + + let result = futures::executor::block_on(wait_for_device_state( + stream, + state_reader(states), + is_disconnected_state, + Duration::from_secs(1), + disconnect_timeout_result, + WaitTarget::Disconnect, + )); + + assert!(matches!(result, Ok(()))); + } + + #[test] + fn device_wait_reports_closed_stream() { + let states = std::rc::Rc::new(std::cell::RefCell::new([50, 50].into())); + let stream = futures::stream::empty::>(); + + let result = futures::executor::block_on(wait_for_device_state( + stream, + state_reader(states), + is_disconnected_state, + Duration::from_secs(1), + disconnect_timeout_result, + WaitTarget::Disconnect, + )); + + assert!(matches!( + result, + Err(ConnectionError::Stuck(message)) if message == "signal stream ended" + )); + } + + #[test] + fn device_wait_timeout_uses_final_state_recheck() { + let states = std::rc::Rc::new(std::cell::RefCell::new( + [50, 50, device_state::DISCONNECTED].into(), + )); + let stream = futures::stream::pending::>(); + + let result = futures::executor::block_on(wait_for_device_state( + stream, + state_reader(states), + is_disconnected_state, + Duration::ZERO, + disconnect_timeout_result, + WaitTarget::Disconnect, + )); + + assert!(matches!(result, Ok(()))); + } + + #[test] + fn device_wait_timeout_reports_final_non_target_state() { + let states = std::rc::Rc::new(std::cell::RefCell::new([50, 50, 110].into())); + let stream = futures::stream::pending::>(); + + let result = futures::executor::block_on(wait_for_device_state( + stream, + state_reader(states), + is_disconnected_state, + Duration::ZERO, + disconnect_timeout_result, + WaitTarget::Disconnect, + )); + + assert!(matches!( + result, + Err(ConnectionError::Stuck(message)) if message == "state 110" + )); + } + + #[test] + fn device_wait_propagates_state_read_error() { + let stream = futures::stream::pending::>(); + + let result = futures::executor::block_on(wait_for_device_state( + stream, + || futures::future::ready(Err(zbus::Error::Failure("state read failed".into()))), + is_disconnected_state, + Duration::from_secs(1), + disconnect_timeout_result, + WaitTarget::Disconnect, + )); + + assert!(matches!( + result, + Err(ConnectionError::Dbus(zbus::Error::Failure(message))) + if message == "state read failed" + )); } } diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs index 69a5f6a6..5bf9f127 100644 --- a/nmrs/src/core/vpn.rs +++ b/nmrs/src/core/vpn.rs @@ -46,17 +46,36 @@ fn detect_vpn_kind( /// Extracts a string from a `Dict` (vpn.data / vpn.secrets) by key. fn dict_str(dict: &zvariant::Dict<'_, '_>, key: &str) -> Option { dict.iter().find_map(|(k, v)| match (k, v) { - (zvariant::Value::Str(k_str), zvariant::Value::Str(v_str)) if k_str.as_str() == key => { - Some(v_str.to_string()) + (zvariant::Value::Str(k_str), value) if k_str.as_str() == key => { + match unbox_variant(value) { + zvariant::Value::Str(v_str) => Some(v_str.to_string()), + _ => None, + } } _ => None, }) } +fn unbox_variant<'a, 'v>(mut value: &'a zvariant::Value<'v>) -> &'a zvariant::Value<'v> { + while let zvariant::Value::Value(inner) = value { + value = inner; + } + value +} + +fn dict_value<'a, 'v>( + dict: &'a zvariant::Dict<'v, 'v>, + key: &str, +) -> Option<&'a zvariant::Value<'v>> { + dict.iter().find_map(|(k, v)| { + matches!(k, zvariant::Value::Str(k_str) if k_str.as_str() == key).then(|| unbox_variant(v)) + }) +} + /// Converts a full `Dict` to `HashMap`. fn dict_to_map(dict: &zvariant::Dict<'_, '_>) -> HashMap { dict.iter() - .filter_map(|(k, v)| match (k, v) { + .filter_map(|(k, v)| match (k, unbox_variant(v)) { (zvariant::Value::Str(k_str), zvariant::Value::Str(v_str)) => { Some((k_str.to_string(), v_str.to_string())) } @@ -129,13 +148,32 @@ fn decode_wg_first_peer( (pk, ep, ips, ka) } zvariant::Value::Array(arr) => { - if let Some(zvariant::Value::Dict(dict)) = arr.first() { + if let Some(first) = arr.first() + && let zvariant::Value::Dict(dict) = unbox_variant(first) + { let pk = dict_str(dict, "public-key"); let ep = dict_str(dict, "endpoint"); - let ips = dict_str(dict, "allowed-ips") - .map(|s| s.split(';').map(|p| p.trim().to_string()).collect()) - .unwrap_or_default(); - let ka = dict_str(dict, "persistent-keepalive").and_then(|s| s.parse().ok()); + let ips = match dict_value(dict, "allowed-ips") { + Some(zvariant::Value::Str(value)) => value + .split(';') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect(), + Some(zvariant::Value::Array(values)) => values + .iter() + .filter_map(|value| match unbox_variant(value) { + zvariant::Value::Str(value) => Some(value.to_string()), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + let ka = match dict_value(dict, "persistent-keepalive") { + Some(zvariant::Value::U32(value)) => Some(*value), + Some(zvariant::Value::Str(value)) => value.parse().ok(), + _ => None, + }; return (pk, ep, ips, ka); } (None, None, vec![], None) @@ -948,19 +986,7 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result settings_map .get("wireguard") .and_then(|wg_sec| wg_sec.get("peers")) - .and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str().to_string()), - _ => None, - }) - .and_then(|peers| { - let first = peers.split(',').next()?.trim().to_string(); - for tok in first.split_whitespace() { - if let Some(rest) = tok.strip_prefix("endpoint=") { - return Some(rest.to_string()); - } - } - None - }), + .and_then(|peers| decode_wg_first_peer(peers).1), VpnKind::Plugin => extract_openvpn_gateway(&settings_map), }; @@ -1083,13 +1109,7 @@ fn extract_openvpn_details( ) -> Option { let remote_raw = extract_openvpn_data_value(settings_map, "remote")?; - let (remote, port) = if let Some(idx) = remote_raw.rfind(':') { - let host = remote_raw[..idx].to_string(); - let port = remote_raw[idx + 1..].parse::().unwrap_or(1194); - (host, port) - } else { - (remote_raw, 1194) - }; + let (remote, port) = parse_openvpn_remote(&remote_raw); let protocol = if extract_openvpn_data_value(settings_map, "proto-tcp").as_deref() == Some("yes") { @@ -1114,31 +1134,47 @@ fn extract_openvpn_details( }) } +fn parse_openvpn_remote(remote: &str) -> (String, u16) { + const DEFAULT_PORT: u16 = 1194; + + if let Some(bracketed) = remote.strip_prefix('[') + && let Some((host, suffix)) = bracketed.split_once(']') + { + let port = suffix + .strip_prefix(':') + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_PORT); + return (host.to_string(), port); + } + + if remote.matches(':').count() == 1 + && let Some((host, raw_port)) = remote.rsplit_once(':') + { + return ( + host.to_string(), + raw_port.parse::().unwrap_or(DEFAULT_PORT), + ); + } + + (remote.to_string(), DEFAULT_PORT) +} + fn extract_wireguard_details( settings_map: &HashMap>>, ) -> Option { let wg_sec = settings_map.get("wireguard")?; - let public_key = wg_sec.get("public-key").and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.to_string()), - _ => None, - }); - - let endpoint = wg_sec + let (peer_public_key, endpoint, _, _) = wg_sec .get("peers") - .and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str().to_string()), + .map(decode_wg_first_peer) + .unwrap_or((None, None, Vec::new(), None)); + let public_key = wg_sec + .get("public-key") + .and_then(|value| match unbox_variant(value) { + zvariant::Value::Str(value) => Some(value.to_string()), _ => None, }) - .and_then(|peers| { - let first = peers.split(',').next()?.trim().to_string(); - for tok in first.split_whitespace() { - if let Some(rest) = tok.strip_prefix("endpoint=") { - return Some(rest.to_string()); - } - } - None - }); + .or(peer_public_key); Some(VpnDetails::WireGuard { public_key, @@ -1233,6 +1269,95 @@ mod tests { } } + #[test] + fn decode_openconnect_prefers_data_username_and_preserves_flags() { + let data = HashMap::from([ + ("gateway".to_string(), "vpn.example.com".to_string()), + ("username".to_string(), "data-user".to_string()), + ("protocol".to_string(), "anyconnect".to_string()), + ]); + let mut settings = + vpn_settings_with_service("org.freedesktop.NetworkManager.openconnect", data); + settings.get_mut("vpn").unwrap().insert( + "user-name".into(), + zvariant::Value::Str("section-user".into()), + ); + settings + .get_mut("vpn") + .unwrap() + .insert("password-flags".into(), zvariant::Value::U32(1)); + + assert!(matches!( + vpn_type_from_settings(VpnKind::Plugin, &settings), + VpnType::OpenConnect { + gateway: Some(ref gateway), + user_name: Some(ref user), + protocol: Some(ref protocol), + password_flags, + } if gateway == "vpn.example.com" + && user == "data-user" + && protocol == "anyconnect" + && password_flags.agent_owned() + )); + } + + #[test] + fn decode_pptp_falls_back_to_section_username() { + let data = HashMap::from([("gateway".to_string(), "pptp.example.com".to_string())]); + let mut settings = vpn_settings_with_service("org.freedesktop.NetworkManager.pptp", data); + settings.get_mut("vpn").unwrap().insert( + "user-name".into(), + zvariant::Value::Str("fallback-user".into()), + ); + + assert!(matches!( + vpn_type_from_settings(VpnKind::Plugin, &settings), + VpnType::Pptp { + gateway: Some(ref gateway), + user_name: Some(ref user), + password_flags: VpnSecretFlags(0), + } if gateway == "pptp.example.com" && user == "fallback-user" + )); + } + + #[test] + fn decode_plugin_without_vpn_section_is_empty_generic() { + let settings = HashMap::new(); + assert!(matches!( + vpn_type_from_settings(VpnKind::Plugin, &settings), + VpnType::Generic { + ref service_type, + ref data, + ref secrets, + user_name: None, + password_flags: VpnSecretFlags(0), + } if service_type.is_empty() && data.is_empty() && secrets.is_empty() + )); + } + + #[test] + fn malformed_plugin_sections_fall_back_without_panicking() { + let vpn = HashMap::from([ + ("service-type".into(), zvariant::Value::U32(7)), + ("data".into(), zvariant::Value::Str("not-a-dict".into())), + ("secrets".into(), zvariant::Value::U32(9)), + ("user-name".into(), zvariant::Value::Str("".into())), + ("password-flags".into(), zvariant::Value::Str("bad".into())), + ]); + let settings = HashMap::from([("vpn".into(), vpn)]); + + assert!(matches!( + vpn_type_from_settings(VpnKind::Plugin, &settings), + VpnType::Generic { + ref service_type, + ref data, + ref secrets, + user_name: None, + password_flags: VpnSecretFlags(0), + } if service_type.is_empty() && data.is_empty() && secrets.is_empty() + )); + } + #[test] fn decode_strongswan() { let data = HashMap::from([ @@ -1395,6 +1520,50 @@ mod tests { } } + #[test] + fn openvpn_remote_parser_handles_hosts_ports_and_ipv6() { + assert_eq!( + parse_openvpn_remote("vpn.example.com:443"), + ("vpn.example.com".into(), 443) + ); + assert_eq!( + parse_openvpn_remote("vpn.example.com"), + ("vpn.example.com".into(), 1194) + ); + assert_eq!( + parse_openvpn_remote("vpn.example.com:not-a-port"), + ("vpn.example.com".into(), 1194) + ); + assert_eq!( + parse_openvpn_remote("vpn.example.com:70000"), + ("vpn.example.com".into(), 1194) + ); + assert_eq!( + parse_openvpn_remote("[2001:db8::1]:443"), + ("2001:db8::1".into(), 443) + ); + assert_eq!( + parse_openvpn_remote("2001:db8::1"), + ("2001:db8::1".into(), 1194) + ); + } + + #[test] + fn openvpn_details_uses_comp_lzo_fallback() { + let settings = openvpn_settings_with_data(HashMap::from([ + ("remote".to_string(), "vpn.example.com".to_string()), + ("comp-lzo".to_string(), "yes".to_string()), + ])); + assert!(matches!( + extract_openvpn_details(&settings), + Some(VpnDetails::OpenVpn { + port: 1194, + compression: Some(ref compression), + .. + }) if compression == "lzo" + )); + } + fn wireguard_settings( pairs: Vec<(&str, zvariant::Value<'static>)>, ) -> HashMap>> { @@ -1430,4 +1599,93 @@ mod tests { _ => panic!("expected WireGuard variant"), } } + + #[test] + fn decode_wireguard_string_peer_representation() { + let settings = wireguard_settings(vec![ + ( + "private-key", + zvariant::Value::Str("private-key-material".into()), + ), + ( + "peers", + zvariant::Value::Str( + "public-key=peer-key endpoint=vpn.example.com:51820 \ + allowed-ips=0.0.0.0/0;::/0 persistent-keepalive=25, \ + public-key=second" + .into(), + ), + ), + ]); + + assert!(matches!( + vpn_type_from_settings(VpnKind::WireGuard, &settings), + VpnType::WireGuard { + private_key: Some(ref private_key), + peer_public_key: Some(ref public_key), + endpoint: Some(ref endpoint), + ref allowed_ips, + persistent_keepalive: Some(25), + } if private_key == "private-key-material" + && public_key == "peer-key" + && endpoint == "vpn.example.com:51820" + && allowed_ips == &["0.0.0.0/0".to_string(), "::/0".to_string()] + )); + } + + #[test] + fn decode_wireguard_native_array_peer_representation() { + let peer: HashMap> = HashMap::from([ + ("public-key".into(), zvariant::Value::Str("peer-key".into())), + ( + "endpoint".into(), + zvariant::Value::Str("vpn.example.com:51820".into()), + ), + ( + "allowed-ips".into(), + zvariant::Value::from(vec!["10.0.0.0/8".to_string(), "::/0".to_string()]), + ), + ("persistent-keepalive".into(), zvariant::Value::U32(30)), + ]); + let settings = wireguard_settings(vec![("peers", zvariant::Value::from(vec![peer]))]); + + assert!(matches!( + vpn_type_from_settings(VpnKind::WireGuard, &settings), + VpnType::WireGuard { + private_key: None, + peer_public_key: Some(ref public_key), + endpoint: Some(ref endpoint), + ref allowed_ips, + persistent_keepalive: Some(30), + } if public_key == "peer-key" + && endpoint == "vpn.example.com:51820" + && allowed_ips == &["10.0.0.0/8".to_string(), "::/0".to_string()] + )); + + assert!(matches!( + extract_wireguard_details(&settings), + Some(VpnDetails::WireGuard { + public_key: Some(ref public_key), + endpoint: Some(ref endpoint), + }) if public_key == "peer-key" && endpoint == "vpn.example.com:51820" + )); + } + + #[test] + fn malformed_wireguard_fields_return_empty_details() { + let settings = wireguard_settings(vec![ + ("private-key", zvariant::Value::U32(1)), + ("peers", zvariant::Value::U32(2)), + ]); + assert_eq!( + vpn_type_from_settings(VpnKind::WireGuard, &settings), + VpnType::WireGuard { + private_key: None, + peer_public_key: None, + endpoint: None, + allowed_ips: Vec::new(), + persistent_keepalive: None, + } + ); + } } diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs index 7815200d..dc89f2c0 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -82,18 +82,3 @@ pub(crate) async fn current_bluetooth_bdaddr(conn: &Connection) -> Option + Send>>; + /// Monitors device state changes on all network devices. /// /// Subscribes to `StateChanged` signals on all network devices. When any signal @@ -34,49 +36,75 @@ use crate::dbus::{NMDeviceProxy, NMProxy}; /// ``` pub async fn monitor_device_changes( conn: &Connection, - mut shutdown: watch::Receiver<()>, + shutdown: watch::Receiver<()>, callback: F, + ready_tx: oneshot::Sender>, ) -> Result<()> where F: Fn() + Send + 'static, { - let nm = NMProxy::new(conn).await?; - - // Use dynamic dispatch to handle different signal stream types - let mut streams: Vec + Send>>> = Vec::new(); - - // Subscribe to DeviceAdded and DeviceRemoved signals from main NetworkManager - // This is more reliable than subscribing to individual devices - let device_added_stream = nm.receive_device_added().await?; - let device_removed_stream = nm.receive_device_removed().await?; - let state_changed_stream = nm.receive_state_changed().await?; - - streams.push(Box::pin(device_added_stream.map(|_| ()))); - streams.push(Box::pin(device_removed_stream.map(|_| ()))); - streams.push(Box::pin(state_changed_stream.map(|_| ()))); - - trace!("Subscribed to NetworkManager device signals"); - - // Also subscribe to individual device state changes for existing devices - let devices = nm.get_devices().await?; - for dev_path in devices { - if let Ok(dev) = NMDeviceProxy::builder(conn) - .path(dev_path.clone())? - .build() - .await - && let Ok(state_stream) = dev.receive_device_state_changed().await - { - streams.push(Box::pin(state_stream.map(|_| ()))); - trace!("Subscribed to state change signals on device: {dev_path}"); + let setup: Result> = async { + let nm = NMProxy::new(conn).await?; + + // Use dynamic dispatch to handle different signal stream types. + let mut streams: Vec = Vec::new(); + + // Main-manager signals cover hotplug and global state changes. + let device_added_stream = nm.receive_device_added().await?; + let device_removed_stream = nm.receive_device_removed().await?; + let state_changed_stream = nm.receive_state_changed().await?; + + streams.push(Box::pin(device_added_stream.map(|_| ()))); + streams.push(Box::pin(device_removed_stream.map(|_| ()))); + streams.push(Box::pin(state_changed_stream.map(|_| ()))); + + trace!("Subscribed to NetworkManager device signals"); + + // Existing devices also expose more specific state transitions. + for dev_path in nm.get_devices().await? { + if let Ok(dev) = NMDeviceProxy::builder(conn) + .path(dev_path.clone())? + .build() + .await + && let Ok(state_stream) = dev.receive_device_state_changed().await + { + streams.push(Box::pin(state_stream.map(|_| ()))); + trace!("Subscribed to state change signals on device: {dev_path}"); + } } + + Ok(streams) } + .await; + + let streams = match setup { + Ok(streams) => streams, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return Ok(()); + } + }; debug!( "Monitoring {} signal streams for device changes", streams.len() ); - // Merge all streams and listen for any signal + if ready_tx.send(Ok(())).is_err() { + return Ok(()); + } + + run_device_change_streams(shutdown, streams, callback).await +} + +async fn run_device_change_streams( + mut shutdown: watch::Receiver<()>, + streams: Vec, + callback: F, +) -> Result<()> +where + F: Fn() + Send + 'static, +{ let mut merged = futures::stream::select_all(streams); loop { @@ -96,3 +124,66 @@ where } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use futures::stream; + + use super::*; + + #[tokio::test] + async fn signal_invokes_callback_before_ended_stream_is_reported() { + let (_shutdown_tx, shutdown_rx) = watch::channel(()); + let calls = Arc::new(AtomicUsize::new(0)); + let callback_calls = Arc::clone(&calls); + let streams: Vec = vec![Box::pin(stream::iter([()]))]; + + let result = run_device_change_streams(shutdown_rx, streams, move || { + callback_calls.fetch_add(1, Ordering::SeqCst); + }) + .await; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(matches!( + result, + Err(ConnectionError::Stuck(message)) + if message == "device monitoring stream ended unexpectedly" + )); + } + + #[tokio::test] + async fn shutdown_stops_monitor_without_invoking_callback() { + let (shutdown_tx, shutdown_rx) = watch::channel(()); + let calls = Arc::new(AtomicUsize::new(0)); + let callback_calls = Arc::clone(&calls); + let streams: Vec = vec![Box::pin(stream::pending())]; + + let monitor = run_device_change_streams(shutdown_rx, streams, move || { + callback_calls.fetch_add(1, Ordering::SeqCst); + }); + let request_shutdown = async move { + tokio::task::yield_now().await; + shutdown_tx.send(()).expect("monitor still listening"); + }; + + let (result, ()) = tokio::join!(monitor, request_shutdown); + assert!(result.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn empty_signal_set_is_an_error() { + let (_shutdown_tx, shutdown_rx) = watch::channel(()); + + let result = run_device_change_streams(shutdown_rx, Vec::new(), || {}).await; + + assert!(matches!( + result, + Err(ConnectionError::Stuck(message)) + if message == "device monitoring stream ended unexpectedly" + )); + } +} diff --git a/nmrs/src/monitoring/events.rs b/nmrs/src/monitoring/events.rs index 3b44b40f..d8d5959d 100644 --- a/nmrs/src/monitoring/events.rs +++ b/nmrs/src/monitoring/events.rs @@ -2,9 +2,9 @@ use std::pin::Pin; -use futures::channel::mpsc; +use futures::channel::{mpsc, oneshot}; use futures::stream::{Stream, StreamExt}; -use log::{trace, warn}; +use log::trace; use zbus::Connection; use zvariant::OwnedObjectPath; @@ -25,62 +25,98 @@ enum InternalEvent { DeviceRemoved, } +enum InternalEventAction { + Event(NetworkEvent), + Error(ConnectionError), + MonitorAccessPoint(OwnedObjectPath), + MonitorDevice(OwnedObjectPath), +} + +fn classify_internal_event(event: InternalEvent) -> InternalEventAction { + match event { + InternalEvent::Event(event) => InternalEventAction::Event(event), + InternalEvent::Error(error) => InternalEventAction::Error(error), + InternalEvent::AccessPointAdded(path) => InternalEventAction::MonitorAccessPoint(path), + InternalEvent::AccessPointRemoved => { + InternalEventAction::Event(NetworkEvent::AccessPointsChanged) + } + InternalEvent::DeviceAdded(path) => InternalEventAction::MonitorDevice(path), + InternalEvent::DeviceRemoved => InternalEventAction::Event(device_change_event(None)), + } +} + /// Creates a unified refresh-oriented stream of NetworkManager events. pub(crate) async fn network_events(conn: &Connection) -> Result { - NMProxy::new(conn).await?; - let (tx, rx) = mpsc::unbounded(); + let (ready_tx, ready_rx) = oneshot::channel(); let conn = conn.clone(); tokio::spawn(async move { - if let Err(err) = run_network_events(conn, tx.clone()).await { + if let Err(err) = run_network_events(conn, tx.clone(), ready_tx).await { let _ = tx.unbounded_send(Err(err)); } }); + ready_rx.await.map_err(|_| { + ConnectionError::Stuck("network event task ended before becoming ready".into()) + })??; + Ok(Box::pin(rx)) } async fn run_network_events( conn: Connection, tx: mpsc::UnboundedSender>, + ready_tx: oneshot::Sender>, ) -> Result<()> { - let nm = NMProxy::new(&conn).await?; - let dbus = zbus::fdo::DBusProxy::new(&conn).await?; - let mut streams = base_network_event_streams(&nm, &dbus).await?; - - match settings::settings_events(&conn).await { - Ok(settings_stream) => { - streams.push(Box::pin(settings_stream.map(|item| match item { - Ok(change) => InternalEvent::Event(settings_change_event(change)), - Err(err) => InternalEvent::Error(err), - }))); - } - Err(err) => warn!("failed to subscribe to settings events: {err}"), + macro_rules! setup_or_report { + ($future:expr) => { + match $future.await { + Ok(value) => value, + Err(error) => { + let _ = ready_tx.send(Err(error.into())); + return Ok(()); + } + } + }; } - for stream in device_state_streams(&conn, &nm).await? { + let nm = setup_or_report!(NMProxy::new(&conn)); + let dbus = setup_or_report!(zbus::fdo::DBusProxy::new(&conn)); + let mut streams = setup_or_report!(base_network_event_streams(&nm, &dbus)); + + let settings_stream = setup_or_report!(settings::settings_events(&conn)); + streams.push(Box::pin(settings_stream.map(|item| match item { + Ok(change) => InternalEvent::Event(settings_change_event(change)), + Err(err) => InternalEvent::Error(err), + }))); + + for stream in setup_or_report!(device_state_streams(&conn, &nm)) { streams.push(stream); } - for stream in access_point_streams(&conn, &nm).await? { + for stream in setup_or_report!(access_point_streams(&conn, &nm)) { streams.push(stream); } + if ready_tx.send(Ok(())).is_err() { + return Ok(()); + } + let mut merged = futures::stream::select_all(streams); while let Some(internal) = merged.next().await { - match internal { - InternalEvent::Event(event) => { + match classify_internal_event(internal) { + InternalEventAction::Event(event) => { if !send_event(&tx, event) { return Ok(()); } } - InternalEvent::Error(err) => { + InternalEventAction::Error(err) => { if !send_error(&tx, err) { return Ok(()); } } - InternalEvent::AccessPointAdded(path) => { + InternalEventAction::MonitorAccessPoint(path) => { if !send_event(&tx, NetworkEvent::AccessPointsChanged) { return Ok(()); } @@ -89,12 +125,7 @@ async fn run_network_events( Err(err) => trace!("failed to monitor access point {path}: {err}"), } } - InternalEvent::AccessPointRemoved => { - if !send_event(&tx, NetworkEvent::AccessPointsChanged) { - return Ok(()); - } - } - InternalEvent::DeviceAdded(path) => { + InternalEventAction::MonitorDevice(path) => { let event = device_changed_for_path(&conn, &path).await; if !send_event(&tx, event) { return Ok(()); @@ -112,11 +143,6 @@ async fn run_network_events( Err(err) => trace!("failed to monitor wireless device {path}: {err}"), } } - InternalEvent::DeviceRemoved => { - if !send_event(&tx, device_change_event(None)) { - return Ok(()); - } - } } } @@ -335,27 +361,114 @@ fn send_error(tx: &mpsc::UnboundedSender>, err: ConnectionE #[cfg(test)] mod tests { + use futures::StreamExt; + use super::*; + fn path(value: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(value).expect("valid object path") + } + #[test] - fn settings_change_maps_to_network_event() { - let event = settings_change_event(SettingsChange::Reloaded); + fn internal_event_classifier_preserves_every_event_kind() { + assert!(matches!( + classify_internal_event(InternalEvent::Event(NetworkEvent::ConnectivityChanged)), + InternalEventAction::Event(NetworkEvent::ConnectivityChanged) + )); + assert!(matches!( + classify_internal_event(InternalEvent::Error(ConnectionError::Stuck( + "stream failed".into() + ))), + InternalEventAction::Error(ConnectionError::Stuck(message)) + if message == "stream failed" + )); + + let access_point = path("/org/freedesktop/NetworkManager/AccessPoint/4"); + match classify_internal_event(InternalEvent::AccessPointAdded(access_point.clone())) { + InternalEventAction::MonitorAccessPoint(actual) => { + assert_eq!(actual, access_point); + } + _ => panic!("expected access-point monitoring action"), + } + assert!(matches!( + classify_internal_event(InternalEvent::AccessPointRemoved), + InternalEventAction::Event(NetworkEvent::AccessPointsChanged) + )); + let device = path("/org/freedesktop/NetworkManager/Devices/2"); + match classify_internal_event(InternalEvent::DeviceAdded(device.clone())) { + InternalEventAction::MonitorDevice(actual) => assert_eq!(actual, device), + _ => panic!("expected device monitoring action"), + } assert!(matches!( - event, - NetworkEvent::SettingsChanged(SettingsChange::Reloaded) + classify_internal_event(InternalEvent::DeviceRemoved), + InternalEventAction::Event(NetworkEvent::DeviceChanged { interface: None }) )); } #[test] - fn device_change_keeps_interface_name() { + fn settings_change_preserves_variant_and_path() { + let expected = path("/org/freedesktop/NetworkManager/Settings/17"); + let event = settings_change_event(SettingsChange::Updated { + path: expected.clone(), + }); + + match event { + NetworkEvent::SettingsChanged(SettingsChange::Updated { path }) => { + assert_eq!(path, expected) + } + other => panic!("expected updated settings event, got {other:?}"), + } + } + + #[test] + fn device_change_preserves_known_and_unknown_interfaces() { let event = device_change_event(Some("wlan0".into())); match event { NetworkEvent::DeviceChanged { interface } => { assert_eq!(interface.as_deref(), Some("wlan0")); } - _ => panic!("unexpected event"), + other => panic!("expected device event, got {other:?}"), } + + assert!(matches!( + device_change_event(None), + NetworkEvent::DeviceChanged { interface: None } + )); + } + + #[tokio::test] + async fn send_event_delivers_value_and_reports_closed_consumer() { + let (tx, mut rx) = mpsc::unbounded(); + + assert!(send_event(&tx, NetworkEvent::ConnectivityChanged)); + assert!(matches!( + rx.next().await.expect("network event").unwrap(), + NetworkEvent::ConnectivityChanged + )); + + drop(rx); + assert!(!send_event(&tx, NetworkEvent::AccessPointsChanged)); + } + + #[tokio::test] + async fn send_error_delivers_error_and_reports_closed_consumer() { + let (tx, mut rx) = mpsc::unbounded(); + + assert!(send_error( + &tx, + ConnectionError::Stuck("signal ended".into()) + )); + assert!(matches!( + rx.next().await.expect("network error"), + Err(ConnectionError::Stuck(message)) if message == "signal ended" + )); + + drop(rx); + assert!(!send_error( + &tx, + ConnectionError::Stuck("consumer gone".into()) + )); } } diff --git a/nmrs/src/monitoring/network.rs b/nmrs/src/monitoring/network.rs index 6748bcec..fe736d87 100644 --- a/nmrs/src/monitoring/network.rs +++ b/nmrs/src/monitoring/network.rs @@ -9,7 +9,7 @@ use log::{debug, trace, warn}; use std::collections::HashSet; use std::pin::Pin; use tokio::select; -use tokio::sync::watch; +use tokio::sync::{oneshot, watch}; use zbus::Connection; use zvariant::OwnedObjectPath; @@ -27,6 +27,37 @@ enum NetworkChange { DeviceAdded(OwnedObjectPath), } +#[derive(Debug, PartialEq, Eq)] +enum NetworkChangeAction { + Notify, + AccessPointAdded { + path: OwnedObjectPath, + newly_monitored: bool, + }, + DeviceAdded(OwnedObjectPath), +} + +fn apply_network_change( + change: NetworkChange, + monitored_access_points: &mut HashSet, +) -> NetworkChangeAction { + match change { + NetworkChange::Added(path) => { + let newly_monitored = monitored_access_points.insert(path.to_string()); + NetworkChangeAction::AccessPointAdded { + path, + newly_monitored, + } + } + NetworkChange::Removed(path) => { + monitored_access_points.remove(path.as_str()); + NetworkChangeAction::Notify + } + NetworkChange::SignalStrengthChanged => NetworkChangeAction::Notify, + NetworkChange::DeviceAdded(path) => NetworkChangeAction::DeviceAdded(path), + } +} + /// Monitors access point changes on all Wi-Fi devices. /// /// Subscribes to `AccessPointAdded` and `AccessPointRemoved` signals on all @@ -49,18 +80,89 @@ pub async fn monitor_network_changes( conn: &Connection, mut shutdown: watch::Receiver<()>, callback: F, + ready_tx: oneshot::Sender>, ) -> Result<()> where F: Fn() + Send + 'static, { + let (streams, mut monitored_access_points) = match initial_network_change_streams(conn).await { + Ok(setup) => setup, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return Ok(()); + } + }; + + debug!( + "Monitoring {} signal streams for network changes", + streams.len() + ); + + if ready_tx.send(Ok(())).is_err() { + return Ok(()); + } + + // Merge all streams and listen for any signal + let mut merged = futures::stream::select_all(streams); + + loop { + select! { + _ = shutdown.changed() => { + debug!("Network monitoring shutdown requested"); + return Ok(()); + } + signal = merged.next() => { + match signal.map(|change| { + apply_network_change(change, &mut monitored_access_points) + }) { + Some(NetworkChangeAction::AccessPointAdded { + path, + newly_monitored, + }) => { + if newly_monitored { + match access_point_strength_stream(conn, path.clone()).await { + Ok(stream) => merged.push(stream), + Err(err) => debug!( + "Failed to monitor signal strength for access point {}: {}", + path, err + ), + } + } + callback(); + } + Some(NetworkChangeAction::Notify) => callback(), + Some(NetworkChangeAction::DeviceAdded(dev_path)) => { + if let Err(err) = subscribe_wifi_device( + conn, + &dev_path, + &mut merged, + &mut monitored_access_points, + ) + .await + { + trace!("Hotplugged device {dev_path} is not Wi-Fi or failed: {err}"); + } else { + debug!("Subscribed to hotplugged Wi-Fi device: {dev_path}"); + callback(); + } + } + None => return Err(ConnectionError::Stuck( + "network monitoring stream ended unexpectedly".into(), + )), + } + } + } + } +} + +async fn initial_network_change_streams( + conn: &Connection, +) -> Result<(Vec, HashSet)> { let nm = NMProxy::new(conn).await?; let devices = nm.get_devices().await?; - - // Use dynamic dispatch to handle different signal stream types let mut streams: Vec = Vec::new(); let mut monitored_access_points = HashSet::new(); - // Subscribe to signals on all Wi-Fi devices for dev_path in devices { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? @@ -135,61 +237,7 @@ where warn!("No Wi-Fi devices found to monitor (listening for hotplug)"); } - debug!( - "Monitoring {} signal streams for network changes", - streams.len() - ); - - // Merge all streams and listen for any signal - let mut merged = futures::stream::select_all(streams); - - loop { - select! { - _ = shutdown.changed() => { - debug!("Network monitoring shutdown requested"); - return Ok(()); - } - signal = merged.next() => { - match signal { - Some(NetworkChange::Added(path)) => { - if monitored_access_points.insert(path.to_string()) { - match access_point_strength_stream(conn, path.clone()).await { - Ok(stream) => merged.push(stream), - Err(err) => debug!( - "Failed to monitor signal strength for access point {}: {}", - path, err - ), - } - } - callback(); - } - Some(NetworkChange::Removed(path)) => { - monitored_access_points.remove(path.as_str()); - callback(); - } - Some(NetworkChange::SignalStrengthChanged) => callback(), - Some(NetworkChange::DeviceAdded(dev_path)) => { - if let Err(err) = subscribe_wifi_device( - conn, - &dev_path, - &mut merged, - &mut monitored_access_points, - ) - .await - { - trace!("Hotplugged device {dev_path} is not Wi-Fi or failed: {err}"); - } else { - debug!("Subscribed to hotplugged Wi-Fi device: {dev_path}"); - callback(); - } - } - None => return Err(ConnectionError::Stuck( - "network monitoring stream ended unexpectedly".into(), - )), - } - } - } - } + Ok((streams, monitored_access_points)) } async fn subscribe_wifi_device( @@ -268,3 +316,59 @@ async fn access_point_strength_stream( Ok(Box::pin(stream)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(value).expect("valid object path") + } + + #[test] + fn access_point_tracking_distinguishes_new_duplicate_and_removed_paths() { + let access_point = path("/org/freedesktop/NetworkManager/AccessPoint/7"); + let mut monitored = HashSet::new(); + + assert_eq!( + apply_network_change(NetworkChange::Added(access_point.clone()), &mut monitored), + NetworkChangeAction::AccessPointAdded { + path: access_point.clone(), + newly_monitored: true, + } + ); + assert_eq!(monitored, HashSet::from([access_point.to_string()])); + + assert_eq!( + apply_network_change(NetworkChange::Added(access_point.clone()), &mut monitored), + NetworkChangeAction::AccessPointAdded { + path: access_point.clone(), + newly_monitored: false, + } + ); + assert_eq!(monitored.len(), 1); + + assert_eq!( + apply_network_change(NetworkChange::Removed(access_point), &mut monitored), + NetworkChangeAction::Notify + ); + assert!(monitored.is_empty()); + } + + #[test] + fn non_membership_changes_preserve_tracking_and_action() { + let existing = path("/org/freedesktop/NetworkManager/AccessPoint/3"); + let device = path("/org/freedesktop/NetworkManager/Devices/2"); + let mut monitored = HashSet::from([existing.to_string()]); + + assert_eq!( + apply_network_change(NetworkChange::SignalStrengthChanged, &mut monitored), + NetworkChangeAction::Notify + ); + assert_eq!( + apply_network_change(NetworkChange::DeviceAdded(device.clone()), &mut monitored), + NetworkChangeAction::DeviceAdded(device) + ); + assert_eq!(monitored, HashSet::from([existing.to_string()])); + } +} diff --git a/nmrs/src/monitoring/settings.rs b/nmrs/src/monitoring/settings.rs index 5ae3d44d..0213e464 100644 --- a/nmrs/src/monitoring/settings.rs +++ b/nmrs/src/monitoring/settings.rs @@ -2,7 +2,7 @@ use std::pin::Pin; -use futures::channel::mpsc; +use futures::channel::{mpsc, oneshot}; use futures::stream::{Stream, StreamExt}; use log::{trace, warn}; use zbus::Connection; @@ -27,65 +27,82 @@ pub(crate) async fn settings_events(conn: &Connection) -> Result>, + ready_tx: oneshot::Sender>, ) -> Result<()> { - let settings = NMSettingsProxy::new(&conn).await?; - let mut streams: Vec = Vec::new(); - - let new_connection = settings.receive_new_connection().await?; - streams.push(Box::pin(new_connection.map(|signal| { - signal.args().map_or_else( - |_| SettingsSignal::Unknown, - |args| SettingsSignal::Added(args.connection().clone()), - ) - }))); - - let connection_removed = settings.receive_connection_removed().await?; - streams.push(Box::pin(connection_removed.map(|signal| { - signal.args().map_or_else( - |_| SettingsSignal::Unknown, - |args| SettingsSignal::Removed(args.connection().clone()), - ) - }))); - - streams.push(Box::pin( - settings - .receive_connections_changed() - .await - .skip(1) - .map(|_| SettingsSignal::Reloaded), - )); - - for path in settings.list_connections().await? { - match connection_settings_streams(&conn, path.clone()).await { - Ok(connection_streams) => streams.extend(connection_streams), - Err(err) => warn!("failed to monitor settings connection {path}: {err}"), + let setup: Result<_> = async { + let settings = NMSettingsProxy::new(&conn).await?; + let mut streams: Vec = Vec::new(); + + let new_connection = settings.receive_new_connection().await?; + streams.push(Box::pin(new_connection.map(|signal| { + signal.args().map_or_else( + |_| SettingsSignal::Unknown, + |args| SettingsSignal::Added(args.connection().clone()), + ) + }))); + + let connection_removed = settings.receive_connection_removed().await?; + streams.push(Box::pin(connection_removed.map(|signal| { + signal.args().map_or_else( + |_| SettingsSignal::Unknown, + |args| SettingsSignal::Removed(args.connection().clone()), + ) + }))); + + streams.push(Box::pin( + settings + .receive_connections_changed() + .await + .skip(1) + .map(|_| SettingsSignal::Reloaded), + )); + + for path in settings.list_connections().await? { + match connection_settings_streams(&conn, path.clone()).await { + Ok(connection_streams) => streams.extend(connection_streams), + Err(err) => warn!("failed to monitor settings connection {path}: {err}"), + } } + + Ok((settings, streams)) + } + .await; + + let (_settings, streams) = match setup { + Ok(setup) => setup, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return Ok(()); + } + }; + + if ready_tx.send(Ok(())).is_err() { + return Ok(()); } let mut merged = futures::stream::select_all(streams); while let Some(signal) = merged.next().await { match signal { SettingsSignal::Added(path) => { - if !send_change( - &tx, - settings_signal_to_change(SettingsSignal::Added(path.clone())), - ) { - return Ok(()); - } match connection_settings_streams(&conn, path.clone()).await { Ok(connection_streams) => { for stream in connection_streams { @@ -94,6 +111,9 @@ async fn run_settings_events( } Err(err) => warn!("failed to monitor new settings connection {path}: {err}"), } + if !send_change(&tx, settings_signal_to_change(SettingsSignal::Added(path))) { + return Ok(()); + } } signal => { if !send_change(&tx, settings_signal_to_change(signal)) { @@ -147,6 +167,8 @@ fn settings_signal_to_change(signal: SettingsSignal) -> SettingsChange { #[cfg(test)] mod tests { + use futures::StreamExt; + use super::*; fn path(value: &str) -> OwnedObjectPath { @@ -154,27 +176,50 @@ mod tests { } #[test] - fn settings_added_signal_maps_to_change() { - let change = settings_signal_to_change(SettingsSignal::Added(path( - "/org/freedesktop/NetworkManager/Settings/1", - ))); - - assert!(matches!(change, SettingsChange::Added { .. })); + fn every_settings_signal_maps_to_the_exact_public_change() { + let added_path = path("/org/freedesktop/NetworkManager/Settings/1"); + let removed_path = path("/org/freedesktop/NetworkManager/Settings/2"); + let updated_path = path("/org/freedesktop/NetworkManager/Settings/3"); + + match settings_signal_to_change(SettingsSignal::Added(added_path.clone())) { + SettingsChange::Added { path } => assert_eq!(path, added_path), + other => panic!("expected added change, got {other:?}"), + } + match settings_signal_to_change(SettingsSignal::Removed(removed_path.clone())) { + SettingsChange::Removed { path } => assert_eq!(path, removed_path), + other => panic!("expected removed change, got {other:?}"), + } + match settings_signal_to_change(SettingsSignal::Updated(updated_path.clone())) { + SettingsChange::Updated { path } => assert_eq!(path, updated_path), + other => panic!("expected updated change, got {other:?}"), + } + assert!(matches!( + settings_signal_to_change(SettingsSignal::Reloaded), + SettingsChange::Reloaded + )); + assert!(matches!( + settings_signal_to_change(SettingsSignal::Unknown), + SettingsChange::Unknown + )); } - #[test] - fn settings_updated_signal_maps_to_change() { - let change = settings_signal_to_change(SettingsSignal::Updated(path( - "/org/freedesktop/NetworkManager/Settings/2", - ))); + #[tokio::test] + async fn send_change_delivers_the_value_and_reports_closed_consumer() { + let (tx, mut rx) = mpsc::unbounded(); + let expected_path = path("/org/freedesktop/NetworkManager/Settings/9"); - assert!(matches!(change, SettingsChange::Updated { .. })); - } - - #[test] - fn settings_reloaded_signal_maps_to_change() { - let change = settings_signal_to_change(SettingsSignal::Reloaded); + assert!(send_change( + &tx, + SettingsChange::Removed { + path: expected_path.clone(), + } + )); + match rx.next().await.expect("settings result").unwrap() { + SettingsChange::Removed { path } => assert_eq!(path, expected_path), + other => panic!("expected removed change, got {other:?}"), + } - assert!(matches!(change, SettingsChange::Reloaded)); + drop(rx); + assert!(!send_change(&tx, SettingsChange::Unknown)); } } diff --git a/nmrs/src/types/constants.rs b/nmrs/src/types/constants.rs index f7246074..5e4b710d 100644 --- a/nmrs/src/types/constants.rs +++ b/nmrs/src/types/constants.rs @@ -10,6 +10,8 @@ pub mod device_type { pub const BLUETOOTH: u32 = 5; /// Mobile broadband / WWAN modem device. pub const MODEM: u32 = 8; + /// Virtual Ethernet pair device. + pub const VETH: u32 = 20; // pub const WIFI_P2P: u32 = 30; // pub const LOOPBACK: u32 = 32; } diff --git a/nmrs/src/types/device_type_registry.rs b/nmrs/src/types/device_type_registry.rs index 77a580b6..5090413b 100644 --- a/nmrs/src/types/device_type_registry.rs +++ b/nmrs/src/types/device_type_registry.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use std::sync::OnceLock; +use super::constants::device_type; + /// Trait for device type-specific behavior. /// /// Implement this trait to add support for a new device type. @@ -87,6 +89,23 @@ impl DeviceTypeInfo for EthernetDeviceType { } } +/// Linux virtual Ethernet pair device type implementation. +struct VethDeviceType; + +impl DeviceTypeInfo for VethDeviceType { + fn nm_type_code(&self) -> u32 { + device_type::VETH + } + + fn display_name(&self) -> &'static str { + "Veth" + } + + fn connection_type(&self) -> &'static str { + "802-3-ethernet" + } +} + /// WiFi P2P device type implementation. struct WifiP2PDeviceType; @@ -223,6 +242,7 @@ fn registry() -> &'static HashMap> { let types: Vec> = vec![ Box::new(EthernetDeviceType), + Box::new(VethDeviceType), Box::new(WifiDeviceType), Box::new(WifiP2PDeviceType), Box::new(LoopbackDeviceType), @@ -286,101 +306,56 @@ pub fn has_global_enabled_state(code: u32) -> bool { .unwrap_or(false) } +/// Returns whether a raw NetworkManager device type uses wired Ethernet settings. +pub fn is_wired(code: u32) -> bool { + connection_type_for_code(code) == Some("802-3-ethernet") +} + #[cfg(test)] mod tests { use super::*; #[test] - fn wifi_type_info() { - let info = get_device_type_info(2).expect("WiFi should be registered"); - assert_eq!(info.nm_type_code(), 2); - assert_eq!(info.display_name(), "Wi-Fi"); - assert_eq!(info.connection_type(), "802-11-wireless"); - assert!(info.supports_scanning()); - assert!(info.requires_specific_object()); - assert!(info.has_global_enabled_state()); - } - - #[test] - fn ethernet_type_info() { - let info = get_device_type_info(1).expect("Ethernet should be registered"); - assert_eq!(info.nm_type_code(), 1); - assert_eq!(info.display_name(), "Ethernet"); - assert_eq!(info.connection_type(), "802-3-ethernet"); - assert!(!info.supports_scanning()); - assert!(!info.requires_specific_object()); - } - - #[test] - fn wireguard_type_info() { - let info = get_device_type_info(29).expect("WireGuard should be registered"); - assert_eq!(info.nm_type_code(), 29); - assert_eq!(info.display_name(), "WireGuard"); - assert_eq!(info.connection_type(), "wireguard"); - } - - #[test] - fn loopback_type_info() { - let info = get_device_type_info(32).expect("Loopback should be registered"); - assert_eq!(info.nm_type_code(), 32); - assert_eq!(info.display_name(), "Loopback"); - assert_eq!(info.connection_type(), "loopback"); - assert!(!info.supports_scanning()); - assert!(!info.requires_specific_object()); - assert!(!info.has_global_enabled_state()); - } - - #[test] - fn unknown_device_type() { - let info = get_device_type_info(999); - assert!(info.is_none()); - } - - #[test] - fn display_name_for_unknown() { - let name = display_name_for_code(999); - assert_eq!(name, "Other(999)"); - } - - #[test] - fn wifi_supports_scanning() { - assert!(supports_scanning(2)); - assert!(!supports_scanning(1)); - } - - #[test] - fn wifi_requires_specific_object() { - assert!(requires_specific_object(2)); - assert!(!requires_specific_object(1)); - } - - #[test] - fn wifi_has_global_enabled_state() { - assert!(has_global_enabled_state(2)); - assert!(!has_global_enabled_state(1)); - } + fn registry_matches_networkmanager_metadata() { + let expected = [ + (1, "Ethernet", "802-3-ethernet", false, false, false, true), + (2, "Wi-Fi", "802-11-wireless", true, true, true, false), + (11, "VLAN", "vlan", false, false, false, false), + (12, "Bond", "bond", false, false, false, false), + (13, "Bridge", "bridge", false, false, false, false), + (16, "TUN", "tun", false, false, false, false), + (20, "Veth", "802-3-ethernet", false, false, false, true), + (29, "WireGuard", "wireguard", false, false, false, false), + (30, "Wi-Fi P2P", "wifi-p2p", true, false, false, false), + (32, "Loopback", "loopback", false, false, false, false), + ]; - #[test] - fn all_registered_types_have_connection_type() { - for code in [1u32, 2, 11, 12, 13, 16, 29, 30, 32] { - let conn_type = connection_type_for_code(code); - assert!( - conn_type.is_some(), - "Device type {} should have a connection type", - code - ); + assert_eq!(registry().len(), expected.len()); + for (code, name, connection_type, scanning, specific_object, global_state, wired) in + expected + { + let info = get_device_type_info(code) + .unwrap_or_else(|| panic!("device type {code} should be registered")); + assert_eq!(info.nm_type_code(), code); + assert_eq!(info.display_name(), name); + assert_eq!(display_name_for_code(code), name); + assert_eq!(info.connection_type(), connection_type); + assert_eq!(connection_type_for_code(code), Some(connection_type)); + assert_eq!(supports_scanning(code), scanning); + assert_eq!(requires_specific_object(code), specific_object); + assert_eq!(has_global_enabled_state(code), global_state); + assert_eq!(is_wired(code), wired); } } #[test] - fn registry_is_consistent() { - let reg = registry(); - for (code, type_info) in reg.iter() { - assert_eq!( - *code, - type_info.nm_type_code(), - "Registry key must match type code" - ); - } + fn unknown_code_has_safe_fallbacks() { + assert!(get_device_type_info(999).is_none()); + assert_eq!(display_name_for_code(999), "Other(999)"); + assert_eq!(connection_type_for_code(999), None); + assert!(!supports_scanning(999)); + assert!(!requires_specific_object(999)); + assert!(!has_global_enabled_state(999)); + assert!(!is_wired(999)); } } diff --git a/nmrs/src/util/cert_store.rs b/nmrs/src/util/cert_store.rs index 9c37ebae..3a11ef06 100644 --- a/nmrs/src/util/cert_store.rs +++ b/nmrs/src/util/cert_store.rs @@ -16,6 +16,14 @@ use std::{ use crate::{ConnectionError, util::validation::validate_connection_name}; +struct TemporaryFile(PathBuf); + +impl Drop for TemporaryFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + /// Writes PEM bytes for one material type and returns an **absolute** path for `vpn.data`. /// /// `cert_type`: `"ca"`, `"cert"`, `"key"`, or `"ta"` (tls-auth static key). @@ -28,6 +36,8 @@ pub fn store_inline_cert( cert_type: &str, pem_data: &str, ) -> Result { + // Validate the material type before creating anything on disk. + let filename = filename_for(cert_type)?; let dir = connection_cert_dir(connection_name)?; fs::create_dir_all(&dir).map_err(|e| { ConnectionError::VpnFailed(format!( @@ -47,9 +57,9 @@ pub fn store_inline_cert( })?; } - let filename = filename_for(cert_type)?; let path = dir.join(filename); - let tmp_path = dir.join(format!(".{filename}.tmp")); + let tmp_path = dir.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4())); + let _temporary_file = TemporaryFile(tmp_path.clone()); { let mut opts = OpenOptions::new(); @@ -77,13 +87,11 @@ pub fn store_inline_cert( { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)).map_err(|e| { - let _ = fs::remove_file(&tmp_path); ConnectionError::VpnFailed(format!("cert store: chmod {}: {e}", tmp_path.display())) })?; } fs::rename(&tmp_path, &path).map_err(|e| { - let _ = fs::remove_file(&tmp_path); ConnectionError::VpnFailed(format!( "cert store: rename {} -> {}: {e}", tmp_path.display(), @@ -161,7 +169,47 @@ fn filename_for(cert_type: &str) -> Result<&'static str, ConnectionError> { #[cfg(test)] mod tests { use super::*; - use crate::util::test_utils::with_fake_xdg; + use crate::util::test_utils::{ENV_LOCK, with_fake_xdg}; + + struct EnvRestore { + xdg: Option, + home: Option, + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + // SAFETY: each caller holds ENV_LOCK until after this guard drops. + unsafe { + match &self.xdg { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + match &self.home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + } + } + + fn lock_env() -> (std::sync::MutexGuard<'static, ()>, EnvRestore) { + let lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let restore = EnvRestore { + xdg: std::env::var_os("XDG_DATA_HOME"), + home: std::env::var_os("HOME"), + }; + (lock, restore) + } + + fn temporary_files(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "tmp")) + .collect() + } #[test] fn write_read_cleanup_cycle() { @@ -191,14 +239,209 @@ mod tests { }); } + #[test] + fn all_supported_material_types_use_fixed_filenames() { + let cases = [ + ("ca", "ca.pem"), + ("cert", "cert.pem"), + ("key", "key.pem"), + ("ta", "ta.key"), + ("tls-crypt", "tls-crypt.key"), + ]; + + for (cert_type, expected) in cases { + assert_eq!(filename_for(cert_type).unwrap(), expected); + } + } + + #[test] + fn unknown_material_type_is_rejected_without_creating_a_directory() { + with_fake_xdg(|| { + let data_home = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap()); + let result = store_inline_cert("unknown-type", "bogus", "secret"); + + assert!(matches!( + result, + Err(ConnectionError::InvalidAddress(message)) + if message == "unknown cert_type \"bogus\" (expected ca, cert, key, ta, tls-crypt)" + )); + assert!(!data_home.join("nmrs/certs/unknown-type").exists()); + }); + } + + #[test] + fn connection_name_cannot_escape_the_cert_root() { + with_fake_xdg(|| { + for name in ["../outside", "nested/name", r"nested\name"] { + let result = connection_cert_dir(name); + assert!(matches!( + result, + Err(ConnectionError::InvalidAddress(message)) + if message == "connection name must not contain path separators" + )); + } + + for name in [".", ".."] { + let result = connection_cert_dir(name); + assert!(matches!( + result, + Err(ConnectionError::InvalidAddress(message)) + if message == "invalid connection name" + )); + } + }); + } + + #[test] + fn overwrite_replaces_contents_and_removes_temporary_file() { + with_fake_xdg(|| { + let first = store_inline_cert("overwrite", "ca", "old").unwrap(); + let second = store_inline_cert("overwrite", "ca", "new").unwrap(); + + assert_eq!(second, first); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new"); + assert!(temporary_files(second.parent().unwrap()).is_empty()); + }); + } + + #[cfg(unix)] + #[test] + fn concurrent_writes_are_atomic_and_do_not_share_temporary_files() { + with_fake_xdg(|| { + let payloads: Vec = (0..8) + .map(|index| format!("-----BEGIN KEY-----\npayload-{index}\n-----END KEY-----\n")) + .collect(); + let handles: Vec<_> = payloads + .iter() + .cloned() + .map(|payload| { + std::thread::spawn(move || store_inline_cert("concurrent", "key", &payload)) + }) + .collect(); + + let paths: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().unwrap().unwrap()) + .collect(); + assert!(paths.iter().all(|path| path == &paths[0])); + + let final_payload = std::fs::read_to_string(&paths[0]).unwrap(); + assert!(payloads.contains(&final_payload)); + assert!(temporary_files(paths[0].parent().unwrap()).is_empty()); + }); + } + + #[test] + fn rename_failure_removes_temporary_file() { + with_fake_xdg(|| { + let data_home = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap()); + let cert_dir = data_home.join("nmrs/certs/rename-failure"); + std::fs::create_dir_all(cert_dir.join("ca.pem")).unwrap(); + + let result = store_inline_cert("rename-failure", "ca", "secret"); + + assert!(matches!( + result, + Err(ConnectionError::VpnFailed(message)) + if message.contains("cert store: rename") + )); + assert!(temporary_files(&cert_dir).is_empty()); + assert!(cert_dir.join("ca.pem").is_dir()); + }); + } + + #[test] + fn create_directory_io_error_has_operation_context() { + with_fake_xdg(|| { + let data_home = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap()); + std::fs::remove_dir(&data_home).unwrap(); + std::fs::write(&data_home, "not a directory").unwrap(); + + let result = store_inline_cert("io-error", "ca", "secret"); + + assert!(matches!( + result, + Err(ConnectionError::VpnFailed(message)) + if message.contains("cert store: create directory") + && message.contains("nmrs/certs/io-error") + )); + }); + } + + #[test] + fn cleanup_io_error_has_operation_context() { + with_fake_xdg(|| { + let data_home = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap()); + let target = data_home.join("nmrs/certs/not-a-directory"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "file").unwrap(); + + let result = cleanup_certs("not-a-directory"); + + assert!(matches!( + result, + Err(ConnectionError::VpnFailed(message)) + if message.contains("cert store: remove") + && message.contains("nmrs/certs/not-a-directory") + )); + }); + } + + #[test] + fn cleanup_rejects_unsafe_connection_names() { + with_fake_xdg(|| { + let result = cleanup_certs("../outside"); + assert!(matches!( + result, + Err(ConnectionError::InvalidAddress(message)) + if message == "connection name must not contain path separators" + )); + }); + } + + #[test] + fn xdg_data_home_falls_back_to_home_when_xdg_is_empty() { + let (_lock, _restore) = lock_env(); + let home = std::env::temp_dir().join(format!("nmrs-home-{}", uuid::Uuid::new_v4())); + // SAFETY: this test holds ENV_LOCK and EnvRestore restores both variables. + unsafe { + std::env::set_var("XDG_DATA_HOME", ""); + std::env::set_var("HOME", &home); + } + + assert_eq!(xdg_data_home().unwrap(), home.join(".local/share")); + } + + #[test] + fn xdg_data_home_reports_missing_home() { + let (_lock, _restore) = lock_env(); + // SAFETY: this test holds ENV_LOCK and EnvRestore restores both variables. + unsafe { + std::env::remove_var("XDG_DATA_HOME"); + std::env::remove_var("HOME"); + } + + assert!(matches!( + xdg_data_home(), + Err(ConnectionError::VpnFailed(message)) + if message == "cert store: HOME is not set (cannot resolve XDG data directory)" + )); + } + #[cfg(unix)] #[test] fn permissions_are_rw_for_owner_only() { use std::os::unix::fs::PermissionsExt; with_fake_xdg(|| { let p = store_inline_cert("perm", "key", "secret").unwrap(); - let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); + let file_mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + let dir_mode = std::fs::metadata(p.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(file_mode, 0o600); + assert_eq!(dir_mode, 0o700); }); } } diff --git a/nmrs/src/util/test_utils.rs b/nmrs/src/util/test_utils.rs index 7270ba15..b6e49cc3 100644 --- a/nmrs/src/util/test_utils.rs +++ b/nmrs/src/util/test_utils.rs @@ -18,7 +18,7 @@ pub static ENV_LOCK: Mutex<()> = Mutex::new(()); /// 2. Creates a unique temp directory /// 3. Sets `XDG_DATA_HOME` to that directory /// 4. Runs the provided closure -/// 5. Cleans up the env var and temp directory +/// 5. Restores the previous env var value and removes the temp directory /// /// If the closure panics, cleanup still happens (via Drop) but the mutex /// will be poisoned. Use `ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())` @@ -29,6 +29,11 @@ pub fn with_fake_xdg(f: impl FnOnce() -> R) -> R { poisoned.into_inner() }); + with_fake_xdg_unlocked(f) +} + +fn with_fake_xdg_unlocked(f: impl FnOnce() -> R) -> R { + let previous_xdg_data_home = std::env::var_os("XDG_DATA_HOME"); let base = std::env::temp_dir().join(format!("nmrs-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&base).expect("failed to create temp directory for test"); @@ -40,17 +45,83 @@ pub fn with_fake_xdg(f: impl FnOnce() -> R) -> R { // Use a guard struct to ensure cleanup happens even on panic struct Cleanup { base: std::path::PathBuf, + previous_xdg_data_home: Option, } impl Drop for Cleanup { fn drop(&mut self) { + // SAFETY: tests using this helper serialize environment access on ENV_LOCK. unsafe { - std::env::remove_var("XDG_DATA_HOME"); + match &self.previous_xdg_data_home { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } } let _ = std::fs::remove_dir_all(&self.base); } } - let _cleanup = Cleanup { base }; + let _cleanup = Cleanup { + base, + previous_xdg_data_home, + }; f() } + +#[cfg(test)] +mod tests { + use super::*; + + struct RestoreXdg(Option); + + impl Drop for RestoreXdg { + fn drop(&mut self) { + // SAFETY: the test holds ENV_LOCK until this guard is dropped. + unsafe { + match &self.0 { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + } + + #[test] + fn fake_xdg_restores_existing_value() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = RestoreXdg(std::env::var_os("XDG_DATA_HOME")); + let original = std::env::temp_dir().join("nmrs-original-xdg"); + // SAFETY: the test holds ENV_LOCK and RestoreXdg restores the process state. + unsafe { std::env::set_var("XDG_DATA_HOME", &original) }; + + with_fake_xdg_unlocked(|| { + assert_ne!( + std::env::var_os("XDG_DATA_HOME").as_deref(), + Some(original.as_os_str()) + ); + }); + + assert_eq!( + std::env::var_os("XDG_DATA_HOME").as_deref(), + Some(original.as_os_str()) + ); + } + + #[test] + fn fake_xdg_removes_value_when_initially_unset() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = RestoreXdg(std::env::var_os("XDG_DATA_HOME")); + // SAFETY: the test holds ENV_LOCK and RestoreXdg restores the process state. + unsafe { std::env::remove_var("XDG_DATA_HOME") }; + + with_fake_xdg_unlocked(|| { + assert!(std::env::var_os("XDG_DATA_HOME").is_some()); + }); + + assert!(std::env::var_os("XDG_DATA_HOME").is_none()); + } +} diff --git a/nmrs/src/util/validation.rs b/nmrs/src/util/validation.rs index 328b8718..0167e849 100644 --- a/nmrs/src/util/validation.rs +++ b/nmrs/src/util/validation.rs @@ -261,39 +261,53 @@ fn validate_path_or_blob( /// Returns `ConnectionError::InvalidPrivateKey` or `InvalidPublicKey` if invalid. fn validate_wireguard_key(key: &str, key_type: &str) -> Result<(), ConnectionError> { if key.is_empty() { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} cannot be empty", - key_type - ))); + return Err(invalid_wireguard_key( + key_type, + format!("{} cannot be empty", key_type), + )); } // Check length (base64 encoded 32 bytes = 44 chars with padding) if key.len() != WIREGUARD_KEY_BASE64_LEN { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} must be {} characters (base64 encoded), got {}", + return Err(invalid_wireguard_key( key_type, - WIREGUARD_KEY_BASE64_LEN, - key.len() - ))); + format!( + "{} must be {} characters (base64 encoded), got {}", + key_type, + WIREGUARD_KEY_BASE64_LEN, + key.len() + ), + )); } // Validate base64 and length match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key) { Ok(decoded) => { if decoded.len() != WIREGUARD_KEY_BYTES { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} must decode to {} bytes, got {}", + return Err(invalid_wireguard_key( key_type, - WIREGUARD_KEY_BYTES, - decoded.len() - ))); + format!( + "{} must decode to {} bytes, got {}", + key_type, + WIREGUARD_KEY_BYTES, + decoded.len() + ), + )); } Ok(()) } - Err(e) => Err(ConnectionError::InvalidPrivateKey(format!( - "{} is not valid base64: {}", - key_type, e - ))), + Err(e) => Err(invalid_wireguard_key( + key_type, + format!("{} is not valid base64: {}", key_type, e), + )), + } +} + +fn invalid_wireguard_key(key_type: &str, message: String) -> ConnectionError { + if key_type.to_ascii_lowercase().contains("public key") { + ConnectionError::InvalidPublicKey(message) + } else { + ConnectionError::InvalidPrivateKey(message) } } @@ -312,28 +326,7 @@ fn validate_wireguard_peer(peer: &WireGuardPeer) -> Result<(), ConnectionError> validate_wireguard_key(&peer.public_key, "Peer public key")?; // Validate gateway (should be host:port) - if peer.gateway.is_empty() { - return Err(ConnectionError::InvalidGateway( - "Peer gateway cannot be empty".to_string(), - )); - } - - if !peer.gateway.contains(':') { - return Err(ConnectionError::InvalidGateway(format!( - "Peer gateway must be in 'host:port' format, got '{}'", - peer.gateway - ))); - } - - // Validate port number - if let Some(port_str) = peer.gateway.split(':').next_back() - && port_str.parse::().is_err() - { - return Err(ConnectionError::InvalidGateway(format!( - "Invalid port number in gateway '{}'", - peer.gateway - ))); - } + validate_wireguard_gateway(&peer.gateway, "Peer")?; // Validate allowed IPs if peer.allowed_ips.is_empty() { @@ -369,6 +362,47 @@ fn validate_wireguard_peer(peer: &WireGuardPeer) -> Result<(), ConnectionError> Ok(()) } +fn validate_wireguard_gateway(gateway: &str, label: &str) -> Result<(), ConnectionError> { + if gateway.trim().is_empty() { + return Err(ConnectionError::InvalidGateway(format!( + "{label} gateway cannot be empty" + ))); + } + + let (host, port_str) = gateway.rsplit_once(':').ok_or_else(|| { + ConnectionError::InvalidGateway(format!( + "{label} gateway must be in 'host:port' format, got '{gateway}'" + )) + })?; + if host.trim().is_empty() { + return Err(ConnectionError::InvalidGateway(format!( + "{label} gateway host cannot be empty" + ))); + } + if host.contains(':') + && !(host.starts_with('[') + && host.ends_with(']') + && host[1..host.len() - 1] + .parse::() + .is_ok()) + { + return Err(ConnectionError::InvalidGateway(format!( + "{label} IPv6 gateway must use '[address]:port' format, got '{gateway}'" + ))); + } + + let port = port_str.parse::().map_err(|_| { + ConnectionError::InvalidGateway(format!("Invalid port number in gateway '{gateway}'")) + })?; + if port == 0 { + return Err(ConnectionError::InvalidGateway(format!( + "Port number in gateway '{gateway}' cannot be 0" + ))); + } + + Ok(()) +} + /// Validates CIDR notation (e.g., "10.0.0.0/24" or "2001:db8::/32"). /// /// # Errors @@ -406,8 +440,7 @@ fn validate_cidr(cidr: &str) -> Result<(), ConnectionError> { prefix_num ))); } - // Basic IPv6 validation (contains colons and hex digits) - if !address.chars().all(|c| c.is_ascii_hexdigit() || c == ':') { + if address.parse::().is_err() { return Err(ConnectionError::InvalidAddress(format!( "Invalid IPv6 address '{}'", address @@ -464,28 +497,7 @@ pub fn validate_vpn_credentials(creds: &VpnCredentials) -> Result<(), Connection validate_connection_name(&creds.name)?; // Validate gateway - if creds.gateway.is_empty() { - return Err(ConnectionError::InvalidGateway( - "VPN gateway cannot be empty".to_string(), - )); - } - - if !creds.gateway.contains(':') { - return Err(ConnectionError::InvalidGateway(format!( - "VPN gateway must be in 'host:port' format, got '{}'", - creds.gateway - ))); - } - - // Validate port number - if let Some(port_str) = creds.gateway.split(':').next_back() - && port_str.parse::().is_err() - { - return Err(ConnectionError::InvalidGateway(format!( - "Invalid port number in gateway '{}'", - creds.gateway - ))); - } + validate_wireguard_gateway(&creds.gateway, "VPN")?; // Validate private key validate_wireguard_key(&creds.private_key, "Private key")?; @@ -545,7 +557,7 @@ fn validate_ip_address(ip: &str) -> Result<(), ConnectionError> { } if ip.contains(':') { - if !ip.chars().all(|c| c.is_ascii_hexdigit() || c == ':') { + if ip.parse::().is_err() { return Err(ConnectionError::InvalidAddress(format!( "Invalid IPv6 address '{}'", ip @@ -608,7 +620,7 @@ pub fn validate_openvpn_config(config: &OpenVpnConfig) -> Result<(), ConnectionE if let Some(ref auth_type) = config.auth_type { match auth_type { OpenVpnAuthType::Password => { - if config.username.as_deref().unwrap_or("").is_empty() { + if config.username.as_deref().unwrap_or("").trim().is_empty() { return Err(ConnectionError::InvalidAddress( "Username is required for password authentication".to_string(), )); @@ -618,7 +630,7 @@ pub fn validate_openvpn_config(config: &OpenVpnConfig) -> Result<(), ConnectionE validate_openvpn_cert_paths(config)?; } OpenVpnAuthType::PasswordTls => { - if config.username.as_deref().unwrap_or("").is_empty() { + if config.username.as_deref().unwrap_or("").trim().is_empty() { return Err(ConnectionError::InvalidAddress( "Username is required for password+TLS authentication".to_string(), )); @@ -664,6 +676,12 @@ pub fn validate_openvpn_config(config: &OpenVpnConfig) -> Result<(), ConnectionE "OpenVPN route destination cannot be empty".to_string(), )); } + if route.dest.parse::().is_err() { + return Err(ConnectionError::InvalidAddress(format!( + "Invalid OpenVPN route destination '{}'", + route.dest + ))); + } if route.prefix > 32 { return Err(ConnectionError::InvalidAddress(format!( "OpenVPN route prefix must be at most 32, got {}", @@ -802,7 +820,37 @@ pub fn validate_bssid(bssid: &str) -> Result<(), ConnectionError> { #[cfg(test)] mod tests { use super::*; - use crate::api::models::{EapMethod, EapOptions, Phase2}; + use crate::api::models::{EapMethod, EapOptions, Phase2, VpnKind, VpnRoute}; + + macro_rules! assert_error_message { + ($result:expr, $variant:ident, $expected:expr) => { + match $result { + Err(ConnectionError::$variant(message)) => assert_eq!(message, $expected), + other => panic!( + "expected {}::{}, got {other:?}", + stringify!(ConnectionError), + stringify!($variant) + ), + } + }; + } + + const VALID_WIREGUARD_KEY: &str = "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM="; + + fn base_vpn_credentials() -> VpnCredentials { + VpnCredentials::new( + VpnKind::WireGuard, + "WireGuard", + "vpn.example.com:51820", + VALID_WIREGUARD_KEY, + "10.0.0.2/24", + vec![WireGuardPeer::new( + VALID_WIREGUARD_KEY, + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + )], + ) + } #[test] fn test_validate_ssid_valid() { @@ -814,14 +862,37 @@ mod tests { #[test] fn test_validate_ssid_empty() { - assert!(validate_ssid("").is_err()); - assert!(validate_ssid(" ").is_err()); + assert_error_message!(validate_ssid(""), InvalidAddress, "SSID cannot be empty"); + assert_error_message!( + validate_ssid(" "), + InvalidAddress, + "SSID cannot be only whitespace" + ); } #[test] fn test_validate_ssid_too_long() { let long_ssid = "123456789012345678901234567890123"; // 33 bytes - assert!(validate_ssid(long_ssid).is_err()); + assert_error_message!( + validate_ssid(long_ssid), + InvalidAddress, + "SSID too long: 33 bytes (max 32 bytes)" + ); + } + + #[test] + fn test_validate_ssid_uses_utf8_byte_boundary() { + let max_multibyte_ssid = "é".repeat(16); + assert_eq!(max_multibyte_ssid.len(), 32); + assert!(validate_ssid(&max_multibyte_ssid).is_ok()); + + let too_long_multibyte_ssid = "é".repeat(17); + assert_eq!(too_long_multibyte_ssid.len(), 34); + assert_error_message!( + validate_ssid(&too_long_multibyte_ssid), + InvalidAddress, + "SSID too long: 34 bytes (max 32 bytes)" + ); } #[test] @@ -836,7 +907,26 @@ mod tests { #[test] fn test_validate_connection_name_too_long() { let long_name = "a".repeat(256); - assert!(validate_connection_name(&long_name).is_err()); + assert_error_message!( + validate_connection_name(&long_name), + InvalidAddress, + "Connection name too long: 256 bytes (max 255 bytes)" + ); + } + + #[test] + fn test_validate_connection_name_uses_utf8_byte_boundary() { + let max_multibyte_name = format!("a{}", "é".repeat(127)); + assert_eq!(max_multibyte_name.len(), 255); + assert!(validate_connection_name(&max_multibyte_name).is_ok()); + + let too_long_multibyte_name = "é".repeat(128); + assert_eq!(too_long_multibyte_name.len(), 256); + assert_error_message!( + validate_connection_name(&too_long_multibyte_name), + InvalidAddress, + "Connection name too long: 256 bytes (max 255 bytes)" + ); } #[test] @@ -866,7 +956,11 @@ mod tests { let psk = WifiSecurity::WpaPsk { psk: "short".to_string(), }; - assert!(validate_wifi_security(&psk).is_err()); + assert_error_message!( + validate_wifi_security(&psk), + InvalidAddress, + "WPA-PSK password too short: 5 characters (minimum 8 characters)" + ); } #[test] @@ -874,7 +968,11 @@ mod tests { let psk = WifiSecurity::WpaPsk { psk: "a".repeat(64), }; - assert!(validate_wifi_security(&psk).is_err()); + assert_error_message!( + validate_wifi_security(&psk), + InvalidAddress, + "WPA-PSK password too long: 64 characters (maximum 63 characters)" + ); } #[test] @@ -920,7 +1018,11 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP identity cannot be empty" + ); } #[test] @@ -943,7 +1045,11 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP CA certificate path must start with 'file://'" + ); } #[test] @@ -966,7 +1072,11 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "WPA3-EAP 192bit requires authentication method TLS" + ); } #[test] @@ -1035,7 +1145,11 @@ mod tests { client_cert_blob: Some(b"client_cert_blob".to_vec()), }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP private key path and blob cannot be provided at the same time" + ); } #[test] @@ -1058,7 +1172,11 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP private key path must start with 'file://'" + ); } #[test] @@ -1081,7 +1199,11 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP client certificate path must start with 'file://'" + ); } #[test] @@ -1104,7 +1226,11 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP private key must be provided" + ); } #[test] @@ -1127,7 +1253,44 @@ mod tests { client_cert_blob: None, }, }; - assert!(validate_wifi_security(&eap).is_err()); + assert_error_message!( + validate_wifi_security(&eap), + InvalidAddress, + "EAP client certificate must be provided" + ); + } + + #[test] + fn eap_password_and_optional_identity_fields_have_exact_errors() { + let base = EapOptions::new("user@example.com", "password").with_system_ca_certs(true); + + let mut empty_password = base.clone(); + empty_password.password.clear(); + assert_error_message!( + validate_wifi_security(&WifiSecurity::WpaEap { + opts: empty_password + }), + InvalidAddress, + "EAP password cannot be empty" + ); + + let mut empty_anonymous = base.clone(); + empty_anonymous.anonymous_identity = Some(" ".into()); + assert_error_message!( + validate_wifi_security(&WifiSecurity::WpaEap { + opts: empty_anonymous + }), + InvalidAddress, + "EAP anonymous identity cannot be empty if provided" + ); + + let mut empty_domain = base; + empty_domain.domain_suffix_match = Some("\t".into()); + assert_error_message!( + validate_wifi_security(&WifiSecurity::WpaEap { opts: empty_domain }), + InvalidAddress, + "EAP domain suffix match cannot be empty if provided" + ); } #[test] @@ -1145,10 +1308,34 @@ mod tests { #[test] fn test_validate_cidr_invalid() { - assert!(validate_cidr("10.0.0.0").is_err()); // Missing prefix - assert!(validate_cidr("10.0.0.0/33").is_err()); // Invalid prefix - assert!(validate_cidr("256.0.0.0/24").is_err()); // Invalid octet - assert!(validate_cidr("10.0.0/24").is_err()); // Wrong number of octets + for (cidr, expected) in [ + ( + "10.0.0.0", + "Invalid CIDR notation '10.0.0.0' (must be 'address/prefix')", + ), + ("10.0.0.0/33", "IPv4 prefix length 33 is too large (max 32)"), + ("256.0.0.0/24", "IPv4 octet 256 is too large (max 255)"), + ( + "10.0.0/24", + "Invalid IPv4 address '10.0.0' (must have 4 octets)", + ), + ] { + assert_error_message!(validate_cidr(cidr), InvalidAddress, expected); + } + } + + #[test] + fn malformed_ipv6_cidr_is_rejected() { + assert_error_message!( + validate_cidr("2001:::1/64"), + InvalidAddress, + "Invalid IPv6 address '2001:::1'" + ); + assert_error_message!( + validate_cidr("2001:db8::/129"), + InvalidAddress, + "IPv6 prefix length 129 is too large (max 128)" + ); } #[test] @@ -1160,9 +1347,33 @@ mod tests { #[test] fn test_validate_ip_address_ipv4_invalid() { - assert!(validate_ip_address("256.1.1.1").is_err()); - assert!(validate_ip_address("192.168.1").is_err()); - assert!(validate_ip_address("192.168.1.1.1").is_err()); + for (address, expected) in [ + ( + "256.1.1.1", + "IPv4 octet 256 is too large (max 255) in address '256.1.1.1'", + ), + ( + "192.168.1", + "Invalid IPv4 address '192.168.1' (must have 4 octets)", + ), + ( + "192.168.1.1.1", + "Invalid IPv4 address '192.168.1.1.1' (must have 4 octets)", + ), + ] { + assert_error_message!(validate_ip_address(address), InvalidAddress, expected); + } + } + + #[test] + fn malformed_ipv6_addresses_are_rejected() { + for address in [":::1", "2001:db8:::1", "gggg::1"] { + assert_error_message!( + validate_ip_address(address), + InvalidAddress, + format!("Invalid IPv6 address '{address}'") + ); + } } #[test] @@ -1175,13 +1386,211 @@ mod tests { #[test] fn test_validate_wireguard_key_invalid_length() { let key = "tooshort"; - assert!(validate_wireguard_key(key, "Test key").is_err()); + assert_error_message!( + validate_wireguard_key(key, "Test key"), + InvalidPrivateKey, + "Test key must be 44 characters (base64 encoded), got 8" + ); } #[test] fn test_validate_wireguard_key_invalid_base64() { - let key = "!!!invalid-base64-characters-here!!!"; - assert!(validate_wireguard_key(key, "Test key").is_err()); + let key = "!".repeat(WIREGUARD_KEY_BASE64_LEN); + match validate_wireguard_key(&key, "Test key") { + Err(ConnectionError::InvalidPrivateKey(message)) => { + assert!(message.starts_with("Test key is not valid base64:")); + } + other => panic!("expected InvalidPrivateKey base64 error, got {other:?}"), + } + } + + #[test] + fn test_validate_wireguard_key_invalid_decoded_length() { + let key = "A".repeat(WIREGUARD_KEY_BASE64_LEN); + assert_error_message!( + validate_wireguard_key(&key, "Private key"), + InvalidPrivateKey, + "Private key must decode to 32 bytes, got 33" + ); + } + + #[test] + fn public_key_errors_use_public_key_variant() { + assert_error_message!( + validate_wireguard_key("short", "Peer public key"), + InvalidPublicKey, + "Peer public key must be 44 characters (base64 encoded), got 5" + ); + } + + #[test] + fn vpn_credentials_valid_happy_path() { + assert!(validate_vpn_credentials(&base_vpn_credentials()).is_ok()); + } + + #[test] + fn vpn_credentials_validate_gateway_before_key_material() { + let mut credentials = base_vpn_credentials(); + credentials.gateway.clear(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "VPN gateway cannot be empty" + ); + + credentials.gateway = "vpn.example.com".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "VPN gateway must be in 'host:port' format, got 'vpn.example.com'" + ); + + credentials.gateway = "vpn.example.com:not-a-port".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "Invalid port number in gateway 'vpn.example.com:not-a-port'" + ); + + credentials.gateway = "vpn.example.com:0".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "Port number in gateway 'vpn.example.com:0' cannot be 0" + ); + + credentials.gateway = ":51820".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "VPN gateway host cannot be empty" + ); + + credentials.gateway = "2001:db8::1:51820".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "VPN IPv6 gateway must use '[address]:port' format, got '2001:db8::1:51820'" + ); + + credentials.gateway = "[2001:db8::1]:51820".into(); + assert!(validate_vpn_credentials(&credentials).is_ok()); + } + + #[test] + fn vpn_credentials_validate_private_key_address_and_peer_presence() { + let mut credentials = base_vpn_credentials(); + credentials.private_key = "short".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidPrivateKey, + "Private key must be 44 characters (base64 encoded), got 5" + ); + + credentials.private_key = VALID_WIREGUARD_KEY.into(); + credentials.address = "10.0.0.2".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidAddress, + "Invalid CIDR notation '10.0.0.2' (must be 'address/prefix')" + ); + + credentials.address = "10.0.0.2/24".into(); + credentials.peers.clear(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidPeers, + "VPN must have at least one peer configured" + ); + } + + #[test] + fn vpn_credentials_prefix_peer_errors_with_index() { + let mut credentials = base_vpn_credentials(); + credentials.peers[0].public_key = "short".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidPublicKey, + "Peer 0: Peer public key must be 44 characters (base64 encoded), got 5" + ); + + credentials.peers[0].public_key = VALID_WIREGUARD_KEY.into(); + credentials.peers[0].gateway.clear(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "Peer 0: Peer gateway cannot be empty" + ); + + credentials.peers[0].gateway = "vpn.example.com:0".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "Peer 0: Port number in gateway 'vpn.example.com:0' cannot be 0" + ); + + credentials.peers[0].gateway = "2001:db8::1:51820".into(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidGateway, + "Peer 0: Peer IPv6 gateway must use '[address]:port' format, got '2001:db8::1:51820'" + ); + + credentials.peers[0].gateway = "[2001:db8::1]:51820".into(); + credentials.peers[0].allowed_ips.clear(); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidPeers, + "Peer 0: Peer must have at least one allowed IP range" + ); + + credentials.peers[0].allowed_ips = vec!["0.0.0.0/0".into()]; + credentials.peers[0].persistent_keepalive = Some(0); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidPeers, + "Peer 0: Persistent keepalive must be greater than 0 if specified" + ); + + credentials.peers[0].persistent_keepalive = Some(65_536); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidPeers, + "Peer 0: Persistent keepalive too large: 65536 (max 65535)" + ); + } + + #[test] + fn vpn_credentials_validate_optional_dns_and_mtu() { + let mut credentials = base_vpn_credentials(); + credentials.dns = Some(Vec::new()); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidAddress, + "DNS server list cannot be empty if provided" + ); + + credentials.dns = Some(vec!["not-an-ip".into()]); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidAddress, + "Invalid IPv4 address 'not-an-ip' (must have 4 octets)" + ); + + credentials.dns = Some(vec!["1.1.1.1".into(), "2001:4860:4860::8888".into()]); + credentials.mtu = Some(575); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidAddress, + "MTU too small: 575 (minimum 576)" + ); + + credentials.mtu = Some(9001); + assert_error_message!( + validate_vpn_credentials(&credentials), + InvalidAddress, + "MTU too large: 9001 (maximum 9000)" + ); } #[test] @@ -1193,22 +1602,43 @@ mod tests { #[test] fn test_validate_bluetooth_address_invalid_format() { - assert!(validate_bluetooth_address("00-1A-7D-DA-71-13").is_err()); - assert!(validate_bluetooth_address("001A7DDA7113").is_err()); - assert!(validate_bluetooth_address("00:1A:7D:DA:711:3").is_err()); + for address in ["00-1A-7D-DA-71-13", "001A7DDA7113"] { + assert_error_message!( + validate_bluetooth_address(address), + InvalidAddress, + format!("Invalid Bluetooth Address '{address}' (must have 6 segments)") + ); + } + assert_error_message!( + validate_bluetooth_address("00:1A:7D:DA:711:3"), + InvalidAddress, + "Invalid segment '711' in Bluetooth Address '00:1A:7D:DA:711:3' (must be 2 characters)" + ); } #[test] fn test_validate_bluetooth_address_invalid_char() { - assert!(validate_bluetooth_address("00:1A:7D:DA:71:GG").is_err()); - assert!(validate_bluetooth_address("00:1A:7D:DA:71:!!").is_err()); + for segment in ["GG", "!!"] { + let address = format!("00:1A:7D:DA:71:{segment}"); + assert_error_message!( + validate_bluetooth_address(&address), + InvalidAddress, + format!( + "Invalid segment '{segment}' in Bluetooth Address '{address}' (must be hex digits)" + ) + ); + } } #[test] fn test_validate_bluetooth_address_invalid_length() { - assert!(validate_bluetooth_address("00:1A:7D").is_err()); - assert!(validate_bluetooth_address("00:1A:7D:DA:71:13:FF").is_err()); - assert!(validate_bluetooth_address("").is_err()); + for address in ["00:1A:7D", "00:1A:7D:DA:71:13:FF", ""] { + assert_error_message!( + validate_bluetooth_address(address), + InvalidAddress, + format!("Invalid Bluetooth Address '{address}' (must have 6 segments)") + ); + } } fn base_openvpn_config() -> OpenVpnConfig { @@ -1223,31 +1653,63 @@ mod tests { #[test] fn test_validate_openvpn_empty_name() { let config = OpenVpnConfig::new("", "vpn.example.com", 1194, false); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Connection name cannot be empty" + ); } #[test] fn test_validate_openvpn_whitespace_name() { let config = OpenVpnConfig::new(" ", "vpn.example.com", 1194, false); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Connection name cannot be only whitespace" + ); } #[test] fn test_validate_openvpn_empty_remote() { let config = OpenVpnConfig::new("MyVPN", "", 1194, false); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidGateway, + "OpenVPN remote server cannot be empty" + ); } #[test] fn test_validate_openvpn_whitespace_remote() { let config = OpenVpnConfig::new("MyVPN", " ", 1194, false); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidGateway, + "OpenVPN remote server cannot be empty" + ); } #[test] fn test_validate_openvpn_password_auth_missing_username() { let config = base_openvpn_config().with_auth_type(OpenVpnAuthType::Password); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Username is required for password authentication" + ); + } + + #[test] + fn test_validate_openvpn_password_auth_rejects_whitespace_username() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::Password) + .with_username(" "); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Username is required for password authentication" + ); } #[test] @@ -1261,7 +1723,11 @@ mod tests { #[test] fn test_validate_openvpn_tls_auth_missing_certs() { let config = base_openvpn_config().with_auth_type(OpenVpnAuthType::Tls); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "CA certificate path is required for TLS authentication" + ); } #[test] @@ -1269,7 +1735,11 @@ mod tests { let config = base_openvpn_config() .with_auth_type(OpenVpnAuthType::Tls) .with_ca_cert("/path/to/ca.crt"); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Client certificate path is required for TLS authentication" + ); } #[test] @@ -1289,7 +1759,11 @@ mod tests { .with_ca_cert("/path/to/ca.crt") .with_client_cert("/path/to/client.crt") .with_client_key("/path/to/client.key"); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Username is required for password+TLS authentication" + ); } #[test] @@ -1297,7 +1771,11 @@ mod tests { let config = base_openvpn_config() .with_auth_type(OpenVpnAuthType::PasswordTls) .with_username("user"); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "CA certificate path is required for TLS authentication" + ); } #[test] @@ -1320,13 +1798,21 @@ mod tests { #[test] fn test_validate_openvpn_empty_cert_path_provided() { let config = base_openvpn_config().with_ca_cert(""); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "CA certificate path cannot be empty if provided" + ); } #[test] fn test_validate_openvpn_whitespace_cert_path() { let config = base_openvpn_config().with_client_cert(" "); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Client certificate path cannot be empty if provided" + ); } #[test] @@ -1338,25 +1824,41 @@ mod tests { #[test] fn test_validate_openvpn_empty_dns_list() { let config = base_openvpn_config().with_dns(vec![]); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "DNS server list cannot be empty if provided" + ); } #[test] fn test_validate_openvpn_invalid_dns() { let config = base_openvpn_config().with_dns(vec!["not-an-ip".into()]); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Invalid IPv4 address 'not-an-ip' (must have 4 octets)" + ); } #[test] fn test_validate_openvpn_mtu_too_small() { let config = base_openvpn_config().with_mtu(100); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "MTU too small: 100 (minimum 576)" + ); } #[test] fn test_validate_openvpn_mtu_too_large() { let config = base_openvpn_config().with_mtu(10000); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "MTU too large: 10000 (maximum 9000)" + ); } #[test] @@ -1386,7 +1888,11 @@ mod tests { password: None, retry: false, }); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Proxy server address cannot be empty" + ); } #[test] @@ -1408,7 +1914,80 @@ mod tests { port: 1080, retry: false, }); - assert!(validate_openvpn_config(&config).is_err()); + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + "Proxy server address cannot be empty" + ); + } + + #[test] + fn openvpn_routes_validate_destination_prefix_and_next_hop() { + let empty = base_openvpn_config().with_routes(vec![VpnRoute::new("", 24)]); + assert_error_message!( + validate_openvpn_config(&empty), + InvalidAddress, + "OpenVPN route destination cannot be empty" + ); + + let malformed = base_openvpn_config().with_routes(vec![VpnRoute::new("not-an-ip", 24)]); + assert_error_message!( + validate_openvpn_config(&malformed), + InvalidAddress, + "Invalid OpenVPN route destination 'not-an-ip'" + ); + + let prefix = base_openvpn_config().with_routes(vec![VpnRoute::new("10.0.0.0", 33)]); + assert_error_message!( + validate_openvpn_config(&prefix), + InvalidAddress, + "OpenVPN route prefix must be at most 32, got 33" + ); + + let next_hop = base_openvpn_config() + .with_routes(vec![VpnRoute::new("10.0.0.0", 24).next_hop("bad-gateway")]); + assert_error_message!( + validate_openvpn_config(&next_hop), + InvalidAddress, + "Invalid IPv4 address 'bad-gateway' (must have 4 octets)" + ); + + let valid = base_openvpn_config().with_routes(vec![ + VpnRoute::new("10.0.0.0", 24) + .next_hop("192.168.1.1") + .metric(10), + ]); + assert!(validate_openvpn_config(&valid).is_ok()); + } + + #[test] + fn openvpn_timers_reject_zero_with_directive_specific_error() { + let cases = [ + ("ping", base_openvpn_config().with_ping(0)), + ("ping-exit", base_openvpn_config().with_ping_exit(0)), + ("ping-restart", base_openvpn_config().with_ping_restart(0)), + ("reneg-sec", base_openvpn_config().with_reneg_seconds(0)), + ( + "connect-timeout", + base_openvpn_config().with_connect_timeout(0), + ), + ]; + + for (label, config) in cases { + assert_error_message!( + validate_openvpn_config(&config), + InvalidAddress, + format!("{label} must be greater than 0 if set") + ); + } + + let valid = base_openvpn_config() + .with_ping(1) + .with_ping_exit(1) + .with_ping_restart(1) + .with_reneg_seconds(1) + .with_connect_timeout(1); + assert!(validate_openvpn_config(&valid).is_ok()); } #[test] @@ -1445,21 +2024,33 @@ mod tests { #[test] fn test_validate_bssid_too_short() { - assert!(validate_bssid("AA:BB:CC:DD:EE").is_err()); + assert_error_message!( + validate_bssid("AA:BB:CC:DD:EE"), + InvalidBssid, + "AA:BB:CC:DD:EE" + ); } #[test] fn test_validate_bssid_empty() { - assert!(validate_bssid("").is_err()); + assert_error_message!(validate_bssid(""), InvalidBssid, ""); } #[test] fn test_validate_bssid_unicode() { - assert!(validate_bssid("AA:BB:CC:DD:EE:ÀÀ").is_err()); + assert_error_message!( + validate_bssid("AA:BB:CC:DD:EE:ÀÀ"), + InvalidBssid, + "AA:BB:CC:DD:EE:ÀÀ" + ); } #[test] fn test_validate_bssid_invalid_segment() { - assert!(validate_bssid("GG:BB:CC:DD:EE:FF").is_err()); + assert_error_message!( + validate_bssid("GG:BB:CC:DD:EE:FF"), + InvalidBssid, + "GG:BB:CC:DD:EE:FF" + ); } } diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 8433a3d7..3e678e1e 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1,1607 +1,1580 @@ +use std::collections::HashMap; +use std::future::Future; +use std::panic::{AssertUnwindSafe, resume_unwind}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use futures::{FutureExt, StreamExt}; +use nmrs::agent::{SecretAgent, SecretAgentFlags, SecretAgentHandle, SecretSetting}; +use nmrs::builders::WireGuardBuilder; +use nmrs::raw::zvariant::{OwnedObjectPath, OwnedValue, Value}; use nmrs::{ - ConnectionError, DeviceState, DeviceType, NetworkManager, OpenVpnAuthType, StateReason, - VpnKind, WifiSecurity, WireGuardConfig, WireGuardPeer, reason_to_error, + ActiveConnection, ActiveConnectionState, ConnectionError, DeviceState, MonitorHandle, + NetworkEvent, NetworkEventStream, NetworkManager, SettingsChange, SettingsEventStream, + SettingsPatch, SettingsSummary, TimeoutConfig, WifiKeyMgmt, WifiScope, WifiSecurity, + WireGuardPeer, }; use serial_test::serial; -use std::time::Duration; -use tokio::time::sleep; +use tokio::time::{sleep, timeout}; +use uuid::Uuid; + +const DBUS_TIMEOUT: Duration = Duration::from_secs(10); +const EVENT_TIMEOUT: Duration = Duration::from_secs(10); +const WIFI_TIMEOUT: Duration = Duration::from_secs(50); + +fn required_env(name: &str) -> String { + match std::env::var(name) { + Ok(value) if !value.trim().is_empty() => value, + Ok(_) => panic!("{name} must not be empty"), + Err(error) => panic!( + "{name} is required for this ignored integration test ({error}); use the isolated test harness" + ), + } +} -/// Helper function to check if NetworkManager is available -/// Returns true if we can connect to NetworkManager, false otherwise -async fn is_networkmanager_available() -> bool { - NetworkManager::new().await.is_ok() +fn required_capability(name: &str) { + let value = required_env(name); + assert_eq!(value, "1", "{name} must be set to 1, got {value:?}"); } -/// Check if WiFi is available -async fn has_wifi_device(nm: &NetworkManager) -> bool { - nm.list_wireless_devices() +async fn bounded( + description: &str, + duration: Duration, + operation: impl Future, +) -> T { + timeout(duration, operation) .await - .map(|d| !d.is_empty()) - .unwrap_or(false) + .unwrap_or_else(|_| panic!("timed out after {duration:?}: {description}")) } -/// Check if Ethernet is available -async fn has_ethernet_device(nm: &NetworkManager) -> bool { - nm.list_wired_devices() - .await - .map(|d| !d.is_empty()) - .unwrap_or(false) +async fn network_manager() -> NetworkManager { + required_capability("NMRS_REQUIRE_NETWORKMANAGER"); + + let config = TimeoutConfig::new() + .with_connection_timeout(Duration::from_secs(40)) + .with_disconnect_timeout(Duration::from_secs(15)); + bounded( + "connect to the system D-Bus and NetworkManager", + DBUS_TIMEOUT, + NetworkManager::with_config(config), + ) + .await + .expect("the harness declared NetworkManager available, but initialization failed") } -/// Skip tests if NetworkManager is not available outside the integration harness. -macro_rules! require_networkmanager { - () => { - if !is_networkmanager_available().await { - if std::env::var_os("NMRS_REQUIRE_NETWORKMANAGER").is_some() { - panic!("NetworkManager is required but unavailable"); +async fn next_settings_change( + stream: &mut SettingsEventStream, + description: &str, + mut matches: impl FnMut(&SettingsChange) -> bool, +) -> SettingsChange { + timeout(EVENT_TIMEOUT, async { + loop { + match stream.next().await { + Some(Ok(change)) if matches(&change) => return change, + Some(Ok(_)) => {} + Some(Err(error)) => panic!("settings event stream failed: {error}"), + None => panic!("settings event stream ended before {description}"), } - eprintln!("Skipping test: NetworkManager not available"); - return; } - }; + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")) } -/// Skip tests if WiFi device is not available outside the WiFi integration harness. -macro_rules! require_wifi { - ($nm:expr) => { - if !has_wifi_device($nm).await { - if std::env::var_os("NMRS_REQUIRE_WIFI").is_some() { - panic!("WiFi is required but no WiFi device is available"); +async fn next_network_event( + stream: &mut NetworkEventStream, + description: &str, + mut matches: impl FnMut(&NetworkEvent) -> bool, +) -> NetworkEvent { + timeout(EVENT_TIMEOUT, async { + loop { + match stream.next().await { + Some(Ok(event)) if matches(&event) => return event, + Some(Ok(_)) => {} + Some(Err(error)) => panic!("network event stream failed: {error}"), + None => panic!("network event stream ended before {description}"), } - eprintln!("Skipping test: No WiFi device available"); - return; } - }; + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")) } -/// Skip tests if Ethernet device is not available -macro_rules! require_ethernet { - ($nm:expr) => { - if !has_ethernet_device($nm).await { - eprintln!("Skipping test: No Ethernet device available"); - return; - } - }; +fn change_has_path(change: &SettingsChange, expected_kind: &str, expected_path: &str) -> bool { + match (expected_kind, change) { + ("added", SettingsChange::Added { path }) + | ("updated", SettingsChange::Updated { path }) + | ("removed", SettingsChange::Removed { path }) => path.as_str() == expected_path, + _ => false, + } } -#[tokio::test] -#[serial] -async fn test_networkmanager_initialization() { - require_networkmanager!(); - - let _nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); +async fn cleanup_saved_profile(nm: &NetworkManager, uuid: &str) -> Vec { + match timeout(DBUS_TIMEOUT, nm.delete_saved_connection(uuid)).await { + Ok(Ok(())) => Vec::new(), + Ok(Err(ConnectionError::SavedConnectionNotFound(missing))) if missing == uuid => Vec::new(), + Ok(Err(error)) => vec![format!("delete saved profile {uuid}: {error}")], + Err(_) => vec![format!("delete saved profile {uuid}: timed out")], + } } -/// Test listing devices -#[tokio::test] -#[serial] -async fn test_list_devices() { - require_networkmanager!(); +async fn cleanup_wifi_profile(wifi: &WifiScope, ssid: &str) -> Vec { + let mut failures = Vec::new(); - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - let devices = nm.list_devices().await.expect("Failed to list devices"); + match timeout(DBUS_TIMEOUT, wifi.disconnect()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(format!("disconnect {ssid:?}: {error}")), + Err(_) => failures.push(format!("disconnect {ssid:?}: timed out")), + } + match timeout(WIFI_TIMEOUT, wifi.forget(ssid)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(format!("forget {ssid:?}: {error}")), + Err(_) => failures.push(format!("forget {ssid:?}: timed out")), + } - assert!(!devices.is_empty(), "Expected at least one device"); + failures +} - for device in &devices { - assert!(!device.path.is_empty(), "Device path should not be empty"); - assert!( - !device.interface.is_empty(), - "Device interface should not be empty" - ); +async fn disconnect_device(nm: &NetworkManager, interface: &str) -> nmrs::Result<()> { + let path = nm.get_device_by_interface(interface).await?; + let proxy = nmrs::raw::zbus::Proxy::new( + nm.dbus_connection(), + "org.freedesktop.NetworkManager", + path, + "org.freedesktop.NetworkManager.Device", + ) + .await?; + let state = DeviceState::from(proxy.get_property::("State").await?); + if matches!( + state, + DeviceState::Unmanaged | DeviceState::Unavailable | DeviceState::Disconnected + ) { + return Ok(()); } -} -/// Test WiFi enabled state -#[tokio::test] -#[serial] -async fn test_wifi_enabled_get_set() { - require_networkmanager!(); + proxy.call_method("Disconnect", &()).await?; + loop { + let state = DeviceState::from(proxy.get_property::("State").await?); + if matches!(state, DeviceState::Unavailable | DeviceState::Disconnected) { + return Ok(()); + } + sleep(Duration::from_millis(25)).await; + } +} - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); +async fn cleanup_wired_profile(nm: &NetworkManager, interface: &str) -> Vec { + let mut failures = Vec::new(); + match timeout(DBUS_TIMEOUT, disconnect_device(nm, interface)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(format!("disconnect {interface}: {error}")), + Err(_) => failures.push(format!("disconnect {interface}: timed out")), + } - let initial_state = nm - .wifi_state() - .await - .expect("Failed to get WiFi enabled state") - .enabled; + match timeout(DBUS_TIMEOUT, nm.get_saved_connection_uuid(interface)).await { + Ok(Ok(Some(uuid))) => failures.extend(cleanup_saved_profile(nm, &uuid).await), + Ok(Ok(None)) => {} + Ok(Err(error)) => failures.push(format!("resolve {interface} profile: {error}")), + Err(_) => failures.push(format!("resolve {interface} profile: timed out")), + } - match nm.set_wireless_enabled(!initial_state).await { - Ok(_) => { - sleep(Duration::from_millis(500)).await; + failures +} - let new_state = nm - .wifi_state() - .await - .expect("Failed to get WiFi enabled state after toggle") - .enabled; - - if new_state == initial_state { - eprintln!( - "Warning: WiFi state didn't change (may lack permissions). Initial: {}, New: {}", - initial_state, new_state - ); - return; - } - } - Err(e) => { - eprintln!("Failed to toggle WiFi (may lack permissions): {}", e); - return; - } +async fn cleanup_vpn_profile(nm: &NetworkManager, uuid: &str) -> Vec { + let mut failures = Vec::new(); + match timeout(DBUS_TIMEOUT, nm.disconnect_vpn_by_uuid(uuid)).await { + Ok(Ok(())) => {} + Ok(Err(ConnectionError::VpnNotFound(missing))) if missing == uuid => {} + Ok(Err(error)) => failures.push(format!("disconnect VPN {uuid}: {error}")), + Err(_) => failures.push(format!("disconnect VPN {uuid}: timed out")), } + failures.extend(cleanup_saved_profile(nm, uuid).await); + failures +} - nm.set_wireless_enabled(initial_state) - .await - .expect("Failed to restore WiFi enabled state"); - - sleep(Duration::from_millis(500)).await; +async fn cleanup_secret_agent(handle: SecretAgentHandle) -> Option { + match timeout(DBUS_TIMEOUT, handle.unregister()).await { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(format!("unregister secret agent: {error}")), + Err(_) => Some("unregister secret agent: timed out".into()), + } +} - let restored_state = nm - .wifi_state() - .await - .expect("Failed to get WiFi enabled state after restore") - .enabled; - assert_eq!( - restored_state, initial_state, - "WiFi state should be restored to original" - ); +#[derive(Debug)] +struct RawActiveConnection { + path: OwnedObjectPath, + connection_path: OwnedObjectPath, + id: String, + connection_type: String, + state: u32, } -#[tokio::test] -#[serial] -async fn test_wifi_hardware_enabled() { - require_networkmanager!(); +async fn raw_active_connection( + nm: &NetworkManager, + uuid: &str, +) -> nmrs::Result> { + let active_paths = raw_active_paths(nm).await?; + + for path in active_paths { + let active = nmrs::raw::zbus::Proxy::new( + nm.dbus_connection(), + "org.freedesktop.NetworkManager", + path.clone(), + "org.freedesktop.NetworkManager.Connection.Active", + ) + .await?; + if active.get_property::("Uuid").await? != uuid { + continue; + } - let nm = NetworkManager::new() - .await - .expect("Failed to connect to NetworkManager"); + return Ok(Some(RawActiveConnection { + path, + connection_path: active.get_property("Connection").await?, + id: active.get_property("Id").await?, + connection_type: active.get_property("Type").await?, + state: active.get_property("State").await?, + })); + } - require_wifi!(&nm); + Ok(None) +} - // Read-only property — just verify the call succeeds - let state = nm - .wifi_state() +async fn raw_active_paths(nm: &NetworkManager) -> nmrs::Result> { + let manager = nmrs::raw::zbus::Proxy::new( + nm.dbus_connection(), + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + "org.freedesktop.NetworkManager", + ) + .await?; + manager + .get_property::>("ActiveConnections") .await - .expect("Failed to get WiFi radio state"); - let _ = state.hardware_enabled; + .map_err(Into::into) } -/// Test waiting for WiFi to be ready -#[tokio::test] -#[serial] -async fn test_wait_for_wifi_ready() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); +async fn stop_monitor(description: &str, handle: MonitorHandle) -> Option { + match timeout(DBUS_TIMEOUT, handle.stop()).await { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(format!("stop {description}: {error}")), + Err(_) => Some(format!("stop {description}: timed out")), + } +} - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); - - // Wait for WiFi to be ready - let result = nm.wait_for_wifi_ready().await; - - // This should either succeed or fail gracefully - // We don't assert success because WiFi might not be ready in all test environments - match result { - Ok(_) => {} - Err(e) => { - eprintln!( - "WiFi not ready (this may be expected in some environments): {}", - e - ); +fn finish_after_cleanup( + outcome: Result<(), Box>, + cleanup_failures: Vec, +) { + if let Err(payload) = outcome { + for failure in cleanup_failures { + eprintln!("cleanup after integration-test panic failed: {failure}"); } + resume_unwind(payload); } + + assert!( + cleanup_failures.is_empty(), + "integration cleanup failed: {}", + cleanup_failures.join("; ") + ); +} + +async fn active_connections(nm: &NetworkManager) -> Vec { + bounded( + "list typed active connections", + DBUS_TIMEOUT, + nm.list_active_connections(), + ) + .await + .expect("failed to list typed active connections") } -/// Test scanning networks +/// Exercises NetworkManager's settings API against the isolated D-Bus harness. +/// +/// This is ignored intentionally: a normal `cargo test` must never discover or +/// mutate the developer's host NetworkManager. The CI/Docker harness opts in. #[tokio::test] #[serial] -async fn test_scan_networks() { - require_networkmanager!(); +#[ignore = "requires NMRS_REQUIRE_NETWORKMANAGER=1 and an isolated NetworkManager"] +async fn networkmanager_profile_crud_and_settings_events() { + let nm = network_manager().await; + let mut events = bounded( + "subscribe to saved-connection settings events", + DBUS_TIMEOUT, + nm.settings_events(), + ) + .await + .expect("failed to subscribe to saved-connection settings events"); + let mut network_events = bounded( + "subscribe to unified NetworkManager events", + DBUS_TIMEOUT, + nm.network_events(), + ) + .await + .expect("failed to subscribe to unified NetworkManager events"); + + let id = format!("nmrs-integration-{}", Uuid::new_v4()); + let renamed_id = format!("{id}-updated"); + let uuid = Uuid::new_v4(); + let uuid_string = uuid.to_string(); + let outcome = AssertUnwindSafe(async { + let settings = WireGuardBuilder::new(&id) + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.203.0.2/24") + .add_peer(WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "192.0.2.1:51820", + vec!["10.204.0.0/16".into()], + )) + .mtu(1380) + .uuid(uuid) + .autoconnect(false) + .build() + .expect("the integration WireGuard profile must be valid"); + + let path = bounded( + "add a WireGuard settings profile", + DBUS_TIMEOUT, + nm.add_connection(settings), + ) + .await + .expect("NetworkManager rejected a valid WireGuard settings profile"); + let path_string = path.as_str().to_owned(); + + let added = next_settings_change(&mut events, "the profile Added event", |change| { + change_has_path(change, "added", &path_string) + }) + .await; + assert!( + matches!(added, SettingsChange::Added { .. }), + "expected an Added event, got {added:?}" + ); + let unified_added = next_network_event( + &mut network_events, + "the unified SettingsChanged(Added) event", + |event| { + matches!( + event, + NetworkEvent::SettingsChanged(SettingsChange::Added { path }) + if path.as_str() == path_string + ) + }, + ) + .await; + assert!( + matches!( + unified_added, + NetworkEvent::SettingsChanged(SettingsChange::Added { ref path }) + if path.as_str() == path_string + ), + "expected the exact unified Added event, got {unified_added:?}" + ); - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); + let brief = bounded( + "list saved connection identities", + DBUS_TIMEOUT, + nm.list_saved_connections_brief(), + ) + .await + .expect("failed to list saved connection identities") + .into_iter() + .find(|profile| profile.uuid == uuid_string) + .expect("the newly added profile was absent from the brief listing"); + assert_eq!(brief.path, path); + assert_eq!(brief.id, id); + assert_eq!(brief.connection_type, "wireguard"); + + let profile = bounded( + "decode the saved WireGuard profile", + DBUS_TIMEOUT, + nm.get_saved_connection(&uuid_string), + ) + .await + .expect("failed to load the newly added WireGuard profile"); + assert_eq!(profile.path, path); + assert_eq!(profile.id, id); + assert_eq!(profile.connection_type, "wireguard"); + assert!(!profile.autoconnect); + match profile.summary { + SettingsSummary::WireGuard { + mtu, + peer_count, + first_peer_endpoint, + .. + } => { + assert_eq!(mtu, Some(1380)); + assert_eq!(peer_count, 1); + assert_eq!(first_peer_endpoint.as_deref(), Some("192.0.2.1:51820")); + } + other => panic!("expected a WireGuard settings summary, got {other:?}"), + } - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); + let mut patch = SettingsPatch::default(); + patch.id = Some(renamed_id.clone()); + patch.autoconnect = Some(true); + patch.autoconnect_priority = Some(42); + bounded( + "update the saved profile", + DBUS_TIMEOUT, + nm.update_saved_connection(&uuid_string, patch), + ) + .await + .expect("failed to update the saved profile"); + + let updated_event = next_settings_change(&mut events, "the profile Updated event", |change| { + change_has_path(change, "updated", &path_string) + }) + .await; + assert!( + matches!(updated_event, SettingsChange::Updated { .. }), + "expected an Updated event, got {updated_event:?}" + ); + let unified_updated = next_network_event( + &mut network_events, + "the unified SettingsChanged(Updated) event", + |event| { + matches!( + event, + NetworkEvent::SettingsChanged(SettingsChange::Updated { path }) + if path.as_str() == path_string + ) + }, + ) + .await; + assert!( + matches!( + unified_updated, + NetworkEvent::SettingsChanged(SettingsChange::Updated { ref path }) + if path.as_str() == path_string + ), + "expected the exact unified Updated event, got {unified_updated:?}" + ); + let updated = bounded( + "reload the updated profile", + DBUS_TIMEOUT, + nm.get_saved_connection(&uuid_string), + ) + .await + .expect("failed to reload the updated profile"); + assert_eq!(updated.id, renamed_id); + assert!(updated.autoconnect); + assert_eq!(updated.autoconnect_priority, 42); + + bounded( + "delete the saved profile", + DBUS_TIMEOUT, + nm.delete_saved_connection(&uuid_string), + ) + .await + .expect("failed to delete the saved profile"); + let removed_event = next_settings_change(&mut events, "the profile Removed event", |change| { + change_has_path(change, "removed", &path_string) + }) + .await; + assert!( + matches!(removed_event, SettingsChange::Removed { .. }), + "expected a Removed event, got {removed_event:?}" + ); + let unified_removed = next_network_event( + &mut network_events, + "the unified SettingsChanged(Removed) event", + |event| { + matches!( + event, + NetworkEvent::SettingsChanged(SettingsChange::Removed { path }) + if path.as_str() == path_string + ) + }, + ) + .await; + assert!( + matches!( + unified_removed, + NetworkEvent::SettingsChanged(SettingsChange::Removed { ref path }) + if path.as_str() == path_string + ), + "expected the exact unified Removed event, got {unified_removed:?}" + ); - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; + let ids = bounded( + "list profiles after deletion", + DBUS_TIMEOUT, + nm.list_saved_connection_ids(), + ) + .await + .expect("failed to list profiles after deletion"); + assert!(!ids.iter().any(|candidate| candidate == &renamed_id)); - // Request a scan - let result = nm.scan_networks(None).await; + let error = bounded( + "load a deleted profile", + DBUS_TIMEOUT, + nm.get_saved_connection(&uuid_string), + ) + .await + .expect_err("loading a deleted profile must fail"); + assert!( + matches!(error, ConnectionError::SavedConnectionNotFound(ref missing) if missing == &uuid_string), + "expected SavedConnectionNotFound for {uuid_string}, got {error:?}" + ); + }) + .catch_unwind() + .await; - // Scan should either succeed or fail gracefully - match result { - Ok(_) => { - // Success - wait a bit for scan to complete - sleep(Duration::from_secs(2)).await; - } - Err(e) => { - eprintln!("Scan failed (may be expected in some environments): {}", e); - } - } + let cleanup_failures = cleanup_saved_profile(&nm, &uuid_string).await; + finish_after_cleanup(outcome, cleanup_failures); } -/// Test listing networks +/// Exercises a real NetworkManager-to-agent secret request while activating a +/// native WireGuard VPN, plus registration ownership and cleanup rules. #[tokio::test] #[serial] -async fn test_list_networks() { - require_networkmanager!(); +#[ignore = "requires NMRS_REQUIRE_NETWORKMANAGER=1 and an isolated NetworkManager"] +async fn networkmanager_secret_agent_registration_lifecycle() { + let nm = network_manager().await; + let suffix = Uuid::new_v4().simple().to_string(); + let invalid_identifier = format!("com.nmrs:integration.Agent{suffix}"); + let invalid_error = match bounded( + "reject an invalid secret-agent identifier", + DBUS_TIMEOUT, + SecretAgent::builder() + .with_identifier(&invalid_identifier) + .register(), + ) + .await + { + Err(error) => error, + Ok((handle, _requests)) => { + bounded( + "unregister unexpectedly accepted invalid agent", + DBUS_TIMEOUT, + handle.unregister(), + ) + .await + .expect("failed to clean up unexpectedly accepted invalid agent"); + panic!("NetworkManager accepted invalid agent identifier {invalid_identifier:?}"); + } + }; + assert!( + matches!( + invalid_error, + ConnectionError::AgentRegistration { ref context } + if context.contains("registering secret agent") + && context.contains("InvalidIdentifier") + ), + "expected NetworkManager's InvalidIdentifier registration rejection, got {invalid_error:?}" + ); - let nm = NetworkManager::new() + let identifier = format!("com.nmrs.integration.Agent{suffix}"); + let (handle, mut requests) = bounded( + "register the first secret agent", + DBUS_TIMEOUT, + SecretAgent::builder() + .with_identifier(&identifier) + .register(), + ) + .await + .expect("failed to register the first secret agent"); + let mut active_handle = Some(handle); + let profile_id = format!("nmrs-agent-wireguard-{suffix}"); + let profile_uuid = Uuid::new_v4().to_string(); + let private_key = "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM="; + + let outcome = AssertUnwindSafe(async { + let duplicate_error = match bounded( + "reject a duplicate secret-agent identifier", + DBUS_TIMEOUT, + SecretAgent::builder() + .with_identifier(&identifier) + .register(), + ) .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); + { + Err(error) => error, + Ok((duplicate, _duplicate_requests)) => { + bounded( + "unregister unexpectedly accepted duplicate agent", + DBUS_TIMEOUT, + duplicate.unregister(), + ) + .await + .expect("failed to clean up unexpectedly accepted duplicate agent"); + panic!("NetworkManager accepted duplicate agent identifier {identifier:?}"); + } + }; + assert!( + matches!(duplicate_error, ConnectionError::AgentAlreadyRegistered), + "expected AgentAlreadyRegistered for duplicate registration, got {duplicate_error:?}" + ); - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) + let reregister_error = bounded( + "reject re-registration while the agent is active", + DBUS_TIMEOUT, + active_handle + .as_ref() + .expect("the primary agent handle disappeared") + .reregister(), + ) .await - .expect("Failed to enable WiFi"); - - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; + .expect_err("an active secret agent must not re-register"); + assert!( + matches!(reregister_error, ConnectionError::AgentAlreadyRegistered), + "expected AgentAlreadyRegistered for active re-registration, got {reregister_error:?}" + ); - // Request a scan first - let _ = nm.scan_networks(None).await; - sleep(Duration::from_secs(2)).await; + let profile_uuid_value = Uuid::parse_str(&profile_uuid) + .expect("the generated integration profile UUID must parse"); + let mut settings = WireGuardBuilder::new(&profile_id) + .private_key(private_key) + .address("10.207.0.2/24") + .add_peer(WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "192.0.2.1:51820", + vec!["10.208.0.0/16".into()], + )) + .uuid(profile_uuid_value) + .autoconnect(false) + .build() + .expect("the agent-owned WireGuard profile must be valid"); + let wireguard = settings + .get_mut("wireguard") + .expect("the WireGuard builder omitted its settings section"); + assert!( + wireguard.remove("private-key").is_some(), + "the WireGuard builder omitted its private key" + ); + wireguard.insert("private-key-flags", Value::from(1u32)); - // List networks - let networks = nm - .list_networks(None) + let profile_path = bounded( + "add the agent-owned WireGuard profile", + DBUS_TIMEOUT, + nm.add_connection(settings), + ) .await - .expect("Failed to list networks"); + .expect("NetworkManager rejected the agent-owned WireGuard profile"); - // Verify network structure - for network in &networks { + let missing_uuid = Uuid::new_v4().to_string(); + let missing_error = bounded( + "reject activation of a missing VPN UUID", + DBUS_TIMEOUT, + nm.connect_vpn_by_uuid(&missing_uuid), + ) + .await + .expect_err("activation of a missing VPN UUID must fail"); assert!( - !network.ssid.is_empty() || network.ssid == "", - "SSID should not be empty (unless hidden)" + matches!(missing_error, ConnectionError::VpnNotFound(ref missing) if missing == &missing_uuid), + "expected VpnNotFound for {missing_uuid}, got {missing_error:?}" ); - // `list_networks` can include deduplicated entries where device identity - // is not populated; that is valid for this API. - } -} -/// Ensure the virtual access point is visible when the WiFi integration harness runs. -#[tokio::test] -#[serial] -async fn test_hwsim_access_point_is_discovered() { - let expected_ssid = match std::env::var("NMRS_EXPECT_WIFI_SSID") { - Ok(ssid) => ssid, - Err(_) => return, - }; - let interface = std::env::var("NMRS_WIFI_INTERFACE") - .expect("WiFi integration harness did not provide the station interface"); + let get_secrets = async { + let profile = nmrs::raw::zbus::Proxy::new( + nm.dbus_connection(), + "org.freedesktop.NetworkManager", + profile_path.clone(), + "org.freedesktop.NetworkManager.Settings.Connection", + ) + .await + .expect("failed to create the saved-profile D-Bus proxy"); + let reply = profile + .call_method("GetSecrets", &("wireguard",)) + .await + .expect("NetworkManager failed to route GetSecrets to the registered agent"); + reply + .body() + .deserialize::>>() + .expect("NetworkManager returned a malformed GetSecrets reply") + }; + let secret_exchange = async { + let request = bounded( + "receive NetworkManager's saved-profile GetSecrets request", + DBUS_TIMEOUT, + requests.next(), + ) + .await + .expect("the secret-agent request stream closed during activation"); + assert_eq!(request.connection_uuid, profile_uuid); + assert_eq!(request.connection_id, profile_id); + assert_eq!(request.connection_type, "wireguard"); + assert_eq!(request.connection_path, profile_path); + assert!( + matches!(request.setting, SecretSetting::Other(ref name) if name == "wireguard"), + "expected a wireguard secret request, got {:?}", + request.setting + ); + assert_eq!( + request.flags, + SecretAgentFlags::USER_REQUESTED, + "saved-profile GetSecrets used unexpected request flags: {:?}", + request.flags, + ); + assert!(request.hints.is_empty()); + assert!(request.existing_secrets.is_empty()); - require_networkmanager!(); + let mut reply = HashMap::new(); + reply.insert( + "private-key".into(), + OwnedValue::from(nmrs::raw::zvariant::Str::from(private_key)), + ); + request + .responder + .raw("wireguard", reply) + .await + .expect("failed to route the WireGuard secret reply to NetworkManager"); + }; + let (returned_secrets, ()) = tokio::join!(get_secrets, secret_exchange); + let returned_private_key = <&str>::try_from( + returned_secrets + .get("wireguard") + .and_then(|setting| setting.get("private-key")) + .expect("the GetSecrets reply omitted wireguard.private-key"), + ) + .expect("wireguard.private-key was not returned as a string"); + assert_eq!(returned_private_key, private_key); + + let mut wireguard_overlay = HashMap::new(); + wireguard_overlay.insert( + "private-key".into(), + OwnedValue::from(nmrs::raw::zvariant::Str::from(returned_private_key)), + ); + wireguard_overlay.insert("private-key-flags".into(), OwnedValue::from(0u32)); + let mut overlay = HashMap::new(); + overlay.insert("wireguard".into(), wireguard_overlay); + let mut patch = SettingsPatch::default(); + patch.raw_overlay = Some(overlay); + bounded( + "persist the agent-provided WireGuard private key", + DBUS_TIMEOUT, + nm.update_saved_connection(&profile_uuid, patch), + ) + .await + .expect("failed to persist the agent-provided WireGuard private key"); + + bounded( + "activate the configured WireGuard profile", + WIFI_TIMEOUT, + nm.connect_vpn_by_uuid(&profile_uuid), + ) + .await + .expect("WireGuard activation failed after persisting the agent-provided key"); + + let raw_active = bounded( + "inspect the active WireGuard connection over D-Bus", + DBUS_TIMEOUT, + raw_active_connection(&nm, &profile_uuid), + ) + .await + .expect("failed to inspect active connections over D-Bus") + .expect("NetworkManager omitted the activated WireGuard connection"); + assert_ne!(raw_active.path.as_str(), "/"); + assert_eq!(raw_active.connection_path, profile_path); + assert_eq!(raw_active.id, profile_id); + assert_eq!(raw_active.connection_type, "wireguard"); + assert_eq!(raw_active.state, 2, "raw active state was not Activated"); + + let mut last_active = Vec::new(); + let active_vpn = timeout(EVENT_TIMEOUT, async { + loop { + last_active = active_connections(&nm).await; + if let Some(vpn) = last_active.iter().find_map(|connection| match connection { + ActiveConnection::Vpn(vpn) + if vpn.uuid == profile_uuid + && vpn.state == ActiveConnectionState::Activated + && vpn.interface.as_deref() == Some("wg-nmrs-agent") + && vpn + .ip4_address + .as_deref() + .is_some_and(|address| address.starts_with("10.207.0.2/")) => + { + Some(vpn.clone()) + } + _ => None, + }) { + break vpn; + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "typed active connections never exposed the configured WireGuard state: {last_active:?}" + ) + }); + assert_eq!(active_vpn.id, profile_id); + assert_eq!(active_vpn.state, ActiveConnectionState::Activated); + assert_eq!(active_vpn.interface.as_deref(), Some("wg-nmrs-agent")); + assert!( + active_vpn + .ip4_address + .as_deref() + .is_some_and(|address| address.starts_with("10.207.0.2/")), + "typed VPN connection omitted its configured address: {active_vpn:?}" + ); - let nm = NetworkManager::new() + bounded( + "deactivate the WireGuard VPN", + DBUS_TIMEOUT, + nm.disconnect_vpn_by_uuid(&profile_uuid), + ) + .await + .expect("failed to deactivate the WireGuard VPN"); + timeout(EVENT_TIMEOUT, async { + loop { + let raw_absent = raw_active_paths(&nm) + .await + .expect("failed to inspect D-Bus state after VPN deactivation") + .iter() + .all(|path| path != &raw_active.path); + if raw_absent { + let typed_absent = + !active_connections(&nm).await.iter().any(|connection| { + matches!(connection, ActiveConnection::Vpn(vpn) if vpn.uuid == profile_uuid) + }); + if typed_absent { + break; + } + } + sleep(Duration::from_millis(25)).await; + } + }) .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); + .expect("WireGuard remained active after D-Bus deactivation"); - nm.set_wireless_enabled(true) + bounded( + "delete the agent-owned WireGuard profile", + DBUS_TIMEOUT, + nm.delete_saved_connection(&profile_uuid), + ) .await - .expect("Failed to enable WiFi"); + .expect("failed to delete the agent-owned WireGuard profile"); - let wifi = nm.wifi(&interface); - for _ in 0..3 { - wifi.scan() + let primary = active_handle + .take() + .expect("the primary agent handle disappeared before unregister"); + bounded( + "unregister the first secret agent", + DBUS_TIMEOUT, + primary.unregister(), + ) + .await + .expect("failed to unregister the first secret agent"); + assert!( + bounded( + "wait for the first request stream to close", + DBUS_TIMEOUT, + requests.next(), + ) .await - .expect("Failed to scan for the virtual access point"); - sleep(Duration::from_secs(2)).await; + .is_none(), + "secret request stream remained open after unregister" + ); - let networks = wifi - .list_networks() + let (replacement, mut replacement_requests) = bounded( + "re-register the released secret-agent identifier", + DBUS_TIMEOUT, + SecretAgent::builder() + .with_identifier(&identifier) + .register(), + ) + .await + .expect("the identifier was not released after unregister"); + active_handle = Some(replacement); + let replacement = active_handle + .take() + .expect("the replacement agent handle disappeared before unregister"); + bounded( + "unregister the replacement secret agent", + DBUS_TIMEOUT, + replacement.unregister(), + ) + .await + .expect("failed to unregister the replacement secret agent"); + assert!( + bounded( + "wait for the replacement request stream to close", + DBUS_TIMEOUT, + replacement_requests.next(), + ) .await - .expect("Failed to list scanned networks"); + .is_none(), + "replacement request stream remained open after unregister" + ); + }) + .catch_unwind() + .await; - if let Some(network) = networks - .iter() - .find(|network| network.ssid == expected_ssid) - { - assert!(network.secured, "The virtual access point must be secured"); - assert!(network.is_psk, "The virtual access point must use WPA-PSK"); - return; - } + let mut cleanup_failures = cleanup_vpn_profile(&nm, &profile_uuid).await; + if let Some(handle) = active_handle.take() + && let Some(failure) = cleanup_secret_agent(handle).await + { + cleanup_failures.push(failure); } - - panic!("The virtual access point {expected_ssid:?} was not discovered"); + finish_after_cleanup(outcome, cleanup_failures); } -/// Test getting current SSID +/// Exercises a deterministic veth/DHCP wired connection without touching the +/// container's Docker-provided `eth0` interface. #[tokio::test] #[serial] -async fn test_current_ssid() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); +#[ignore = "requires NMRS_REQUIRE_WIRED=1 and the isolated veth harness"] +async fn wired_connection_lifecycle() { + required_capability("NMRS_REQUIRE_WIRED"); + let interface = required_env("NMRS_WIRED_INTERFACE"); + let nm = network_manager().await; + + let outcome = AssertUnwindSafe(async { + let devices = bounded("list wired devices", DBUS_TIMEOUT, nm.list_wired_devices()) + .await + .expect("failed to list wired devices"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .unwrap_or_else(|| { + panic!("managed veth interface {interface:?} was missing: {devices:?}") + }); + assert_eq!(device.managed, Some(true)); + assert!(!device.path.is_empty()); + + let details = bounded( + "list detailed wired devices", + DBUS_TIMEOUT, + nm.list_wired_device_details(), + ) + .await + .expect("failed to list detailed wired devices"); + let detail = details + .iter() + .find(|device| device.interface == interface) + .expect("managed veth was absent from detailed wired devices"); + assert!(!detail.path.is_empty()); + assert!(!detail.hw_address.is_empty()); + assert!(detail.active_connection_id.is_none()); + + bounded( + "connect the managed veth client", + WIFI_TIMEOUT, + nm.connect_wired(), + ) + .await + .expect("wired activation or DHCP failed"); + let saved_uuid = bounded( + "resolve the wired profile UUID", + DBUS_TIMEOUT, + nm.get_saved_connection_uuid(&interface), + ) + .await + .expect("failed to resolve the wired profile UUID") + .expect("wired activation did not create a saved profile"); + + let active = active_connections(&nm).await; + let active_wired = active + .iter() + .find_map(|connection| match connection { + ActiveConnection::Wired(wired) + if wired.interface.as_deref() == Some(interface.as_str()) => + { + Some(wired.clone()) + } + _ => None, + }) + .unwrap_or_else(|| { + panic!("typed active connections omitted the veth connection: {active:?}") + }); + assert_eq!(active_wired.id, interface); + assert_eq!(active_wired.uuid, saved_uuid); + assert_eq!(active_wired.state, ActiveConnectionState::Activated); + assert!( + active_wired + .ip4_address + .as_deref() + .is_some_and(|address| address.starts_with("192.168.251.")), + "typed wired connection omitted its DHCP address" + ); - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) + let connected_details = bounded( + "read connected wired details", + DBUS_TIMEOUT, + nm.list_wired_device_details(), + ) .await - .expect("Failed to enable WiFi"); - - // Get current SSID (may be None if not connected) - let current_ssid = nm.current_ssid().await; - - // If connected, SSID should not be empty - if let Some(ssid) = current_ssid { + .expect("failed to read connected wired details"); + let connected = connected_details + .iter() + .find(|device| device.interface == interface) + .expect("connected veth was absent from detailed wired devices"); + assert_eq!(connected.state, DeviceState::Activated); + assert_eq!( + connected.active_connection_id.as_deref(), + Some(interface.as_str()) + ); assert!( - !ssid.is_empty(), - "Current SSID should not be empty if connected" + connected + .ip4_address + .as_deref() + .is_some_and(|address| address.starts_with("192.168.251.")) ); - } -} - -/// Test getting current connection info -#[tokio::test] -#[serial] -async fn test_current_connection_info() { - require_networkmanager!(); - let nm = NetworkManager::new() + bounded( + "disconnect the managed veth client", + DBUS_TIMEOUT, + disconnect_device(&nm, &interface), + ) + .await + .expect("failed to disconnect the managed veth client"); + timeout(EVENT_TIMEOUT, async { + loop { + if !active_connections(&nm).await.iter().any(|connection| { + matches!(connection, ActiveConnection::Wired(wired) if wired.uuid == saved_uuid) + }) { + break; + } + sleep(Duration::from_millis(25)).await; + } + }) .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); + .expect("typed wired connection remained active after disconnect"); - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) + let disconnected_details = bounded( + "read disconnected wired details", + DBUS_TIMEOUT, + nm.list_wired_device_details(), + ) .await - .expect("Failed to enable WiFi"); - - // Get current connection info (may be None if not connected) - let info = nm.current_connection_info().await; - - // If connected, SSID should not be empty - if let Some((ssid, _frequency)) = info { + .expect("failed to read disconnected wired details"); + let disconnected = disconnected_details + .iter() + .find(|device| device.interface == interface) + .expect("disconnected veth was absent from detailed wired devices"); + assert_eq!(disconnected.state, DeviceState::Disconnected); + assert!(disconnected.active_connection_id.is_none()); + + bounded( + "delete the wired profile", + DBUS_TIMEOUT, + nm.delete_saved_connection(&saved_uuid), + ) + .await + .expect("failed to delete the wired profile"); assert!( - !ssid.is_empty(), - "Current SSID should not be empty if connected" + bounded( + "resolve wired profile after deletion", + DBUS_TIMEOUT, + nm.get_saved_connection_uuid(&interface), + ) + .await + .expect("failed to resolve wired profile after deletion") + .is_none() ); - } + }) + .catch_unwind() + .await; + + let cleanup_failures = cleanup_wired_profile(&nm, &interface).await; + finish_after_cleanup(outcome, cleanup_failures); } -/// Test showing details +/// Proves discovery, WPA authentication, DHCP, saved-secret reuse, and cleanup +/// against the deterministic mac80211_hwsim access point. #[tokio::test] #[serial] -async fn test_show_details() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); +#[ignore = "requires the isolated mac80211_hwsim WiFi harness"] +async fn wifi_wpa_saved_connection_lifecycle() { + required_capability("NMRS_REQUIRE_WIFI"); + let interface = required_env("NMRS_WIFI_INTERFACE"); + let ssid = required_env("NMRS_EXPECT_WIFI_SSID"); + let absent_ssid = format!("{ssid}-absent"); + let password = required_env("NMRS_WIFI_PASSWORD"); + assert!( + (8..=63).contains(&password.len()), + "NMRS_WIFI_PASSWORD must be a valid WPA passphrase" + ); - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); + let nm = network_manager().await; + let initial_wifi_enabled = bounded( + "read the initial WiFi radio state", + DBUS_TIMEOUT, + nm.wifi_state(), + ) + .await + .expect("failed to capture the WiFi radio state before the test") + .enabled; + let wifi = nm.wifi(&interface); + let device_callback_count = Arc::new(AtomicUsize::new(0)); + let callback_count = Arc::clone(&device_callback_count); + let network_callback_count = Arc::new(AtomicUsize::new(0)); + let network_callback = Arc::clone(&network_callback_count); + let mut device_monitor = None; + let mut network_monitor = None; + + let outcome = AssertUnwindSafe(async { + device_monitor = Some( + bounded( + "start the WiFi device callback monitor", + DBUS_TIMEOUT, + nm.monitor_device_changes(move || { + callback_count.fetch_add(1, Ordering::SeqCst); + }), + ) + .await + .expect("the WiFi device callback monitor did not become ready"), + ); + network_monitor = Some( + bounded( + "start the WiFi network callback monitor", + DBUS_TIMEOUT, + nm.monitor_network_changes(move || { + network_callback.fetch_add(1, Ordering::SeqCst); + }), + ) + .await + .expect("the WiFi network callback monitor did not become ready"), + ); - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; + bounded( + "enable the WiFi radio", + DBUS_TIMEOUT, + nm.set_wireless_enabled(true), + ) + .await + .expect("the harness declared WiFi available, but enabling it failed"); + bounded( + "wait for the WiFi device to become ready", + DBUS_TIMEOUT, + nm.wait_for_wifi_ready(), + ) + .await + .expect("the harness WiFi device did not become ready"); + + let devices = bounded( + "list wireless devices", + DBUS_TIMEOUT, + nm.list_wireless_devices(), + ) + .await + .expect("failed to list wireless devices"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .unwrap_or_else(|| { + panic!("harness WiFi interface {interface:?} was not managed: {devices:?}") + }); + assert!(!device.path.is_empty()); + assert_eq!(device.managed, Some(true)); + + bounded( + "remove any stale test profile", + DBUS_TIMEOUT, + wifi.forget(&ssid), + ) + .await + .expect("failed to remove a stale test profile"); + bounded( + "remove any stale absent-network profile", + DBUS_TIMEOUT, + wifi.forget(&absent_ssid), + ) + .await + .expect("failed to remove a stale absent-network profile"); + + let absent_error = bounded( + "reject an absent SSID", + WIFI_TIMEOUT, + wifi.connect( + &absent_ssid, + WifiSecurity::WpaPsk { + psk: password.clone(), + }, + ), + ) + .await + .expect_err("connecting to an absent SSID must fail"); + assert!( + matches!(absent_error, ConnectionError::NotFound), + "expected NotFound for absent SSID, got {absent_error:?}" + ); + assert!( + !bounded( + "check absent SSID profile", + DBUS_TIMEOUT, + nm.has_saved_connection(&absent_ssid), + ) + .await + .expect("failed to check the absent SSID profile"), + "an absent SSID created a saved profile" + ); - // Request a scan first - let _ = nm.scan_networks(None).await; - sleep(Duration::from_secs(2)).await; + bounded("scan for the harness AP", DBUS_TIMEOUT, wifi.scan()) + .await + .expect("the harness WiFi scan failed"); + let network = timeout(Duration::from_secs(15), async { + loop { + let networks = wifi + .list_networks() + .await + .expect("listing WiFi scan results failed"); + if let Some(network) = networks.into_iter().find(|network| network.ssid == ssid) { + return network; + } + sleep(Duration::from_millis(500)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("the expected access point {ssid:?} was not discovered")); + assert_eq!(network.device, interface); + assert!(network.secured); + assert!(network.is_psk); + assert!(!network.is_eap); + assert!(!network.best_bssid.is_empty()); + assert!( + network + .bssids + .iter() + .any(|bssid| bssid == &network.best_bssid) + ); - // List networks - let networks = nm - .list_networks(None) - .await - .expect("Failed to list networks"); - - // Try to show details for the first network (if any) - if let Some(network) = networks.first() { - let result = nm.show_details(network).await; - - match result { - Ok(details) => { - // Verify details structure - assert_eq!(details.ssid, network.ssid, "SSID should match"); - assert!(!details.bssid.is_empty(), "BSSID should not be empty"); - assert!(details.strength <= 100, "Strength should be <= 100"); - assert!(!details.mode.is_empty(), "Mode should not be empty"); - assert!(!details.security.is_empty(), "Security should not be empty"); - assert!(!details.status.is_empty(), "Status should not be empty"); + network_callback_count.store(0, Ordering::SeqCst); + bounded( + "disable WiFi to remove the monitored access point", + DBUS_TIMEOUT, + nm.set_wireless_enabled(false), + ) + .await + .expect("failed to disable WiFi for the network-monitor contract"); + timeout(EVENT_TIMEOUT, async { + while network_callback_count.load(Ordering::SeqCst) == 0 { + sleep(Duration::from_millis(25)).await; } - Err(e) => { - // Network might have disappeared between scan and details request - eprintln!("Failed to show details (may be expected): {}", e); + }) + .await + .expect("network callback was not delivered when the access point disappeared"); + bounded( + "re-enable WiFi after the network-monitor contract", + DBUS_TIMEOUT, + nm.set_wireless_enabled(true), + ) + .await + .expect("failed to re-enable WiFi after the network-monitor contract"); + bounded( + "wait for WiFi after the network-monitor contract", + DBUS_TIMEOUT, + nm.wait_for_wifi_ready(), + ) + .await + .expect("the WiFi device did not recover after re-enabling it"); + bounded( + "rescan after the network-monitor contract", + DBUS_TIMEOUT, + wifi.scan(), + ) + .await + .expect("the post-monitor WiFi scan failed"); + let access_point = timeout(Duration::from_secs(15), async { + loop { + let access_points = wifi + .list_access_points() + .await + .expect("listing per-BSSID access points failed"); + if let Some(access_point) = access_points + .into_iter() + .find(|access_point| access_point.ssid == ssid) + { + return access_point; + } + sleep(Duration::from_millis(500)).await; } - } - } -} - -/// Test checking if a connection is saved -#[tokio::test] -#[serial] -async fn test_has_saved_connection() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); + }) + .await + .unwrap_or_else(|_| panic!("the access point {ssid:?} did not return after re-enabling")); + assert_eq!(access_point.interface, interface); + assert_eq!(access_point.ssid_bytes, ssid.as_bytes()); + assert!(!access_point.bssid.is_empty()); + assert!(access_point.frequency_mhz > 0); + assert!(access_point.security.psk); + let expected_bssid = access_point.bssid.clone(); + + let wrong_psk_error = bounded( + "reject an incorrect WPA passphrase", + WIFI_TIMEOUT, + wifi.connect( + &ssid, + WifiSecurity::WpaPsk { + psk: "nmrs-definitely-wrong-password".into(), + }, + ), + ) + .await + .expect_err("an incorrect WPA passphrase must fail"); + assert!( + matches!(wrong_psk_error, ConnectionError::AuthFailed), + "expected AuthFailed for an incorrect WPA passphrase, got {wrong_psk_error:?}" + ); + assert!( + !bounded( + "check state after rejected WPA authentication", + DBUS_TIMEOUT, + nm.is_connected(&ssid), + ) + .await + .expect("failed to query state after rejected WPA authentication") + ); + assert!( + !active_connections(&nm) + .await + .iter() + .any(|active| matches!(active, ActiveConnection::Wifi(wifi) if wifi.ssid == ssid)), + "rejected WPA authentication left an active WiFi connection" + ); + assert!( + !bounded( + "check profile after rejected WPA authentication", + DBUS_TIMEOUT, + nm.has_saved_connection(&ssid), + ) + .await + .expect("failed to query profile after rejected WPA authentication"), + "rejected WPA authentication left a saved bad profile" + ); - // Test with a non-existent SSID - let result = nm - .has_saved_connection("__NONEXISTENT_TEST_SSID__") + device_callback_count.store(0, Ordering::SeqCst); + bounded( + "connect to the WPA access point", + WIFI_TIMEOUT, + wifi.connect( + &ssid, + WifiSecurity::WpaPsk { + psk: password.clone(), + }, + ), + ) + .await + .expect("WPA authentication or DHCP activation failed"); + timeout(EVENT_TIMEOUT, async { + while device_callback_count.load(Ordering::SeqCst) == 0 { + sleep(Duration::from_millis(25)).await; + } + }) .await - .expect("Failed to check saved connection"); - assert!( - !result, - "Non-existent SSID should not have saved connection" - ); + .expect("device callback was not delivered during WiFi activation"); + assert!( + bounded( + "check connected state", + DBUS_TIMEOUT, + nm.is_connected(&ssid) + ) + .await + .expect("failed to query connected state") + ); + let current_ssid = bounded("read the current SSID", DBUS_TIMEOUT, nm.current_ssid()).await; + assert_eq!(current_ssid.as_deref(), Some(ssid.as_str())); + + let active = bounded( + "read the active WiFi network", + DBUS_TIMEOUT, + nm.current_network(), + ) + .await + .expect("failed to read the active WiFi network") + .expect("connect returned success without an active WiFi network"); + assert_eq!(active.ssid, ssid); + assert_eq!(active.device, interface); + assert!(active.is_active); + let ip4_address = active + .ip4_address + .as_deref() + .expect("successful activation did not acquire an IPv4 DHCP lease"); + assert!( + ip4_address.starts_with("192.168.250."), + "unexpected DHCP address {ip4_address:?}" + ); - // Test with empty SSID - let _result = nm - .has_saved_connection("") - .await - .expect("Failed to check saved connection for empty SSID"); -} + assert!( + bounded( + "check for the saved WiFi profile", + DBUS_TIMEOUT, + nm.has_saved_connection(&ssid), + ) + .await + .expect("failed to query the saved WiFi profile") + ); + let saved_path = bounded( + "resolve the saved WiFi path", + DBUS_TIMEOUT, + nm.get_saved_connection_path(&ssid), + ) + .await + .expect("failed to resolve the saved WiFi path") + .expect("successful WPA connection did not create a saved profile"); + assert_ne!(saved_path.as_str(), "/"); + let saved_uuid = bounded( + "resolve the saved WiFi UUID", + DBUS_TIMEOUT, + nm.get_saved_connection_uuid(&ssid), + ) + .await + .expect("failed to resolve the saved WiFi UUID") + .expect("successful WPA connection had no saved UUID"); + let saved = bounded( + "decode the saved WiFi profile", + DBUS_TIMEOUT, + nm.get_saved_connection(&saved_uuid), + ) + .await + .expect("failed to decode the saved WiFi profile"); + assert_eq!(saved.id, ssid); + assert_eq!(saved.connection_type, "802-11-wireless"); + match saved.summary { + SettingsSummary::Wifi { + ssid: saved_ssid, + security: Some(security), + .. + } => { + assert_eq!(saved_ssid, ssid); + assert_eq!(security.key_mgmt, WifiKeyMgmt::WpaPsk); + } + other => panic!("expected a WPA WiFi settings summary, got {other:?}"), + } -/// Test getting the path of a saved connection -#[tokio::test] -#[serial] -async fn test_get_saved_connection_path() { - require_networkmanager!(); + let active = active_connections(&nm).await; + let typed_wifi = active + .iter() + .find_map(|connection| match connection { + ActiveConnection::Wifi(wifi) if wifi.ssid == ssid => Some(wifi.clone()), + _ => None, + }) + .unwrap_or_else(|| { + panic!("typed active connections omitted the connected WiFi network: {active:?}") + }); + assert_eq!(typed_wifi.id, ssid); + assert_eq!(typed_wifi.uuid, saved_uuid); + assert_eq!(typed_wifi.ssid, ssid); + assert_eq!(typed_wifi.interface.as_deref(), Some(interface.as_str())); + assert_eq!(typed_wifi.bssid.as_deref(), Some(expected_bssid.as_str())); + assert!(typed_wifi.strength.is_some()); + assert_eq!(typed_wifi.state, ActiveConnectionState::Activated); + assert!( + typed_wifi + .ip4_address + .as_deref() + .is_some_and(|address| address.starts_with("192.168.250.")), + "typed active WiFi connection omitted its DHCP address" + ); - let nm = NetworkManager::new() + bounded( + "disconnect the WiFi device", + DBUS_TIMEOUT, + wifi.disconnect(), + ) .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); + .expect("failed to disconnect after the initial WPA connection"); + assert!( + !bounded( + "check disconnected state", + DBUS_TIMEOUT, + nm.is_connected(&ssid), + ) + .await + .expect("failed to query disconnected state") + ); + assert!( + !active_connections(&nm).await.iter().any( + |active| matches!(active, ActiveConnection::Wifi(wifi) if wifi.uuid == saved_uuid) + ), + "typed active WiFi connection remained after disconnect" + ); - // Test with a non-existent SSID - let result = nm - .get_saved_connection_path("__NONEXISTENT_TEST_SSID__") + bounded( + "reconnect with NetworkManager's saved PSK", + WIFI_TIMEOUT, + wifi.connect(&ssid, WifiSecurity::WpaPsk { psk: String::new() }), + ) .await - .expect("Failed to get saved connection path"); - assert!( - result.is_none(), - "Non-existent SSID should not have saved connection path" - ); + .expect("saved-credential WPA reconnect failed"); + assert!( + bounded( + "check saved-credential reconnect", + DBUS_TIMEOUT, + nm.is_connected(&ssid), + ) + .await + .expect("failed to query the saved-credential reconnect") + ); - // Test with empty SSID - let result = nm - .get_saved_connection_path("") + bounded( + "forget the active WiFi profile", + WIFI_TIMEOUT, + wifi.forget(&ssid), + ) .await - .expect("Failed to get saved connection path for empty SSID"); - // Result can be Some or None depending on system state - let _ = result; -} + .expect("failed to disconnect and forget the WiFi profile"); + assert!( + !bounded( + "check profile removal", + DBUS_TIMEOUT, + nm.has_saved_connection(&ssid), + ) + .await + .expect("failed to query profile removal") + ); + assert!( + bounded( + "resolve path after forgetting", + DBUS_TIMEOUT, + nm.get_saved_connection_path(&ssid), + ) + .await + .expect("failed to resolve the profile path after forgetting") + .is_none() + ); + assert!( + bounded( + "resolve UUID after forgetting", + DBUS_TIMEOUT, + nm.get_saved_connection_uuid(&ssid), + ) + .await + .expect("failed to resolve the profile UUID after forgetting") + .is_none() + ); -/// Test getting the UUID of a saved connection -#[tokio::test] -#[serial] -async fn test_get_saved_connection_uuid() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - let result = nm - .get_saved_connection_uuid("__NONEXISTENT_TEST_SSID__") - .await - .expect("Failed to get saved connection UUID"); - assert!( - result.is_none(), - "Non-existent SSID should not have saved connection UUID" - ); - - let result = nm - .get_saved_connection_uuid("") - .await - .expect("Failed to get saved connection UUID for empty SSID"); - let _ = result; -} - -/// Test connecting to an open network -#[tokio::test] -#[serial] -async fn test_connect_open_network() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); - - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; - - // Request a scan first - let _ = nm.scan_networks(None).await; - sleep(Duration::from_secs(2)).await; - - // List networks to find an open network - let networks = nm - .list_networks(None) - .await - .expect("Failed to list networks"); - - // Find an open network (if any) - let open_network = networks.iter().find(|n| !n.secured); - - if let Some(network) = open_network { - let test_ssid = &network.ssid; - - // Skip if SSID is hidden or empty - if test_ssid.is_empty() || test_ssid == "" { - eprintln!("Skipping: Found open network but SSID is hidden/empty"); - return; - } - - // Try to connect to the open network - let result = nm.connect(test_ssid, None, WifiSecurity::Open).await; - - match result { - Ok(_) => { - // Connection succeeded - wait a bit and verify - sleep(Duration::from_secs(3)).await; - let current = nm.current_ssid().await; - if let Some(connected_ssid) = current { - // May or may not match depending on connection success - eprintln!("Connected SSID: {}", connected_ssid); - } - } - Err(e) => { - // Connection failed - this is acceptable in test environments - eprintln!("Connection failed (may be expected): {}", e); - } - } - } else { - eprintln!("No open networks found for testing"); - } -} - -/// Test connecting to a PSK network with an empty password -#[tokio::test] -#[serial] -async fn test_connect_psk_network_with_empty_password() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); - - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; - - // Request a scan first - let _ = nm.scan_networks(None).await; - sleep(Duration::from_secs(2)).await; - - // List networks to find a PSK network - let networks = nm - .list_networks(None) - .await - .expect("Failed to list networks"); - - // Find a PSK network (if any) - let psk_network = networks.iter().find(|n| n.is_psk); - - if let Some(network) = psk_network { - let test_ssid = &network.ssid; - - // Skip if SSID is hidden or empty - if test_ssid.is_empty() || test_ssid == "" { - eprintln!("Skipping: Found PSK network but SSID is hidden/empty"); - return; - } - - // Check if we have a saved connection for this network - let has_saved = nm - .has_saved_connection(test_ssid) - .await - .expect("Failed to check saved connection"); - - if has_saved { - // Try to connect with empty password (should use saved credentials) - let result = nm - .connect(test_ssid, None, WifiSecurity::WpaPsk { psk: String::new() }) - .await; - - match result { - Ok(_) => { - // Connection succeeded - wait a bit - sleep(Duration::from_secs(3)).await; - } - Err(e) => { - // Connection failed - this is acceptable - eprintln!("Connection with saved credentials failed: {}", e); - } - } - } else { - eprintln!("No saved connection for PSK network, skipping test"); - } - } else { - eprintln!("No PSK networks found for testing"); - } -} - -/// Test forgetting a nonexistent network -#[tokio::test] -#[serial] -async fn test_forget_nonexistent_network() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - // Try to forget a non-existent network - let result = nm.forget("__NONEXISTENT_TEST_SSID_TO_FORGET__").await; - - // This should fail since the network doesn't exist - assert!( - result.is_err(), - "Forgetting non-existent network should fail" - ); -} - -/// Test device states -#[tokio::test] -#[serial] -async fn test_device_states() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - let devices = nm.list_devices().await.expect("Failed to list devices"); - - // Verify that all devices have valid states - for device in &devices { - // DeviceState should be one of the known states - // The struct is non-exhaustive and so we allow Other(_) - match device.state { - DeviceState::Unmanaged - | DeviceState::Unavailable - | DeviceState::Disconnected - | DeviceState::Prepare - | DeviceState::Config - | DeviceState::NeedAuth - | DeviceState::IpConfig - | DeviceState::IpCheck - | DeviceState::Secondaries - | DeviceState::Activated - | DeviceState::Deactivating - | DeviceState::Failed - | DeviceState::Other(_) => {} - _ => { - panic!("Invalid device state: {:?}", device.state); - } - } - } -} - -/// Test device types -#[tokio::test] -#[serial] -async fn test_device_types() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - let devices = nm.list_devices().await.expect("Failed to list devices"); - - // Verify that all devices have valid types - for device in &devices { - // DeviceType should be one of the known types - // The struct is non-exhaustive and so we allow Other(_) - match device.device_type { - DeviceType::Ethernet - | DeviceType::Wifi - | DeviceType::Bluetooth - | DeviceType::WifiP2P - | DeviceType::Loopback - | DeviceType::Other(_) => { - // Valid type - } - _ => { - panic!("Invalid device type: {:?}", device.device_type); - } - } - } -} - -/// Test network properties -#[tokio::test] -#[serial] -async fn test_network_properties() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); - - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; - - // Request a scan first - let _ = nm.scan_networks(None).await; - sleep(Duration::from_secs(2)).await; - - // List networks - let networks = nm - .list_networks(None) + let error = bounded( + "reject an empty PSK without saved credentials", + DBUS_TIMEOUT, + wifi.connect(&ssid, WifiSecurity::WpaPsk { psk: String::new() }), + ) .await - .expect("Failed to list networks"); - - // Verify network properties - for network in &networks { - // SSID should not be empty (unless hidden) + .expect_err("an empty PSK without a saved profile must fail"); assert!( - !network.ssid.is_empty() || network.ssid == "", - "SSID should not be empty" + matches!(error, ConnectionError::MissingPassword), + "expected MissingPassword after forgetting saved credentials, got {error:?}" ); - - // `device` may be empty for deduplicated scan entries; only validate - // normalized fields that are guaranteed by this API. - - // If strength is Some, it should be <= 100 - if let Some(strength) = network.strength { - assert!(strength <= 100, "Strength should be <= 100"); - } - - // Security flags should be consistent - if !network.secured { - assert!(!network.is_psk, "Unsecured network should not be PSK"); - assert!(!network.is_eap, "Unsecured network should not be EAP"); - } + }) + .catch_unwind() + .await; + + let mut cleanup_failures = cleanup_wifi_profile(&wifi, &ssid).await; + match timeout(WIFI_TIMEOUT, wifi.forget(&absent_ssid)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => cleanup_failures.push(format!("forget {absent_ssid:?}: {error}")), + Err(_) => cleanup_failures.push(format!("forget {absent_ssid:?}: timed out")), } -} - -/// Test multiple scan requests -#[tokio::test] -#[serial] -async fn test_multiple_scan_requests() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); - - // Wait for WiFi to be ready - let _ = nm.wait_for_wifi_ready().await; - - // Request multiple scans - for i in 0..3 { - nm.wait_for_wifi_ready().await.expect("WiFi not ready"); - - let result = nm.scan_networks(None).await; - match result { - Ok(_) => eprintln!("Scan {} succeeded", i + 1), - Err(e) => eprintln!("Scan {} failed: {}", i + 1, e), - } - - nm.wait_for_wifi_ready() - .await - .expect("WiFi did not recover"); - sleep(Duration::from_secs(3)).await; - } - - // List networks after multiple scans - let networks = nm - .list_networks(None) - .await - .expect("Failed to list networks"); - eprintln!("Found {} networks after multiple scans", networks.len()); -} - -/// Test concurrent operations -#[tokio::test] -#[serial] -async fn test_concurrent_operations() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - // Ensure WiFi is enabled - nm.set_wireless_enabled(true) - .await - .expect("Failed to enable WiFi"); - - // Run multiple operations concurrently - let (devices_result, wifi_state_result, networks_result) = - tokio::join!(nm.list_devices(), nm.wifi_state(), nm.list_networks(None)); - - // All should succeed - assert!(devices_result.is_ok(), "list_devices should succeed"); - assert!(wifi_state_result.is_ok(), "wifi_state should succeed"); - // networks_result may fail if WiFi is not ready, which is acceptable - let _ = networks_result; -} - -/// Test that reason_to_error maps auth failures correctly -#[test] -fn reason_to_error_auth_mapping() { - // Supplicant failed (code 9) should map to AuthFailed - assert!(matches!(reason_to_error(9), ConnectionError::AuthFailed)); - - // Supplicant disconnected (code 7) should map to AuthFailed - assert!(matches!(reason_to_error(7), ConnectionError::AuthFailed)); - - // DHCP failed (code 17) should map to DhcpFailed - assert!(matches!(reason_to_error(17), ConnectionError::DhcpFailed)); - - // SSID not found (code 70) should map to NotFound - assert!(matches!(reason_to_error(70), ConnectionError::NotFound)); -} - -/// Test StateReason conversions -#[test] -fn state_reason_conversion() { - assert_eq!(StateReason::from(9), StateReason::SupplicantFailed); - assert_eq!(StateReason::from(70), StateReason::SsidNotFound); - assert_eq!(StateReason::from(999), StateReason::Other(999)); -} - -/// Test ConnectionError display formatting -#[test] -fn connection_error_display() { - let auth_err = ConnectionError::AuthFailed; - assert_eq!(format!("{}", auth_err), "authentication failed"); - - let not_found_err = ConnectionError::NotFound; - assert_eq!(format!("{}", not_found_err), "network not found"); - - let timeout_err = ConnectionError::Timeout; - assert_eq!(format!("{}", timeout_err), "connection timeout"); - - let stuck_err = ConnectionError::Stuck("config".into()); - assert_eq!( - format!("{}", stuck_err), - "connection stuck in state: config" - ); -} - -/// Test forgetting a network returns NoSavedConnection error -#[tokio::test] -#[serial] -async fn forget_returns_no_saved_connection_error() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_wifi!(&nm); - - let result = nm.forget("__NONEXISTENT_TEST_SSID__").await; - - match result { - Err(ConnectionError::NoSavedConnection) => { - // Expected error type - } - Err(e) => { - panic!("Expected NoSavedConnection error, got: {}", e); - } - Ok(_) => { - // Error is Expected in case of failed operation only. - println!("Expected response, got success"); - } - } -} - -/// Test listing wired devices -#[tokio::test] -#[serial] -async fn test_list_wired_devices() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - let devices = nm - .list_wired_devices() - .await - .expect("Failed to list wired devices"); - - // Verify device structure for wired devices - for device in &devices { - assert!(!device.path.is_empty(), "Device path should not be empty"); - assert!( - !device.interface.is_empty(), - "Device interface should not be empty" - ); - assert_eq!( - device.device_type, - DeviceType::Ethernet, - "Device type should be Ethernet" - ); - } -} - -/// Test connecting to wired device -#[tokio::test] -#[serial] -async fn test_connect_wired() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - require_ethernet!(&nm); - - // Try to connect to wired device - let result = nm.connect_wired().await; - - match result { - Ok(_) => { - // Connection succeeded or is waiting for cable - eprintln!("Wired connection initiated successfully"); - } - Err(e) => { - // Connection failed - this is acceptable in test environments - eprintln!("Wired connection failed (may be expected): {}", e); - } - } -} - -/// Helper to create test VPN configuration -fn create_test_vpn_creds(name: &str) -> WireGuardConfig { - let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "test.example.com:51820", - vec!["0.0.0.0/0".into(), "::/0".into()], - ) - .with_persistent_keepalive(25); - - WireGuardConfig::new( - name, - "test.example.com:51820", - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.100.0.2/24", - vec![peer], - ) - .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) - .with_mtu(1420) -} - -/// Test listing VPN connections -#[tokio::test] -#[serial] -async fn test_list_vpn_connections() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - // List VPN connections (should not fail even if empty) - let result = nm.list_vpn_connections().await; - assert!(result.is_ok(), "Should be able to list VPN connections"); - - let vpns = result.unwrap(); - eprintln!("Found {} VPN connection(s)", vpns.len()); - - // Verify structure of any VPN connections found - for vpn in &vpns { - assert!(!vpn.name.is_empty(), "VPN name should not be empty"); - eprintln!("VPN: {} ({:?})", vpn.name, vpn.vpn_type); - } -} - -/// Test VPN connection lifecycle (does not actually connect) -#[tokio::test] -#[serial] -async fn test_vpn_lifecycle_dry_run() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - // Note: This test does NOT actually connect to a VPN - // It only tests the API structure and error handling - - // Create test credentials - let creds = create_test_vpn_creds("test_vpn_lifecycle"); - - // Attempt to connect (will likely fail as test server doesn't exist) - let result = nm.connect_vpn(creds).await; - - match result { - Ok(_) => { - eprintln!("VPN connection succeeded (unexpected in test)"); - // Clean up - let _ = nm.disconnect_vpn("test_vpn_lifecycle").await; - let _ = nm.forget_vpn("test_vpn_lifecycle").await; - } - Err(e) => { - eprintln!("VPN connection failed as expected: {}", e); - // This is expected since we're using fake credentials - } - } -} - -/// Test VPN disconnection with non-existent VPN -#[tokio::test] -#[serial] -async fn test_disconnect_nonexistent_vpn() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - // Disconnecting a non-existent VPN should succeed (idempotent) - let result = nm.disconnect_vpn("nonexistent_vpn_connection_12345").await; - assert!( - result.is_ok(), - "Disconnecting non-existent VPN should succeed" - ); -} - -/// Test forgetting non-existent VPN -#[tokio::test] -#[serial] -async fn test_forget_nonexistent_vpn() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - // Forgetting a non-existent VPN will return Ok - // Error is Expected in case of failed operation only - let result = nm.forget_vpn("nonexistent_vpn_connection_12345").await; - assert!( - result.is_ok(), - "Forgetting non-existent VPN should return error" - ); - - match result { - Err(ConnectionError::NoSavedConnection) => { - eprintln!("Correct error: NoSavedConnection"); - } - Err(e) => { - panic!("Unexpected error type: {}", e); - } - Ok(_) => { - println!("Correct response: NoSavedConnection"); - } - } -} - -/// Test getting info for non-existent VPN -#[tokio::test] -#[serial] -async fn test_get_nonexistent_vpn_info() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - // Getting info for non-existent/inactive VPN should fail - let result = nm.get_vpn_info("nonexistent_vpn_connection_12345").await; - assert!( - result.is_err(), - "Getting info for non-existent VPN should return error" - ); - - match result { - Err(ConnectionError::NoVpnConnection) => { - eprintln!("Correct error: NoVpnConnection"); - } - Err(e) => { - eprintln!("Error (acceptable): {}", e); - } - Ok(_) => { - panic!("Should have failed"); - } - } -} - -/// Test VPN type enum -#[tokio::test] -#[serial] -async fn test_vpn_type() { - // Verify VPN types are properly defined - let wg = VpnKind::WireGuard; - assert_eq!(format!("{:?}", wg), "WireGuard"); -} - -/// Test WireGuard peer structure -#[tokio::test] -#[serial] -async fn test_wireguard_peer_structure() { - let peer = WireGuardPeer::new( - "test_key", - "test.example.com:51820", - vec!["0.0.0.0/0".into()], - ) - .with_preshared_key("psk") - .with_persistent_keepalive(25); - - assert_eq!(peer.public_key, "test_key"); - assert_eq!(peer.gateway, "test.example.com:51820"); - assert_eq!(peer.allowed_ips.len(), 1); - assert_eq!(peer.preshared_key, Some("psk".into())); - assert_eq!(peer.persistent_keepalive, Some(25)); -} - -/// Test VPN configuration structure -#[tokio::test] -#[serial] -async fn test_vpn_credentials_structure() { - let creds = create_test_vpn_creds("test_credentials"); - - assert_eq!(creds.name, "test_credentials"); - assert_eq!(creds.peers.len(), 1); - assert_eq!(creds.address, "10.100.0.2/24"); - assert!(creds.dns.is_some()); - assert_eq!(creds.dns.as_ref().unwrap().len(), 2); - assert_eq!(creds.mtu, Some(1420)); -} - -/// Check if Bluetooth is available -#[allow(dead_code)] -async fn has_bluetooth_device(nm: &NetworkManager) -> bool { - nm.list_bluetooth_devices() - .await - .map(|d| !d.is_empty()) - .unwrap_or(false) -} - -/// Skip tests if Bluetooth device is not available -#[allow(unused_macros)] -macro_rules! require_bluetooth { - ($nm:expr) => { - if !has_bluetooth_device($nm).await { - eprintln!("Skipping test: No Bluetooth device available"); - return; - } - }; -} - -/// Test listing Bluetooth devices -#[tokio::test] -#[serial] -async fn test_list_bluetooth_devices() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - let devices = nm - .list_bluetooth_devices() - .await - .expect("Failed to list Bluetooth devices"); - - // Verify device structure for Bluetooth devices - for device in &devices { - assert!( - !device.bdaddr.is_empty(), - "Bluetooth address should not be empty" - ); - eprintln!( - "Bluetooth device: {} ({}) - {}", - device.alias.as_deref().unwrap_or("unknown"), - device.bdaddr, - device.bt_caps - ); - } -} - -/// Test Bluetooth device type enum -#[test] -fn test_bluetooth_network_role() { - use nmrs::models::BluetoothNetworkRole; - - let panu = BluetoothNetworkRole::PanU; - assert_eq!(format!("{}", panu), "PANU"); - - let dun = BluetoothNetworkRole::Dun; - assert_eq!(format!("{}", dun), "DUN"); -} - -/// Test BluetoothIdentity structure -#[test] -fn test_bluetooth_identity_structure() { - use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; - - let identity = - BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU).unwrap(); - - assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); - assert!(matches!( - identity.bt_device_type, - BluetoothNetworkRole::PanU - )); -} - -/// Test BluetoothDevice structure -#[test] -fn test_bluetooth_device_structure() { - use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; - - let role = BluetoothNetworkRole::PanU as u32; - let device = BluetoothDevice::new( - "00:1A:7D:DA:71:13".into(), - Some("MyPhone".into()), - Some("Phone".into()), - role, - DeviceState::Activated, - ); - - assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); - assert_eq!(device.name, Some("MyPhone".into())); - assert_eq!(device.alias, Some("Phone".into())); - assert_eq!(device.state, DeviceState::Activated); -} - -/// Test BluetoothDevice display -#[test] -fn test_bluetooth_device_display() { - use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; - - let role = BluetoothNetworkRole::PanU as u32; - let device = BluetoothDevice::new( - "00:1A:7D:DA:71:13".into(), - Some("MyPhone".into()), - Some("Phone".into()), - role, - DeviceState::Activated, - ); - - let display = format!("{}", device); - assert!(display.contains("Phone")); - assert!(display.contains("00:1A:7D:DA:71:13")); -} - -/// Test Device::is_bluetooth method -#[tokio::test] -#[serial] -async fn test_device_is_bluetooth() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - let devices = nm.list_devices().await.expect("Failed to list devices"); - - for device in &devices { - if device.is_bluetooth() { - assert_eq!(device.device_type, DeviceType::Bluetooth); - eprintln!("Found Bluetooth device: {}", device.interface); - } - } -} - -/// Test Bluetooth device in all devices list -#[tokio::test] -#[serial] -async fn test_bluetooth_in_device_types() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - let devices = nm.list_devices().await.expect("Failed to list devices"); - - // Check if any Bluetooth devices exist - let bluetooth_devices: Vec<_> = devices - .iter() - .filter(|d| matches!(d.device_type, DeviceType::Bluetooth)) - .collect(); - - if !bluetooth_devices.is_empty() { - eprintln!("Found {} Bluetooth device(s)", bluetooth_devices.len()); - for device in bluetooth_devices { - eprintln!(" - {}: {}", device.interface, device.state); - } - } else { - eprintln!("No Bluetooth devices found (this is OK)"); - } -} - -/// Test ConnectionError::NoBluetoothDevice -#[test] -fn test_connection_error_no_bluetooth_device() { - let err = ConnectionError::NoBluetoothDevice; - assert_eq!(format!("{}", err), "Bluetooth device not found"); -} - -/// Test BluetoothNetworkRole conversion from u32 -#[test] -fn test_bluetooth_network_role_from_u32() { - use nmrs::models::BluetoothNetworkRole; - - assert!(matches!( - BluetoothNetworkRole::from(0), - BluetoothNetworkRole::PanU - )); - assert!(matches!( - BluetoothNetworkRole::from(1), - BluetoothNetworkRole::Dun - )); - // Unknown values should default to PanU - assert!(matches!( - BluetoothNetworkRole::from(999), - BluetoothNetworkRole::PanU - )); -} - -// --- OpenVPN import tests --- - -/// Test that OpenVpnBuilder::from_ovpn_str produces correct settings for a -/// full TLS config, and that build_openvpn_connection serializes them. -#[test] -fn test_ovpn_import_tls_roundtrip() { - use nmrs::ConnectionOptions; - use nmrs::builders::{OpenVpnBuilder, build_openvpn_connection}; - - let ovpn = "\ -remote vpn.example.com 1194 udp -ca /etc/openvpn/ca.crt -cert /etc/openvpn/client.crt -key /etc/openvpn/client.key -cipher AES-256-GCM -auth SHA256 -tls-auth /etc/openvpn/ta.key 1 -"; - let config = OpenVpnBuilder::from_ovpn_str(ovpn, "roundtrip-test") - .unwrap() - .build() - .unwrap(); - - assert_eq!(config.remote, "vpn.example.com"); - assert_eq!(config.port, 1194); - assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); - assert_eq!(config.cipher, Some("AES-256-GCM".into())); - assert_eq!(config.auth, Some("SHA256".into())); - assert_eq!(config.tls_auth_key, Some("/etc/openvpn/ta.key".into())); - assert_eq!(config.tls_auth_direction, Some(1)); - - let opts = ConnectionOptions::new(false); - let settings = build_openvpn_connection(&config, &opts).unwrap(); - assert!(settings.contains_key("connection")); - assert!(settings.contains_key("vpn")); -} - -/// Test that from_ovpn_str infers password+TLS auth when both -/// auth-user-pass and cert/key are present. -#[test] -fn test_ovpn_import_password_tls() { - use nmrs::builders::OpenVpnBuilder; - - let ovpn = "\ -remote vpn.example.com 443 tcp -auth-user-pass -ca /etc/openvpn/ca.crt -cert /etc/openvpn/client.crt -key /etc/openvpn/client.key -"; - let config = OpenVpnBuilder::from_ovpn_str(ovpn, "pw-tls-test") - .unwrap() - .username("user") - .build() - .unwrap(); - - assert_eq!(config.auth_type, Some(OpenVpnAuthType::PasswordTls)); - assert!(config.tcp); - assert_eq!(config.port, 443); -} - -/// Test that the caller can override parsed settings before build. -#[test] -fn test_ovpn_import_override() { - use nmrs::builders::OpenVpnBuilder; - - let ovpn = "\ -remote vpn.example.com 1194 -ca /etc/openvpn/ca.crt -cert /etc/openvpn/client.crt -key /etc/openvpn/client.key -"; - let config = OpenVpnBuilder::from_ovpn_str(ovpn, "override-test") - .unwrap() - .port(443) - .tcp(true) - .dns(vec!["1.1.1.1".into()]) - .mtu(1400) - .remote_cert_tls("server") - .build() - .unwrap(); - - assert_eq!(config.port, 443); - assert!(config.tcp); - assert_eq!(config.dns, Some(vec!["1.1.1.1".into()])); - assert_eq!(config.mtu, Some(1400)); - assert_eq!(config.remote_cert_tls, Some("server".into())); -} - -/// Test airplane mode toggle (set and get) -/// -/// This tests the aggregate airplane mode operation combining WiFi, WWAN, and Bluetooth. -/// Specifically validates that set_airplane_mode returns Ok(()) even if Bluetooth -/// adapter settle failures occur, as long as WiFi/WWAN toggles succeed. -/// This is a regression test for the fix where BluetoothToggleFailed is treated as -/// non-fatal in the aggregate operation. -#[tokio::test] -#[serial] -async fn test_airplane_mode_toggle() { - require_networkmanager!(); - - let nm = NetworkManager::new() - .await - .expect("Failed to create NetworkManager"); - - // Get initial airplane mode state - let initial_state = nm - .airplane_mode_state() - .await - .expect("Failed to get airplane mode state"); - - if !initial_state.wifi.present - && !initial_state.wwan.present - && !initial_state.bluetooth.present + if let Some(handle) = device_monitor.take() + && let Some(failure) = stop_monitor("WiFi device callback monitor", handle).await { - eprintln!("Skipping test: no controllable radios present on host"); - return; - } - - let is_airplane_mode = initial_state.is_airplane_mode(); - println!( - "Initial airplane mode state: is_airplane_mode={}, WiFi enabled={}, WWAN enabled={}, Bluetooth enabled={}", - is_airplane_mode, - initial_state.wifi.enabled, - initial_state.wwan.enabled, - initial_state.bluetooth.enabled - ); - - // Toggle airplane mode to opposite state - let target_enabled = !is_airplane_mode; - println!("Toggling airplane mode to: {}", target_enabled); - - let mut failures: Vec = Vec::new(); - let wifi_or_wwan_present = initial_state.wifi.present || initial_state.wwan.present; - - if let Err(e) = nm.set_airplane_mode(target_enabled).await { - // BluetoothToggleFailed is expected on Bluetooth-only hosts (no Wi-Fi/WWAN). - // Only treat this as a regression if Wi-Fi or WWAN is present. - if wifi_or_wwan_present { - failures.push(format!( - "set_airplane_mode toggle returned error (regression candidate): {e}" - )); - } else { - println!("set_airplane_mode error on Bluetooth-only host (expected): {e}"); - } - } else { - println!("Airplane mode toggle succeeded"); - } - - // Give the radios time to settle (especially Bluetooth with its 2-second timeout) - sleep(Duration::from_secs(3)).await; - - // Verify the toggle took effect - let new_state = match nm.airplane_mode_state().await { - Ok(state) => Some(state), - Err(e) => { - failures.push(format!( - "Failed to get airplane mode state after toggle: {e}" - )); - None - } - }; - - if let Some(state) = &new_state { - let new_is_airplane_mode = state.is_airplane_mode(); - println!( - "New airplane mode state: is_airplane_mode={}, WiFi enabled={}, WWAN enabled={}, Bluetooth enabled={}", - new_is_airplane_mode, state.wifi.enabled, state.wwan.enabled, state.bluetooth.enabled - ); - - let expected_radio_on = !target_enabled; - let wifi_or_wwan_present = state.wifi.present || state.wwan.present; - for (name, radio) in [ - ("WiFi", state.wifi), - ("WWAN", state.wwan), - ("Bluetooth", state.bluetooth), - ] { - // BluetoothToggleFailed is non-fatal when Wi-Fi/WWAN are present, - // so skip the Bluetooth assertion on those hosts. - if name == "Bluetooth" && wifi_or_wwan_present { - continue; - } - if radio.present && radio.enabled != expected_radio_on { - failures.push(format!( - "{name} enabled mismatch after toggle: expected {}, got {}", - expected_radio_on, radio.enabled - )); - } - } - } - - // Best-effort restore to initial aggregate state. - // NOTE: set_airplane_mode(false) turns ALL radios on, so this does not truly - // restore individual radio states. If the host started in a mixed state - // (e.g., Wi-Fi on but Bluetooth off), radios may end up in a different - // configuration than before. We log the result but don't assert on it. - println!( - "Restoring airplane mode to initial state: {}", - is_airplane_mode - ); - - if let Err(e) = nm.set_airplane_mode(is_airplane_mode).await { - println!("Best-effort restore failed (not a test failure): {e}"); + cleanup_failures.push(failure); } - - // Give radios time to settle again - sleep(Duration::from_secs(3)).await; - - // Log restored state for diagnostics (no assertions—restoration is best-effort) - match nm.airplane_mode_state().await { - Ok(restored_state) => { - println!( - "Restored airplane mode state: is_airplane_mode={}, WiFi enabled={}, WWAN enabled={}, Bluetooth enabled={}", - restored_state.is_airplane_mode(), - restored_state.wifi.enabled, - restored_state.wwan.enabled, - restored_state.bluetooth.enabled - ); - } - Err(e) => println!("Could not read restored state: {e}"), + if let Some(handle) = network_monitor.take() + && let Some(failure) = stop_monitor("WiFi network callback monitor", handle).await + { + cleanup_failures.push(failure); } - - if !failures.is_empty() { - panic!("{}", failures.join("\n")); + match timeout(DBUS_TIMEOUT, nm.set_wireless_enabled(initial_wifi_enabled)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => cleanup_failures.push(format!( + "restore WiFi radio enabled={initial_wifi_enabled}: {error}" + )), + Err(_) => cleanup_failures.push(format!( + "restore WiFi radio enabled={initial_wifi_enabled}: timed out" + )), } + finish_after_cleanup(outcome, cleanup_failures); } diff --git a/nmrs/tests/validation_test.rs b/nmrs/tests/validation_test.rs deleted file mode 100644 index 7269ab5e..00000000 --- a/nmrs/tests/validation_test.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Tests for input validation. -//! -//! These tests verify that invalid inputs are rejected before attempting -//! D-Bus operations, providing clear error messages to users. - -use nmrs::{ConnectionError, EapOptions, WifiSecurity, WireGuardConfig, WireGuardPeer}; -use zvariant::OwnedObjectPath; - -#[test] -fn test_invalid_ssid_empty() { - // Empty SSID should be rejected - let result = std::panic::catch_unwind(|| { - // This would be caught at validation time, not at runtime - // We'll test this through the actual API when we can mock D-Bus - }); - // For now, just verify the test compiles - assert!(result.is_ok()); -} - -#[test] -fn test_invalid_ssid_too_long() { - // SSID longer than 32 bytes should be rejected - let long_ssid = "a".repeat(33); - assert!(long_ssid.len() > 32); -} - -#[test] -fn test_valid_ssid() { - let valid_ssids = vec![ - "MyNetwork", - "Test-Network_123", - "A", - "12345678901234567890123456789012", // Exactly 32 bytes - ]; - - for ssid in valid_ssids { - assert!(ssid.len() <= 32, "SSID '{}' should be valid", ssid); - } -} - -#[test] -fn test_invalid_wpa_psk_too_short() { - let short_psk = WifiSecurity::WpaPsk { - psk: "short".to_string(), // Less than 8 characters - }; - - // Validation will catch this - assert!(short_psk.is_psk()); -} - -#[test] -fn test_invalid_wpa_psk_too_long() { - let long_psk = WifiSecurity::WpaPsk { - psk: "a".repeat(64), // More than 63 characters - }; - - assert!(long_psk.is_psk()); -} - -#[test] -fn test_valid_wpa_psk() { - let binding = "a".repeat(63); - let valid_passwords = vec![ - "password", // 8 chars (minimum) - "password123", // 11 chars - &binding, // 63 chars (maximum) - ]; - - for password in valid_passwords { - let psk = WifiSecurity::WpaPsk { - psk: password.to_string(), - }; - assert!(psk.is_psk()); - } -} - -#[test] -fn test_empty_wpa_psk_allowed() { - // Empty PSK is allowed (for using saved credentials) - let empty_psk = WifiSecurity::WpaPsk { psk: String::new() }; - assert!(empty_psk.is_psk()); -} - -#[test] -fn test_invalid_eap_empty_identity() { - let opts = EapOptions::new("", "password").with_system_ca_certs(true); - - let eap = WifiSecurity::WpaEap { opts }; - - assert!(eap.is_eap()); -} - -#[test] -fn test_invalid_eap_ca_cert_path() { - let opts = - EapOptions::new("user@example.com", "password").with_ca_cert_path("/etc/ssl/cert.pem"); // Missing file:// prefix - - let eap = WifiSecurity::WpaEap { opts }; - - assert!(eap.is_eap()); -} - -#[test] -fn test_valid_eap() { - let opts = EapOptions::new("user@example.com", "password") - .with_anonymous_identity("anonymous@example.com") - .with_domain_suffix_match("example.com") - .with_ca_cert_path("file:///etc/ssl/cert.pem"); - - let eap = WifiSecurity::WpaEap { opts }; - - assert!(eap.is_eap()); -} - -#[test] -fn test_invalid_vpn_empty_name() { - let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "vpn.example.com:51820", - vec!["0.0.0.0/0".to_string()], - ) - .with_persistent_keepalive(25); - - let creds = WireGuardConfig::new( - "", // Empty name should be rejected - "vpn.example.com:51820", - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.0.0.2/24", - vec![peer], - ) - .with_dns(vec!["1.1.1.1".to_string()]); - - // Validation will catch this - assert_eq!(creds.name, ""); -} - -#[test] -fn test_invalid_vpn_gateway_no_port() { - let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "vpn.example.com:51820", - vec!["0.0.0.0/0".to_string()], - ) - .with_persistent_keepalive(25); - - let creds = WireGuardConfig::new( - "TestVPN", - "vpn.example.com", // Missing port - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.0.0.2/24", - vec![peer], - ) - .with_dns(vec!["1.1.1.1".to_string()]); - - // Validation will catch missing port - assert!(!creds.gateway.contains(':')); -} - -#[test] -fn test_invalid_vpn_no_peers() { - let creds = WireGuardConfig::new( - "TestVPN", - "vpn.example.com:51820", - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.0.0.2/24", - vec![], // No peers should be rejected - ) - .with_dns(vec!["1.1.1.1".to_string()]); - - // Validation will catch empty peers - assert!(creds.peers.is_empty()); -} - -#[test] -fn test_invalid_vpn_bad_cidr() { - let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "vpn.example.com:51820", - vec!["0.0.0.0/0".to_string()], - ) - .with_persistent_keepalive(25); - - let creds = WireGuardConfig::new( - "TestVPN", - "vpn.example.com:51820", - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.0.0.2", // Missing /prefix - vec![peer], - ) - .with_dns(vec!["1.1.1.1".to_string()]); - - // Validation will catch invalid CIDR - assert!(!creds.address.contains('/')); -} - -#[test] -fn test_invalid_vpn_mtu_too_small() { - let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "vpn.example.com:51820", - vec!["0.0.0.0/0".to_string()], - ) - .with_persistent_keepalive(25); - - let creds = WireGuardConfig::new( - "TestVPN", - "vpn.example.com:51820", - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.0.0.2/24", - vec![peer], - ) - .with_dns(vec!["1.1.1.1".to_string()]) - .with_mtu(500); // Too small (minimum is 576) - - // Validation will catch MTU too small - assert!(creds.mtu.unwrap() < 576); -} - -#[test] -fn test_valid_vpn_credentials() { - let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "vpn.example.com:51820", - vec!["0.0.0.0/0".to_string(), "::/0".to_string()], - ) - .with_persistent_keepalive(25); - - let creds = WireGuardConfig::new( - "TestVPN", - "vpn.example.com:51820", - "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", - "10.0.0.2/24", - vec![peer], - ) - .with_dns(vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()]) - .with_mtu(1420); - - // All fields should be valid - assert!(!creds.name.is_empty()); - assert!(creds.gateway.contains(':')); - assert!(!creds.peers.is_empty()); - assert!(creds.mtu.unwrap() >= 576 && creds.mtu.unwrap() <= 9000); -} - -#[test] -fn test_default_object_path() { - let object_path = OwnedObjectPath::try_from("/").unwrap(); - assert_eq!(object_path, OwnedObjectPath::default()) -} - -#[test] -fn test_connection_error_types() { - // Verify that our error types exist and can be constructed - let _err1 = ConnectionError::NotFound; - let _err2 = ConnectionError::AuthFailed; - let _err3 = ConnectionError::Timeout; - let _err4 = ConnectionError::InvalidAddress("test".to_string()); - let _err5 = ConnectionError::InvalidGateway("test".to_string()); - let _err6 = ConnectionError::InvalidPeers("test".to_string()); - let _err7 = ConnectionError::InvalidPrivateKey("test".to_string()); - let _err8 = ConnectionError::InvalidPublicKey("test".to_string()); - let _err9 = ConnectionError::MissingPassword; -} diff --git a/scripts/ci/run-networkmanager-tests.sh b/scripts/ci/run-networkmanager-tests.sh index 5c99f6c0..ab03947e 100755 --- a/scripts/ci/run-networkmanager-tests.sh +++ b/scripts/ci/run-networkmanager-tests.sh @@ -6,15 +6,33 @@ readonly mode="${1:-all}" readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly project_root="$(cd "${script_dir}/../.." && pwd)" readonly runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/nmrs-integration.XXXXXX")" +readonly dbus_log="${runtime_dir}/dbus.log" +readonly udev_log="${runtime_dir}/udev.log" readonly networkmanager_log="${runtime_dir}/networkmanager.log" readonly hostapd_log="${runtime_dir}/hostapd.log" +readonly dnsmasq_log="${runtime_dir}/dnsmasq.log" +readonly wired_dnsmasq_log="${runtime_dir}/wired-dnsmasq.log" readonly wpa_supplicant_log="${runtime_dir}/wpa_supplicant.log" readonly hostapd_config="${runtime_dir}/hostapd.conf" readonly networkmanager_config="${runtime_dir}/NetworkManager.conf" +readonly dnsmasq_leases="${runtime_dir}/dnsmasq.leases" +readonly wired_dnsmasq_leases="${runtime_dir}/wired-dnsmasq.leases" +readonly hwsim_ssid="nmrs-hwsim" +readonly hwsim_password="nmrs-hwsim-password" +readonly hwsim_gateway="192.168.250.1" +readonly wired_client_interface="nmrs-client" +readonly wired_server_interface="nmrs-server" +readonly wired_gateway="192.168.251.1" +readonly wireguard_interface="wg-nmrs-agent" +dbus_pid="" +udev_pid="" networkmanager_pid="" hostapd_pid="" +dnsmasq_pid="" +wired_dnsmasq_pid="" wpa_supplicant_pid="" hwsim_station_interface="" +wired_veth_created=false stop_process() { local pid="$1" @@ -26,9 +44,36 @@ stop_process() { } cleanup() { + local exit_code=$? + stop_process "${networkmanager_pid}" stop_process "${wpa_supplicant_pid}" + stop_process "${dnsmasq_pid}" + stop_process "${wired_dnsmasq_pid}" stop_process "${hostapd_pid}" + if [[ "${wired_veth_created}" == true ]] && ip link show "${wired_client_interface}" >/dev/null 2>&1; then + ip link delete "${wired_client_interface}" || true + fi + stop_process "${udev_pid}" + stop_process "${dbus_pid}" + + if (( exit_code != 0 )); then + echo "Integration harness failed; service logs follow:" >&2 + for log_file in \ + "${dbus_log}" \ + "${udev_log}" \ + "${networkmanager_log}" \ + "${wpa_supplicant_log}" \ + "${hostapd_log}" \ + "${dnsmasq_log}" \ + "${wired_dnsmasq_log}"; do + if [[ -s "${log_file}" ]]; then + printf '\n===== %s =====\n' "$(basename "${log_file}")" >&2 + cat "${log_file}" >&2 || true + fi + done + fi + rm -rf "${runtime_dir}" } @@ -47,6 +92,79 @@ print_wpa_supplicant_log() { cat "${wpa_supplicant_log}" >&2 || true } +print_dnsmasq_log() { + echo "dnsmasq did not become ready. Its log follows:" >&2 + cat "${dnsmasq_log}" >&2 || true +} + +start_dbus() { + mkdir -p /run/dbus + rm -f /run/dbus/system_bus_socket + + dbus-daemon \ + --config-file="${project_root}/scripts/ci/dbus-system.conf" \ + --nofork \ + --nopidfile >"${dbus_log}" 2>&1 & + dbus_pid=$! + + for _ in $(seq 1 15); do + if dbus-send \ + --system \ + --dest=org.freedesktop.DBus \ + --type=method_call \ + --print-reply \ + /org/freedesktop/DBus \ + org.freedesktop.DBus.ListNames >/dev/null 2>&1; then + return + fi + + if ! kill -0 "${dbus_pid}" 2>/dev/null; then + echo "The isolated system D-Bus exited before becoming ready" >&2 + cat "${dbus_log}" >&2 || true + exit 1 + fi + + sleep 1 + done + + echo "The isolated system D-Bus did not become ready" >&2 + cat "${dbus_log}" >&2 || true + exit 1 +} + +start_udev() { + local udevd + + if [[ -x /usr/lib/systemd/systemd-udevd ]]; then + udevd=/usr/lib/systemd/systemd-udevd + elif [[ -x /lib/systemd/systemd-udevd ]]; then + udevd=/lib/systemd/systemd-udevd + else + echo "systemd-udevd is required for deterministic veth initialization" >&2 + exit 1 + fi + + mkdir -p /run/udev + "${udevd}" --debug --resolve-names=never >"${udev_log}" 2>&1 & + udev_pid=$! + + for _ in $(seq 1 15); do + if udevadm control --ping >/dev/null 2>&1; then + return + fi + if ! kill -0 "${udev_pid}" 2>/dev/null; then + echo "The private udev daemon exited before becoming ready" >&2 + cat "${udev_log}" >&2 || true + exit 1 + fi + sleep 1 + done + + echo "The private udev daemon did not become ready" >&2 + cat "${udev_log}" >&2 || true + exit 1 +} + start_wpa_supplicant() { mkdir -p /run/wpa_supplicant @@ -105,11 +223,11 @@ setup_hwsim_access_point() { printf '%s\n' \ "interface=${ap_interface}" \ 'driver=nl80211' \ - 'ssid=nmrs-hwsim' \ + "ssid=${hwsim_ssid}" \ 'hw_mode=g' \ 'channel=1' \ 'wpa=2' \ - 'wpa_passphrase=nmrs-hwsim-password' \ + "wpa_passphrase=${hwsim_password}" \ 'wpa_key_mgmt=WPA-PSK' \ 'rsn_pairwise=CCMP' >"${hostapd_config}" @@ -134,6 +252,43 @@ setup_hwsim_access_point() { exit 1 fi + ip link set "${ap_interface}" up + ip address replace "${hwsim_gateway}/24" dev "${ap_interface}" + + # NetworkManager's activation does not complete until DHCP succeeds. Run a + # DHCP-only dnsmasq bound to the hwsim AP interface; port=0 avoids exposing + # a DNS listener in the host network namespace used by the WiFi container. + dnsmasq \ + --no-daemon \ + --conf-file=/dev/null \ + --interface="${ap_interface}" \ + --bind-interfaces \ + --port=0 \ + --dhcp-authoritative \ + --dhcp-range=192.168.250.10,192.168.250.50,255.255.255.0,1h \ + --dhcp-option=3,"${hwsim_gateway}" \ + --dhcp-leasefile="${dnsmasq_leases}" \ + --log-dhcp >"${dnsmasq_log}" 2>&1 & + dnsmasq_pid=$! + + for _ in $(seq 1 10); do + if grep --quiet 'DHCP, IP range' "${dnsmasq_log}"; then + break + fi + + if ! kill -0 "${dnsmasq_pid}" 2>/dev/null; then + print_dnsmasq_log + exit 1 + fi + + sleep 1 + done + + if ! kill -0 "${dnsmasq_pid}" 2>/dev/null; then + print_dnsmasq_log + exit 1 + fi + # Keep NetworkManager away from the runner's interfaces and AP radio. printf '%s\n' \ '[main]' \ @@ -150,6 +305,73 @@ setup_hwsim_access_point() { 'managed=1' >"${networkmanager_config}" } +setup_veth_network() { + # The pair normally disappears with its container namespace. Remove a + # stale pair defensively for interactive/reused-container runs. + if ip link show "${wired_client_interface}" >/dev/null 2>&1; then + ip link delete "${wired_client_interface}" + elif ip link show "${wired_server_interface}" >/dev/null 2>&1; then + ip link delete "${wired_server_interface}" + fi + ip link add "${wired_client_interface}" type veth peer name "${wired_server_interface}" + wired_veth_created=true + ip address replace "${wired_gateway}/24" dev "${wired_server_interface}" + ip link set "${wired_server_interface}" up + ip link set "${wired_client_interface}" up + udevadm trigger --action=add --subsystem-match=net + udevadm settle --timeout=10 + + dnsmasq \ + --no-daemon \ + --conf-file=/dev/null \ + --interface="${wired_server_interface}" \ + --bind-interfaces \ + --port=0 \ + --dhcp-authoritative \ + --dhcp-range=192.168.251.10,192.168.251.50,255.255.255.0,1h \ + --dhcp-option=3,"${wired_gateway}" \ + --dhcp-leasefile="${wired_dnsmasq_leases}" \ + --log-dhcp >"${wired_dnsmasq_log}" 2>&1 & + wired_dnsmasq_pid=$! + + for _ in $(seq 1 10); do + if grep --quiet 'DHCP, IP range' "${wired_dnsmasq_log}"; then + break + fi + if ! kill -0 "${wired_dnsmasq_pid}" 2>/dev/null; then + echo "Wired dnsmasq exited before becoming ready" >&2 + cat "${wired_dnsmasq_log}" >&2 || true + exit 1 + fi + sleep 1 + done + if ! kill -0 "${wired_dnsmasq_pid}" 2>/dev/null; then + echo "Wired dnsmasq did not become ready" >&2 + cat "${wired_dnsmasq_log}" >&2 || true + exit 1 + fi + + # Docker's eth0 stays visible but unmanaged. Only the private veth client + # may be selected by the isolated NetworkManager wired test. + printf '%s\n' \ + '[main]' \ + 'plugins=keyfile' \ + 'no-auto-default=*' \ + 'auth-polkit=root-only' \ + 'dhcp=internal' \ + '' \ + '[keyfile]' \ + "unmanaged-devices=*,except:interface-name:${wired_client_interface},except:interface-name:${wireguard_interface}" \ + '' \ + '[device-veth-client]' \ + "match-device=interface-name:=${wired_client_interface}" \ + 'managed=1' \ + '' \ + '[device-wireguard]' \ + "match-device=interface-name:=${wireguard_interface}" \ + 'managed=1' >"${networkmanager_config}" +} + trap cleanup EXIT case "${mode}" in @@ -162,17 +384,21 @@ esac if [[ "${mode}" == "wifi-integration" ]]; then setup_hwsim_access_point +elif [[ "${mode}" == "all" || "${mode}" == "integration" ]]; then + start_udev + setup_veth_network fi -mkdir -p /run/dbus -dbus-daemon \ - --config-file="${project_root}/scripts/ci/dbus-system.conf" \ - --fork \ - --nopidfile +start_dbus if [[ "${mode}" == "wifi-integration" ]]; then start_wpa_supplicant + NetworkManager \ + --config="${networkmanager_config}" \ + --no-daemon \ + --log-level=INFO >"${networkmanager_log}" 2>&1 & +elif [[ "${mode}" == "all" || "${mode}" == "integration" ]]; then NetworkManager \ --config="${networkmanager_config}" \ --no-daemon \ @@ -228,19 +454,50 @@ if [[ "${mode}" == "wifi-integration" ]]; then fi export NMRS_REQUIRE_WIFI=1 - export NMRS_EXPECT_WIFI_SSID=nmrs-hwsim + export NMRS_EXPECT_WIFI_SSID="${hwsim_ssid}" + export NMRS_WIFI_PASSWORD="${hwsim_password}" export NMRS_WIFI_INTERFACE="${hwsim_station_interface}" +elif [[ "${mode}" == "all" || "${mode}" == "integration" ]]; then + nmcli device set "${wired_client_interface}" managed yes + + for _ in $(seq 1 30); do + wired_state="$(nmcli --terse --fields DEVICE,TYPE,STATE device status | awk -F: -v interface="${wired_client_interface}" '$1 == interface && $2 == "ethernet" { print $3; exit }')" + if [[ "${wired_state}" == "disconnected" || "${wired_state}" == "connected" ]]; then + break + fi + sleep 1 + done + + if [[ "${wired_state:-}" != "disconnected" && "${wired_state:-}" != "connected" ]]; then + echo "NetworkManager did not make ${wired_client_interface} ready" >&2 + nmcli device status >&2 || true + nmcli -f GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.REASON,GENERAL.NM-MANAGED \ + device show "${wired_client_interface}" >&2 || true + print_networkmanager_log + exit 1 + fi + + export NMRS_REQUIRE_WIRED=1 + export NMRS_WIRED_INTERFACE="${wired_client_interface}" fi case "${mode}" in all) cargo test --locked --all-features --workspace + cargo test --locked --test integration_test --all-features \ + networkmanager_ -- --ignored --test-threads=1 + cargo test --locked --test integration_test --all-features \ + wired_ -- --ignored --test-threads=1 ;; integration) - cargo test --locked --test integration_test --all-features + cargo test --locked --test integration_test --all-features \ + networkmanager_ -- --ignored --test-threads=1 + cargo test --locked --test integration_test --all-features \ + wired_ -- --ignored --test-threads=1 ;; wifi-integration) - cargo test --locked --test integration_test --all-features + cargo test --locked --test integration_test --all-features \ + wifi_ -- --ignored --test-threads=1 ;; shell) bash From 0585f7a10f091680d0084e56969081770f26163c Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 11:26:57 -0400 Subject: [PATCH 2/4] fix(ci): dont depend on ethtool --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62e5b397..3c808f5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,10 +108,32 @@ jobs: - name: Load virtual WiFi radios run: | + set -euo pipefail + sudo -n modprobe -r mac80211_hwsim || true + mapfile -t interfaces_before < <( + iw dev | awk '$1 == "Interface" { print $2 }' | sort -u + ) + sudo -n modprobe mac80211_hwsim radios=2 udevadm settle --timeout=10 + mapfile -t interfaces_after < <( + iw dev | awk '$1 == "Interface" { print $2 }' | sort -u + ) + mapfile -t hwsim_interfaces < <( + comm -13 \ + <(printf '%s\n' "${interfaces_before[@]}") \ + <(printf '%s\n' "${interfaces_after[@]}") + ) + if (( ${#hwsim_interfaces[@]} != 2 )); then + echo "Expected the hwsim module to create two interfaces, found ${#hwsim_interfaces[@]}" >&2 + iw dev >&2 || true + exit 1 + fi + + printf 'NMRS_HOST_HWSIM_INTERFACES=%s\n' "${hwsim_interfaces[*]}" >> "${GITHUB_ENV}" + - name: Release virtual WiFi radios from host NetworkManager run: | set -euo pipefail @@ -121,39 +143,48 @@ jobs: echo "Host hwsim/NetworkManager diagnostics:" >&2 iw dev >&2 || true nmcli device status >&2 || true - sudo -n journalctl -u NetworkManager --no-pager -n 100 >&2 || true + nmcli general permissions >&2 || true + journalctl -u NetworkManager --no-pager -n 100 >&2 || true } trap diagnose ERR - if ! command -v ethtool >/dev/null 2>&1; then - echo "ethtool is required to identify mac80211_hwsim interfaces" >&2 + read -r -a hwsim_interfaces <<< "${NMRS_HOST_HWSIM_INTERFACES:?missing hwsim interface list}" + if (( ${#hwsim_interfaces[@]} != 2 )); then + echo "Expected exactly two recorded hwsim interfaces, found ${#hwsim_interfaces[@]}" >&2 exit 1 fi - mapfile -t hwsim_interfaces < <( - iw dev | awk '$1 == "Interface" { print $2 }' | - while read -r interface; do - if ethtool -i "${interface}" 2>/dev/null | - grep --fixed-strings --quiet 'driver: mac80211_hwsim'; then - printf '%s\n' "${interface}" - fi - done | sort - ) - if (( ${#hwsim_interfaces[@]} != 2 )); then - echo "Expected exactly two host mac80211_hwsim interfaces, found ${#hwsim_interfaces[@]}" >&2 + if [[ ! -S /run/dbus/system_bus_socket ]]; then + echo "Host system D-Bus socket is unavailable" >&2 exit 1 fi - for interface in "${hwsim_interfaces[@]}"; do - sudo -n nmcli device set "${interface}" managed no - done - for interface in "${hwsim_interfaces[@]}"; do - managed="$(sudo -n nmcli --get-values GENERAL.NM-MANAGED device show "${interface}")" - if [[ "${managed}" != "no" ]]; then - echo "Host NetworkManager still manages ${interface}: NM-MANAGED=${managed}" >&2 - exit 1 - fi - done + # The self-hosted runner can load hwsim but is not authorized by + # host polkit to control NetworkManager. Use the already-required + # privileged test image to make this narrowly scoped D-Bus change. + docker compose run --build --rm --no-deps \ + -e NMRS_HOST_HWSIM_INTERFACES \ + -v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro \ + --entrypoint bash \ + test-integration \ + -euo pipefail -c ' + read -r -a hwsim_interfaces <<< "${NMRS_HOST_HWSIM_INTERFACES:?missing hwsim interface list}" + if (( ${#hwsim_interfaces[@]} != 2 )); then + echo "Expected exactly two hwsim interfaces in the helper, found ${#hwsim_interfaces[@]}" >&2 + exit 1 + fi + + for interface in "${hwsim_interfaces[@]}"; do + nmcli device set "${interface}" managed no + done + for interface in "${hwsim_interfaces[@]}"; do + managed="$(nmcli --get-values GENERAL.NM-MANAGED device show "${interface}")" + if [[ "${managed}" != "no" ]]; then + echo "Host NetworkManager still manages ${interface}: NM-MANAGED=${managed}" >&2 + exit 1 + fi + done + ' - name: Run integration tests with NetworkManager and virtual Ethernet run: docker compose run --build --rm test-integration From e4412421691ad98081bdd1086b43aaae00c7fbdb Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 11:35:27 -0400 Subject: [PATCH 3/4] ci: fix wifi scan contract --- nmrs/CHANGELOG.md | 2 ++ nmrs/src/core/scan.rs | 14 +++----------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index db11819d..8dd134a1 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -32,6 +32,8 @@ All notable changes to the `nmrs` crate will be documented in this file. NetworkManager failure reasons instead of reporting false timeouts. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) - Keep active-connection snapshots usable when NetworkManager removes an enumerated connection object while its properties are being read. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Retain the discovering interface on inactive Wi-Fi scan results, matching + the documented `Network::device` contract. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) - Map secret-agent registration conflicts to the documented typed errors and handle concurrent same-key requests, cancellation, closed responders, and bounded-queue backpressure without false cancellation or hangs. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) diff --git a/nmrs/src/core/scan.rs b/nmrs/src/core/scan.rs index b7960b88..af9c2492 100644 --- a/nmrs/src/core/scan.rs +++ b/nmrs/src/core/scan.rs @@ -209,11 +209,9 @@ pub(crate) async fn list_networks( }; let net = Network { - device: if ap.is_active { - ap.interface.clone() - } else { - String::new() - }, + // A scan result is always associated with the interface that + // discovered its access point, regardless of connection state. + device: ap.interface.clone(), ssid: ap.ssid.clone(), bssid: Some(ap.bssid.clone()), strength: Some(ap.strength), @@ -240,12 +238,6 @@ pub(crate) async fn list_networks( // Populate `known` by checking saved connections for net in groups.values_mut() { net.known = has_saved_connection(conn, &net.ssid).await.unwrap_or(false); - if net.device.is_empty() - && net.is_active - && let Some(ap) = aps.iter().find(|a| a.ssid == net.ssid && a.is_active) - { - net.device.clone_from(&ap.interface); - } } Ok(groups.into_values().collect()) From a3fc6fa14ec1dd37be0a01047c892f6e59bf1f28 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sat, 18 Jul 2026 11:39:34 -0400 Subject: [PATCH 4/4] fix(#505): wait for WiFi radio after rfkill recovery Treat a managed WiFi device in NetworkManagers transient Unavailable state as a readiness candidate instead of returning WifiNotReady immediately. --- nmrs/CHANGELOG.md | 2 ++ nmrs/src/core/device.rs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 8dd134a1..fc560dcd 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -34,6 +34,8 @@ All notable changes to the `nmrs` crate will be documented in this file. enumerated connection object while its properties are being read. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) - Retain the discovering interface on inactive Wi-Fi scan results, matching the documented `Network::device` contract. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) +- Wait through NetworkManager's transient `Unavailable` state while a managed + Wi-Fi radio recovers from a rfkill transition. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) - Map secret-agent registration conflicts to the documented typed errors and handle concurrent same-key requests, cancellation, closed responders, and bounded-queue backpressure without false cancellation or hangs. ([#505](https://github.com/freedesktop-rs/nmrs/pull/505)) diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 767267fc..c5fdc62f 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -392,6 +392,8 @@ pub(crate) async fn wait_for_wifi_ready(conn: &Connection) -> Result<()> { let mut found_wifi_device = false; // Prefer a ready device. An unmanaged radio can appear before a usable one. + // A managed radio can temporarily be Unavailable while rfkill is lifted, + // so keep it as a wait candidate. for dev_path in devices { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? @@ -414,9 +416,7 @@ pub(crate) async fn wait_for_wifi_ready(conn: &Connection) -> Result<()> { return Ok(()); } - if !matches!(state, DeviceState::Unmanaged | DeviceState::Unavailable) - && pending_wifi_device.is_none() - { + if state != DeviceState::Unmanaged && pending_wifi_device.is_none() { pending_wifi_device = Some(dev_path); } }