Skip to main content

monitord/
networkd.rs

1//! # networkd module
2//!
3//! All structs, enums and methods specific to systemd-networkd.
4//! Enumerations were copied from <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::sync::Arc;
11
12use int_enum::IntEnum;
13use serde_repr::*;
14use strum_macros::EnumIter;
15use strum_macros::EnumString;
16use thiserror::Error;
17use tokio::sync::RwLock;
18use tracing::debug;
19use tracing::error;
20
21use crate::MachineStats;
22
23#[derive(Error, Debug)]
24pub enum MonitordNetworkdError {
25    #[error("Networkd D-Bus error: {0}")]
26    ZbusError(#[from] zbus::Error),
27    #[error("IO error: {0}")]
28    IoError(#[from] std::io::Error),
29}
30
31/// Address configuration state of a networkd-managed interface.
32/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
33#[allow(non_camel_case_types)]
34#[derive(
35    Serialize_repr,
36    Deserialize_repr,
37    Clone,
38    Copy,
39    Debug,
40    Default,
41    Eq,
42    PartialEq,
43    EnumIter,
44    EnumString,
45    IntEnum,
46    strum_macros::Display,
47)]
48#[repr(u8)]
49pub enum AddressState {
50    /// Address state could not be determined
51    #[default]
52    unknown = 0,
53    /// No addresses are configured on this interface
54    off = 1,
55    /// Addresses are configured but none provide full connectivity (e.g. link-local only)
56    degraded = 2,
57    /// At least one globally routable address is configured
58    routable = 3,
59}
60
61/// Administrative state of a networkd-managed interface (networkd's own management lifecycle).
62/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
63#[allow(non_camel_case_types)]
64#[derive(
65    Serialize_repr,
66    Deserialize_repr,
67    Clone,
68    Copy,
69    Debug,
70    Default,
71    Eq,
72    PartialEq,
73    EnumIter,
74    EnumString,
75    IntEnum,
76    strum_macros::Display,
77)]
78#[repr(u8)]
79pub enum AdminState {
80    /// Administrative state could not be determined
81    #[default]
82    unknown = 0,
83    /// Interface is pending configuration by networkd
84    pending = 1,
85    /// networkd failed to configure this interface
86    failed = 2,
87    /// Interface is currently being configured by networkd
88    configuring = 3,
89    /// Interface has been successfully configured by networkd
90    configured = 4,
91    /// Interface is not managed by networkd
92    unmanaged = 5,
93    /// Interface is lingering (was managed but its .network file was removed)
94    linger = 6,
95}
96
97/// Enumeration of a true (yes) / false (no) options - e.g. required for online
98#[allow(non_camel_case_types)]
99#[derive(
100    Serialize_repr,
101    Deserialize_repr,
102    Clone,
103    Copy,
104    Debug,
105    Default,
106    Eq,
107    PartialEq,
108    EnumIter,
109    EnumString,
110    IntEnum,
111    strum_macros::Display,
112)]
113#[repr(u8)]
114pub enum BoolState {
115    #[default]
116    unknown = u8::MAX,
117    #[strum(
118        serialize = "false",
119        serialize = "False",
120        serialize = "no",
121        serialize = "No"
122    )]
123    False = 0,
124    #[strum(
125        serialize = "true",
126        serialize = "True",
127        serialize = "yes",
128        serialize = "Yes"
129    )]
130    True = 1,
131}
132
133/// Physical carrier (link layer) state of a networkd-managed interface.
134/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
135#[allow(non_camel_case_types)]
136#[derive(
137    Serialize_repr,
138    Deserialize_repr,
139    Clone,
140    Copy,
141    Debug,
142    Default,
143    Eq,
144    PartialEq,
145    EnumIter,
146    EnumString,
147    IntEnum,
148    strum_macros::Display,
149)]
150#[repr(u8)]
151pub enum CarrierState {
152    /// Carrier state could not be determined
153    #[default]
154    unknown = 0,
155    /// Interface is administratively down (IFF_UP not set)
156    off = 1,
157    /// Interface is up but no carrier signal detected (cable unplugged or no link partner)
158    #[strum(serialize = "no-carrier", serialize = "no_carrier")]
159    no_carrier = 2,
160    /// Carrier detected but interface is in a dormant/standby state
161    dormant = 3,
162    /// Carrier detected but in a degraded condition
163    #[strum(serialize = "degraded-carrier", serialize = "degraded_carrier")]
164    degraded_carrier = 4,
165    /// Full carrier signal present and link is operational
166    carrier = 5,
167    /// Interface is enslaved to a bond/bridge master
168    enslaved = 6,
169}
170
171/// Overall online state of the system as determined by systemd-networkd-wait-online logic.
172/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
173#[allow(non_camel_case_types)]
174#[derive(
175    Serialize_repr,
176    Deserialize_repr,
177    Clone,
178    Copy,
179    Debug,
180    Default,
181    Eq,
182    PartialEq,
183    EnumIter,
184    EnumString,
185    IntEnum,
186    strum_macros::Display,
187)]
188#[repr(u8)]
189pub enum OnlineState {
190    /// Online state could not be determined
191    #[default]
192    unknown = 0,
193    /// No required interfaces are online
194    offline = 1,
195    /// Some required interfaces are online but not all
196    partial = 2,
197    /// All required interfaces are online
198    online = 3,
199}
200
201/// Operational state of a networkd-managed interface combining carrier and address information.
202/// Ref: <https://github.com/systemd/systemd/blob/main/src/libsystemd/sd-network/network-util.h>
203#[allow(non_camel_case_types)]
204#[derive(
205    Serialize_repr,
206    Deserialize_repr,
207    Clone,
208    Copy,
209    Debug,
210    Default,
211    Eq,
212    PartialEq,
213    EnumIter,
214    EnumString,
215    IntEnum,
216    strum_macros::Display,
217)]
218#[repr(u8)]
219pub enum OperState {
220    /// Operational state could not be determined
221    #[default]
222    unknown = 0,
223    /// Interface is missing from the system
224    missing = 1,
225    /// Interface is administratively down
226    off = 2,
227    /// Interface is up but has no carrier signal
228    #[strum(serialize = "no-carrier", serialize = "no_carrier")]
229    no_carrier = 3,
230    /// Interface has carrier but is in a dormant/standby state
231    dormant = 4,
232    /// Interface carrier is in a degraded condition
233    #[strum(serialize = "degraded-carrier", serialize = "degraded_carrier")]
234    degraded_carrier = 5,
235    /// Interface has carrier but no addresses configured
236    carrier = 6,
237    /// Interface is operational but only has link-local or non-routable addresses
238    degraded = 7,
239    /// Interface is enslaved to a bond/bridge master
240    enslaved = 8,
241    /// Interface is fully operational with at least one routable address
242    routable = 9,
243}
244
245/// Per-interface state collected from systemd-networkd state files in /run/systemd/netif/links/
246#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
247pub struct InterfaceState {
248    /// Combined address state across all address families (IPv4 + IPv6)
249    pub address_state: AddressState,
250    /// networkd administrative state (whether networkd has finished configuring this interface)
251    pub admin_state: AdminState,
252    /// Physical carrier (link layer) state of the interface
253    pub carrier_state: CarrierState,
254    /// IPv4-specific address state (off, degraded, or routable)
255    pub ipv4_address_state: AddressState,
256    /// IPv6-specific address state (off, degraded, or routable)
257    pub ipv6_address_state: AddressState,
258    /// Interface name as reported by the kernel (e.g. "eth0", "enp3s0")
259    pub name: String,
260    /// Path to the .network configuration file applied to this interface
261    pub network_file: String,
262    /// Operational state combining carrier detection and address configuration
263    pub oper_state: OperState,
264    /// Whether this interface is required for the system to be considered online
265    pub required_for_online: BoolState,
266}
267
268/// Get interface id + name from dbus list_links API.
269///
270/// Kept for environments where sysfs is restricted but the bus is
271/// reachable; the file fallback prefers [`read_ifindex_map`] (kernel truth,
272/// no IPC) and only reaches this when sysfs yields no usable mapping.
273async fn get_interface_links(
274    connection: &zbus::Connection,
275) -> Result<HashMap<i32, String>, MonitordNetworkdError> {
276    let p = crate::dbus::zbus_networkd::ManagerProxy::builder(connection)
277        .cache_properties(zbus::proxy::CacheProperties::No)
278        .build()
279        .await?;
280    let links = p.list_links().await?;
281    let mut link_int_to_name: HashMap<i32, String> = HashMap::new();
282    for network_link in links {
283        link_int_to_name.insert(network_link.0, network_link.1);
284    }
285    Ok(link_int_to_name)
286}
287
288/// Build the ifindex-to-name map from sysfs instead of D-Bus.
289///
290/// `/sys/class/net/<name>/ifindex` is kernel truth — the same source
291/// `Manager.ListLinks` ultimately reflects — so the file fallback can map
292/// state-file names (which are ifindexes) without touching the bus. Reads
293/// `sysfs_root` (normally `/sys`) so container collection can pass
294/// `/proc/<leader>/root/sys` and unit tests a fake tree.
295///
296/// Unreadable entries are skipped, not fatal: an interface that vanishes
297/// mid-walk simply has no entry, and `parse_interface_stats` already
298/// reports an empty name for unknown ids.
299pub(crate) async fn read_ifindex_map(sysfs_root: &Path) -> HashMap<i32, String> {
300    let mut map = HashMap::new();
301    let mut dir = match tokio::fs::read_dir(sysfs_root.join("class/net")).await {
302        Ok(dir) => dir,
303        Err(err) => {
304            debug!(
305                "Unable to read {}/class/net: {:?}",
306                sysfs_root.display(),
307                err
308            );
309            return map;
310        }
311    };
312    while let Ok(Some(entry)) = dir.next_entry().await {
313        // Entries are symlinks into /sys/devices, which file_type()
314        // reports as symlink, not dir (file_type() does NOT follow
315        // links). Only genuine non-links are skipped up front — e.g.
316        // /sys/class/net/bonding_masters when the bonding module is
317        // loaded, where joining ifindex would yield ENOTDIR every cycle
318        // in daemon mode. A dangling symlink (interface vanishing
319        // mid-walk) still reports is_symlink, so it passes the guard
320        // and is handled by the read_to_string arm below at debug level.
321        let kind = entry.file_type().await;
322        if !kind.is_ok_and(|kind| kind.is_symlink() || kind.is_dir()) {
323            continue;
324        }
325        let name = entry.file_name().to_string_lossy().into_owned();
326        match tokio::fs::read_to_string(entry.path().join("ifindex")).await {
327            Ok(contents) => match contents.trim().parse::<i32>() {
328                Ok(ifindex) => {
329                    map.insert(ifindex, name);
330                }
331                // An interface vanishing mid-walk simply has no entry;
332                // parse_interface_stats already reports an empty name for
333                // unknown ids. Debug, not error: routine, not actionable.
334                Err(err) => debug!("Unable to parse ifindex for interface {}: {:?}", name, err),
335            },
336            Err(err) => debug!("Unable to read ifindex for interface {}: {:?}", name, err),
337        }
338    }
339    map
340}
341
342/// Aggregated systemd-networkd state: per-interface details and total managed interface count
343#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, PartialEq)]
344pub struct NetworkdState {
345    /// State details for each networkd-managed interface
346    pub interfaces_state: Vec<InterfaceState>,
347    /// Total number of interfaces managed by networkd (those with a NETWORK_FILE entry)
348    pub managed_interfaces: u64,
349}
350
351pub const NETWORKD_STATE_FILES: &str = "/run/systemd/netif/links";
352
353/// Parse a networkd state file contents + convert int ID to name via DBUS
354pub fn parse_interface_stats(
355    interface_state_str: &str,
356    interface_id: i32,
357    interface_id_to_name: &HashMap<i32, String>,
358) -> Result<InterfaceState, MonitordNetworkdError> {
359    let mut interface_state = InterfaceState::default();
360
361    // Pull interface name out of list_links generated HashMap (once, not per line)
362    if interface_id > 0 {
363        if let Some(name) = interface_id_to_name.get(&interface_id) {
364            interface_state.name = name.clone();
365        }
366    }
367
368    for line in interface_state_str.lines() {
369        // Skip comments + lines without =
370        if !line.contains('=') {
371            continue;
372        }
373
374        let (key, value) = line
375            .split_once('=')
376            .expect("Unable to split a network state line");
377        match key {
378            "ADDRESS_STATE" => {
379                interface_state.address_state =
380                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
381            }
382            "ADMIN_STATE" => {
383                interface_state.admin_state =
384                    AdminState::from_str(value).unwrap_or(AdminState::unknown)
385            }
386            "CARRIER_STATE" => {
387                interface_state.carrier_state =
388                    CarrierState::from_str(value).unwrap_or(CarrierState::unknown)
389            }
390            "IPV4_ADDRESS_STATE" => {
391                interface_state.ipv4_address_state =
392                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
393            }
394            "IPV6_ADDRESS_STATE" => {
395                interface_state.ipv6_address_state =
396                    AddressState::from_str(value).unwrap_or(AddressState::unknown)
397            }
398            "NETWORK_FILE" => interface_state.network_file = value.to_string(),
399            "OPER_STATE" => {
400                interface_state.oper_state =
401                    OperState::from_str(value).unwrap_or(OperState::unknown)
402            }
403            "REQUIRED_FOR_ONLINE" => {
404                interface_state.required_for_online =
405                    BoolState::from_str(value).unwrap_or(BoolState::unknown)
406            }
407            _ => continue,
408        };
409    }
410
411    Ok(interface_state)
412}
413
414/// Parse interface state files in directory supplied.
415///
416/// The ifindex-to-name map comes from `maybe_network_int_to_name` when the
417/// caller has one; otherwise it is read from sysfs (`sysfs_root`, normally
418/// `/sys`) — kernel truth, no IPC. The D-Bus `ListLinks` lookup remains
419/// only as a last resort for environments where sysfs is restricted but
420/// the bus is reachable.
421pub async fn parse_interface_state_files(
422    states_path: &PathBuf,
423    maybe_network_int_to_name: Option<HashMap<i32, String>>,
424    sysfs_root: &Path,
425    maybe_dbus: Option<(&crate::DbusCell, u64)>,
426) -> Result<NetworkdState, MonitordNetworkdError> {
427    let mut managed_interface_count: u64 = 0;
428    let mut interfaces_state = vec![];
429
430    let network_int_to_name = match maybe_network_int_to_name {
431        Some(valid_hashmap) => valid_hashmap,
432        None => {
433            let sysfs_map = read_ifindex_map(sysfs_root).await;
434            if !sysfs_map.is_empty() {
435                sysfs_map
436            } else if let Some((dbus, dbus_timeout)) = maybe_dbus {
437                // Last resort only: resolving the cell connects, so an
438                // empty sysfs on a bus-less host fails here, not earlier.
439                // MonitordError today only wraps zbus errors, so this
440                // unwrap is exhaustive by construction.
441                let connection = crate::dbus_connection(dbus, dbus_timeout)
442                    .await
443                    .map_err(|e| MonitordNetworkdError::ZbusError(e.into_zbus()))?;
444                match get_interface_links(&connection).await {
445                    Ok(hashmap) => hashmap,
446                    Err(err) => {
447                        error!(
448                            "Unable to get interface links via DBUS - is networkd running?: {:#?}",
449                            err
450                        );
451                        return Ok(NetworkdState::default());
452                    }
453                }
454            } else {
455                error!(
456                    "Unable to map interface ids to names: sysfs gave no entries and no D-Bus cell supplied"
457                );
458                return Ok(NetworkdState::default());
459            }
460        }
461    };
462
463    let mut state_file_dir_entries = tokio::fs::read_dir(states_path).await?;
464    while let Some(state_file) = state_file_dir_entries.next_entry().await? {
465        if !state_file.path().is_file() {
466            continue;
467        }
468        let interface_stats_file_str = tokio::fs::read_to_string(state_file.path()).await?;
469        if !interface_stats_file_str.contains("NETWORK_FILE") {
470            continue;
471        }
472        managed_interface_count += 1;
473        let fname = state_file.file_name();
474        let interface_id: i32 = i32::from_str(fname.to_str().unwrap_or("0")).unwrap_or(0);
475        match parse_interface_stats(
476            &interface_stats_file_str,
477            interface_id,
478            &network_int_to_name,
479        ) {
480            Ok(interface_state) => interfaces_state.push(interface_state),
481            Err(err) => error!(
482                "Unable to parse interface statistics for {:?}: {}",
483                state_file.path().into_os_string(),
484                err
485            ),
486        }
487    }
488    Ok(NetworkdState {
489        interfaces_state,
490        managed_interfaces: managed_interface_count,
491    })
492}
493
494/// Async wrapper than can update networkd stats when passed a locked struct.
495///
496/// `sysfs_root` backs the sysfs ifindex map (normally `/sys`; container
497/// collection passes `/proc/<leader>/root/sys`). Takes the shared D-Bus
498/// cell rather than a connection: the sysfs map serves the file fallback
499/// without ever connecting, and the cell is resolved only when sysfs
500/// yields nothing usable — the same lazy pattern as
501/// `update_boot_blame_stats`.
502pub async fn update_networkd_stats(
503    states_path: PathBuf,
504    maybe_network_int_to_name: Option<HashMap<i32, String>>,
505    sysfs_root: PathBuf,
506    maybe_dbus: Option<(crate::DbusCell, u64)>,
507    locked_machine_stats: Arc<RwLock<MachineStats>>,
508) -> anyhow::Result<()> {
509    // A container has no valid bus fallback: the host bus could only ever
510    // describe host links, so containers pass None and an empty sysfs
511    // means nameless rows rather than cross-namespace mislabelling.
512    let maybe_dbus_ref = maybe_dbus.as_ref().map(|(cell, timeout)| (cell, *timeout));
513    match parse_interface_state_files(
514        &states_path,
515        maybe_network_int_to_name,
516        &sysfs_root,
517        maybe_dbus_ref,
518    )
519    .await
520    {
521        Ok(networkd_stats) => {
522            let mut machine_stats = locked_machine_stats.write().await;
523            machine_stats.networkd = networkd_stats;
524        }
525        Err(err) => error!("networkd stats failed: {:?}", err),
526    }
527    Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::fs::File;
534    use std::io::Write;
535    use tempfile::tempdir;
536
537    const MOCK_INTERFACE_STATE: &str = r###"# This is private data. Do not parse.
538ADMIN_STATE=configured
539OPER_STATE=routable
540CARRIER_STATE=carrier
541ADDRESS_STATE=routable
542IPV4_ADDRESS_STATE=degraded
543IPV6_ADDRESS_STATE=routable
544ONLINE_STATE=online
545REQUIRED_FOR_ONLINE=yes
546REQUIRED_OPER_STATE_FOR_ONLINE=degraded:routable
547REQUIRED_FAMILY_FOR_ONLINE=any
548ACTIVATION_POLICY=up
549NETWORK_FILE=/etc/systemd/network/69-eno4.network
550NETWORK_FILE_DROPINS=""
551DNS=8.8.8.8 8.8.4.4
552NTP=
553SIP=
554DOMAINS=
555ROUTE_DOMAINS=
556LLMNR=yes
557MDNS=no
558"###;
559
560    fn return_expected_interface_state() -> InterfaceState {
561        InterfaceState {
562            address_state: AddressState::routable,
563            admin_state: AdminState::configured,
564            carrier_state: CarrierState::carrier,
565            ipv4_address_state: AddressState::degraded,
566            ipv6_address_state: AddressState::routable,
567            name: "eth0".to_string(),
568            network_file: "/etc/systemd/network/69-eno4.network".to_string(),
569            oper_state: OperState::routable,
570            required_for_online: BoolState::True,
571        }
572    }
573
574    fn return_mock_int_name_hashmap() -> Option<HashMap<i32, String>> {
575        let mut h: HashMap<i32, String> = HashMap::new();
576        h.insert(2, String::from("eth0"));
577        h.insert(69, String::from("eth69"));
578        Some(h)
579    }
580
581    #[test]
582    fn test_parse_interface_stats() {
583        assert_eq!(
584            return_expected_interface_state(),
585            parse_interface_stats(
586                MOCK_INTERFACE_STATE,
587                2,
588                &return_mock_int_name_hashmap().expect("Failed to get a mock int name hashmap"),
589            )
590            .expect("Failed to parse interface stats"),
591        );
592    }
593
594    #[test]
595    fn test_parse_interface_stats_json() {
596        // 'name' stays as an empty string cause we don't pass in networkctl json or an interface id
597        let expected_interface_state_json = r###"{"address_state":3,"admin_state":4,"carrier_state":5,"ipv4_address_state":2,"ipv6_address_state":3,"name":"","network_file":"/etc/systemd/network/69-eno4.network","oper_state":9,"required_for_online":1}"###;
598        let stats = parse_interface_stats(MOCK_INTERFACE_STATE, 0, &HashMap::new()).unwrap();
599        let stats_json = serde_json::to_string(&stats).unwrap();
600        assert_eq!(expected_interface_state_json.to_string(), stats_json);
601    }
602
603    #[tokio::test]
604    async fn test_parse_interface_state_files() -> Result<(), MonitordNetworkdError> {
605        let expected_files = NetworkdState {
606            interfaces_state: vec![return_expected_interface_state()],
607            managed_interfaces: 1,
608        };
609
610        let temp_dir = tempdir()?;
611        // Filename of '2' is important as it needs to correspond to the interface id / index
612        let file_path = temp_dir.path().join("2");
613        let mut state_file = File::create(file_path)?;
614        writeln!(state_file, "{}", MOCK_INTERFACE_STATE)?;
615
616        let path = PathBuf::from(temp_dir.path());
617        assert_eq!(
618            expected_files,
619            parse_interface_state_files(
620                &path,
621                return_mock_int_name_hashmap(),
622                Path::new("/nonexistent-sysfs"),
623                None, // No DBUS in tests
624            )
625            .await
626            .expect("Problem with parsing interface stte files")
627        );
628        Ok(())
629    }
630
631    #[tokio::test]
632    async fn test_read_ifindex_map_from_fake_sysfs() {
633        // Layout mirrors /sys/class/net, where each name is a symlink
634        // into /sys/devices — the reader must follow links, not require
635        // real directories.
636        let temp_dir = tempdir().expect("temp dir");
637        let class_net = temp_dir.path().join("class/net");
638        std::fs::create_dir_all(&class_net).expect("create class dir");
639        let devices = temp_dir.path().join("devices");
640        for (name, ifindex) in [("eth0", "2\n"), ("wlan0", "3\n")] {
641            let target = devices.join(name);
642            std::fs::create_dir_all(&target).expect("create fake device dir");
643            std::fs::write(target.join("ifindex"), ifindex).expect("write fake ifindex");
644            std::os::unix::fs::symlink(&target, class_net.join(name)).expect("link iface");
645        }
646        // A regular file (like bonding_masters) is skipped, not fatal.
647        std::fs::write(class_net.join("bonding_masters"), "").expect("write regular file");
648
649        let map = read_ifindex_map(temp_dir.path()).await;
650        assert_eq!(map.get(&2), Some(&"eth0".to_string()));
651        assert_eq!(map.get(&3), Some(&"wlan0".to_string()));
652        assert_eq!(map.len(), 2);
653    }
654
655    #[tokio::test]
656    async fn test_parse_interface_state_files_uses_sysfs_map() {
657        // End to end without D-Bus and without a caller map: the state
658        // file's ifindex resolves via the fake sysfs tree.
659        let temp_dir = tempdir().expect("temp dir");
660        let links_dir = temp_dir.path().join("links");
661        std::fs::create_dir_all(&links_dir).expect("create links dir");
662        let mut state_file = File::create(links_dir.join("2")).expect("create state file");
663        writeln!(state_file, "{}", MOCK_INTERFACE_STATE).expect("write state file");
664        // Symlink like real sysfs (see test_read_ifindex_map_from_fake_sysfs).
665        let target = temp_dir.path().join("devices/eth0");
666        std::fs::create_dir_all(&target).expect("create device dir");
667        std::fs::write(target.join("ifindex"), "2\n").expect("write ifindex");
668        std::fs::create_dir_all(temp_dir.path().join("class/net")).expect("create class dir");
669        std::os::unix::fs::symlink(&target, temp_dir.path().join("class/net/eth0"))
670            .expect("link iface");
671
672        let state = parse_interface_state_files(
673            &PathBuf::from(&links_dir),
674            None,
675            temp_dir.path(),
676            None, // No DBUS: sysfs must serve the map
677        )
678        .await
679        .expect("parse state files");
680        assert_eq!(state.managed_interfaces, 1);
681        assert_eq!(state.interfaces_state[0].name, "eth0");
682    }
683
684    #[tokio::test]
685    async fn test_parse_interface_state_files_empty_sysfs_no_conn() {
686        // Empty sysfs and no connection: honest empty result, not a hang
687        // or a panic waiting for D-Bus.
688        let temp_dir = tempdir().expect("temp dir");
689        let links_dir = temp_dir.path().join("links");
690        std::fs::create_dir_all(&links_dir).expect("create links dir");
691        let state =
692            parse_interface_state_files(&PathBuf::from(&links_dir), None, temp_dir.path(), None)
693                .await
694                .expect("parse state files");
695        assert_eq!(state, NetworkdState::default());
696    }
697
698    #[test]
699    fn test_enums_to_ints() -> Result<(), MonitordNetworkdError> {
700        assert_eq!(3, AddressState::routable as u64);
701        let carrier_state_int: u8 = u8::from(CarrierState::degraded_carrier);
702        assert_eq!(4, carrier_state_int);
703        assert_eq!(1, BoolState::True as i64);
704        let bool_state_false_int: u8 = u8::from(BoolState::False);
705        assert_eq!(0, bool_state_false_int);
706
707        Ok(())
708    }
709}