From c9e4a36920960d106513e109fa34a2b5e4aaf7e3 Mon Sep 17 00:00:00 2001 From: Gris Ge Date: Mon, 12 Oct 2026 14:47:57 +0800 Subject: [PATCH 1/4] examples: one query example per subsystem and list them in README Rename the `dump_*` examples to `query_*` and add `query_pause_conf` and `set_pause_conf` which show the parsed configuration API. The README follows the nl-wireguard structure and links to one example per subsystem instead of inlining the code. Signed-off-by: Gris Ge --- Cargo.toml | 2 +- examples/{dump_channels.rs => query_channels.rs} | 0 examples/{dump_coalesce.rs => query_coalesce.rs} | 0 examples/{dump_eeprom_page.rs => query_eeprom.rs} | 0 examples/{dump_features.rs => query_features.rs} | 0 examples/{dump_fec.rs => query_fec.rs} | 0 examples/{dump_link_mode.rs => query_link_mode.rs} | 0 examples/{dump_pause.rs => query_pause_conf.rs} | 0 examples/{dump_rings.rs => query_rings.rs} | 0 examples/{dump_tsinfo.rs => query_tsinfo.rs} | 0 examples/{set_rx_count.rs => set_channel_count.rs} | 10 +++++----- 11 files changed, 6 insertions(+), 6 deletions(-) rename examples/{dump_channels.rs => query_channels.rs} (100%) rename examples/{dump_coalesce.rs => query_coalesce.rs} (100%) rename examples/{dump_eeprom_page.rs => query_eeprom.rs} (100%) rename examples/{dump_features.rs => query_features.rs} (100%) rename examples/{dump_fec.rs => query_fec.rs} (100%) rename examples/{dump_link_mode.rs => query_link_mode.rs} (100%) rename examples/{dump_pause.rs => query_pause_conf.rs} (100%) rename examples/{dump_rings.rs => query_rings.rs} (100%) rename examples/{dump_tsinfo.rs => query_tsinfo.rs} (100%) rename examples/{set_rx_count.rs => set_channel_count.rs} (76%) diff --git a/Cargo.toml b/Cargo.toml index 25da188..17fb9e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,5 +37,5 @@ tokio = { version = "1.44.0", features = ["macros", "rt", "rt-multi-thread"] } env_logger = "0.9.3" [[example]] -name = "dump_pause" +name = "query_pause_conf" required-features = ["tokio_socket"] diff --git a/examples/dump_channels.rs b/examples/query_channels.rs similarity index 100% rename from examples/dump_channels.rs rename to examples/query_channels.rs diff --git a/examples/dump_coalesce.rs b/examples/query_coalesce.rs similarity index 100% rename from examples/dump_coalesce.rs rename to examples/query_coalesce.rs diff --git a/examples/dump_eeprom_page.rs b/examples/query_eeprom.rs similarity index 100% rename from examples/dump_eeprom_page.rs rename to examples/query_eeprom.rs diff --git a/examples/dump_features.rs b/examples/query_features.rs similarity index 100% rename from examples/dump_features.rs rename to examples/query_features.rs diff --git a/examples/dump_fec.rs b/examples/query_fec.rs similarity index 100% rename from examples/dump_fec.rs rename to examples/query_fec.rs diff --git a/examples/dump_link_mode.rs b/examples/query_link_mode.rs similarity index 100% rename from examples/dump_link_mode.rs rename to examples/query_link_mode.rs diff --git a/examples/dump_pause.rs b/examples/query_pause_conf.rs similarity index 100% rename from examples/dump_pause.rs rename to examples/query_pause_conf.rs diff --git a/examples/dump_rings.rs b/examples/query_rings.rs similarity index 100% rename from examples/dump_rings.rs rename to examples/query_rings.rs diff --git a/examples/dump_tsinfo.rs b/examples/query_tsinfo.rs similarity index 100% rename from examples/dump_tsinfo.rs rename to examples/query_tsinfo.rs diff --git a/examples/set_rx_count.rs b/examples/set_channel_count.rs similarity index 76% rename from examples/set_rx_count.rs rename to examples/set_channel_count.rs index b7ec1d6..9859a5a 100644 --- a/examples/set_rx_count.rs +++ b/examples/set_channel_count.rs @@ -15,10 +15,10 @@ fn main() { .enable_io() .build() .unwrap(); - rt.block_on(set_rx_count(link_name)); + rt.block_on(set_channel_count(link_name)); } -async fn set_rx_count(iface_name: &str) { +async fn set_channel_count(iface_name: &str) { let (connection, mut handle, _) = ethtool::new_connection().unwrap(); tokio::spawn(connection); @@ -32,15 +32,15 @@ async fn set_rx_count(iface_name: &str) { fn usage() { eprintln!( "usage: - cargo run --example set_rx_count -- + cargo run --example set_channel_count -- Note that you need to run this program as root. Instead of running cargo as root, build the example normally: - cd ethtool ; cargo build --example set_rx_count + cd ethtool ; cargo build --example set_channel_count Then find the binary in the target directory: - cd target/debug/example ; sudo ./set_rx_count " + cd target/debug/examples ; sudo ./set_channel_count " ); } From b4d67d44ffc33b1e829a56fbd2e7a25375de60ce Mon Sep 17 00:00:00 2001 From: Gris Ge Date: Mon, 12 Oct 2026 14:48:00 +0800 Subject: [PATCH 2/4] tests: add per-subsystem integration tests One query test per subsystem and a set test for the subsystems with apply support (pause and channels). The loopback interface is used when it supports the subsystem, the netdevsim tests skip when no netdevsim device is available, and `EOPNOTSUPP`/`EPERM` make a test skip so `cargo test` passes without `CAP_NET_ADMIN`. Signed-off-by: Gris Ge --- tests/channel.rs | 74 ++++++++++++++++++++++++++++++++++++++++ tests/coalesce.rs | 48 ++++++++++++++++++++++++++ tests/common/mod.rs | 61 +++++++++++++++++++++++++++++++++ tests/dump_link_modes.rs | 33 ------------------ tests/eeprom.rs | 38 +++++++++++++++++++++ tests/feature.rs | 38 +++++++++++++++++++++ tests/fec.rs | 46 +++++++++++++++++++++++++ tests/get_features_lo.rs | 39 --------------------- tests/link_mode.rs | 34 ++++++++++++++++++ tests/ring.rs | 46 +++++++++++++++++++++++++ tests/tsinfo.rs | 39 +++++++++++++++++++++ 11 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 tests/channel.rs create mode 100644 tests/coalesce.rs create mode 100644 tests/common/mod.rs delete mode 100644 tests/dump_link_modes.rs create mode 100644 tests/eeprom.rs create mode 100644 tests/feature.rs create mode 100644 tests/fec.rs delete mode 100644 tests/get_features_lo.rs create mode 100644 tests/link_mode.rs create mode 100644 tests/ring.rs create mode 100644 tests/tsinfo.rs diff --git a/tests/channel.rs b/tests/channel.rs new file mode 100644 index 0000000..7dedd43 --- /dev/null +++ b/tests/channel.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +//! Query and set tests for the channel subsystem. +//! +//! The loopback interface does not support channels, netdevsim is required. +//! See [common::netdevsim_iface] for how to instantiate one, the tests skip +//! when netdevsim is not available. + +mod common; + +use ethtool::{EthtoolAttr, EthtoolChannelAttr, EthtoolCmd, EthtoolHeader}; +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_channels_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!( + "skipped query_channels_netdevsim: netdevsim is not available" + ); + return; + }; + + let mut handle = common::connect().await; + let mut stream = handle + .channel() + .get(Some(&iface_name)) + .execute() + .await + .expect("failed to request the channels"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a channel reply")); + } + + assert_eq!(msgs.len(), 1); + let ethtool_msg = &msgs[0].payload; + assert_eq!(ethtool_msg.cmd, EthtoolCmd::ChannelGetReply); + match ðtool_msg.nlas[0] { + EthtoolAttr::Channel(EthtoolChannelAttr::Header(headers)) => { + assert!( + headers.contains(&EthtoolHeader::DevName(iface_name.clone())), + "unexpected channel header {headers:?}" + ); + } + nla => panic!("unexpected first channel attribute {nla:?}"), + } +} + +#[tokio::test] +async fn set_channel_count_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!( + "skipped set_channel_count_netdevsim: netdevsim is not available" + ); + return; + }; + + let mut handle = common::connect().await; + let result = handle + .channel() + .set(&iface_name) + .combined_count(1) + .execute() + .await; + + if let Err(err) = result { + if common::is_skippable(&err) { + eprintln!("skipped set_channel_count_netdevsim: {err}"); + return; + } + panic!("failed to set the channel count: {err}"); + } +} diff --git a/tests/coalesce.rs b/tests/coalesce.rs new file mode 100644 index 0000000..836c70c --- /dev/null +++ b/tests/coalesce.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the coalesce subsystem. +//! +//! The loopback interface does not support coalesce, netdevsim is required. +//! See [common::netdevsim_iface] for how to instantiate one, the test skips +//! when netdevsim is not available. + +mod common; + +use ethtool::{EthtoolAttr, EthtoolCmd, EthtoolCoalesceAttr, EthtoolHeader}; +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_coalesce_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!( + "skipped query_coalesce_netdevsim: netdevsim is not available" + ); + return; + }; + + let mut handle = common::connect().await; + let mut stream = handle + .coalesce() + .get(Some(&iface_name)) + .execute() + .await + .expect("failed to request the coalesce parameters"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a coalesce reply")); + } + + assert_eq!(msgs.len(), 1); + let ethtool_msg = &msgs[0].payload; + assert_eq!(ethtool_msg.cmd, EthtoolCmd::CoalesceGetReply); + match ðtool_msg.nlas[0] { + EthtoolAttr::Coalesce(EthtoolCoalesceAttr::Header(headers)) => { + assert!( + headers.contains(&EthtoolHeader::DevName(iface_name.clone())), + "unexpected coalesce header {headers:?}" + ); + } + nla => panic!("unexpected first coalesce attribute {nla:?}"), + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..bf55158 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +//! Helpers shared by the integration tests. + +#![allow(dead_code)] + +use ethtool::{EthtoolError, EthtoolHandle}; + +/// `EPERM`, the kernel returns it when the test process misses +/// `CAP_NET_ADMIN`. +const EPERM: i32 = 1; + +/// `EOPNOTSUPP`, the kernel returns it when the interface does not support +/// the command. +const EOPNOTSUPP: i32 = 95; + +/// Whether `err` makes a test skip instead of fail. +/// +/// A test skips when the interface does not support the command +/// (`EOPNOTSUPP`) or when the process is not privileged enough +/// (`EPERM`), so that `cargo test` passes without `CAP_NET_ADMIN`. +pub fn is_skippable(err: &EthtoolError) -> bool { + let EthtoolError::NetlinkError(nl_err) = err else { + return false; + }; + matches!( + nl_err.code.map(|code| code.get()), + Some(code) if code == -EOPNOTSUPP || code == -EPERM + ) +} + +/// Name of the first netdevsim interface, or `None` when netdevsim is not +/// available, in which case the caller skips the test. +/// +/// Instantiate a device to run the netdevsim tests: +/// +/// ```bash +/// sudo modprobe netdevsim +/// echo "1 1" | sudo tee /sys/bus/netdevsim/new_device +/// ``` +pub fn netdevsim_iface() -> Option { + for device in std::fs::read_dir("/sys/bus/netdevsim/devices") + .ok()? + .flatten() + { + if let Ok(mut ifaces) = std::fs::read_dir(device.path().join("net")) { + if let Some(Ok(iface)) = ifaces.next() { + return Some(iface.file_name().to_string_lossy().into_owned()); + } + } + } + None +} + +/// Open a new ethtool netlink connection and spawn its processing loop. +pub async fn connect() -> EthtoolHandle { + let (connection, handle, _) = + ethtool::new_connection().expect("failed to open netlink socket"); + tokio::spawn(connection); + handle +} diff --git a/tests/dump_link_modes.rs b/tests/dump_link_modes.rs deleted file mode 100644 index 5d7260e..0000000 --- a/tests/dump_link_modes.rs +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT - -use futures_util::stream::StreamExt; - -#[test] -// CI container normally have a veth for external communication which support -// link modes of ethtool. -fn test_dump_link_modes() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_io() - .build() - .unwrap(); - rt.block_on(dump_link_modes()); -} - -async fn dump_link_modes() { - let (connection, mut handle, _) = ethtool::new_connection().unwrap(); - tokio::spawn(connection); - - let mut link_modes_handle = - handle.link_mode().get(None).execute().await.unwrap(); - - let mut msgs = Vec::new(); - while let Some(Ok(msg)) = link_modes_handle.next().await { - msgs.push(msg); - } - assert!(!msgs.is_empty()); - let ethtool_msg = &msgs[0].payload; - println!("ethtool_msg {ethtool_msg:?}"); - - assert!(ethtool_msg.cmd == ethtool::EthtoolCmd::LinkModeGetReply); - assert!(ethtool_msg.nlas.len() > 1); -} diff --git a/tests/eeprom.rs b/tests/eeprom.rs new file mode 100644 index 0000000..eea5254 --- /dev/null +++ b/tests/eeprom.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the module EEPROM subsystem. +//! +//! Module EEPROM requires SFP hardware which none of the virtual NICs +//! provide, therefore the test skips unless the queried interface has one. + +mod common; + +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_eeprom_lo() { + let mut handle = common::connect().await; + + let mut stream = handle + .eeprom() + .get(Some("lo"), 0, 1, 0, 0, 0x50) + .execute() + .await + .expect("failed to request the module EEPROM page"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + match reply { + Ok(msg) => msgs.push(msg), + Err(err) if common::is_skippable(&err) => { + eprintln!("skipped query_eeprom_lo: no module EEPROM: {err}"); + return; + } + Err(err) => { + panic!("failed to parse the module EEPROM reply: {err:?}") + } + } + } + + assert_eq!(msgs.len(), 1); +} diff --git a/tests/feature.rs b/tests/feature.rs new file mode 100644 index 0000000..1590d7c --- /dev/null +++ b/tests/feature.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the feature subsystem. + +mod common; + +use ethtool::{EthtoolAttr, EthtoolCmd, EthtoolFeatureAttr, EthtoolHeader}; +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_features_lo() { + let mut handle = common::connect().await; + + let mut stream = handle + .feature() + .get(Some("lo")) + .execute() + .await + .expect("failed to request the features of `lo`"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a feature reply")); + } + + assert_eq!(msgs.len(), 1); + let ethtool_msg = &msgs[0].payload; + + assert_eq!(ethtool_msg.cmd, EthtoolCmd::FeatureGetReply); + assert!(ethtool_msg.nlas.len() > 1); + assert_eq!( + ethtool_msg.nlas[0], + EthtoolAttr::Feature(EthtoolFeatureAttr::Header(vec![ + EthtoolHeader::DevIndex(1), + EthtoolHeader::DevName("lo".into()) + ])) + ); +} diff --git a/tests/fec.rs b/tests/fec.rs new file mode 100644 index 0000000..4b5122e --- /dev/null +++ b/tests/fec.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the FEC subsystem. +//! +//! The loopback interface does not support FEC, netdevsim is required. See +//! [common::netdevsim_iface] for how to instantiate one, the test skips +//! when netdevsim is not available. + +mod common; + +use ethtool::{EthtoolAttr, EthtoolCmd, EthtoolFecAttr, EthtoolHeader}; +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_fec_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!("skipped query_fec_netdevsim: netdevsim is not available"); + return; + }; + + let mut handle = common::connect().await; + let mut stream = handle + .fec() + .get(Some(&iface_name)) + .execute() + .await + .expect("failed to request the FEC parameters"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a FEC reply")); + } + + assert_eq!(msgs.len(), 1); + let ethtool_msg = &msgs[0].payload; + assert_eq!(ethtool_msg.cmd, EthtoolCmd::FecGetReply); + match ðtool_msg.nlas[0] { + EthtoolAttr::Fec(EthtoolFecAttr::Header(headers)) => { + assert!( + headers.contains(&EthtoolHeader::DevName(iface_name.clone())), + "unexpected FEC header {headers:?}" + ); + } + nla => panic!("unexpected first FEC attribute {nla:?}"), + } +} diff --git a/tests/get_features_lo.rs b/tests/get_features_lo.rs deleted file mode 100644 index 08dd2e2..0000000 --- a/tests/get_features_lo.rs +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MIT - -use futures_util::stream::StreamExt; - -#[test] -fn test_get_features_of_loopback() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_io() - .build() - .unwrap(); - rt.block_on(get_feature(Some("lo"))); -} - -async fn get_feature(iface_name: Option<&str>) { - let (connection, mut handle, _) = ethtool::new_connection().unwrap(); - tokio::spawn(connection); - - let mut feature_handle = - handle.feature().get(iface_name).execute().await.unwrap(); - - let mut msgs = Vec::new(); - while let Some(Ok(msg)) = feature_handle.next().await { - msgs.push(msg); - } - assert!(msgs.len() == 1); - let ethtool_msg = &msgs[0].payload; - - assert!(ethtool_msg.cmd == ethtool::EthtoolCmd::FeatureGetReply); - assert!(ethtool_msg.nlas.len() > 1); - assert!( - ethtool_msg.nlas[0] - == ethtool::EthtoolAttr::Feature( - ethtool::EthtoolFeatureAttr::Header(vec![ - ethtool::EthtoolHeader::DevIndex(1), - ethtool::EthtoolHeader::DevName("lo".into()) - ]) - ) - ); -} diff --git a/tests/link_mode.rs b/tests/link_mode.rs new file mode 100644 index 0000000..587fa10 --- /dev/null +++ b/tests/link_mode.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the link mode subsystem. + +mod common; + +use ethtool::EthtoolCmd; +use futures_util::stream::StreamExt; + +#[tokio::test] +// CI container normally have a veth for external communication which support +// link modes of ethtool. +async fn query_link_modes_dump() { + let mut handle = common::connect().await; + + let mut stream = handle + .link_mode() + .get(None) + .execute() + .await + .expect("failed to request the link modes"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a link mode reply")); + } + + assert!(!msgs.is_empty()); + let ethtool_msg = &msgs[0].payload; + println!("ethtool_msg {ethtool_msg:?}"); + + assert_eq!(ethtool_msg.cmd, EthtoolCmd::LinkModeGetReply); + assert!(ethtool_msg.nlas.len() > 1); +} diff --git a/tests/ring.rs b/tests/ring.rs new file mode 100644 index 0000000..8c6a287 --- /dev/null +++ b/tests/ring.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the ring subsystem. +//! +//! The loopback interface does not support rings, netdevsim is required. +//! See [common::netdevsim_iface] for how to instantiate one, the test skips +//! when netdevsim is not available. + +mod common; + +use ethtool::{EthtoolAttr, EthtoolCmd, EthtoolHeader, EthtoolRingAttr}; +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_rings_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!("skipped query_rings_netdevsim: netdevsim is not available"); + return; + }; + + let mut handle = common::connect().await; + let mut stream = handle + .ring() + .get(Some(&iface_name)) + .execute() + .await + .expect("failed to request the ring parameters"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a ring reply")); + } + + assert_eq!(msgs.len(), 1); + let ethtool_msg = &msgs[0].payload; + assert_eq!(ethtool_msg.cmd, EthtoolCmd::RingGetReply); + match ðtool_msg.nlas[0] { + EthtoolAttr::Ring(EthtoolRingAttr::Header(headers)) => { + assert!( + headers.contains(&EthtoolHeader::DevName(iface_name.clone())), + "unexpected ring header {headers:?}" + ); + } + nla => panic!("unexpected first ring attribute {nla:?}"), + } +} diff --git a/tests/tsinfo.rs b/tests/tsinfo.rs new file mode 100644 index 0000000..f6c20ed --- /dev/null +++ b/tests/tsinfo.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +//! Query test for the timestamping subsystem. + +mod common; + +use ethtool::{EthtoolAttr, EthtoolCmd, EthtoolHeader, EthtoolTsInfoAttr}; +use futures_util::stream::StreamExt; + +#[tokio::test] +async fn query_tsinfo_lo() { + let mut handle = common::connect().await; + + // The loopback interface provides software timestamping. + let mut stream = handle + .tsinfo() + .get(Some("lo")) + .execute() + .await + .expect("failed to request the timestamping information of `lo`"); + + let mut msgs = Vec::new(); + while let Some(reply) = stream.next().await { + msgs.push(reply.expect("failed to parse a timestamping reply")); + } + + assert_eq!(msgs.len(), 1); + let ethtool_msg = &msgs[0].payload; + assert_eq!(ethtool_msg.cmd, EthtoolCmd::TsInfoGetReply); + match ðtool_msg.nlas[0] { + EthtoolAttr::TsInfo(EthtoolTsInfoAttr::Header(headers)) => { + assert!( + headers.contains(&EthtoolHeader::DevName("lo".to_string())), + "unexpected timestamping header {headers:?}" + ); + } + nla => panic!("unexpected first timestamping attribute {nla:?}"), + } +} From e8cbcabe3b753dc7b6184a30512a0bfc3a6487e8 Mon Sep 17 00:00:00 2001 From: Gris Ge Date: Mon, 12 Oct 2026 14:49:05 +0800 Subject: [PATCH 3/4] cargo: use sudo to run integration tests Signed-off-by: Gris Ge --- .cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5608611 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-gnu] +runner = 'sudo' From 010b4ebeda5a2dc4a8e5197f5d096c00314c1350 Mon Sep 17 00:00:00 2001 From: Gris Ge Date: Mon, 12 Oct 2026 14:47:49 +0800 Subject: [PATCH 4/4] pause: add parsed configuration and set support By mimicing nl-wireguard design, introducing pause support: - `handle.get_pause(iface_name: &str)`: query pause config of specified interface - `handle.dump_pause()`: dump all pause config of all interfaces. - `handle.apply_pause()`: set pause config. Example code included. Signed-off-by: Gris Ge --- Cargo.toml | 6 +- README.md | 50 +++- TODO.md | 22 +- examples/apply_pause_conf.rs | 52 ++++ ...{query_pause_conf.rs => get_pause_conf.rs} | 24 +- src/error.rs | 3 + src/header.rs | 2 +- src/lib.rs | 7 +- src/message.rs | 5 + src/pause/attr.rs | 30 +- src/pause/get.rs | 20 +- src/pause/handle.rs | 83 +++++- src/pause/mod.rs | 4 + src/pause/parsed.rs | 260 ++++++++++++++++++ src/pause/set.rs | 43 +++ src/tests/pause.rs | 78 +++++- tests/pause.rs | 84 ++++++ 17 files changed, 739 insertions(+), 34 deletions(-) create mode 100644 examples/apply_pause_conf.rs rename examples/{query_pause_conf.rs => get_pause_conf.rs} (50%) create mode 100644 src/pause/parsed.rs create mode 100644 src/pause/set.rs create mode 100644 tests/pause.rs diff --git a/Cargo.toml b/Cargo.toml index 17fb9e6..d1d2503 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,5 +37,9 @@ tokio = { version = "1.44.0", features = ["macros", "rt", "rt-multi-thread"] } env_logger = "0.9.3" [[example]] -name = "query_pause_conf" +name = "get_pause_conf" +required-features = ["tokio_socket"] + +[[example]] +name = "apply_pause_conf" required-features = ["tokio_socket"] diff --git a/README.md b/README.md index 3a7627e..14dabbe 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# Rust crate for linux ethtool +# Rust crate for linux ethtool netlink management + +This crate provides methods to query and configure the Linux network +interface settings via the ethtool generic netlink protocol. + +The examples are built with the crate and need root (`CAP_NET_ADMIN`) to run: + +```bash +cargo build --examples +sudo ./target/debug/examples/get_pause_conf +``` + +## Query + +- [get or dump pause configuration](examples/get_pause_conf.rs) +- [query features](examples/query_features.rs) +- [query link modes](examples/query_link_mode.rs) +- [query rings](examples/query_rings.rs) +- [query coalesce](examples/query_coalesce.rs) +- [query timestamping information](examples/query_tsinfo.rs) +- [query FEC](examples/query_fec.rs) +- [query channels](examples/query_channels.rs) +- [query module EEPROM](examples/query_eeprom.rs) + +## Set + +- [apply pause configuration](examples/apply_pause_conf.rs) +- [set channel count](examples/set_channel_count.rs) + +## Notes + +- `EthtoolHandle::get_pause()` takes a mandatory interface name and returns + a single `EthtoolPauseParsed`, `EthtoolHandle::dump_pause()` takes no + interface name and returns a `Vec`. +- `EthtoolHandle::apply_pause()` applies an `EthtoolPauseParsed`, usually + one returned by `get_pause()` or `dump_pause()` after modifying it. The + sub-handle request builders (`handle.pause().get(...)`, + `handle.pause().set(...)`) stay available as well. +- `EthtoolPauseParsed` is `#[non_exhaustive]`, so it can not be built with + a struct literal outside of this crate. Use `Default::default()` and + assign the properties instead. +- A parsed configuration holds only the properties the kernel reported, a + field holding `None` is not sent in a set request and the kernel keeps + its current value. +- The statistics properties of `EthtoolPauseParsed` are read-only, the + kernel rejects them in a set request, therefore they are not sent. +- `apply_pause_conf` shows how a queried configuration is modified and + applied back, `set_channel_count` uses the builder of the channel set + request. diff --git a/TODO.md b/TODO.md index 0575d90..da571c1 100644 --- a/TODO.md +++ b/TODO.md @@ -26,6 +26,8 @@ virtio-net, veth and netdevsim do not report tx types/rx filters. - Non-empty `EthtoolLinkModeAttr::Ours`/`Peer` bitsets are not covered, none of the available virtual NICs reports supported link modes. +- `EthtoolPauseStatAttr::TxPauseStormEvents` parse path is not covered, + netdevsim does not report pause storm events. ## API review @@ -44,8 +46,6 @@ `ETHTOOL_A_RINGS_TX_PUSH_BUF_LEN_MAX` (16), `ETHTOOL_A_RINGS_HDS_THRESH` (17), `ETHTOOL_A_RINGS_HDS_THRESH_MAX` (18). -- `pause`: `ETHTOOL_A_PAUSE_STATS_SRC` (6) and - `ETHTOOL_A_PAUSE_STAT_TX_PAUSE_STORM_EVENTS` (4). - `tsinfo`: `ETHTOOL_A_TSINFO_HWTSTAMP_PROVIDER` (7), `ETHTOOL_A_TSINFO_HWTSTAMP_SOURCE` (8), `ETHTOOL_A_TSINFO_HWTSTAMP_PHYINDEX` (9). @@ -53,6 +53,24 @@ `OFFSET`/`LENGTH`/`PAGE`/`BANK`/`I2C_ADDRESS` fall through to `DefaultNla` (they are only used in requests today). +## apply support + +`pause` is the pilot for the parsed/apply design: + +- A get request executes into parsed types (`EthtoolPauseParsed`): the flat + `EthtoolHandle::get_pause()` takes a mandatory interface name and returns + one parsed configuration, `EthtoolHandle::dump_pause()` returns all of + them. `EthtoolHandle::apply_pause()` applies a parsed type back with a + set request. The old sub-handle request builders stay available. +- `build()` of the parsed type emits only fields holding `Some` (the + kernel keeps the value of the others) and validates the input, failing + with `EthtoolError::InvalidInput`. +- Roll out to the commands with kernel `*_SET` support not covered yet: + `link_mode` (`ADVERTISING`), `feature` (`WANTED`), `ring`, `coalesce`, + `fec` (`MODES`), `tsinfo`, EEE. +- `EthtoolError` could hold `{kind, msg, netlink_msg}` like + `WireguardError` instead of the current enum. + ## missing support - `EthtoolMessage` parses a subset of ethtool commands only: `STATS_GET`, diff --git a/examples/apply_pause_conf.rs b/examples/apply_pause_conf.rs new file mode 100644 index 0000000..27951f5 --- /dev/null +++ b/examples/apply_pause_conf.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +use std::env; + +// Once we find a way to load netsimdev kernel module in CI, we can convert this +// to a test +fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + usage(); + return; + } + let link_name = &args[1]; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .unwrap(); + rt.block_on(apply_pause_conf(link_name)); +} + +async fn apply_pause_conf(iface_name: &str) { + let (connection, mut handle, _) = ethtool::new_connection().unwrap(); + tokio::spawn(connection); + + // Fetch the current configuration, change what is wanted and apply it + // back. Fields left as None are not touched by the kernel. + let mut config = handle.get_pause(iface_name).await.unwrap(); + config.rx = Some(true); + config.tx = Some(true); + + let result = handle.apply_pause(config).await; + + if let Err(error) = result { + panic!("{:?}", error); + } +} + +fn usage() { + eprintln!( + "usage: + cargo run --example apply_pause_conf -- + +Note that you need to run this program as root. Instead of running cargo as root, +build the example normally: + + cd ethtool ; cargo build --example apply_pause_conf + +Then find the binary in the target directory: + + cd target/debug/examples ; sudo ./apply_pause_conf " + ); +} diff --git a/examples/query_pause_conf.rs b/examples/get_pause_conf.rs similarity index 50% rename from examples/query_pause_conf.rs rename to examples/get_pause_conf.rs index 7834e61..b6b2834 100644 --- a/examples/query_pause_conf.rs +++ b/examples/get_pause_conf.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -use futures_util::stream::StreamExt; +use std::env; // Once we find a way to load netsimdev kernel module in CI, we can convert this // to a test @@ -10,20 +10,22 @@ fn main() { .enable_io() .build() .unwrap(); - rt.block_on(get_pause(None)); + rt.block_on(run()); } -async fn get_pause(iface_name: Option<&str>) { +async fn run() { let (connection, mut handle, _) = ethtool::new_connection().unwrap(); tokio::spawn(connection); - let mut pause_handle = - handle.pause().get(iface_name).execute().await.unwrap(); - - let mut msgs = Vec::new(); - while let Some(Ok(msg)) = pause_handle.next().await { - msgs.push(msg); + match env::args().nth(1) { + Some(iface_name) => { + let config = handle.get_pause(&iface_name).await.unwrap(); + println!("{config:?}"); + } + None => { + for config in handle.dump_pause().await.unwrap() { + println!("{config:?}"); + } + } } - assert!(!msgs.is_empty()); - println!("{msgs:?}"); } diff --git a/src/error.rs b/src/error.rs index c0d3193..48e650f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,6 +18,9 @@ pub enum EthtoolError { #[error("A netlink request failed")] RequestFailed(String), + #[error("Invalid input: {0}")] + InvalidInput(String), + #[error("A bug in this crate")] Bug(String), } diff --git a/src/header.rs b/src/header.rs index 93944a6..9d1e41f 100644 --- a/src/header.rs +++ b/src/header.rs @@ -7,7 +7,7 @@ use netlink_packet_core::{ Nla, NlaBuffer, Parseable, }; -const ALTIFNAMSIZ: usize = 128; +pub(crate) const ALTIFNAMSIZ: usize = 128; const ETHTOOL_A_HEADER_DEV_INDEX: u16 = 1; const ETHTOOL_A_HEADER_DEV_NAME: u16 = 2; const ETHTOOL_A_HEADER_FLAGS: u16 = 3; diff --git a/src/lib.rs b/src/lib.rs index 8cf2fd2..e01aed0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,7 +54,7 @@ pub use link_mode::{ pub use message::{EthtoolAttr, EthtoolCmd, EthtoolMessage}; pub use pause::{ EthtoolPauseAttr, EthtoolPauseGetRequest, EthtoolPauseHandle, - EthtoolPauseStatAttr, + EthtoolPauseParsed, EthtoolPauseSetRequest, EthtoolPauseStatAttr, }; pub use ring::{EthtoolRingAttr, EthtoolRingGetRequest, EthtoolRingHandle}; pub use tsinfo::{ @@ -62,3 +62,8 @@ pub use tsinfo::{ }; pub(crate) use handle::ethtool_execute; + +// Compile the examples of the README as doc tests. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/src/message.rs b/src/message.rs index 6274cc7..98db611 100644 --- a/src/message.rs +++ b/src/message.rs @@ -17,6 +17,9 @@ use crate::{ }; const ETHTOOL_MSG_PAUSE_GET: u8 = 21; +/// The kernel uses the same numeric value for the `GET_REPLY` (kernel +/// namespace) and the `SET` (user namespace) command. +const ETHTOOL_MSG_PAUSE_SET: u8 = 22; const ETHTOOL_MSG_PAUSE_GET_REPLY: u8 = 22; const ETHTOOL_MSG_FEATURES_GET: u8 = 11; const ETHTOOL_MSG_FEATURES_GET_REPLY: u8 = 11; @@ -40,6 +43,7 @@ const ETHTOOL_MSG_MODULE_EEPROM_GET_REPLY: u8 = 32; pub enum EthtoolCmd { PauseGet, PauseGetReply, + PauseSet, FeatureGet, FeatureGetReply, LinkModeGet, @@ -64,6 +68,7 @@ impl From for u8 { match cmd { EthtoolCmd::PauseGet => ETHTOOL_MSG_PAUSE_GET, EthtoolCmd::PauseGetReply => ETHTOOL_MSG_PAUSE_GET_REPLY, + EthtoolCmd::PauseSet => ETHTOOL_MSG_PAUSE_SET, EthtoolCmd::FeatureGet => ETHTOOL_MSG_FEATURES_GET, EthtoolCmd::FeatureGetReply => ETHTOOL_MSG_FEATURES_GET_REPLY, EthtoolCmd::LinkModeGet => ETHTOOL_MSG_LINKMODES_GET, diff --git a/src/pause/attr.rs b/src/pause/attr.rs index 516505c..8a21377 100644 --- a/src/pause/attr.rs +++ b/src/pause/attr.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT use netlink_packet_core::{ - emit_u64, parse_u64, parse_u8, DecodeError, DefaultNla, Emitable, - ErrorContext, Nla, NlaBuffer, NlasIterator, Parseable, NLA_F_NESTED, + emit_u32, emit_u64, parse_u32, parse_u64, parse_u8, DecodeError, + DefaultNla, Emitable, ErrorContext, Nla, NlaBuffer, NlasIterator, + Parseable, NLA_F_NESTED, }; use crate::{EthtoolAttr, EthtoolHeader}; @@ -12,21 +13,24 @@ const ETHTOOL_A_PAUSE_AUTONEG: u16 = 2; const ETHTOOL_A_PAUSE_RX: u16 = 3; const ETHTOOL_A_PAUSE_TX: u16 = 4; const ETHTOOL_A_PAUSE_STATS: u16 = 5; +const ETHTOOL_A_PAUSE_STATS_SRC: u16 = 6; const ETHTOOL_A_PAUSE_STAT_TX_FRAMES: u16 = 2; const ETHTOOL_A_PAUSE_STAT_RX_FRAMES: u16 = 3; +const ETHTOOL_A_PAUSE_STAT_TX_PAUSE_STORM_EVENTS: u16 = 4; #[derive(Debug, PartialEq, Eq, Clone)] pub enum EthtoolPauseStatAttr { Rx(u64), Tx(u64), + TxPauseStormEvents(u64), Other(DefaultNla), } impl Nla for EthtoolPauseStatAttr { fn value_len(&self) -> usize { match self { - Self::Rx(_) | Self::Tx(_) => 8, + Self::Rx(_) | Self::Tx(_) | Self::TxPauseStormEvents(_) => 8, Self::Other(attr) => attr.value_len(), } } @@ -35,13 +39,18 @@ impl Nla for EthtoolPauseStatAttr { match self { Self::Rx(_) => ETHTOOL_A_PAUSE_STAT_RX_FRAMES, Self::Tx(_) => ETHTOOL_A_PAUSE_STAT_TX_FRAMES, + Self::TxPauseStormEvents(_) => { + ETHTOOL_A_PAUSE_STAT_TX_PAUSE_STORM_EVENTS + } Self::Other(attr) => attr.kind(), } } fn emit_value(&self, buffer: &mut [u8]) { match self { - Self::Rx(value) | Self::Tx(value) => { + Self::Rx(value) + | Self::Tx(value) + | Self::TxPauseStormEvents(value) => { emit_u64(buffer, *value).unwrap() } Self::Other(ref attr) => attr.emit_value(buffer), @@ -63,6 +72,11 @@ impl<'a, T: AsRef<[u8]> + ?Sized> Parseable> parse_u64(payload) .context("invalid ETHTOOL_A_PAUSE_STAT_RX_FRAMES value")?, ), + ETHTOOL_A_PAUSE_STAT_TX_PAUSE_STORM_EVENTS => { + Self::TxPauseStormEvents(parse_u64(payload).context( + "invalid ETHTOOL_A_PAUSE_STAT_TX_PAUSE_STORM_EVENTS value", + )?) + } _ => Self::Other( DefaultNla::parse(buf).context("invalid NLA (unknown kind)")?, ), @@ -77,6 +91,7 @@ pub enum EthtoolPauseAttr { Rx(bool), Tx(bool), Stats(Vec), + StatsSrc(u32), Other(DefaultNla), } @@ -86,6 +101,7 @@ impl Nla for EthtoolPauseAttr { Self::Header(hdrs) => hdrs.as_slice().buffer_len(), Self::AutoNeg(_) | Self::Rx(_) | Self::Tx(_) => 1, Self::Stats(ref nlas) => nlas.as_slice().buffer_len(), + Self::StatsSrc(_) => 4, Self::Other(attr) => attr.value_len(), } } @@ -97,6 +113,7 @@ impl Nla for EthtoolPauseAttr { Self::Rx(_) => ETHTOOL_A_PAUSE_RX, Self::Tx(_) => ETHTOOL_A_PAUSE_TX, Self::Stats(_) => ETHTOOL_A_PAUSE_STATS | NLA_F_NESTED, + Self::StatsSrc(_) => ETHTOOL_A_PAUSE_STATS_SRC, Self::Other(attr) => attr.kind(), } } @@ -108,6 +125,7 @@ impl Nla for EthtoolPauseAttr { buffer[0] = *value as u8 } Self::Stats(ref nlas) => nlas.as_slice().emit(buffer), + Self::StatsSrc(value) => emit_u32(buffer, *value).unwrap(), Self::Other(ref attr) => attr.emit_value(buffer), } } @@ -156,6 +174,10 @@ impl<'a, T: AsRef<[u8]> + ?Sized> Parseable> } Self::Stats(nlas) } + ETHTOOL_A_PAUSE_STATS_SRC => Self::StatsSrc( + parse_u32(payload) + .context("invalid ETHTOOL_A_PAUSE_STATS_SRC value")?, + ), _ => Self::Other( DefaultNla::parse(buf).context("invalid NLA (unknown kind)")?, ), diff --git a/src/pause/get.rs b/src/pause/get.rs index 66e9b47..44dfc1a 100644 --- a/src/pause/get.rs +++ b/src/pause/get.rs @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT -use futures_util::Stream; -use netlink_packet_generic::GenlMessage; +use futures_util::{Stream, StreamExt}; -use crate::{ethtool_execute, EthtoolError, EthtoolHandle, EthtoolMessage}; +use crate::{ + ethtool_execute, EthtoolError, EthtoolHandle, EthtoolMessage, + EthtoolPauseParsed, +}; pub struct EthtoolPauseGetRequest { handle: EthtoolHandle, @@ -18,10 +20,12 @@ impl EthtoolPauseGetRequest { } } + /// Retrieve the pause configuration of one interface, or of all + /// interfaces when no interface name was given. pub async fn execute( self, ) -> Result< - impl Stream, EthtoolError>>, + impl Stream>, EthtoolError, > { let EthtoolPauseGetRequest { @@ -30,6 +34,12 @@ impl EthtoolPauseGetRequest { } = self; let ethtool_msg = EthtoolMessage::new_pause_get(iface_name.as_deref()); - ethtool_execute(&mut handle, iface_name.is_none(), ethtool_msg).await + let response = + ethtool_execute(&mut handle, iface_name.is_none(), ethtool_msg) + .await?; + + Ok(response.map(|msg| { + msg.map(|genl_msg| EthtoolPauseParsed::from(genl_msg.payload)) + })) } } diff --git a/src/pause/handle.rs b/src/pause/handle.rs index 6db72f7..ec07365 100644 --- a/src/pause/handle.rs +++ b/src/pause/handle.rs @@ -1,6 +1,76 @@ // SPDX-License-Identifier: MIT -use crate::{EthtoolHandle, EthtoolPauseGetRequest}; +use futures_util::StreamExt; + +use crate::{ + EthtoolError, EthtoolHandle, EthtoolPauseGetRequest, EthtoolPauseParsed, + EthtoolPauseSetRequest, +}; + +impl EthtoolHandle { + /// Retrieve the pause configuration of `iface_name` (equivalent to + /// `ethtool -a eth1`). + /// + /// The `iface_name` is mandatory, use [EthtoolHandle::dump_pause] to + /// query all interfaces. + pub async fn get_pause( + &mut self, + iface_name: &str, + ) -> Result { + let mut stream = + EthtoolPauseGetRequest::new(self.clone(), Some(iface_name)) + .execute() + .await?; + + let mut configs = Vec::new(); + while let Some(config) = stream.next().await { + configs.push(config?); + } + + match configs.len() { + 1 => Ok(configs.remove(0)), + 0 => Err(EthtoolError::Bug(format!( + "Got no pause reply from kernel for interface `{iface_name}`" + ))), + count => Err(EthtoolError::Bug(format!( + "Got {count} pause replies from kernel for interface \ + `{iface_name}`" + ))), + } + } + + /// Retrieve the pause configuration of all interfaces (equivalent to + /// `ethtool -a`). + pub async fn dump_pause( + &mut self, + ) -> Result, EthtoolError> { + let mut stream = EthtoolPauseGetRequest::new(self.clone(), None) + .execute() + .await?; + + let mut configs = Vec::new(); + while let Some(config) = stream.next().await { + configs.push(config?); + } + + Ok(configs) + } + + /// Apply a pause configuration (equivalent to `ethtool -A eth1 ...`). + /// + /// Only fields holding `Some` in `config` are sent, the kernel keeps the + /// current value of the other pause properties. The interface is taken + /// from [EthtoolPauseParsed::iface_name] or, when it is not set, from + /// [EthtoolPauseParsed::iface_index]. + pub async fn apply_pause( + &mut self, + config: EthtoolPauseParsed, + ) -> Result<(), EthtoolError> { + EthtoolPauseSetRequest::new(self.clone(), config) + .execute() + .await + } +} pub struct EthtoolPauseHandle(EthtoolHandle); @@ -14,4 +84,15 @@ impl EthtoolPauseHandle { pub fn get(&mut self, iface_name: Option<&str>) -> EthtoolPauseGetRequest { EthtoolPauseGetRequest::new(self.0.clone(), iface_name) } + + /// Apply a pause configuration (equivalent to `ethtool -A eth1 ...`). + /// + /// Only fields holding `Some` in `config` are sent, the kernel keeps + /// the current value of the other pause properties. + pub fn set( + &mut self, + config: EthtoolPauseParsed, + ) -> EthtoolPauseSetRequest { + EthtoolPauseSetRequest::new(self.0.clone(), config) + } } diff --git a/src/pause/mod.rs b/src/pause/mod.rs index 486cd95..c34232a 100644 --- a/src/pause/mod.rs +++ b/src/pause/mod.rs @@ -3,8 +3,12 @@ mod attr; mod get; mod handle; +mod parsed; +mod set; pub(crate) use attr::parse_pause_nlas; pub use attr::{EthtoolPauseAttr, EthtoolPauseStatAttr}; pub use get::EthtoolPauseGetRequest; pub use handle::EthtoolPauseHandle; +pub use parsed::EthtoolPauseParsed; +pub use set::EthtoolPauseSetRequest; diff --git a/src/pause/parsed.rs b/src/pause/parsed.rs new file mode 100644 index 0000000..24aa940 --- /dev/null +++ b/src/pause/parsed.rs @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT + +use super::attr::{EthtoolPauseAttr, EthtoolPauseStatAttr}; +use crate::{ + header::ALTIFNAMSIZ, EthtoolAttr, EthtoolCmd, EthtoolError, EthtoolHeader, + EthtoolMessage, +}; + +/// Pause configuration of one interface, as `ethtool -a` reports it. +/// +/// A field holding `None` is not emitted by [EthtoolPauseParsed::build], so +/// a configuration parsed from a reply can be modified and applied without +/// changing what was not touched. +/// +/// The statistics fields ([EthtoolPauseParsed::rx_frames], +/// [EthtoolPauseParsed::tx_frames], +/// [EthtoolPauseParsed::tx_pause_storm_events] and +/// [EthtoolPauseParsed::stats_src]) are read-only, the kernel rejects them +/// in a set request, therefore [EthtoolPauseParsed::build] ignores them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct EthtoolPauseParsed { + pub iface_name: Option, + pub iface_index: Option, + pub autoneg: Option, + pub rx: Option, + pub tx: Option, + /// Number of received pause frames. + pub rx_frames: Option, + /// Number of transmitted pause frames. + pub tx_frames: Option, + /// Read-only, ignored by [EthtoolPauseParsed::build]. + pub tx_pause_storm_events: Option, + /// Source of the statistics, `ETHTOOL_MAC_STATS_SRC_*`. + pub stats_src: Option, +} + +impl From for EthtoolPauseParsed { + fn from(msg: EthtoolMessage) -> Self { + let mut parsed = Self::default(); + for nla in msg.nlas { + match nla { + EthtoolAttr::Pause(pause_attr) => match pause_attr { + EthtoolPauseAttr::Header(headers) => { + for header in headers { + match header { + EthtoolHeader::DevIndex(index) => { + parsed.iface_index = Some(index) + } + EthtoolHeader::DevName(name) => { + parsed.iface_name = Some(name) + } + _ => { + log::debug!( + "Unsupported pause header {header:?}" + ); + } + } + } + } + EthtoolPauseAttr::AutoNeg(value) => { + parsed.autoneg = Some(value) + } + EthtoolPauseAttr::Rx(value) => parsed.rx = Some(value), + EthtoolPauseAttr::Tx(value) => parsed.tx = Some(value), + EthtoolPauseAttr::Stats(stats) => { + for stat in stats { + match stat { + EthtoolPauseStatAttr::Rx(value) => { + parsed.rx_frames = Some(value) + } + EthtoolPauseStatAttr::Tx(value) => { + parsed.tx_frames = Some(value) + } + EthtoolPauseStatAttr::TxPauseStormEvents( + value, + ) => parsed.tx_pause_storm_events = Some(value), + EthtoolPauseStatAttr::Other(nla) => { + log::debug!( + "Unsupported pause stat attribute \ + {nla:?}" + ); + } + } + } + } + EthtoolPauseAttr::StatsSrc(value) => { + parsed.stats_src = Some(value) + } + EthtoolPauseAttr::Other(nla) => { + log::debug!("Unsupported EthtoolPauseAttr {nla:?}"); + } + }, + nla => log::debug!("Unsupported EthtoolAttr {nla:?}"), + } + } + parsed + } +} + +impl EthtoolPauseParsed { + /// Build the `PAUSE_SET` [EthtoolMessage]. + /// + /// Only fields holding `Some` are emitted, the kernel keeps the current + /// value of the other pause properties. The read-only statistics fields + /// and the statistics source are ignored. + /// + /// This fails with [EthtoolError::InvalidInput] when neither + /// [EthtoolPauseParsed::iface_name] nor + /// [EthtoolPauseParsed::iface_index] is set or when the interface name + /// is invalid. + pub fn build(&self) -> Result { + let mut header = Vec::new(); + // The kernel resolves the interface from either the name or the + // index, sending both makes no sense. The name wins, like + // `ethtool -A` does. + if let Some(iface_name) = self.iface_name.as_ref() { + if iface_name.len() >= ALTIFNAMSIZ { + return Err(EthtoolError::InvalidInput(format!( + "`iface_name` can not be longer than {} bytes, but \ + `{iface_name}` holds {} bytes", + ALTIFNAMSIZ - 1, + iface_name.len() + ))); + } + if iface_name.contains('\0') { + return Err(EthtoolError::InvalidInput( + "`iface_name` can not hold a NUL byte, the kernel would \ + use the name in front of it" + .to_string(), + )); + } + header.push(EthtoolHeader::DevName(iface_name.clone())); + } else if let Some(iface_index) = self.iface_index { + header.push(EthtoolHeader::DevIndex(iface_index)); + } else { + return Err(EthtoolError::InvalidInput( + "Neither `iface_name` nor `iface_index` is defined".to_string(), + )); + } + + let mut nlas = + vec![EthtoolAttr::Pause(EthtoolPauseAttr::Header(header))]; + if let Some(autoneg) = self.autoneg { + nlas.push(EthtoolAttr::Pause(EthtoolPauseAttr::AutoNeg(autoneg))); + } + if let Some(rx) = self.rx { + nlas.push(EthtoolAttr::Pause(EthtoolPauseAttr::Rx(rx))); + } + if let Some(tx) = self.tx { + nlas.push(EthtoolAttr::Pause(EthtoolPauseAttr::Tx(tx))); + } + + Ok(EthtoolMessage { + cmd: EthtoolCmd::PauseSet, + nlas, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_uses_iface_name_when_both_are_defined() { + // A parsed reply holds both, the kernel only needs one. + let parsed = EthtoolPauseParsed { + iface_name: Some("eth1".to_string()), + iface_index: Some(3), + ..Default::default() + }; + + let msg = parsed.build().unwrap(); + + assert_eq!( + msg.nlas, + vec![EthtoolAttr::Pause(EthtoolPauseAttr::Header(vec![ + EthtoolHeader::DevName("eth1".to_string()) + ]))] + ); + } + + #[test] + fn build_accepts_iface_index_only() { + let parsed = EthtoolPauseParsed { + iface_index: Some(3), + ..Default::default() + }; + + let msg = parsed.build().unwrap(); + + assert_eq!( + msg.nlas, + vec![EthtoolAttr::Pause(EthtoolPauseAttr::Header(vec![ + EthtoolHeader::DevIndex(3) + ]))] + ); + } + + #[test] + fn build_requires_an_interface() { + let err = EthtoolPauseParsed::default().build().unwrap_err(); + + assert!(matches!(err, EthtoolError::InvalidInput(_))); + } + + #[test] + fn iface_name_is_limited_to_altifnamsiz() { + let longest = EthtoolPauseParsed { + iface_name: Some("a".repeat(ALTIFNAMSIZ - 1)), + ..Default::default() + }; + assert!(longest.build().is_ok()); + + let too_long = EthtoolPauseParsed { + iface_name: Some("a".repeat(ALTIFNAMSIZ)), + ..Default::default() + }; + assert!(matches!( + too_long.build().unwrap_err(), + EthtoolError::InvalidInput(_) + )); + + // The kernel would use the name in front of an embedded NUL. + let with_nul = EthtoolPauseParsed { + iface_name: Some("eth0\0eth1".to_string()), + ..Default::default() + }; + assert!(matches!( + with_nul.build().unwrap_err(), + EthtoolError::InvalidInput(_) + )); + } + + #[test] + fn build_ignores_read_only_fields() { + let parsed = EthtoolPauseParsed { + iface_name: Some("eth1".to_string()), + autoneg: Some(true), + rx_frames: Some(1), + tx_frames: Some(2), + tx_pause_storm_events: Some(3), + stats_src: Some(1), + ..Default::default() + }; + + let msg = parsed.build().unwrap(); + + assert_eq!( + msg.nlas, + vec![ + EthtoolAttr::Pause(EthtoolPauseAttr::Header(vec![ + EthtoolHeader::DevName("eth1".to_string()) + ])), + EthtoolAttr::Pause(EthtoolPauseAttr::AutoNeg(true)), + ] + ); + } +} diff --git a/src/pause/set.rs b/src/pause/set.rs new file mode 100644 index 0000000..03422b8 --- /dev/null +++ b/src/pause/set.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT + +use futures_util::StreamExt; +use netlink_packet_core::{NetlinkMessage, NLM_F_ACK, NLM_F_REQUEST}; +use netlink_packet_generic::GenlMessage; + +use crate::{try_ethtool, EthtoolError, EthtoolHandle, EthtoolPauseParsed}; + +/// Apply a [EthtoolPauseParsed] configuration (equivalent to `ethtool -A +/// eth1 ...`). +/// +/// Only fields holding `Some` are sent, the kernel keeps the current value +/// of the other pause properties. +pub struct EthtoolPauseSetRequest { + handle: EthtoolHandle, + config: EthtoolPauseParsed, +} + +impl EthtoolPauseSetRequest { + pub(crate) fn new( + handle: EthtoolHandle, + config: EthtoolPauseParsed, + ) -> Self { + EthtoolPauseSetRequest { handle, config } + } + + pub async fn execute(self) -> Result<(), EthtoolError> { + let EthtoolPauseSetRequest { mut handle, config } = self; + + let mut nl_msg = + NetlinkMessage::from(GenlMessage::from_payload(config.build()?)); + + nl_msg.header.flags = NLM_F_REQUEST | NLM_F_ACK; + + let mut response = handle.request(nl_msg).await?; + + while let Some(message) = response.next().await { + try_ethtool!(message); + } + + Ok(()) + } +} diff --git a/src/tests/pause.rs b/src/tests/pause.rs index f1ec9dc..2817ebc 100644 --- a/src/tests/pause.rs +++ b/src/tests/pause.rs @@ -12,9 +12,10 @@ use super::parse_and_round_trip; use crate::{ EthtoolAttr, EthtoolCmd, EthtoolHeader, EthtoolMessage, EthtoolPauseAttr, - EthtoolPauseStatAttr, + EthtoolPauseParsed, EthtoolPauseStatAttr, }; -use netlink_packet_core::DefaultNla; +use netlink_packet_core::Emitable; +use netlink_packet_generic::GenlHeader; #[test] fn test_parsing_pause_netdevsim() { @@ -44,6 +45,20 @@ fn test_parsing_pause_netdevsim() { ], } ); + + let parsed = EthtoolPauseParsed::from(msg); + + assert_eq!( + parsed, + EthtoolPauseParsed { + iface_name: Some("sim0".to_string()), + iface_index: Some(163), + autoneg: Some(false), + rx: Some(true), + tx: Some(true), + ..Default::default() + } + ); } #[test] @@ -74,11 +89,7 @@ fn test_parsing_pause_stats_netdevsim() { EthtoolAttr::Pause(EthtoolPauseAttr::AutoNeg(false)), EthtoolAttr::Pause(EthtoolPauseAttr::Rx(true)), EthtoolAttr::Pause(EthtoolPauseAttr::Tx(true)), - // ETHTOOL_A_PAUSE_STATS_SRC (6) - EthtoolAttr::Pause(EthtoolPauseAttr::Other(DefaultNla::new( - 6, - vec![0, 0, 0, 0] - ))), + EthtoolAttr::Pause(EthtoolPauseAttr::StatsSrc(0)), EthtoolAttr::Pause(EthtoolPauseAttr::Stats(vec![ EthtoolPauseStatAttr::Tx(2), EthtoolPauseStatAttr::Rx(1), @@ -86,4 +97,57 @@ fn test_parsing_pause_stats_netdevsim() { ], } ); + + let parsed = EthtoolPauseParsed::from(msg); + + assert_eq!( + parsed, + EthtoolPauseParsed { + iface_name: Some("sim0".to_string()), + iface_index: Some(163), + autoneg: Some(false), + rx: Some(true), + tx: Some(true), + rx_frames: Some(1), + tx_frames: Some(2), + stats_src: Some(0), + ..Default::default() + } + ); +} + +#[test] +fn test_building_pause_set_netdevsim() { + // Kernel reply to `ethtool -a sim0` with the configuration below + // applied, captured with an nlmon device on Linux 7.3. + let raw_reply: Vec = vec![ + 0x16, 0x01, 0x00, 0x00, 0x18, 0x00, 0x01, 0x80, 0x08, 0x00, 0x01, 0x00, + 0xa3, 0x00, 0x00, 0x00, 0x09, 0x00, 0x02, 0x00, 0x73, 0x69, 0x6d, 0x30, + 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x05, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 0x00, 0x04, 0x00, + 0x01, 0x00, 0x00, 0x00, + ]; + + let parsed = EthtoolPauseParsed::from(parse_and_round_trip(&raw_reply)); + + // Request of `ethtool -A sim0 autoneg off rx on tx on`, captured with an + // nlmon device on Linux 7.3. The kernel resolves the interface from the + // name, the device index of the reply is not part of the request. + let raw_request: Vec = vec![ + 0x16, 0x01, 0x00, 0x00, 0x10, 0x00, 0x01, 0x80, 0x09, 0x00, 0x02, 0x00, + 0x73, 0x69, 0x6d, 0x30, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x05, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, + ]; + + let msg = parsed.build().unwrap(); + let header = GenlHeader { + cmd: EthtoolCmd::PauseSet.into(), + version: 1, + }; + let mut buffer = vec![0; header.buffer_len() + msg.buffer_len()]; + header.emit(&mut buffer); + msg.emit(&mut buffer[header.buffer_len()..]); + + assert_eq!(buffer, raw_request); } diff --git a/tests/pause.rs b/tests/pause.rs new file mode 100644 index 0000000..8f362f1 --- /dev/null +++ b/tests/pause.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +//! Query and set tests for the pause subsystem. +//! +//! The loopback interface does not support pause, netdevsim is required. +//! See [common::netdevsim_iface] for how to instantiate one, the tests skip +//! when netdevsim is not available. + +mod common; + +use ethtool::EthtoolPauseParsed; + +async fn get_pause(iface_name: &str) -> EthtoolPauseParsed { + let mut handle = common::connect().await; + + handle + .get_pause(iface_name) + .await + .expect("failed to request the pause configuration") +} + +#[tokio::test] +async fn get_pause_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!("skipped get_pause_netdevsim: netdevsim is not available"); + return; + }; + + let config = get_pause(&iface_name).await; + + assert_eq!(config.iface_name.as_deref(), Some(iface_name.as_str())); + assert!(config.autoneg.is_some()); +} + +#[tokio::test] +async fn dump_pause_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!("skipped dump_pause_netdevsim: netdevsim is not available"); + return; + }; + + let mut handle = common::connect().await; + let configs = handle + .dump_pause() + .await + .expect("failed to dump the pause configurations"); + + let config = configs + .iter() + .find(|config| { + config.iface_name.as_deref() == Some(iface_name.as_str()) + }) + .expect("the netdevsim interface is missing from the dump"); + assert!(config.autoneg.is_some()); +} + +#[tokio::test] +async fn set_pause_netdevsim() { + let Some(iface_name) = common::netdevsim_iface() else { + eprintln!("skipped set_pause_netdevsim: netdevsim is not available"); + return; + }; + + let mut config = EthtoolPauseParsed::default(); + config.iface_name = Some(iface_name.clone()); + config.autoneg = Some(false); + config.rx = Some(true); + config.tx = Some(true); + + let mut handle = common::connect().await; + if let Err(err) = handle.apply_pause(config).await { + if common::is_skippable(&err) { + eprintln!("skipped set_pause_netdevsim: {err}"); + return; + } + panic!("failed to apply the pause configuration: {err}"); + } + + let config = get_pause(&iface_name).await; + + assert_eq!(config.autoneg, Some(false)); + assert_eq!(config.rx, Some(true)); + assert_eq!(config.tx, Some(true)); +}