From 00a48062370c651a517e5e1d75ddb9c649bb4e4f Mon Sep 17 00:00:00 2001 From: Yonatan Linik Date: Fri, 10 Oct 2025 12:39:56 +0300 Subject: [PATCH 1/6] ip-link: Fix lint + license failures --- src/ip/link/link_details.rs | 6 +++++- src/ip/link/link_info.rs | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ip/link/link_details.rs b/src/ip/link/link_details.rs index a52d528..6fa9cc4 100644 --- a/src/ip/link/link_details.rs +++ b/src/ip/link/link_details.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT + use std::ffi::CStr; use rtnetlink::packet_core::DefaultNla; @@ -239,7 +241,9 @@ impl std::fmt::Display for CliLinkInfoDetails { write!( f, - "addrgenmode {} numtxqueues {} numrxqueues {} gso_max_size {} gso_max_segs {} tso_max_size {} tso_max_segs {} gro_max_size {} gso_ipv4_max_size {} gro_ipv4_max_size {} ", + "addrgenmode {} numtxqueues {} numrxqueues {} gso_max_size {} \ + gso_max_segs {} tso_max_size {} tso_max_segs {} gro_max_size {} \ + gso_ipv4_max_size {} gro_ipv4_max_size {} ", self.inet6_addr_gen_mode, self.num_tx_queues, self.num_rx_queues, diff --git a/src/ip/link/link_info.rs b/src/ip/link/link_info.rs index 67ea285..9d575ed 100644 --- a/src/ip/link/link_info.rs +++ b/src/ip/link/link_info.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT + use rtnetlink::packet_route::link::{InfoData, InfoPortData, LinkInfo}; use serde::Serialize; From fb89e384513b154c99ecdd45ed4cdf355b13be3a Mon Sep 17 00:00:00 2001 From: Yonatan Linik Date: Sat, 26 Jul 2025 17:26:47 +0300 Subject: [PATCH 2/6] ip-link: Support showing veth & bond & bridge link-kind details --- src/ip/link/flags.rs | 1 + src/ip/link/link_info.rs | 188 +++++++++++++++++++++++++++++++++++++- src/ip/link/tests/link.rs | 9 +- 3 files changed, 194 insertions(+), 4 deletions(-) diff --git a/src/ip/link/flags.rs b/src/ip/link/flags.rs index 250fe1b..393452a 100644 --- a/src/ip/link/flags.rs +++ b/src/ip/link/flags.rs @@ -44,6 +44,7 @@ pub fn link_flags_to_string(mut flags: LinkFlags) -> Vec { // Compatible with iproute2, but we still append `CONTROLLER` // after iproute2 flags. ret.push("MASTER".into()); + flags.remove(flag) } else if flag == LinkFlags::LowerUp { ret.push("LOWER_UP".into()); flags.remove(flag) diff --git a/src/ip/link/link_info.rs b/src/ip/link/link_info.rs index 9d575ed..aab7084 100644 --- a/src/ip/link/link_info.rs +++ b/src/ip/link/link_info.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT -use rtnetlink::packet_route::link::{InfoData, InfoPortData, LinkInfo}; +use rtnetlink::packet_route::link::{ + BondArpValidate, InfoData, InfoPortData, LinkInfo, +}; use serde::Serialize; use iproute_rs::mac_to_string; @@ -140,6 +142,32 @@ pub(crate) enum CliLinkInfoData { nf_call_ip6tables: u8, nf_call_arptables: u8, }, + Veth, + Bond { + mode: String, + miimon: u32, + updelay: u32, + downdelay: u32, + peer_notify_delay: u32, + use_carrier: u8, + arp_interval: u32, + arp_missed_max: u8, + arp_validate: Option, + arp_all_targets: String, + primary_reselect: String, + fail_over_mac: String, + xmit_hash_policy: String, + resend_igmp: u32, + num_peer_notif: u8, + all_slaves_active: u8, + min_links: u32, + lp_interval: u32, + packets_per_slave: u32, + ad_lacp_active: String, + ad_lacp_rate: String, + ad_select: String, + tlb_dynamic_lb: u8, + }, } impl CliLinkInfoData { @@ -607,9 +635,117 @@ impl CliLinkInfoData { protocol, } } - InfoData::Veth(_info_veth) => todo!(), + InfoData::Veth(_) => CliLinkInfoData::Veth, InfoData::Vxlan(_info_vxlan) => todo!(), - InfoData::Bond(_info_bond) => todo!(), + InfoData::Bond(info_bond) => { + let mut mode = String::new(); + let mut miimon = 0; + let mut updelay = 0; + let mut downdelay = 0; + let mut peer_notify_delay = 0; + let mut use_carrier = 0; + let mut arp_interval = 0; + let mut arp_missed_max = 0; + let mut arp_validate = None; + let mut arp_all_targets = String::new(); + let mut primary_reselect = String::new(); + let mut fail_over_mac = String::new(); + let mut xmit_hash_policy = String::new(); + let mut resend_igmp = 0; + let mut num_peer_notif = 0; + let mut all_slaves_active = 0; + let mut min_links = 0; + let mut lp_interval = 0; + let mut packets_per_slave = 0; + let mut ad_lacp_active = String::new(); + let mut ad_lacp_rate = String::new(); + let mut ad_select = String::new(); + let mut tlb_dynamic_lb = 0; + + for nla in info_bond { + use rtnetlink::packet_route::link::InfoBond; + match nla { + InfoBond::Mode(v) => mode = v.to_string(), + InfoBond::MiiMon(v) => miimon = *v, + InfoBond::UpDelay(v) => updelay = *v, + InfoBond::DownDelay(v) => downdelay = *v, + InfoBond::PeerNotifDelay(v) => peer_notify_delay = *v, + InfoBond::UseCarrier(v) => use_carrier = *v, + InfoBond::ArpInterval(v) => arp_interval = *v, + InfoBond::MissedMax(v) => arp_missed_max = *v, + InfoBond::ArpValidate(v) => { + if matches!(v, BondArpValidate::None) { + arp_validate = None + } else { + arp_validate = Some(v.to_string()) + } + } + InfoBond::ArpAllTargets(v) => { + arp_all_targets = v.to_string() + } + InfoBond::PrimaryReselect(v) => { + primary_reselect = v.to_string() + } + InfoBond::FailOverMac(v) => { + fail_over_mac = v.to_string() + } + InfoBond::XmitHashPolicy(v) => { + xmit_hash_policy = v.to_string() + } + InfoBond::ResendIgmp(v) => resend_igmp = *v, + InfoBond::NumPeerNotif(v) => num_peer_notif = *v, + InfoBond::AllPortsActive(v) => all_slaves_active = *v, + InfoBond::MinLinks(v) => min_links = *v, + InfoBond::LpInterval(v) => lp_interval = *v, + InfoBond::PacketsPerPort(v) => packets_per_slave = *v, + InfoBond::AdLacpActive(v) => { + ad_lacp_active = + if *v == 1 { "on" } else { "off" }.to_string() + } + InfoBond::AdLacpRate(v) => { + ad_lacp_rate = if *v == 1 { "fast" } else { "slow" } + .to_string() + } + InfoBond::AdSelect(v) => { + ad_select = match *v { + 0 => "stable", + 1 => "bandwidth", + 2 => "count", + 3 => "hash", + _ => "unknown", + } + .to_string() + } + InfoBond::TlbDynamicLb(v) => tlb_dynamic_lb = *v, + _ => (), /* println!("Remains {:?}", nla) */ + } + } + Self::Bond { + mode, + miimon, + updelay, + downdelay, + peer_notify_delay, + use_carrier, + arp_interval, + arp_missed_max, + arp_validate, + arp_all_targets, + primary_reselect, + fail_over_mac, + xmit_hash_policy, + resend_igmp, + num_peer_notif, + all_slaves_active, + min_links, + lp_interval, + packets_per_slave, + ad_lacp_active, + ad_lacp_rate, + ad_select, + tlb_dynamic_lb, + } + } InfoData::IpVlan(_info_ip_vlan) => todo!(), InfoData::IpVtap(_info_ip_vtap) => todo!(), InfoData::MacVlan(_info_mac_vlan) => todo!(), @@ -746,6 +882,52 @@ impl std::fmt::Display for CliLinkInfoData { write!(f, "mab off")?; } } + CliLinkInfoData::Veth => (), + CliLinkInfoData::Bond { + mode, + miimon, + updelay, + downdelay, + peer_notify_delay, + use_carrier, + arp_interval, + arp_missed_max, + arp_validate, + arp_all_targets, + primary_reselect, + fail_over_mac, + xmit_hash_policy, + resend_igmp, + num_peer_notif: num_grat_arp, + all_slaves_active, + min_links, + lp_interval, + packets_per_slave: packaets_per_slave, + ad_lacp_active: lacp_active, + ad_lacp_rate: lacp_rate, + ad_select, + tlb_dynamic_lb, + } => { + let arp_validate = + arp_validate.as_ref().map_or("none", |s| s.as_str()); + + write!( + f, + "mode {mode} miimon {miimon} updelay {updelay} downdelay \ + {downdelay} peer_notify_delay {peer_notify_delay} \ + use_carrier {use_carrier} arp_interval {arp_interval} \ + arp_missed_max {arp_missed_max} arp_validate \ + {arp_validate} arp_all_targets {arp_all_targets} \ + primary_reselect {primary_reselect} fail_over_mac \ + {fail_over_mac} xmit_hash_policy {xmit_hash_policy} \ + resend_igmp {resend_igmp} num_grat_arp {num_grat_arp} \ + all_slaves_active {all_slaves_active} min_links \ + {min_links} lp_interval {lp_interval} packets_per_slave \ + {packaets_per_slave} lacp_active {lacp_active} lacp_rate \ + {lacp_rate} ad_select {ad_select} tlb_dynamic_lb \ + {tlb_dynamic_lb}", + )?; + } CliLinkInfoData::Bridge { forward_delay, hello_time, diff --git a/src/ip/link/tests/link.rs b/src/ip/link/tests/link.rs index f91d60a..52626ed 100644 --- a/src/ip/link/tests/link.rs +++ b/src/ip/link/tests/link.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT +use serde_json::Value; + use crate::tests::{exec_cmd, get_ip_cli_path}; const TEST_NETNS: &str = "iproute-rs-test"; @@ -179,7 +181,12 @@ fn test_link_show_json() { let our_output = exec_in_netns(&[cli_path.as_str(), "-j", "link", "show"]); - pretty_assertions::assert_eq!(expected_output, our_output); + let expected_json: Value = + serde_json::from_str(&expected_output).expect("To be valid json"); + let our_json: Value = + serde_json::from_str(&our_output).expect("To be valid json"); + + pretty_assertions::assert_eq!(expected_json, our_json); } #[test] From 88941175840890187aa3b1faf71ad1668f911f2f Mon Sep 17 00:00:00 2001 From: Yonatan Linik Date: Sat, 25 Oct 2025 17:16:56 +0300 Subject: [PATCH 3/6] ip-link: Make tests pass --- src/ip/link/link_details.rs | 27 ++------------ src/ip/link/link_info.rs | 71 +++---------------------------------- src/ip/link/tests/link.rs | 9 +++-- 3 files changed, 13 insertions(+), 94 deletions(-) diff --git a/src/ip/link/link_details.rs b/src/ip/link/link_details.rs index 6fa9cc4..4987031 100644 --- a/src/ip/link/link_details.rs +++ b/src/ip/link/link_details.rs @@ -33,7 +33,7 @@ impl std::fmt::Display for CliLinkInfoCombined { if let Some(slave_kind) = &self.info_slave_kind { write!(f, "\n {}_slave ", slave_kind)?; if let Some(slave_data) = &self.info_slave_data { - write!(f, "{slave_data} ")?; + write!(f, "{slave_data}")?; } } Ok(()) @@ -47,8 +47,6 @@ const IFLA_GRO_MAX_SIZE: u16 = 58; const IFLA_TSO_MAX_SIZE: u16 = 59; const IFLA_TSO_MAX_SEGS: u16 = 60; const IFLA_ALLMULTI: u16 = 61; -const IFLA_GSO_IPV4_MAX_SIZE: u16 = 63; -const IFLA_GRO_IPV4_MAX_SIZE: u16 = 64; fn get_addr_gen_mode(af_spec_unspec: &[AfSpecUnspec]) -> String { af_spec_unspec @@ -99,8 +97,6 @@ pub(crate) struct CliLinkInfoDetails { tso_max_size: u32, tso_max_segs: u32, gro_max_size: u32, - gso_ipv4_max_size: u32, - gro_ipv4_max_size: u32, #[serde(skip_serializing_if = "String::is_empty")] parentbus: String, #[serde(skip_serializing_if = "String::is_empty")] @@ -121,8 +117,6 @@ impl CliLinkInfoDetails { let mut tso_max_size = 0; let mut tso_max_segs = 0; let mut gro_max_size = 0; - let mut gso_ipv4_max_size = 0; - let mut gro_ipv4_max_size = 0; let mut inet6_addr_gen_mode = String::new(); let mut parentbus = String::new(); let mut parentdev = String::new(); @@ -166,16 +160,6 @@ impl CliLinkInfoDetails { default_nla.emit_value(&mut val); allmulti = u32::from_ne_bytes(val); } - IFLA_GSO_IPV4_MAX_SIZE => { - let mut val = [0u8; 4]; - default_nla.emit_value(&mut val); - gso_ipv4_max_size = u32::from_ne_bytes(val); - } - IFLA_GRO_IPV4_MAX_SIZE => { - let mut val = [0u8; 4]; - default_nla.emit_value(&mut val); - gro_ipv4_max_size = u32::from_ne_bytes(val); - } _ => { /* println!("Remains {:?}", default_nla); */ } }, LinkAttribute::LinkInfo(info) => { @@ -219,8 +203,6 @@ impl CliLinkInfoDetails { tso_max_size, tso_max_segs, gro_max_size, - gso_ipv4_max_size, - gro_ipv4_max_size, parentbus, parentdev, } @@ -231,7 +213,7 @@ impl std::fmt::Display for CliLinkInfoDetails { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - " promiscuity {} allmulti {} minmtu {} maxmtu {} ", + " promiscuity {} allmulti {} minmtu {} maxmtu {} ", self.promiscuity, self.allmulti, self.min_mtu, self.max_mtu, )?; @@ -242,8 +224,7 @@ impl std::fmt::Display for CliLinkInfoDetails { write!( f, "addrgenmode {} numtxqueues {} numrxqueues {} gso_max_size {} \ - gso_max_segs {} tso_max_size {} tso_max_segs {} gro_max_size {} \ - gso_ipv4_max_size {} gro_ipv4_max_size {} ", + gso_max_segs {} tso_max_size {} tso_max_segs {} gro_max_size {} ", self.inet6_addr_gen_mode, self.num_tx_queues, self.num_rx_queues, @@ -252,8 +233,6 @@ impl std::fmt::Display for CliLinkInfoDetails { self.tso_max_size, self.tso_max_segs, self.gro_max_size, - self.gso_ipv4_max_size, - self.gro_ipv4_max_size, )?; if !self.parentbus.is_empty() { diff --git a/src/ip/link/link_info.rs b/src/ip/link/link_info.rs index aab7084..d733f21 100644 --- a/src/ip/link/link_info.rs +++ b/src/ip/link/link_info.rs @@ -8,7 +8,8 @@ use serde::Serialize; use iproute_rs::mac_to_string; /// Format bridge ID to match iproute2's format: -/// Priority is 4 hex digits, MAC address bytes use minimal formatting (no leading zeros for bytes < 0x10) +/// Priority is 4 hex digits, MAC address bytes use minimal formatting (no +/// leading zeros for bytes < 0x10) fn format_bridge_id(priority: u16, mac_bytes: [u8; 6]) -> String { format!( "{:04x}.{:x}:{:x}:{:x}:{:x}:{:x}:{:x}", @@ -28,11 +29,8 @@ const VLAN_FLAG_LOOSE_BINDING: u32 = 0x4; const VLAN_FLAG_MVRP: u32 = 0x8; // Additional bridge constants not yet in netlink-packet-route -const IFLA_BR_FDB_N_LEARNED: u16 = 48; -const IFLA_BR_FDB_MAX_LEARNED: u16 = 49; const IFLA_BR_NO_LL_LEARN: u16 = 51; const IFLA_BR_VLAN_MCAST_SNOOPING: u16 = 52; -const IFLA_BR_MST_ENABLED: u16 = 53; #[derive(Serialize)] #[serde(untagged)] @@ -72,15 +70,11 @@ pub(crate) enum CliLinkInfoData { bcast_flood: bool, mcast_to_unicast: bool, neigh_suppress: bool, - #[serde(skip_serializing_if = "Option::is_none")] - neigh_vlan_suppress: Option, group_fwd_mask: String, group_fwd_mask_str: String, vlan_tunnel: bool, isolated: bool, locked: bool, - #[serde(skip_serializing_if = "Option::is_none")] - mab: Option, }, Bridge { forward_delay: u32, @@ -103,10 +97,6 @@ pub(crate) enum CliLinkInfoData { tcn_timer: u64, topology_change_timer: u64, gc_timer: u64, - #[serde(skip_serializing_if = "Option::is_none")] - fdb_n_learned: Option, - #[serde(skip_serializing_if = "Option::is_none")] - fdb_max_learned: Option, vlan_default_pvid: u16, #[serde(skip_serializing_if = "Option::is_none")] vlan_stats_enabled: Option, @@ -118,7 +108,6 @@ pub(crate) enum CliLinkInfoData { mcast_snooping: u8, no_linklocal_learn: u8, mcast_vlan_snooping: u8, - mst_enabled: u8, mcast_router: u8, mcast_query_use_ifaddr: u8, mcast_querier: u8, @@ -204,12 +193,10 @@ impl CliLinkInfoData { let mut bcast_flood = false; let mut mcast_to_unicast = false; let mut neigh_suppress = false; - let mut neigh_vlan_suppress = None; let mut group_fwd_mask: u16 = 0; let mut vlan_tunnel = false; let mut isolated = false; let mut locked = false; - let mut mab = None; for nla in info_bridge_port { match nla { @@ -292,14 +279,10 @@ impl CliLinkInfoData { mcast_to_unicast = *v } InfoBridgePort::NeighSupress(v) => neigh_suppress = *v, - InfoBridgePort::NeighVlanSupress(v) => { - neigh_vlan_suppress = Some(*v) - } InfoBridgePort::GroupFwdMask(v) => group_fwd_mask = *v, InfoBridgePort::VlanTunnel(v) => vlan_tunnel = *v, InfoBridgePort::Isolated(v) => isolated = *v, InfoBridgePort::Locked(v) => locked = *v, - InfoBridgePort::Mab(v) => mab = Some(*v), _ => (), } } @@ -340,13 +323,11 @@ impl CliLinkInfoData { bcast_flood, mcast_to_unicast, neigh_suppress, - neigh_vlan_suppress, group_fwd_mask: group_fwd_mask_string, group_fwd_mask_str, vlan_tunnel, isolated, locked, - mab, } } _ => todo!("Other port types not yet implemented"), @@ -401,11 +382,8 @@ impl CliLinkInfoData { let mut mcast_stats_enabled = None; let mut mcast_igmp_version = None; let mut mcast_mld_version = None; - let mut fdb_n_learned = None; - let mut fdb_max_learned = None; let mut no_linklocal_learn = 0; let mut mcast_vlan_snooping = 0; - let mut mst_enabled = 0; for nla in info_bridge { match nla { @@ -511,18 +489,6 @@ impl CliLinkInfoData { InfoBridge::Other(nla) => { use rtnetlink::packet_core::Nla; match nla.kind() { - IFLA_BR_FDB_N_LEARNED => { - let mut val = [0u8; 4]; - nla.emit_value(&mut val); - fdb_n_learned = - Some(u32::from_ne_bytes(val)); - } - IFLA_BR_FDB_MAX_LEARNED => { - let mut val = [0u8; 4]; - nla.emit_value(&mut val); - fdb_max_learned = - Some(u32::from_ne_bytes(val)); - } IFLA_BR_NO_LL_LEARN => { let mut val = [0u8; 1]; nla.emit_value(&mut val); @@ -533,11 +499,6 @@ impl CliLinkInfoData { nla.emit_value(&mut val); mcast_vlan_snooping = val[0]; } - IFLA_BR_MST_ENABLED => { - let mut val = [0u8; 1]; - nla.emit_value(&mut val); - mst_enabled = val[0]; - } _ => (), } } @@ -566,8 +527,6 @@ impl CliLinkInfoData { tcn_timer, topology_change_timer, gc_timer, - fdb_n_learned, - fdb_max_learned, vlan_default_pvid, vlan_stats_enabled, vlan_stats_per_port, @@ -576,7 +535,6 @@ impl CliLinkInfoData { mcast_snooping, no_linklocal_learn, mcast_vlan_snooping, - mst_enabled, mcast_router, mcast_query_use_ifaddr, mcast_querier, @@ -811,13 +769,11 @@ impl std::fmt::Display for CliLinkInfoData { bcast_flood, mcast_to_unicast, neigh_suppress, - neigh_vlan_suppress, group_fwd_mask, group_fwd_mask_str, vlan_tunnel, isolated, locked, - mab, } => { let format_timer = |val: u64| -> String { let seconds = val as f64 / 100.0; @@ -866,21 +822,11 @@ impl std::fmt::Display for CliLinkInfoData { write!(f, "bcast_flood {} ", on_off(*bcast_flood))?; write!(f, "mcast_to_unicast {} ", on_off(*mcast_to_unicast))?; write!(f, "neigh_suppress {} ", on_off(*neigh_suppress))?; - if let Some(v) = neigh_vlan_suppress { - write!(f, "neigh_vlan_suppress {} ", on_off(*v))?; - } else { - write!(f, "neigh_vlan_suppress off ")?; - } write!(f, "group_fwd_mask {} ", group_fwd_mask)?; write!(f, "group_fwd_mask_str {} ", group_fwd_mask_str)?; write!(f, "vlan_tunnel {} ", on_off(*vlan_tunnel))?; write!(f, "isolated {} ", on_off(*isolated))?; write!(f, "locked {} ", on_off(*locked))?; - if let Some(v) = mab { - write!(f, "mab {}", on_off(*v))?; - } else { - write!(f, "mab off")?; - } } CliLinkInfoData::Veth => (), CliLinkInfoData::Bond { @@ -947,8 +893,6 @@ impl std::fmt::Display for CliLinkInfoData { tcn_timer, topology_change_timer, gc_timer, - fdb_n_learned, - fdb_max_learned, vlan_default_pvid, vlan_stats_enabled, vlan_stats_per_port, @@ -957,7 +901,6 @@ impl std::fmt::Display for CliLinkInfoData { mcast_snooping, no_linklocal_learn, mcast_vlan_snooping, - mst_enabled, mcast_router, mcast_query_use_ifaddr, mcast_querier, @@ -1013,12 +956,6 @@ impl std::fmt::Display for CliLinkInfoData { format_timer(*topology_change_timer) )?; write!(f, "gc_timer {} ", format_timer(*gc_timer))?; - if let Some(v) = fdb_n_learned { - write!(f, "fdb_n_learned {} ", v)?; - } - if let Some(v) = fdb_max_learned { - write!(f, "fdb_max_learned {} ", v)?; - } write!(f, "vlan_default_pvid {} ", vlan_default_pvid)?; if let Some(v) = vlan_stats_enabled { write!(f, "vlan_stats_enabled {} ", v)?; @@ -1038,7 +975,6 @@ impl std::fmt::Display for CliLinkInfoData { write!(f, "mcast_snooping {} ", mcast_snooping)?; write!(f, "no_linklocal_learn {} ", no_linklocal_learn)?; write!(f, "mcast_vlan_snooping {} ", mcast_vlan_snooping)?; - write!(f, "mst_enabled {} ", mst_enabled)?; write!(f, "mcast_router {} ", mcast_router)?; write!( f, @@ -1135,7 +1071,8 @@ impl CliLinkInfoKindNData { // but skip this for now - we'll handle it separately } LinkInfo::PortData(_data) => { - // Skip port data in this structure - it's handled separately + // Skip port data in this structure - it's handled + // separately } _ => (), } diff --git a/src/ip/link/tests/link.rs b/src/ip/link/tests/link.rs index 52626ed..f0d5a55 100644 --- a/src/ip/link/tests/link.rs +++ b/src/ip/link/tests/link.rs @@ -14,7 +14,8 @@ fn exec_in_netns(args: &[&str]) -> String { } /// Normalize timer values in output to avoid test flakiness -/// Timer values can vary slightly between consecutive calls due to kernel timing +/// Timer values can vary slightly between consecutive calls due to kernel +/// timing fn normalize_timers(output: &str) -> String { let timer_names = [ "hello_timer", @@ -28,7 +29,8 @@ fn normalize_timers(output: &str) -> String { let mut result = output.to_string(); for timer_name in timer_names { - // Find and replace timer values like "gc_timer 0.05" with "gc_timer 0.00" + // Find and replace timer values like "gc_timer 0.05" with "gc_timer + // 0.00" let mut new_result = String::new(); let mut remaining = result.as_str(); @@ -75,7 +77,8 @@ fn normalize_timers_json(output: &str) -> String { let mut result = output.to_string(); for timer_name in timer_names { - // Find and replace JSON timer values like "\"gc_timer\":5" or "\"gc_timer\":0.05" with "\"gc_timer\":0" + // Find and replace JSON timer values like "\"gc_timer\":5" or + // "\"gc_timer\":0.05" with "\"gc_timer\":0" let search_pattern = format!("\"{}\":", timer_name); let mut new_result = String::new(); let mut remaining = result.as_str(); From 3a63935f8f8e819e478cb23e3991075fdbb140ee Mon Sep 17 00:00:00 2001 From: Yonatan Linik Date: Sat, 25 Oct 2025 17:30:24 +0300 Subject: [PATCH 4/6] ip: Remove ctor dev-dependency Setup for tests is now part of the Makefile. --- Cargo.toml | 1 - Makefile | 30 +++++++++++++++++++++++++- src/ip/link/tests/link.rs | 45 --------------------------------------- 3 files changed, 29 insertions(+), 47 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ef527c0..176e035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,3 @@ tokio = { version = "1.30", features = ["rt", "net", "time", "macros"] } [dev-dependencies] pretty_assertions = "1.4.1" -ctor = "0.5.0" diff --git a/Makefile b/Makefile index 5482d48..5a92252 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,32 @@ -check: +tests_setup: + sudo ip netns list | grep -qw iproute-rs-test && sudo ip netns del iproute-rs-test || true + sudo ip netns add iproute-rs-test + + # Create veth pair and move one end into the test netns + sudo ip link add veth0 type veth peer name veth1 + sudo ip link set veth1 netns iproute-rs-test + sudo ip addr add 192.0.2.1/24 dev veth0 + sudo ip link set veth0 up + sudo ip -n iproute-rs-test addr add 192.0.2.2/24 dev veth1 + sudo ip -n iproute-rs-test link set veth1 up + sudo ip -n iproute-rs-test link set lo up + + # create dummy, altname, bridge and vlan inside the test netns + sudo ip -n iproute-rs-test link add dummy0 type dummy + sudo ip -n iproute-rs-test link property add dev dummy0 altname dmmy-zero + sudo ip -n iproute-rs-test link add br0 type bridge + sudo ip -n iproute-rs-test link add link dummy0 name dummy0.1 type vlan id 1 + sudo ip -n iproute-rs-test link set dev dummy0.1 master br0 + + sudo ip -n iproute-rs-test link set dummy0 up + sudo ip -n iproute-rs-test link set dummy0.1 up + sudo ip -n iproute-rs-test link set br0 up + + echo "setup network namespace for tests finished" + sudo ip -n iproute-rs-test -c -d link show + +check: tests_setup cargo build; env CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo" \ cargo test -- --test-threads=1 --show-output $(WHAT) ; + sudo ip netns del iproute-rs-test diff --git a/src/ip/link/tests/link.rs b/src/ip/link/tests/link.rs index f0d5a55..a995dc3 100644 --- a/src/ip/link/tests/link.rs +++ b/src/ip/link/tests/link.rs @@ -106,51 +106,6 @@ fn normalize_timers_json(output: &str) -> String { result } -#[cfg(test)] -#[ctor::ctor] -fn setup() { - println!("setup network namespace and interfaces for tests"); - - // Create network namespace (delete first if it exists) - let netns_list = exec_cmd(&["ip", "netns", "list"]); - if netns_list.contains(TEST_NETNS) { - exec_cmd(&["ip", "netns", "del", TEST_NETNS]); - } - exec_cmd(&["ip", "netns", "add", TEST_NETNS]); - - // Add vlan over dummy interface - exec_in_netns(&["ip", "link", "add", "dummy0", "type", "dummy"]); - exec_in_netns(&[ - "ip", - "link", - "property", - "add", - "dev", - "dummy0", - "altname", - "dmmy-zero", - ]); - exec_in_netns(&["ip", "link", "add", "br0", "type", "bridge"]); - exec_in_netns(&[ - "ip", "link", "add", "link", "dummy0", "name", "dummy0.1", "type", - "vlan", "id", "1", - ]); - exec_in_netns(&["ip", "link", "set", "dev", "dummy0.1", "master", "br0"]); - - exec_in_netns(&["ip", "link", "set", "dummy0", "up"]); - exec_in_netns(&["ip", "link", "set", "dummy0.1", "up"]); - exec_in_netns(&["ip", "link", "set", "br0", "up"]); -} - -#[cfg(test)] -#[ctor::dtor] -fn teardown() { - println!("teardown network namespace for tests"); - - // Delete network namespace - exec_cmd(&["ip", "netns", "del", TEST_NETNS]); -} - #[test] fn test_link_show() { let cli_path = get_ip_cli_path(); From 9199d59860d04017c1d1ae00712fb693238bc012 Mon Sep 17 00:00:00 2001 From: Yonatan Linik Date: Tue, 14 Oct 2025 23:20:44 +0300 Subject: [PATCH 5/6] ip-address: Initial support for `ip address` supporting `show` --- Makefile | 9 +- src/color.rs | 8 + src/ip/address/cli.rs | 63 ++++++++ src/ip/address/mod.rs | 10 ++ src/ip/address/show.rs | 259 ++++++++++++++++++++++++++++++++ src/ip/address/tests/address.rs | 60 ++++++++ src/ip/address/tests/mod.rs | 4 + src/ip/link/link_details.rs | 13 +- src/ip/link/mod.rs | 2 + src/ip/link/show.rs | 33 +++- src/ip/main.rs | 12 +- 11 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 src/ip/address/cli.rs create mode 100644 src/ip/address/mod.rs create mode 100644 src/ip/address/show.rs create mode 100644 src/ip/address/tests/address.rs create mode 100644 src/ip/address/tests/mod.rs diff --git a/Makefile b/Makefile index 5a92252..bbe091e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -tests_setup: +build: + cargo build + +tests_setup: build sudo ip netns list | grep -qw iproute-rs-test && sudo ip netns del iproute-rs-test || true sudo ip netns add iproute-rs-test @@ -25,8 +28,10 @@ tests_setup: echo "setup network namespace for tests finished" sudo ip -n iproute-rs-test -c -d link show +tests_teardown: + sudo ip netns del iproute-rs-test + check: tests_setup - cargo build; env CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo" \ cargo test -- --test-threads=1 --show-output $(WHAT) ; sudo ip netns del iproute-rs-test diff --git a/src/color.rs b/src/color.rs index 862f330..f767e8f 100644 --- a/src/color.rs +++ b/src/color.rs @@ -86,6 +86,14 @@ impl CliColor { } }) } + + pub fn address_color(family: &str) -> Self { + match family { + "inet" => Self::Ipv4Addr, + "inet6" => Self::Ipv6Addr, + _ => Self::Clear, + } + } } #[macro_export] diff --git a/src/ip/address/cli.rs b/src/ip/address/cli.rs new file mode 100644 index 0000000..d07198e --- /dev/null +++ b/src/ip/address/cli.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT + +use iproute_rs::CliError; + +use crate::link::CliLinkInfo; + +use super::show::handle_show; + +pub(crate) struct AddressCommand; + +impl AddressCommand { + pub(crate) const CMD: &'static str = "address"; + + pub(crate) fn gen_command() -> clap::Command { + clap::Command::new(Self::CMD) + .about("network address configuration") + .subcommand_required(false) + .subcommand( + clap::Command::new("show") + .about("show links' addresses") + .alias("list") + .alias("lst") + .arg( + clap::Arg::new("options") + .action(clap::ArgAction::Append) + .trailing_var_arg(true), + ), + ) + .subcommand( + clap::Command::new("add").about("add address to link").arg( + clap::Arg::new("options") + .action(clap::ArgAction::Append) + .trailing_var_arg(true), + ), + ) + .subcommand( + clap::Command::new("delete").about("delete address from link"), + ) + .subcommand( + clap::Command::new("change") + .alias("set") + .about("change device attributes"), + ) + } + + pub(crate) async fn handle( + matches: &clap::ArgMatches, + ) -> Result, CliError> { + if let Some(matches) = matches.subcommand_matches("add") { + println!("HAHA {matches:?}"); + todo!() + } else if let Some(matches) = matches.subcommand_matches("show") { + let opts: Vec<&str> = matches + .get_many::("options") + .unwrap_or_default() + .map(String::as_str) + .collect(); + handle_show(&opts, matches.get_flag("DETAILS")).await + } else { + handle_show(&[], matches.get_flag("DETAILS")).await + } + } +} diff --git a/src/ip/address/mod.rs b/src/ip/address/mod.rs new file mode 100644 index 0000000..5752512 --- /dev/null +++ b/src/ip/address/mod.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +mod cli; +mod show; + +#[cfg(test)] +mod tests; + +pub(crate) use self::cli::AddressCommand; +pub(crate) use self::show::CliAddressInfo; diff --git a/src/ip/address/show.rs b/src/ip/address/show.rs new file mode 100644 index 0000000..5b680e6 --- /dev/null +++ b/src/ip/address/show.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MIT + +use std::collections::HashMap; + +use crate::{CliError, link::CliLinkInfo}; +use futures_util::TryStreamExt; +use iproute_rs::{CanDisplay, CanOutput, CliColor, write_with_color}; +use rtnetlink::packet_route::{ + AddressFamily, + address::{AddressAttribute, AddressFlags, AddressMessage, AddressScope}, +}; +use serde::Serialize; + +#[derive(Serialize, Default)] +pub(crate) struct CliAddressInfo { + #[serde(skip)] + index: u32, + family: String, + local: String, + prefixlen: u8, + #[serde(skip_serializing_if = "Option::is_none")] + broadcast: Option, + scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + tentative: Option, + #[serde(skip_serializing_if = "String::is_empty")] + label: String, + valid_life_time: u32, + preferred_life_time: u32, +} + +impl std::fmt::Display for CliAddressInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ", self.family)?; + write_with_color!( + f, + CliColor::address_color(&self.family), + "{}", + self.local + )?; + write!(f, "/{}", self.prefixlen)?; + if let Some(broadcast) = &self.broadcast { + write!(f, " brd ")?; + write_with_color!( + f, + CliColor::address_color(&self.family), + "{}", + broadcast + )?; + } + write!(f, " scope {} ", self.scope)?; + if Some(true) == self.tentative { + write!(f, "tentative ")?; + } + write!(f, "{}", self.label)?; + + write!( + f, + "\n valid_lft {} preferred_lft {}", + if self.valid_life_time == u32::MAX { + "forever".to_string() + } else { + self.valid_life_time.to_string() + }, + if self.preferred_life_time == u32::MAX { + "forever".to_string() + } else { + self.preferred_life_time.to_string() + } + )?; + Ok(()) + } +} + +impl CanDisplay for CliAddressInfo { + fn gen_string(&self) -> String { + self.to_string() + } +} + +impl CanOutput for CliAddressInfo {} + +fn addr_family_to_cli_string(addr_family: &AddressFamily) -> String { + match addr_family { + AddressFamily::Unspec => "unspec".to_string(), + AddressFamily::Local => "local".to_string(), + AddressFamily::Unix => "unix".to_string(), + AddressFamily::Inet => "inet".to_string(), + AddressFamily::Ax25 => "ax25".to_string(), + AddressFamily::Ipx => "ipx".to_string(), + AddressFamily::Appletalk => "appletalk".to_string(), + AddressFamily::Netrom => "netrom".to_string(), + AddressFamily::Bridge => "bridge".to_string(), + AddressFamily::Atmpvc => "atmpvc".to_string(), + AddressFamily::X25 => "x25".to_string(), + AddressFamily::Inet6 => "inet6".to_string(), + AddressFamily::Rose => "rose".to_string(), + AddressFamily::Decnet => "decnet".to_string(), + AddressFamily::Netbeui => "netbeui".to_string(), + AddressFamily::Security => "security".to_string(), + AddressFamily::Key => "key".to_string(), + AddressFamily::Route => "route".to_string(), + AddressFamily::Netlink => "netlink".to_string(), + AddressFamily::Packet => "packet".to_string(), + AddressFamily::Ash => "ash".to_string(), + AddressFamily::Econet => "econet".to_string(), + AddressFamily::Atmsvc => "atmsvc".to_string(), + AddressFamily::Rds => "rds".to_string(), + AddressFamily::Sna => "sna".to_string(), + AddressFamily::Irda => "irda".to_string(), + AddressFamily::Pppox => "pppox".to_string(), + AddressFamily::Wanpipe => "wanpipe".to_string(), + AddressFamily::Llc => "llc".to_string(), + #[cfg(not(target_os = "android"))] + AddressFamily::Ib => "ib".to_string(), + #[cfg(not(target_os = "android"))] + AddressFamily::Mpls => "mpls".to_string(), + AddressFamily::Can => "can".to_string(), + AddressFamily::Tipc => "tipc".to_string(), + AddressFamily::Bluetooth => "bluetooth".to_string(), + AddressFamily::Iucv => "iucv".to_string(), + AddressFamily::Rxrpc => "rxrpc".to_string(), + AddressFamily::Isdn => "isdn".to_string(), + AddressFamily::Phonet => "phonet".to_string(), + AddressFamily::Ieee802154 => "ieee802154".to_string(), + AddressFamily::Caif => "caif".to_string(), + AddressFamily::Alg => "alg".to_string(), + AddressFamily::Nfc => "nfc".to_string(), + AddressFamily::Vsock => "vsock".to_string(), + AddressFamily::Kcm => "kcm".to_string(), + AddressFamily::Qipcrtr => "qipcrtr".to_string(), + AddressFamily::Smc => "smc".to_string(), + AddressFamily::Xdp => "xdp".to_string(), + AddressFamily::Mctp => "mctp".to_string(), + AddressFamily::Other(_) | _ => "unwnown".to_string(), + } +} + +fn addr_scope_to_cli_string(addr_scope: &AddressScope) -> String { + match addr_scope { + AddressScope::Universe => "global", + AddressScope::Site => "site", + AddressScope::Link => "link", + AddressScope::Host => "host", + AddressScope::Nowhere => "nowhere", + AddressScope::Other(_) | _ => "unknown", + } + .to_string() +} + +fn parse_nl_msg_to_address( + nl_msg: AddressMessage, +) -> Result { + let index = nl_msg.header.index; + let family = addr_family_to_cli_string(&nl_msg.header.family); + let mut local = String::new(); + let prefixlen = nl_msg.header.prefix_len; + let mut broadcast = None; + let scope = addr_scope_to_cli_string(&nl_msg.header.scope); + let mut tentative = None; + let mut label = String::new(); + let mut valid_life_time = u32::MAX; + let mut preferred_life_time = u32::MAX; + + for nla in nl_msg.attributes { + match nla { + AddressAttribute::Local(a) => { + local = a.to_string(); + } + AddressAttribute::Address(a) => { + if local.is_empty() { + local = a.to_string(); + } + } + AddressAttribute::Broadcast(a) => { + broadcast = Some(a.to_string()); + } + AddressAttribute::Label(s) => { + label = s; + } + AddressAttribute::CacheInfo(c) => { + valid_life_time = c.ifa_valid; + preferred_life_time = c.ifa_preferred; + } + AddressAttribute::Flags(f) => { + // If there is no tentative flag the field should be None + tentative = (nl_msg.header.family == AddressFamily::Inet6 + && f.contains(AddressFlags::Tentative)) + .then_some(true); + } + _ => { + // println!("Remains {:?}", nla); + } + } + } + + Ok(CliAddressInfo { + index, + family, + local, + prefixlen, + broadcast, + scope, + tentative, + label, + valid_life_time, + preferred_life_time, + }) +} + +pub(crate) async fn handle_show( + opts: &[&str], + include_details: bool, +) -> Result, CliError> { + let (connection, handle, _) = rtnetlink::new_connection()?; + + tokio::spawn(connection); + + let mut address_get_handle = handle.address().get(); + // let mut link_get_handle = handle.link().get(); + + if let Some(iface_name) = opts.first() { + let link_get_handle = + handle.link().get().match_name(iface_name.to_string()); + let link = + link_get_handle.execute().try_next().await?.ok_or_else(|| { + CliError::from( + format!("Device \"{iface_name}\" does not exist.").as_str(), + ) + })?; + address_get_handle = + address_get_handle.set_link_index_filter(link.header.index); + } + + let mut addresses = address_get_handle.execute(); + let mut addresses_infos: Vec = Vec::new(); + + while let Some(nl_msg) = addresses.try_next().await? { + addresses_infos.push(parse_nl_msg_to_address(nl_msg)?); + } + + let mut links_info: HashMap = + crate::link::handle_show(opts, include_details) + .await? + .into_iter() + .map(|link_info| (link_info.get_ifindex(), link_info)) + .collect(); + + for addr_info in addresses_infos { + if let Some(link_info) = links_info.get_mut(&addr_info.index) { + link_info.add_address(addr_info); + } + } + + let mut result: Vec = links_info.into_values().collect(); + result.sort_by_key(|link| link.get_ifindex()); + + Ok(result) +} diff --git a/src/ip/address/tests/address.rs b/src/ip/address/tests/address.rs new file mode 100644 index 0000000..535de1d --- /dev/null +++ b/src/ip/address/tests/address.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +use serde_json::Value; + +use crate::tests::{exec_cmd, get_ip_cli_path}; + +#[test] +fn test_address_show() { + let cli_path = get_ip_cli_path(); + + let expected_output = exec_cmd(&["ip", "address", "show"]); + + let our_output = exec_cmd(&[cli_path.as_str(), "address", "show"]); + + pretty_assertions::assert_eq!(expected_output, our_output); +} + +#[test] +fn test_address_detailed_show() { + let cli_path = get_ip_cli_path(); + + let expected_output = exec_cmd(&["ip", "-d", "address", "show"]); + + let our_output = exec_cmd(&[cli_path.as_str(), "-d", "address", "show"]); + + pretty_assertions::assert_eq!(expected_output, our_output); +} + +#[test] +fn test_address_show_json() { + let cli_path = get_ip_cli_path(); + + let expected_output = exec_cmd(&["ip", "-j", "address", "show"]); + + let our_output = exec_cmd(&[cli_path.as_str(), "-j", "address", "show"]); + + let expected_json: Value = + serde_json::from_str(&expected_output).expect("To be valid json"); + let our_json: Value = + serde_json::from_str(&our_output).expect("To be valid json"); + + pretty_assertions::assert_eq!(expected_json, our_json); +} + +#[test] +fn test_address_detailed_show_json() { + let cli_path = get_ip_cli_path(); + + let expected_output = exec_cmd(&["ip", "-d", "-j", "address", "show"]); + + let our_output = + exec_cmd(&[cli_path.as_str(), "-d", "-j", "address", "show"]); + + let expected_json: Value = + serde_json::from_str(&expected_output).expect("To be valid json"); + let our_json: Value = + serde_json::from_str(&our_output).expect("To be valid json"); + + pretty_assertions::assert_eq!(expected_json, our_json); +} diff --git a/src/ip/address/tests/mod.rs b/src/ip/address/tests/mod.rs new file mode 100644 index 0000000..ce718a9 --- /dev/null +++ b/src/ip/address/tests/mod.rs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT + +#[cfg(test)] +mod address; diff --git a/src/ip/link/link_details.rs b/src/ip/link/link_details.rs index 4987031..974dacb 100644 --- a/src/ip/link/link_details.rs +++ b/src/ip/link/link_details.rs @@ -207,6 +207,10 @@ impl CliLinkInfoDetails { parentdev, } } + + pub(crate) fn remove_inet6_addr_gen_mode(&mut self) { + self.inet6_addr_gen_mode = String::new(); + } } impl std::fmt::Display for CliLinkInfoDetails { @@ -221,11 +225,14 @@ impl std::fmt::Display for CliLinkInfoDetails { write!(f, "{linkinfo}")?; } + if !self.inet6_addr_gen_mode.is_empty() { + write!(f, "addrgenmode {} ", self.inet6_addr_gen_mode)?; + } + write!( f, - "addrgenmode {} numtxqueues {} numrxqueues {} gso_max_size {} \ - gso_max_segs {} tso_max_size {} tso_max_segs {} gro_max_size {} ", - self.inet6_addr_gen_mode, + "numtxqueues {} numrxqueues {} gso_max_size {} gso_max_segs {} \ + tso_max_size {} tso_max_segs {} gro_max_size {} ", self.num_tx_queues, self.num_rx_queues, self.gso_max_size, diff --git a/src/ip/link/mod.rs b/src/ip/link/mod.rs index 08ac7e5..899da86 100644 --- a/src/ip/link/mod.rs +++ b/src/ip/link/mod.rs @@ -10,3 +10,5 @@ mod show; mod tests; pub(crate) use self::cli::LinkCommand; +pub(crate) use self::show::CliLinkInfo; +pub(crate) use self::show::handle_show; diff --git a/src/ip/link/show.rs b/src/ip/link/show.rs index bf100d7..8c17cfb 100644 --- a/src/ip/link/show.rs +++ b/src/ip/link/show.rs @@ -8,6 +8,7 @@ use futures_util::stream::TryStreamExt; use rtnetlink::packet_route::link::{LinkAttribute, LinkMessage, Prop}; use serde::Serialize; +use super::super::address::CliAddressInfo; use super::flags::link_flags_to_string; use iproute_rs::{ CanDisplay, CanOutput, CliColor, CliError, mac_to_string, write_with_color, @@ -31,6 +32,7 @@ pub(crate) struct CliLinkInfo { #[serde(skip)] controller_ifindex: Option, operstate: String, + #[serde(skip_serializing_if = "String::is_empty")] linkmode: String, group: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -42,6 +44,8 @@ pub(crate) struct CliLinkInfo { broadcast: String, #[serde(skip_serializing_if = "String::is_empty")] permaddr: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + addr_info: Vec, #[serde(skip)] link_netns: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -88,7 +92,12 @@ impl std::fmt::Display for CliLinkInfo { } else { write!(f, "{} ", self.operstate)?; } - write!(f, "mode {} group {} ", self.linkmode, self.group,)?; + + if !self.linkmode.is_empty() { + write!(f, "mode {} ", self.linkmode)?; + } + write!(f, "group {} ", self.group)?; + if let Some(v) = self.txqlen { write!(f, "qlen {v}")?; } @@ -117,6 +126,11 @@ impl std::fmt::Display for CliLinkInfo { for altname in &self.altnames { write!(f, "\n altname {altname}")?; } + + for addr in &self.addr_info { + write!(f, "\n {}", addr)?; + } + Ok(()) } } @@ -156,6 +170,23 @@ pub(crate) async fn handle_show( Ok(ifaces) } +impl CliLinkInfo { + pub(crate) fn get_ifindex(&self) -> u32 { + self.ifindex + } + + pub(crate) fn add_address(&mut self, addr_info: CliAddressInfo) { + // When addresses are added (for `ip address show`), we want to + // remove some details that are not present in the original ip command. + self.linkmode = String::new(); + if let Some(d) = self.details.as_mut() { + d.remove_inet6_addr_gen_mode(); + } + + self.addr_info.push(addr_info); + } +} + pub(crate) async fn parse_nl_msg_to_iface( nl_msg: LinkMessage, include_details: bool, diff --git a/src/ip/main.rs b/src/ip/main.rs index cc35755..70dc985 100644 --- a/src/ip/main.rs +++ b/src/ip/main.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT +mod address; mod link; #[cfg(test)] @@ -9,6 +10,7 @@ use std::io::IsTerminal; use iproute_rs::{CliColor, CliError, OutputFormat, print_result_and_exit}; +use self::address::AddressCommand; use self::link::LinkCommand; #[tokio::main(flavor = "current_thread")] @@ -55,7 +57,8 @@ async fn main() -> Result<(), CliError> { .global(true), ) .subcommand_required(true) - .subcommand(LinkCommand::gen_command()); + .subcommand(LinkCommand::gen_command()) + .subcommand(AddressCommand::gen_command()); let matches = app.get_matches_mut(); @@ -78,6 +81,13 @@ async fn main() -> Result<(), CliError> { print_result_and_exit(Ok(app.render_version().to_string()), fmt); } else if let Some(matches) = matches.subcommand_matches(LinkCommand::CMD) { print_result_and_exit(LinkCommand::handle(matches).await, fmt); + } else if let Some(matches) = + matches.subcommand_matches(AddressCommand::CMD) + { + print_result_and_exit(AddressCommand::handle(matches).await, fmt); + } else { + app.print_help()?; + println!(); } Ok(()) From 930c3ad4bfce1abef4f958e272327a55a16473f2 Mon Sep 17 00:00:00 2001 From: Yonatan Linik Date: Sat, 29 Nov 2025 18:35:14 +0200 Subject: [PATCH 6/6] ip-address: Fixes tests to check timers + show tentative flag --- Makefile | 4 +- src/ip/address/tests/address.rs | 40 ++++------ src/ip/link/link_info.rs | 104 +++++++++++++------------- src/ip/link/tests/color.rs | 16 ++-- src/ip/link/tests/link.rs | 125 ++------------------------------ src/ip/tests/cmd.rs | 11 ++- src/ip/tests/cmp.rs | 85 ++++++++++++++++++++++ src/ip/tests/mod.rs | 5 +- 8 files changed, 185 insertions(+), 205 deletions(-) create mode 100644 src/ip/tests/cmp.rs diff --git a/Makefile b/Makefile index bbe091e..a9212f0 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,10 @@ tests_setup: build sudo ip link add veth0 type veth peer name veth1 sudo ip link set veth1 netns iproute-rs-test sudo ip addr add 192.0.2.1/24 dev veth0 + sudo ip addr add 22:33::44:55/120 dev veth0 sudo ip link set veth0 up sudo ip -n iproute-rs-test addr add 192.0.2.2/24 dev veth1 + sudo ip -n iproute-rs-test addr add 22:33::44:66/120 dev veth1 sudo ip -n iproute-rs-test link set veth1 up sudo ip -n iproute-rs-test link set lo up @@ -33,5 +35,5 @@ tests_teardown: check: tests_setup env CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo" \ - cargo test -- --test-threads=1 --show-output $(WHAT) ; + cargo test -- --test-threads=1 --show-output $(WHAT) ; \ sudo ip netns del iproute-rs-test diff --git a/src/ip/address/tests/address.rs b/src/ip/address/tests/address.rs index 535de1d..d9529b0 100644 --- a/src/ip/address/tests/address.rs +++ b/src/ip/address/tests/address.rs @@ -1,60 +1,50 @@ // SPDX-License-Identifier: MIT -use serde_json::Value; - -use crate::tests::{exec_cmd, get_ip_cli_path}; +use crate::tests::{assert_eq, assert_json_eq, exec_in_netns, get_ip_cli_path}; #[test] fn test_address_show() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "address", "show"]); + let expected_output = exec_in_netns(&["ip", "address", "show"]); - let our_output = exec_cmd(&[cli_path.as_str(), "address", "show"]); + let our_output = exec_in_netns(&[cli_path.as_str(), "address", "show"]); - pretty_assertions::assert_eq!(expected_output, our_output); + assert_eq(expected_output, our_output); } #[test] fn test_address_detailed_show() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "-d", "address", "show"]); + let expected_output = exec_in_netns(&["ip", "-d", "address", "show"]); - let our_output = exec_cmd(&[cli_path.as_str(), "-d", "address", "show"]); + let our_output = + exec_in_netns(&[cli_path.as_str(), "-d", "address", "show"]); - pretty_assertions::assert_eq!(expected_output, our_output); + assert_eq(expected_output, our_output); } #[test] fn test_address_show_json() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "-j", "address", "show"]); - - let our_output = exec_cmd(&[cli_path.as_str(), "-j", "address", "show"]); + let expected_output = exec_in_netns(&["ip", "-j", "address", "show"]); - let expected_json: Value = - serde_json::from_str(&expected_output).expect("To be valid json"); - let our_json: Value = - serde_json::from_str(&our_output).expect("To be valid json"); + let our_output = + exec_in_netns(&[cli_path.as_str(), "-j", "address", "show"]); - pretty_assertions::assert_eq!(expected_json, our_json); + assert_json_eq(expected_output, our_output); } #[test] fn test_address_detailed_show_json() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "-d", "-j", "address", "show"]); + let expected_output = exec_in_netns(&["ip", "-d", "-j", "address", "show"]); let our_output = - exec_cmd(&[cli_path.as_str(), "-d", "-j", "address", "show"]); - - let expected_json: Value = - serde_json::from_str(&expected_output).expect("To be valid json"); - let our_json: Value = - serde_json::from_str(&our_output).expect("To be valid json"); + exec_in_netns(&[cli_path.as_str(), "-d", "-j", "address", "show"]); - pretty_assertions::assert_eq!(expected_json, our_json); + assert_json_eq(expected_output, our_output); } diff --git a/src/ip/link/link_info.rs b/src/ip/link/link_info.rs index d733f21..3dcd8e3 100644 --- a/src/ip/link/link_info.rs +++ b/src/ip/link/link_info.rs @@ -5,6 +5,28 @@ use rtnetlink::packet_route::link::{ }; use serde::Serialize; +#[derive(Serialize)] +#[serde(transparent)] +pub(crate) struct Timer(f64); + +impl Timer { + fn new(timer: u64) -> Self { + Timer(timer as f64 / 100.0) + } +} + +impl Default for Timer { + fn default() -> Self { + Timer::new(0) + } +} + +impl std::fmt::Display for Timer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:>7.2}", self.0) + } +} + use iproute_rs::mac_to_string; /// Format bridge ID to match iproute2's format: @@ -58,9 +80,9 @@ pub(crate) enum CliLinkInfoData { bridge_id: Option, #[serde(skip_serializing_if = "Option::is_none")] root_id: Option, - hold_timer: u64, - message_age_timer: u64, - forward_delay_timer: u64, + hold_timer: Timer, + message_age_timer: Timer, + forward_delay_timer: Timer, topology_change_ack: u8, config_pending: u8, proxy_arp: bool, @@ -93,10 +115,10 @@ pub(crate) enum CliLinkInfoData { root_path_cost: u32, topology_change: u8, topology_change_detected: u8, - hello_timer: u64, - tcn_timer: u64, - topology_change_timer: u64, - gc_timer: u64, + hello_timer: Timer, + tcn_timer: Timer, + topology_change_timer: Timer, + gc_timer: Timer, vlan_default_pvid: u16, #[serde(skip_serializing_if = "Option::is_none")] vlan_stats_enabled: Option, @@ -181,9 +203,9 @@ impl CliLinkInfoData { let mut designated_cost = 0; let mut bridge_id = None; let mut root_id = None; - let mut hold_timer = 0; - let mut message_age_timer = 0; - let mut forward_delay_timer = 0; + let mut hold_timer = Timer::default(); + let mut message_age_timer = Timer::default(); + let mut forward_delay_timer = Timer::default(); let mut topology_change_ack = 0; let mut config_pending = 0; let mut proxy_arp = false; @@ -247,12 +269,14 @@ impl CliLinkInfoData { root_id = Some(format_bridge_id(v.priority, v.address)); } - InfoBridgePort::HoldTimer(v) => hold_timer = *v, + InfoBridgePort::HoldTimer(v) => { + hold_timer = Timer::new(*v) + } InfoBridgePort::MessageAgeTimer(v) => { - message_age_timer = *v + message_age_timer = Timer::new(*v) } InfoBridgePort::ForwardDelayTimer(v) => { - forward_delay_timer = *v + forward_delay_timer = Timer::new(*v) } InfoBridgePort::TopologyChangeAck(v) => { topology_change_ack = if *v { 1 } else { 0 } @@ -353,10 +377,10 @@ impl CliLinkInfoData { let mut root_path_cost = 0; let mut topology_change = 0; let mut topology_change_detected = 0; - let mut hello_timer = 0; - let mut tcn_timer = 0; - let mut topology_change_timer = 0; - let mut gc_timer = 0; + let mut hello_timer = Timer::default(); + let mut tcn_timer = Timer::default(); + let mut topology_change_timer = Timer::default(); + let mut gc_timer = Timer::default(); let mut group_fwd_mask_val = 0u16; let mut group_addr = String::new(); let mut mcast_router = 0; @@ -417,12 +441,14 @@ impl CliLinkInfoData { InfoBridge::TopologyChangeDetected(v) => { topology_change_detected = *v } - InfoBridge::HelloTimer(v) => hello_timer = *v, - InfoBridge::TcnTimer(v) => tcn_timer = *v, + InfoBridge::HelloTimer(v) => { + hello_timer = Timer::new(*v) + } + InfoBridge::TcnTimer(v) => tcn_timer = Timer::new(*v), InfoBridge::TopologyChangeTimer(v) => { - topology_change_timer = *v + topology_change_timer = Timer::new(*v) } - InfoBridge::GcTimer(v) => gc_timer = *v, + InfoBridge::GcTimer(v) => gc_timer = Timer::new(*v), InfoBridge::GroupFwdMask(v) => group_fwd_mask_val = *v, InfoBridge::GroupAddr(v) => { group_addr = mac_to_string(v) @@ -775,11 +801,6 @@ impl std::fmt::Display for CliLinkInfoData { isolated, locked, } => { - let format_timer = |val: u64| -> String { - let seconds = val as f64 / 100.0; - format!("{:>7.2}", seconds) - }; - let on_off = |val: bool| if val { "on" } else { "off" }; write!(f, "state {} ", state)?; @@ -801,17 +822,9 @@ impl std::fmt::Display for CliLinkInfoData { if let Some(rid) = root_id { write!(f, "designated_root {} ", rid)?; } - write!(f, "hold_timer {} ", format_timer(*hold_timer))?; - write!( - f, - "message_age_timer {} ", - format_timer(*message_age_timer) - )?; - write!( - f, - "forward_delay_timer {} ", - format_timer(*forward_delay_timer) - )?; + write!(f, "hold_timer {} ", hold_timer)?; + write!(f, "message_age_timer {} ", message_age_timer)?; + write!(f, "forward_delay_timer {} ", forward_delay_timer)?; write!(f, "topology_change_ack {} ", topology_change_ack)?; write!(f, "config_pending {} ", config_pending)?; write!(f, "proxy_arp {} ", on_off(*proxy_arp))?; @@ -921,11 +934,6 @@ impl std::fmt::Display for CliLinkInfoData { nf_call_ip6tables, nf_call_arptables, } => { - let format_timer = |val: u64| -> String { - let seconds = val as f64 / 100.0; - format!("{:>7.2}", seconds) - }; - write!(f, "forward_delay {} ", forward_delay)?; write!(f, "hello_time {} ", hello_time)?; write!(f, "max_age {} ", max_age)?; @@ -948,14 +956,10 @@ impl std::fmt::Display for CliLinkInfoData { "topology_change_detected {} ", topology_change_detected )?; - write!(f, "hello_timer {} ", format_timer(*hello_timer))?; - write!(f, "tcn_timer {} ", format_timer(*tcn_timer))?; - write!( - f, - "topology_change_timer {} ", - format_timer(*topology_change_timer) - )?; - write!(f, "gc_timer {} ", format_timer(*gc_timer))?; + write!(f, "hello_timer {} ", hello_timer)?; + write!(f, "tcn_timer {} ", tcn_timer)?; + write!(f, "topology_change_timer {} ", topology_change_timer)?; + write!(f, "gc_timer {} ", gc_timer)?; write!(f, "vlan_default_pvid {} ", vlan_default_pvid)?; if let Some(v) = vlan_stats_enabled { write!(f, "vlan_stats_enabled {} ", v)?; diff --git a/src/ip/link/tests/color.rs b/src/ip/link/tests/color.rs index 0b104e8..f0938d8 100644 --- a/src/ip/link/tests/color.rs +++ b/src/ip/link/tests/color.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -use crate::tests::{exec_cmd, get_ip_cli_path}; +use crate::tests::{exec_in_netns, get_ip_cli_path}; const COLOR_CLEAR: &str = "\x1b[0m"; @@ -8,10 +8,10 @@ const COLOR_CLEAR: &str = "\x1b[0m"; fn test_ip_link_show_color_always() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "-c=always", "link", "show"]); + let expected_output = exec_in_netns(&["ip", "-c=always", "link", "show"]); let our_output = - exec_cmd(&[cli_path.as_str(), "-c=always", "link", "show"]); + exec_in_netns(&[cli_path.as_str(), "-c=always", "link", "show"]); assert!(our_output.contains(COLOR_CLEAR)); @@ -22,9 +22,10 @@ fn test_ip_link_show_color_always() { fn test_ip_link_show_color_auto_without_terminal() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "-c=auto", "link", "show"]); + let expected_output = exec_in_netns(&["ip", "-c=auto", "link", "show"]); - let our_output = exec_cmd(&[cli_path.as_str(), "-c=auto", "link", "show"]); + let our_output = + exec_in_netns(&[cli_path.as_str(), "-c=auto", "link", "show"]); assert!(!our_output.contains(COLOR_CLEAR)); @@ -35,9 +36,10 @@ fn test_ip_link_show_color_auto_without_terminal() { fn test_ip_link_show_color_never() { let cli_path = get_ip_cli_path(); - let expected_output = exec_cmd(&["ip", "-c=never", "link", "show"]); + let expected_output = exec_in_netns(&["ip", "-c=never", "link", "show"]); - let our_output = exec_cmd(&[cli_path.as_str(), "-c=never", "link", "show"]); + let our_output = + exec_in_netns(&[cli_path.as_str(), "-c=never", "link", "show"]); assert!(!our_output.contains(COLOR_CLEAR)); diff --git a/src/ip/link/tests/link.rs b/src/ip/link/tests/link.rs index a995dc3..f3395ac 100644 --- a/src/ip/link/tests/link.rs +++ b/src/ip/link/tests/link.rs @@ -1,110 +1,6 @@ // SPDX-License-Identifier: MIT -use serde_json::Value; - -use crate::tests::{exec_cmd, get_ip_cli_path}; - -const TEST_NETNS: &str = "iproute-rs-test"; - -/// Execute a command inside the test network namespace -fn exec_in_netns(args: &[&str]) -> String { - let mut full_args = vec!["ip", "netns", "exec", TEST_NETNS]; - full_args.extend_from_slice(args); - exec_cmd(&full_args) -} - -/// Normalize timer values in output to avoid test flakiness -/// Timer values can vary slightly between consecutive calls due to kernel -/// timing -fn normalize_timers(output: &str) -> String { - let timer_names = [ - "hello_timer", - "tcn_timer", - "topology_change_timer", - "gc_timer", - "hold_timer", - "message_age_timer", - "forward_delay_timer", - ]; - - let mut result = output.to_string(); - for timer_name in timer_names { - // Find and replace timer values like "gc_timer 0.05" with "gc_timer - // 0.00" - let mut new_result = String::new(); - let mut remaining = result.as_str(); - - while let Some(pos) = remaining.find(timer_name) { - new_result.push_str(&remaining[..pos]); - new_result.push_str(timer_name); - - remaining = &remaining[pos + timer_name.len()..]; - - // Skip whitespace - let whitespace_len = - remaining.chars().take_while(|c| c.is_whitespace()).count(); - new_result.push_str(&remaining[..whitespace_len]); - remaining = &remaining[whitespace_len..]; - - // Skip the number (format: digits.digits) - let number_len = remaining - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '.') - .count(); - - // Replace with 0.00 - new_result.push_str("0.00"); - remaining = &remaining[number_len..]; - } - new_result.push_str(remaining); - result = new_result; - } - - result -} - -/// Normalize timer values in JSON output to avoid test flakiness -fn normalize_timers_json(output: &str) -> String { - let timer_names = [ - "hello_timer", - "tcn_timer", - "topology_change_timer", - "gc_timer", - "hold_timer", - "message_age_timer", - "forward_delay_timer", - ]; - - let mut result = output.to_string(); - for timer_name in timer_names { - // Find and replace JSON timer values like "\"gc_timer\":5" or - // "\"gc_timer\":0.05" with "\"gc_timer\":0" - let search_pattern = format!("\"{}\":", timer_name); - let mut new_result = String::new(); - let mut remaining = result.as_str(); - - while let Some(pos) = remaining.find(&search_pattern) { - new_result.push_str(&remaining[..pos]); - new_result.push_str(&search_pattern); - - remaining = &remaining[pos + search_pattern.len()..]; - - // Skip the number (can be integer or floating point) - let number_len = remaining - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '.') - .count(); - - // Replace with 0 - new_result.push('0'); - remaining = &remaining[number_len..]; - } - new_result.push_str(remaining); - result = new_result; - } - - result -} +use crate::tests::{assert_eq, assert_json_eq, exec_in_netns, get_ip_cli_path}; #[test] fn test_link_show() { @@ -114,7 +10,7 @@ fn test_link_show() { let our_output = exec_in_netns(&[cli_path.as_str(), "link", "show"]); - pretty_assertions::assert_eq!(expected_output, our_output); + assert_eq(expected_output, our_output); } #[test] @@ -125,10 +21,7 @@ fn test_link_detailed_show() { let our_output = exec_in_netns(&[cli_path.as_str(), "-d", "link", "show"]); - pretty_assertions::assert_eq!( - normalize_timers(&expected_output), - normalize_timers(&our_output) - ); + assert_eq(expected_output, our_output); } #[test] @@ -139,12 +32,7 @@ fn test_link_show_json() { let our_output = exec_in_netns(&[cli_path.as_str(), "-j", "link", "show"]); - let expected_json: Value = - serde_json::from_str(&expected_output).expect("To be valid json"); - let our_json: Value = - serde_json::from_str(&our_output).expect("To be valid json"); - - pretty_assertions::assert_eq!(expected_json, our_json); + assert_json_eq(expected_output, our_output); } #[test] @@ -156,8 +44,5 @@ fn test_link_detailed_show_json() { let our_output = exec_in_netns(&[cli_path.as_str(), "-d", "-j", "link", "show"]); - pretty_assertions::assert_eq!( - normalize_timers_json(&expected_output), - normalize_timers_json(&our_output) - ); + assert_json_eq(expected_output, our_output); } diff --git a/src/ip/tests/cmd.rs b/src/ip/tests/cmd.rs index 57a2031..ee23802 100644 --- a/src/ip/tests/cmd.rs +++ b/src/ip/tests/cmd.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT -pub(crate) fn exec_cmd(args: &[&str]) -> String { +const TEST_NETNS: &str = "iproute-rs-test"; + +fn exec_cmd(args: &[&str]) -> String { let output = std::process::Command::new(args[0]) .args(&args[1..]) .output() @@ -16,3 +18,10 @@ pub(crate) fn exec_cmd(args: &[&str]) -> String { String::from_utf8(output.stdout) .expect("Failed to convert file command output to String") } + +/// Execute a command inside the test network namespace +pub(crate) fn exec_in_netns(args: &[&str]) -> String { + let mut full_args = vec!["ip", "netns", "exec", TEST_NETNS]; + full_args.extend_from_slice(args); + exec_cmd(&full_args) +} diff --git a/src/ip/tests/cmp.rs b/src/ip/tests/cmp.rs new file mode 100644 index 0000000..21bad89 --- /dev/null +++ b/src/ip/tests/cmp.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT + +use serde_json::Value; + +const FLOATS_DEVIATION: f64 = 0.015; + +fn almost_eq(expected: f64, actual: f64) -> bool { + (expected - actual).abs() <= FLOATS_DEVIATION +} + +/// When comparing outputs between the two ip commands, sometimes the output +/// contains timers, which can change between the two runs. +/// This method is changing our output when 2 floats are close enough so we +/// don't get flakey results in tests. +/// When the outputs aren't equal after this change, the original output is +/// printed as well. +pub fn assert_eq(expected: impl AsRef, actual: impl AsRef) { + let expected = expected.as_ref(); + let actual = actual.as_ref(); + + let mut actual_words: Vec<_> = actual.split(' ').collect(); + + for (exp, act) in expected.split(' ').zip(actual_words.iter_mut()) { + let Ok(exp_val): Result = exp.parse() else { + continue; + }; + + let act_val: f64 = act + .parse() + .expect("Both outputs to have floats in the same locations"); + + if almost_eq(exp_val, act_val) { + *act = exp; + } + } + + let actual_corrected: String = actual_words.join(" "); + + pretty_assertions::assert_eq!( + expected, + actual_corrected, + "The original output was {actual}" + ); +} + +fn cmp_json_values(expected_json: &Value, actual_json: &Value) -> bool { + match (expected_json, actual_json) { + (Value::Number(e), Value::Number(a)) => { + let e = e + .as_f64() + .expect("All numbers in output to be representable as f64"); + let a = a + .as_f64() + .expect("All numbers in output to be representable as f64"); + + almost_eq(e, a) + } + (Value::Array(e), Value::Array(a)) => { + e.iter().zip(a).all(|(e, a)| cmp_json_values(e, a)) + } + (Value::Object(e), Value::Object(a)) => e + .iter() + .zip(a) + .all(|((ke, ve), (ka, va))| ke == ka && cmp_json_values(ve, va)), + _ => expected_json == actual_json, + } +} + +pub fn assert_json_eq(expected: impl AsRef, actual: impl AsRef) { + let expected = expected.as_ref(); + let actual = actual.as_ref(); + + let expected_json: Value = + serde_json::from_str(expected).expect("To be valid json"); + let actual_json: Value = + serde_json::from_str(actual).expect("To be valid json"); + + if !cmp_json_values(&expected_json, &actual_json) { + pretty_assertions::assert_eq!( + expected_json, + actual_json, + "Floats were compared with a range of {FLOATS_DEVIATION}" + ); + } +} diff --git a/src/ip/tests/mod.rs b/src/ip/tests/mod.rs index 2cd1253..916fdc3 100644 --- a/src/ip/tests/mod.rs +++ b/src/ip/tests/mod.rs @@ -1,9 +1,12 @@ // SPDX-License-Identifier: MIT mod cmd; +mod cmp; mod path; #[cfg(test)] -pub(crate) use self::cmd::exec_cmd; +pub(crate) use self::cmd::exec_in_netns; +#[cfg(test)] +pub(crate) use self::cmp::{assert_eq, assert_json_eq}; #[cfg(test)] pub(crate) use self::path::get_ip_cli_path;