All checks were successful
check / check (push) Successful in 1m39s
`tests/common/fake_nm.rs` stands up a fake `org.freedesktop.NetworkManager` on a private bus so `cli.rs` and `flow_watch.rs` exercise the real `nm` code paths without a live NetworkManager and without shelling out. Replaces the previous command-capture scaffolding in those two files; scenario coverage (captive portal, exit-node failover, 802.1x, per-network DNS, schedule triggers, Tailscale recovery, SSID verification) is preserved — 139 tests.
1007 lines
33 KiB
Rust
1007 lines
33 KiB
Rust
//! A faithful fake NetworkManager served as a real D-Bus service on a
|
|
//! private `dbus-daemon`, so the production `nm` module (a pure zbus client
|
|
//! against `org.freedesktop.NetworkManager` on the system bus) can be
|
|
//! exercised end-to-end — real D-Bus marshalling, real property reads, real
|
|
//! method calls — with zero test-only code paths in `src/`.
|
|
//!
|
|
//! Two ways to use it:
|
|
//!
|
|
//! - [`launch_private`] — starts a fresh private daemon + fake NM and
|
|
//! returns a [`FakeNmBus`] handle. The caller passes
|
|
//! `DBUS_SYSTEM_BUS_ADDRESS=<addr>` to any subprocess (the CLI sandbox) so
|
|
//! the binary's `Connection::system()` lands on this bus. Independent per
|
|
//! test → safe to run in parallel.
|
|
//! - [`shared`] — one process-wide fake NM bus pointed at by the process
|
|
//! env var (in-process tests can't set per-test env safely). Tests using
|
|
//! it must serialize against each other, which the returned guard does.
|
|
//!
|
|
//! The fake implements the subset of the NetworkManager D-Bus API the
|
|
//! client uses: devices, access points (SSID/strength/security flags),
|
|
//! connection profiles (list/add/get/delete), activation (which lands the
|
|
//! device on the matching AP and marks it ACTIVATED), per-device IP4Config,
|
|
//! and the `WirelessEnabled`/`Connectivity` root properties. It also
|
|
//! records every call for assertions.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::io::BufRead;
|
|
use std::ops::Deref;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
|
|
|
use zbus::fdo;
|
|
use zbus::interface;
|
|
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
|
|
|
pub const NM_DEST: &str = "org.freedesktop.NetworkManager";
|
|
pub const NM_PATH: &str = "/org/freedesktop/NetworkManager";
|
|
pub const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings";
|
|
const DEVICES_PREFIX: &str = "/org/freedesktop/NetworkManager/Devices";
|
|
const APS_PREFIX: &str = "/org/freedesktop/NetworkManager/AccessPoints";
|
|
const ACTIVE_PREFIX: &str = "/org/freedesktop/NetworkManager/ActiveConnection";
|
|
const IP4_PREFIX: &str = "/org/freedesktop/NetworkManager/IP4Config";
|
|
const CONNS_PREFIX: &str = "/org/freedesktop/NetworkManager/Settings";
|
|
|
|
// Security flag constants (mirrors src/nm.rs).
|
|
pub const DEV_TYPE_WIFI: u32 = 2;
|
|
pub const DEV_STATE_ACTIVATED: u32 = 100;
|
|
const AP_FLAG_PRIVACY: u32 = 0x1;
|
|
const SEC_PSK: u32 = 0x100;
|
|
const SEC_802_1X: u32 = 0x200;
|
|
const SEC_SAE: u32 = 0x400;
|
|
|
|
/// Wi-Fi security flavors the fake can advertise for an AP.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum Security {
|
|
Open,
|
|
Wpa2,
|
|
Wpa3,
|
|
Wpa1Wpa2,
|
|
Enterprise,
|
|
Wep,
|
|
}
|
|
|
|
impl Security {
|
|
fn to_flags(self) -> (u32, u32, u32) {
|
|
match self {
|
|
Security::Open => (0, 0, 0),
|
|
Security::Wpa2 => (AP_FLAG_PRIVACY, 0, SEC_PSK),
|
|
Security::Wpa3 => (AP_FLAG_PRIVACY, 0, SEC_SAE),
|
|
Security::Wpa1Wpa2 => (AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK),
|
|
Security::Enterprise => (AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X),
|
|
Security::Wep => (AP_FLAG_PRIVACY, 0, 0),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type SettingsMap = HashMap<String, HashMap<String, OwnedValue>>;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FakeDeviceData {
|
|
pub iface: String,
|
|
pub dev_type: u32,
|
|
pub state: u32,
|
|
pub active_ap: Option<String>,
|
|
pub ip4: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FakeApData {
|
|
pub ssid: Vec<u8>,
|
|
pub strength: u8,
|
|
pub flags: u32,
|
|
pub wpa: u32,
|
|
pub rsn: u32,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct FakeState {
|
|
pub devices: BTreeMap<String, FakeDeviceData>,
|
|
pub aps: BTreeMap<String, FakeApData>,
|
|
pub dev_aps: HashMap<String, Vec<String>>,
|
|
pub connections: BTreeMap<String, SettingsMap>,
|
|
/// active-conn path -> settings-conn path
|
|
pub active_conns: BTreeMap<String, String>,
|
|
/// device path -> active-conn path
|
|
pub dev_active: HashMap<String, String>,
|
|
pub connectivity: u32,
|
|
pub wireless_enabled: bool,
|
|
/// When set, activation lands the device on the AP with this SSID
|
|
/// (simulates an NM autoconnect race landing elsewhere).
|
|
pub land_on: Option<String>,
|
|
/// When set, activation creates a matching AP on the fly if none exists
|
|
/// (hidden networks appear in the scan only after connecting).
|
|
pub connect_any: bool,
|
|
/// When > 0, the next activations fail (simulating a transient NM
|
|
/// failure); each attempted activation decrements the counter.
|
|
pub fail_next_activations: u32,
|
|
pub calls: Vec<String>,
|
|
pub next_dev: u32,
|
|
pub next_ap: u32,
|
|
pub next_conn: u32,
|
|
pub next_active: u32,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Shared {
|
|
state: Arc<Mutex<FakeState>>,
|
|
}
|
|
|
|
pub fn value_bytes(v: &OwnedValue) -> Option<Vec<u8>> {
|
|
match v.deref() {
|
|
Value::Array(a) => {
|
|
let mut out = Vec::new();
|
|
for item in a.inner() {
|
|
if let Value::U8(b) = item {
|
|
out.push(*b);
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Extract an array-of-strings (e.g. `ipv4.dns`, `802-1x.eap`) from a
|
|
/// settings dict value. zvariant has no `TryFrom<&Value>` for `Vec<String>`,
|
|
/// so we peel the `Array` ourselves.
|
|
pub fn value_str_list(v: &OwnedValue) -> Option<Vec<String>> {
|
|
match v.deref() {
|
|
Value::Array(a) => {
|
|
let mut out = Vec::new();
|
|
for item in a.inner() {
|
|
match item {
|
|
Value::Str(s) => out.push(s.as_str().to_string()),
|
|
_ => return None,
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn ov(v: Value<'_>) -> OwnedValue {
|
|
OwnedValue::try_from(v).expect("settings value is ownable")
|
|
}
|
|
|
|
/// OwnedValue isn't `Clone` (only `try_clone`), so settings dicts must be
|
|
/// copied field-by-field when the fake hands one out.
|
|
fn clone_settings(s: &SettingsMap) -> SettingsMap {
|
|
s.iter()
|
|
.map(|(section, vals)| {
|
|
let cloned = vals
|
|
.iter()
|
|
.map(|(k, v)| v.try_clone().map(|c| (k.clone(), c)))
|
|
.collect::<Result<HashMap<String, OwnedValue>, _>>()
|
|
.expect("settings values are ownable");
|
|
(section.clone(), cloned)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn conn_ssid(s: &SettingsMap) -> Option<String> {
|
|
s.get("802-11-wireless")
|
|
.and_then(|m| m.get("ssid"))
|
|
.and_then(value_bytes)
|
|
.map(|b| String::from_utf8_lossy(&b).into_owned())
|
|
}
|
|
|
|
fn conn_id(s: &SettingsMap) -> Option<String> {
|
|
s.get("connection")
|
|
.and_then(|m| m.get("id"))
|
|
.and_then(|v| v.downcast_ref::<String>().ok())
|
|
}
|
|
|
|
fn obj(path: &str) -> OwnedObjectPath {
|
|
OwnedObjectPath::try_from(path).expect("valid object path")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Root interface: org.freedesktop.NetworkManager
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct FakeNm {
|
|
shared: Shared,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager")]
|
|
impl FakeNm {
|
|
async fn get_devices(&self) -> fdo::Result<Vec<OwnedObjectPath>> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(st.devices.keys().map(|p| obj(p)).collect())
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn wireless_enabled(&self) -> fdo::Result<bool> {
|
|
Ok(self.shared.state.lock().unwrap().wireless_enabled)
|
|
}
|
|
|
|
// Setters must return `zbus::Error` (not `fdo::Error`): the macro's
|
|
// generated setter keeps the fallible arm's error type verbatim, and the
|
|
// dispatch future is typed `Result<(), zbus::Error>`.
|
|
#[zbus(property)]
|
|
fn set_wireless_enabled(&self, v: bool) -> zbus::Result<()> {
|
|
self.shared.state.lock().unwrap().wireless_enabled = v;
|
|
Ok(())
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn connectivity(&self) -> fdo::Result<u32> {
|
|
Ok(self.shared.state.lock().unwrap().connectivity)
|
|
}
|
|
|
|
async fn activate_connection(
|
|
&self,
|
|
conn: OwnedObjectPath,
|
|
dev: OwnedObjectPath,
|
|
_specific: OwnedObjectPath,
|
|
#[zbus(connection)] c: &zbus::Connection,
|
|
) -> fdo::Result<OwnedObjectPath> {
|
|
let active = self.register_active(c, conn.as_str(), dev.as_str()).await?;
|
|
Ok(active)
|
|
}
|
|
|
|
async fn add_and_activate_connection2(
|
|
&self,
|
|
settings: SettingsMap,
|
|
dev: OwnedObjectPath,
|
|
_specific: OwnedObjectPath,
|
|
_options: HashMap<String, OwnedValue>,
|
|
#[zbus(connection)] c: &zbus::Connection,
|
|
) -> fdo::Result<(OwnedObjectPath, OwnedObjectPath)> {
|
|
let conn_path = self.save_connection(settings, c).await?;
|
|
let active = self.register_active(c, conn_path.as_str(), dev.as_str()).await?;
|
|
Ok((conn_path, active))
|
|
}
|
|
}
|
|
|
|
impl FakeNm {
|
|
async fn save_connection(&self, settings: SettingsMap, c: &zbus::Connection) -> fdo::Result<OwnedObjectPath> {
|
|
let path = {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push("AddAndActivateConnection2".into());
|
|
// Same UUID-exists semantics as real NetworkManager: this call
|
|
// creates a *new* profile, so a duplicate UUID is an error, not
|
|
// an upsert (updates go through Settings.Connection.Update2).
|
|
if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) {
|
|
return Err(fdo::Error::Failed(
|
|
"A connection with this UUID already exists.".into(),
|
|
));
|
|
}
|
|
let p = format!("{CONNS_PREFIX}/{}", st.next_conn);
|
|
st.next_conn += 1;
|
|
st.connections.insert(p.clone(), settings);
|
|
p
|
|
};
|
|
c.object_server()
|
|
.at(path.as_str(), FakeConn {
|
|
shared: self.shared.clone(),
|
|
path: path.clone(),
|
|
})
|
|
.await?;
|
|
Ok(obj(&path))
|
|
}
|
|
|
|
async fn register_active(
|
|
&self,
|
|
c: &zbus::Connection,
|
|
conn_path: &str,
|
|
dev: &str,
|
|
) -> fdo::Result<OwnedObjectPath> {
|
|
let (active_path, new_ap) = {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push(format!("activate {conn_path} -> {dev}"));
|
|
if st.fail_next_activations > 0 {
|
|
st.fail_next_activations -= 1;
|
|
return Err(fdo::Error::Failed("transient activation failure".into()));
|
|
}
|
|
let ssid = st.connections.get(conn_path).and_then(conn_ssid);
|
|
let target = st.land_on.clone().or(ssid);
|
|
let mut dev_ap = target.as_ref().and_then(|t| {
|
|
let aps = st.dev_aps.get(dev).cloned().unwrap_or_default();
|
|
aps.into_iter().find(|ap| {
|
|
st.aps
|
|
.get(ap)
|
|
.map(|a| String::from_utf8_lossy(&a.ssid).into_owned() == *t)
|
|
.unwrap_or(false)
|
|
})
|
|
});
|
|
// Hidden networks don't appear in a scan until after they're
|
|
// associated; create the AP on the fly in that case.
|
|
let mut new_ap = None;
|
|
if dev_ap.is_none() && st.connect_any {
|
|
if let Some(t) = &target {
|
|
let id = st.next_ap;
|
|
st.next_ap += 1;
|
|
let ap_path = format!("{APS_PREFIX}/{id}");
|
|
st.aps.insert(
|
|
ap_path.clone(),
|
|
FakeApData {
|
|
ssid: t.as_bytes().to_vec(),
|
|
strength: 80,
|
|
flags: AP_FLAG_PRIVACY,
|
|
wpa: 0,
|
|
rsn: SEC_PSK,
|
|
},
|
|
);
|
|
st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone());
|
|
dev_ap = Some(ap_path.clone());
|
|
new_ap = Some(ap_path);
|
|
}
|
|
}
|
|
if let Some(d) = st.devices.get_mut(dev) {
|
|
if let Some(ap) = &dev_ap {
|
|
d.active_ap = Some(ap.clone());
|
|
}
|
|
d.state = DEV_STATE_ACTIVATED;
|
|
}
|
|
let p = format!("{ACTIVE_PREFIX}/{}", st.next_active);
|
|
st.next_active += 1;
|
|
st.active_conns.insert(p.clone(), conn_path.to_string());
|
|
st.dev_active.insert(dev.to_string(), p.clone());
|
|
(p, new_ap)
|
|
};
|
|
if let Some(ap) = &new_ap {
|
|
c.object_server()
|
|
.at(ap.as_str(), FakeAp {
|
|
shared: self.shared.clone(),
|
|
path: ap.clone(),
|
|
})
|
|
.await?;
|
|
}
|
|
c.object_server()
|
|
.at(active_path.as_str(), FakeActive {
|
|
shared: self.shared.clone(),
|
|
path: active_path.clone(),
|
|
})
|
|
.await?;
|
|
Ok(obj(&active_path))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Settings: org.freedesktop.NetworkManager.Settings
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct FakeSettings {
|
|
shared: Shared,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.Settings")]
|
|
impl FakeSettings {
|
|
async fn list_connections(&self) -> fdo::Result<Vec<OwnedObjectPath>> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(st.connections.keys().map(|p| obj(p)).collect())
|
|
}
|
|
|
|
async fn add_connection2(
|
|
&self,
|
|
settings: SettingsMap,
|
|
_flags: u32,
|
|
_args: HashMap<String, OwnedValue>,
|
|
#[zbus(connection)] c: &zbus::Connection,
|
|
) -> fdo::Result<(OwnedObjectPath, HashMap<String, OwnedValue>)> {
|
|
let path = {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push("AddConnection2".into());
|
|
// Real NetworkManager rejects a duplicate UUID with
|
|
// NM_SETTINGS_ERROR_UUID_EXISTS — it does NOT upsert. Existing
|
|
// profiles must be edited via Settings.Connection.Update2; model
|
|
// that here so a client regression fails loudly.
|
|
if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) {
|
|
return Err(fdo::Error::Failed(
|
|
"A connection with this UUID already exists.".into(),
|
|
));
|
|
}
|
|
let p = format!("{CONNS_PREFIX}/{}", st.next_conn);
|
|
st.next_conn += 1;
|
|
st.connections.insert(p.clone(), settings);
|
|
p
|
|
};
|
|
c.object_server()
|
|
.at(path.as_str(), FakeConn {
|
|
shared: self.shared.clone(),
|
|
path: path.clone(),
|
|
})
|
|
.await?;
|
|
Ok((obj(&path), HashMap::new()))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Settings.Connection
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct FakeConn {
|
|
shared: Shared,
|
|
path: String,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.Settings.Connection")]
|
|
impl FakeConn {
|
|
async fn get_settings(&self) -> fdo::Result<SettingsMap> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
let settings = st
|
|
.connections
|
|
.get(&self.path)
|
|
.ok_or_else(|| fdo::Error::UnknownObject(self.path.clone()))?;
|
|
Ok(clone_settings(settings))
|
|
}
|
|
|
|
async fn delete(&self) -> fdo::Result<()> {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push(format!("delete {}", self.path));
|
|
st.connections.remove(&self.path);
|
|
Ok(())
|
|
}
|
|
|
|
async fn update2(
|
|
&self,
|
|
settings: SettingsMap,
|
|
_flags: u32,
|
|
_args: HashMap<String, OwnedValue>,
|
|
) -> fdo::Result<HashMap<String, OwnedValue>> {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push("Update2".into());
|
|
st.connections.insert(self.path.clone(), settings);
|
|
Ok(HashMap::new())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Access point
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct FakeAp {
|
|
shared: Shared,
|
|
path: String,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.AccessPoint")]
|
|
impl FakeAp {
|
|
#[zbus(property)]
|
|
fn ssid(&self) -> fdo::Result<Vec<u8>> {
|
|
Ok(self.shared.state.lock().unwrap().aps[&self.path].ssid.clone())
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn strength(&self) -> fdo::Result<u8> {
|
|
Ok(self.shared.state.lock().unwrap().aps[&self.path].strength)
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn flags(&self) -> fdo::Result<u32> {
|
|
Ok(self.shared.state.lock().unwrap().aps[&self.path].flags)
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn wpa_flags(&self) -> fdo::Result<u32> {
|
|
Ok(self.shared.state.lock().unwrap().aps[&self.path].wpa)
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn rsn_flags(&self) -> fdo::Result<u32> {
|
|
Ok(self.shared.state.lock().unwrap().aps[&self.path].rsn)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Device + Wireless + IP4Config
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct FakeDevice {
|
|
shared: Shared,
|
|
path: String,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.Device")]
|
|
impl FakeDevice {
|
|
#[zbus(property)]
|
|
fn interface(&self) -> fdo::Result<String> {
|
|
Ok(self.shared.state.lock().unwrap().devices[&self.path].iface.clone())
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn device_type(&self) -> fdo::Result<u32> {
|
|
Ok(self.shared.state.lock().unwrap().devices[&self.path].dev_type)
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn state(&self) -> fdo::Result<u32> {
|
|
Ok(self.shared.state.lock().unwrap().devices[&self.path].state)
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn active_connection(&self) -> fdo::Result<OwnedObjectPath> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(st
|
|
.dev_active
|
|
.get(&self.path)
|
|
.map(|p| obj(p))
|
|
.unwrap_or_else(|| obj("/")))
|
|
}
|
|
|
|
#[zbus(property)]
|
|
fn ip4_config(&self) -> fdo::Result<OwnedObjectPath> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(obj(&st.devices[&self.path].ip4))
|
|
}
|
|
|
|
async fn reapply(
|
|
&self,
|
|
_settings: SettingsMap,
|
|
_version: u64,
|
|
_flags: u32,
|
|
) -> fdo::Result<()> {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push("Reapply".into());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FakeWireless {
|
|
shared: Shared,
|
|
path: String,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.Device.Wireless")]
|
|
impl FakeWireless {
|
|
#[zbus(property)]
|
|
fn active_access_point(&self) -> fdo::Result<OwnedObjectPath> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(st.devices[&self.path]
|
|
.active_ap
|
|
.as_ref()
|
|
.map(|p| obj(p))
|
|
.unwrap_or_else(|| obj("/")))
|
|
}
|
|
|
|
async fn get_all_access_points(&self) -> fdo::Result<Vec<OwnedObjectPath>> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(st
|
|
.dev_aps
|
|
.get(&self.path)
|
|
.map(|aps| aps.iter().map(|p| obj(p)).collect())
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
async fn request_scan(&self, _options: HashMap<String, OwnedValue>) -> fdo::Result<()> {
|
|
let mut st = self.shared.state.lock().unwrap();
|
|
st.calls.push("RequestScan".into());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FakeIp4 {
|
|
shared: Shared,
|
|
/// Device path (so the fake can find the device's IP).
|
|
dev: String,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.IP4Config")]
|
|
impl FakeIp4 {
|
|
#[zbus(property)]
|
|
fn addresses(&self) -> fdo::Result<Vec<(u32, u32, u32)>> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
// A fixed, recognizable address: 192.168.1.42/24, gw .1.
|
|
let _ = &st.devices[&self.dev];
|
|
Ok(vec![(0xC0A8012A, 24, 0xC0A80101)])
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Connection.Active
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct FakeActive {
|
|
shared: Shared,
|
|
path: String,
|
|
}
|
|
|
|
#[interface(name = "org.freedesktop.NetworkManager.Connection.Active")]
|
|
impl FakeActive {
|
|
#[zbus(property)]
|
|
fn connection(&self) -> fdo::Result<OwnedObjectPath> {
|
|
let st = self.shared.state.lock().unwrap();
|
|
Ok(obj(&st.active_conns[&self.path]))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Harness
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// A private `dbus-daemon`. Dropping it kills the daemon.
|
|
pub struct Daemon {
|
|
pub addr: String,
|
|
child: Child,
|
|
}
|
|
|
|
impl Drop for Daemon {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
static DBUS_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
|
|
|
/// Start a private `dbus-daemon` with permissive policies. Nothing is
|
|
/// registered on it — attach the fake NM with [`serve_on`] if the test
|
|
/// needs NetworkManager to be present.
|
|
pub fn launch_daemon() -> Daemon {
|
|
let n = DBUS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"breadcrumbs-dbus-{}-{}",
|
|
std::process::id(),
|
|
n
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("create dbus dir");
|
|
let config = dir.join("bus.conf");
|
|
std::fs::write(&config, BUS_CONFIG).expect("write bus config");
|
|
|
|
let mut child = Command::new("dbus-daemon")
|
|
.arg("--nofork")
|
|
.arg("--nopidfile")
|
|
.arg(format!("--config-file={}", config.display()))
|
|
.arg("--print-address=1")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("dbus-daemon must be installed to run the NetworkManager fake tests");
|
|
let mut line = String::new();
|
|
std::io::BufReader::new(child.stdout.take().expect("dbus stdout"))
|
|
.read_line(&mut line)
|
|
.expect("read dbus-daemon address");
|
|
let addr = line
|
|
.trim()
|
|
.split(';')
|
|
.next()
|
|
.expect("address")
|
|
.to_string();
|
|
Daemon { addr, child }
|
|
}
|
|
|
|
/// A fake NM service served on an existing (usually private) bus.
|
|
pub struct FakeNmBus {
|
|
pub addr: String,
|
|
pub state: Arc<Mutex<FakeState>>,
|
|
server: zbus::blocking::Connection,
|
|
}
|
|
|
|
const BUS_CONFIG: &str = r#"<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
|
|
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
|
<busconfig>
|
|
<type>session</type>
|
|
<listen>unix:tmpdir=/tmp</listen>
|
|
<policy context="default">
|
|
<allow send_destination="*" eavesdrop="true"/>
|
|
<allow eavesdrop="true"/>
|
|
<allow own="*"/>
|
|
</policy>
|
|
</busconfig>
|
|
"#;
|
|
|
|
/// Serve the fake NetworkManager on the bus at `addr` and claim its name.
|
|
/// Subprocesses pointed at the same address via `DBUS_SYSTEM_BUS_ADDRESS`
|
|
/// will see this as their system NetworkManager.
|
|
pub fn serve_on(addr: &str) -> FakeNmBus {
|
|
let conn = zbus::blocking::connection::Builder::address(addr)
|
|
.expect("connect to private bus")
|
|
.build()
|
|
.expect("build blocking connection");
|
|
let state = Arc::new(Mutex::new(FakeState {
|
|
connectivity: 4,
|
|
wireless_enabled: true,
|
|
next_dev: 1,
|
|
next_ap: 1,
|
|
next_conn: 1,
|
|
next_active: 1,
|
|
..Default::default()
|
|
}));
|
|
let shared = Shared {
|
|
state: state.clone(),
|
|
};
|
|
|
|
conn.object_server()
|
|
.at(NM_PATH, FakeNm {
|
|
shared: shared.clone(),
|
|
})
|
|
.expect("register fake NM");
|
|
conn.object_server()
|
|
.at(SETTINGS_PATH, FakeSettings {
|
|
shared: shared.clone(),
|
|
})
|
|
.expect("register fake settings");
|
|
conn.request_name(NM_DEST).expect("claim NM name");
|
|
|
|
FakeNmBus {
|
|
addr: addr.to_string(),
|
|
state,
|
|
server: conn,
|
|
}
|
|
}
|
|
|
|
impl FakeNmBus {
|
|
pub fn addr(&self) -> &str {
|
|
&self.addr
|
|
}
|
|
|
|
pub fn reset(&self) {
|
|
let mut st = self.state.lock().unwrap();
|
|
st.devices.clear();
|
|
st.aps.clear();
|
|
st.dev_aps.clear();
|
|
st.connections.clear();
|
|
st.active_conns.clear();
|
|
st.dev_active.clear();
|
|
st.connectivity = 4;
|
|
st.wireless_enabled = true;
|
|
st.land_on = None;
|
|
st.connect_any = false;
|
|
st.fail_next_activations = 0;
|
|
st.calls.clear();
|
|
st.next_dev = 1;
|
|
st.next_ap = 1;
|
|
st.next_conn = 1;
|
|
st.next_active = 1;
|
|
}
|
|
|
|
/// Add a Wi-Fi device; returns its object path. `state` is the device
|
|
/// state (e.g. 100 = ACTIVATED).
|
|
pub fn add_wifi_device(&self, iface: &str, state: u32) -> String {
|
|
let (dev_path, ip4_path) = {
|
|
let mut st = self.state.lock().unwrap();
|
|
let id = st.next_dev;
|
|
st.next_dev += 1;
|
|
let dev_path = format!("{DEVICES_PREFIX}/{id}");
|
|
let ip4_path = format!("{IP4_PREFIX}/{id}");
|
|
st.devices.insert(
|
|
dev_path.clone(),
|
|
FakeDeviceData {
|
|
iface: iface.to_string(),
|
|
dev_type: DEV_TYPE_WIFI,
|
|
state,
|
|
active_ap: None,
|
|
ip4: ip4_path.clone(),
|
|
},
|
|
);
|
|
(dev_path, ip4_path)
|
|
};
|
|
self.server
|
|
.object_server()
|
|
.at(
|
|
dev_path.as_str(),
|
|
FakeDevice {
|
|
shared: self.shared(),
|
|
path: dev_path.clone(),
|
|
},
|
|
)
|
|
.expect("register device");
|
|
self.server
|
|
.object_server()
|
|
.at(
|
|
dev_path.as_str(),
|
|
FakeWireless {
|
|
shared: self.shared(),
|
|
path: dev_path.clone(),
|
|
},
|
|
)
|
|
.expect("register wireless");
|
|
self.server
|
|
.object_server()
|
|
.at(
|
|
ip4_path.as_str(),
|
|
FakeIp4 {
|
|
shared: self.shared(),
|
|
dev: dev_path.clone(),
|
|
},
|
|
)
|
|
.expect("register ip4");
|
|
dev_path
|
|
}
|
|
|
|
fn shared(&self) -> Shared {
|
|
Shared {
|
|
state: self.state.clone(),
|
|
}
|
|
}
|
|
|
|
/// Add an access point to a device; returns its object path.
|
|
pub fn add_ap(&self, dev: &str, ssid: &str, strength: u8, sec: Security) -> String {
|
|
let (ap_path, flags, wpa, rsn) = {
|
|
let mut st = self.state.lock().unwrap();
|
|
let id = st.next_ap;
|
|
st.next_ap += 1;
|
|
let ap_path = format!("{APS_PREFIX}/{id}");
|
|
let (flags, wpa, rsn) = sec.to_flags();
|
|
st.aps.insert(
|
|
ap_path.clone(),
|
|
FakeApData {
|
|
ssid: ssid.as_bytes().to_vec(),
|
|
strength,
|
|
flags,
|
|
wpa,
|
|
rsn,
|
|
},
|
|
);
|
|
st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone());
|
|
(ap_path, flags, wpa, rsn)
|
|
};
|
|
let _ = (flags, wpa, rsn);
|
|
self.server
|
|
.object_server()
|
|
.at(
|
|
ap_path.as_str(),
|
|
FakeAp {
|
|
shared: self.shared(),
|
|
path: ap_path.clone(),
|
|
},
|
|
)
|
|
.expect("register AP");
|
|
ap_path
|
|
}
|
|
|
|
pub fn set_active_ap(&self, dev: &str, ap: &str) {
|
|
let mut st = self.state.lock().unwrap();
|
|
if let Some(d) = st.devices.get_mut(dev) {
|
|
d.active_ap = Some(ap.to_string());
|
|
d.state = DEV_STATE_ACTIVATED;
|
|
}
|
|
}
|
|
|
|
/// When set, any activation lands the device on the AP with this SSID
|
|
/// (simulating an NM autoconnect race).
|
|
pub fn set_land_on(&self, ssid: Option<&str>) {
|
|
self.state.lock().unwrap().land_on = ssid.map(str::to_string);
|
|
}
|
|
|
|
/// When enabled, connecting to a network with no visible AP creates one
|
|
/// (hidden-network semantics).
|
|
pub fn set_connect_any(&self, on: bool) {
|
|
self.state.lock().unwrap().connect_any = on;
|
|
}
|
|
|
|
/// Make the next `n` activation attempts fail (transient-failure
|
|
/// simulation, e.g. for `init --wait` retries).
|
|
pub fn fail_next_activations(&self, n: u32) {
|
|
self.state.lock().unwrap().fail_next_activations = n;
|
|
}
|
|
|
|
pub fn set_connectivity(&self, c: u32) {
|
|
self.state.lock().unwrap().connectivity = c;
|
|
}
|
|
|
|
pub fn set_device_state(&self, dev: &str, state: u32) {
|
|
let mut st = self.state.lock().unwrap();
|
|
if let Some(d) = st.devices.get_mut(dev) {
|
|
d.state = state;
|
|
}
|
|
}
|
|
|
|
/// Save a wireless connection profile (as `nm` would create one) and
|
|
/// return its path.
|
|
pub fn save_connection(&self, ssid: &str, password: Option<&str>) -> String {
|
|
let mut settings: SettingsMap = HashMap::new();
|
|
let mut conn: HashMap<String, OwnedValue> = HashMap::new();
|
|
conn.insert("id".into(), ov(Value::from(ssid.to_string())));
|
|
conn.insert("type".into(), ov(Value::from("802-11-wireless")));
|
|
conn.insert(
|
|
"uuid".into(),
|
|
ov(Value::from("00000000-0000-4000-8000-000000000001")),
|
|
);
|
|
settings.insert("connection".into(), conn);
|
|
|
|
let mut wifi: HashMap<String, OwnedValue> = HashMap::new();
|
|
wifi.insert("ssid".into(), ov(Value::from(ssid.as_bytes().to_vec())));
|
|
wifi.insert("mode".into(), ov(Value::from("infrastructure")));
|
|
settings.insert("802-11-wireless".into(), wifi);
|
|
|
|
if let Some(pw) = password {
|
|
let mut sec: HashMap<String, OwnedValue> = HashMap::new();
|
|
sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk")));
|
|
sec.insert("psk".into(), ov(Value::from(pw.to_string())));
|
|
settings.insert("802-11-wireless-security".into(), sec);
|
|
}
|
|
|
|
let mut ipv4: HashMap<String, OwnedValue> = HashMap::new();
|
|
ipv4.insert("method".into(), ov(Value::from("auto")));
|
|
settings.insert("ipv4".into(), ipv4);
|
|
|
|
let path = {
|
|
let mut st = self.state.lock().unwrap();
|
|
st.calls.push(format!("save {ssid}"));
|
|
let path = format!("{CONNS_PREFIX}/{}", st.next_conn);
|
|
st.next_conn += 1;
|
|
st.connections.insert(path.clone(), settings);
|
|
path
|
|
};
|
|
self.server
|
|
.object_server()
|
|
.at(
|
|
path.as_str(),
|
|
FakeConn {
|
|
shared: self.shared(),
|
|
path: path.clone(),
|
|
},
|
|
)
|
|
.expect("register saved connection");
|
|
path
|
|
}
|
|
|
|
pub fn calls(&self) -> Vec<String> {
|
|
self.state.lock().unwrap().calls.clone()
|
|
}
|
|
|
|
pub fn connection_count(&self) -> usize {
|
|
self.state.lock().unwrap().connections.len()
|
|
}
|
|
|
|
pub fn device_state(&self, dev: &str) -> u32 {
|
|
self.state.lock().unwrap().devices.get(dev).map(|d| d.state).unwrap_or(0)
|
|
}
|
|
|
|
pub fn active_ssid(&self, dev: &str) -> Option<String> {
|
|
let st = self.state.lock().unwrap();
|
|
let ap = st.devices.get(dev)?.active_ap.clone()?;
|
|
st.aps.get(&ap).map(|a| String::from_utf8_lossy(&a.ssid).into_owned())
|
|
}
|
|
|
|
/// SSIDs of every connection activated so far, in activation order.
|
|
pub fn activated_ssids(&self) -> Vec<String> {
|
|
let st = self.state.lock().unwrap();
|
|
st.active_conns
|
|
.values()
|
|
.filter_map(|p| st.connections.get(p).and_then(conn_ssid))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Shared in-process bus (flow_watch tests)
|
|
// ---------------------------------------------------------------------
|
|
|
|
struct SharedBus {
|
|
_daemon: Daemon,
|
|
bus: FakeNmBus,
|
|
}
|
|
|
|
static SHARED: OnceLock<Mutex<SharedBus>> = OnceLock::new();
|
|
|
|
/// The process-wide fake NM bus for in-process tests. The env var
|
|
/// `DBUS_SYSTEM_BUS_ADDRESS` is pointed at it once, so the production
|
|
/// `nm` module (which uses `Connection::system()`) reaches it with zero
|
|
/// test seams. Tests using this must serialize against each other — the
|
|
/// returned guard holds the bus's lock for its whole lifetime.
|
|
pub struct SharedNm {
|
|
guard: MutexGuard<'static, SharedBus>,
|
|
}
|
|
|
|
impl std::ops::Deref for SharedNm {
|
|
type Target = FakeNmBus;
|
|
fn deref(&self) -> &FakeNmBus {
|
|
&self.guard.bus
|
|
}
|
|
}
|
|
|
|
pub fn shared() -> SharedNm {
|
|
let bus = SHARED.get_or_init(|| {
|
|
let daemon = launch_daemon();
|
|
let bus = serve_on(&daemon.addr);
|
|
std::env::set_var("DBUS_SYSTEM_BUS_ADDRESS", &daemon.addr);
|
|
Mutex::new(SharedBus { _daemon: daemon, bus })
|
|
});
|
|
let guard = bus.lock().unwrap_or_else(|e| e.into_inner());
|
|
SharedNm { guard }
|
|
}
|
|
|
|
/// Convenience guard used by tests that only need the bus available (no
|
|
/// state control) — e.g. classify tests that merely observe "no adapter".
|
|
/// Ensures the shared bus is up (and the env var set) before any `nm`
|
|
/// call happens.
|
|
pub fn ensure_shared() -> SharedNm {
|
|
shared()
|
|
}
|