diff --git a/README.md b/README.md index 0cc4f8a9..793d4af0 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ To follow and/or discuss the development of nmrs, you can join the [public Disco - [ ] Team - [ ] TUN/TAP - [ ] VETH -- [ ] VLAN +- [x] VLAN - [ ] VRF - [ ] VXLAN - [ ] Wi-Fi P2P diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 752521da..9a9797ad 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] - Implement loopback support ([#391](https://github.com/cachebag/nmrs/issues/391)) +- Implement add VLAN (802.1Q) device support with VlanConfig model and connection builder([#392](https://github.com/cachebag/nmrs/issues/392)) ## [3.0.1] - 2026-04-25 ### Changed diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 5035d57d..ac7e9db4 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -71,6 +71,7 @@ pub mod bluetooth; pub mod connection_builder; pub mod openvpn_builder; +pub mod vlan; pub mod vpn; pub mod wifi; pub mod wifi_builder; @@ -84,5 +85,6 @@ pub use wireguard_builder::WireGuardBuilder; // Re-export builder functions for convenience pub use bluetooth::build_bluetooth_connection; +pub use vlan::build_vlan_connection; pub use vpn::{build_openvpn_connection, build_wireguard_connection}; pub use wifi::{build_ethernet_connection, build_wifi_connection}; diff --git a/nmrs/src/api/builders/vlan.rs b/nmrs/src/api/builders/vlan.rs new file mode 100644 index 00000000..76a796b9 --- /dev/null +++ b/nmrs/src/api/builders/vlan.rs @@ -0,0 +1,306 @@ +//! VLAN (802.1Q) connection builder. +//! +//! This module provides functions to create VLAN connection settings +//! for NetworkManager. + +use std::collections::HashMap; +use zvariant::Value; + +use crate::ConnectionOptions; +use crate::api::models::{ConnectionError, VlanConfig}; + +/// Builds a VLAN connection settings dictionary for NetworkManager. +/// +/// Creates all necessary settings sections for a VLAN connection including +/// the connection metadata, VLAN-specific settings, and IP configuration. +/// +/// # Arguments +/// +/// * `config` - VLAN configuration +/// * `opts` - Connection options (autoconnect, priority, etc.) +/// +/// # Errors +/// +/// Returns `ConnectionError::InvalidVlanId` if the VLAN ID is out of range. +/// Returns `ConnectionError::InvalidInput` if the parent interface is empty. +/// +/// # Examples +/// +/// ```rust +/// use nmrs::builders::build_vlan_connection; +/// use nmrs::{VlanConfig, ConnectionOptions}; +/// +/// let config = VlanConfig::new("eth0", 100) +/// .with_connection_name("Office VLAN"); +/// let opts = ConnectionOptions::new(true); +/// +/// let settings = build_vlan_connection(&config, &opts).unwrap(); +/// ``` +pub fn build_vlan_connection( + config: &VlanConfig, + opts: &ConnectionOptions, +) -> Result>>, ConnectionError> { + config.validate()?; + + let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); + + // Connection section + conn.insert("connection", connection_section(config, opts)); + + // VLAN section + conn.insert("vlan", vlan_section(config)); + + // IPv4 section (auto by default) + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + conn.insert("ipv4", ipv4); + + // IPv6 section (auto by default) + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("auto")); + conn.insert("ipv6", ipv6); + + Ok(conn) +} + +fn connection_section( + config: &VlanConfig, + opts: &ConnectionOptions, +) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + + s.insert("type", Value::from("vlan")); + s.insert("id", Value::from(config.effective_connection_name())); + s.insert("uuid", Value::from(uuid::Uuid::new_v4().to_string())); + s.insert("autoconnect", Value::from(opts.autoconnect)); + s.insert( + "interface-name", + Value::from(config.effective_interface_name()), + ); + + if let Some(p) = opts.autoconnect_priority { + s.insert("autoconnect-priority", Value::from(p)); + } + + if let Some(r) = opts.autoconnect_retries { + s.insert("autoconnect-retries", Value::from(r)); + } + + s +} + +fn vlan_section(config: &VlanConfig) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + + s.insert("parent", Value::from(config.parent.clone())); + s.insert("id", Value::from(u32::from(config.id))); + + if let Some(flags) = config.flags { + s.insert("flags", Value::from(flags)); + } + + if let Some(ref map) = config.ingress_priority_map { + let entries: Vec> = map.iter().map(|e| Value::from(e.clone())).collect(); + s.insert("ingress-priority-map", Value::Array(entries.into())); + } + + if let Some(ref map) = config.egress_priority_map { + let entries: Vec> = map.iter().map(|e| Value::from(e.clone())).collect(); + s.insert("egress-priority-map", Value::Array(entries.into())); + } + + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_opts() -> ConnectionOptions { + ConnectionOptions { + autoconnect: true, + autoconnect_priority: Some(10), + autoconnect_retries: Some(3), + } + } + + #[test] + fn builds_basic_vlan_connection() { + let config = VlanConfig::new("eth0", 100); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + + assert!(conn.contains_key("connection")); + assert!(conn.contains_key("vlan")); + assert!(conn.contains_key("ipv4")); + assert!(conn.contains_key("ipv6")); + } + + #[test] + fn connection_section_has_correct_type() { + let config = VlanConfig::new("eth0", 100); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let connection = conn.get("connection").unwrap(); + + if let Some(Value::Str(t)) = connection.get("type") { + assert_eq!(t.as_str(), "vlan"); + } else { + panic!("type field missing or wrong type"); + } + } + + #[test] + fn vlan_section_has_parent_and_id() { + let config = VlanConfig::new("enp3s0", 200); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let vlan = conn.get("vlan").unwrap(); + + if let Some(Value::Str(parent)) = vlan.get("parent") { + assert_eq!(parent.as_str(), "enp3s0"); + } else { + panic!("parent field missing or wrong type"); + } + + if let Some(Value::U32(id)) = vlan.get("id") { + assert_eq!(*id, 200); + } else { + panic!("id field missing or wrong type"); + } + } + + #[test] + fn uses_default_interface_name() { + let config = VlanConfig::new("eth0", 100); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let connection = conn.get("connection").unwrap(); + + if let Some(Value::Str(name)) = connection.get("interface-name") { + assert_eq!(name.as_str(), "eth0.100"); + } else { + panic!("interface-name field missing or wrong type"); + } + } + + #[test] + fn uses_custom_interface_name() { + let config = VlanConfig::new("eth0", 100).with_interface_name("office-vlan"); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let connection = conn.get("connection").unwrap(); + + if let Some(Value::Str(name)) = connection.get("interface-name") { + assert_eq!(name.as_str(), "office-vlan"); + } else { + panic!("interface-name field missing or wrong type"); + } + } + + #[test] + fn uses_default_connection_name() { + let config = VlanConfig::new("eth0", 100); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let connection = conn.get("connection").unwrap(); + + if let Some(Value::Str(name)) = connection.get("id") { + assert_eq!(name.as_str(), "VLAN 100 on eth0"); + } else { + panic!("id field missing or wrong type"); + } + } + + #[test] + fn uses_custom_connection_name() { + let config = VlanConfig::new("eth0", 100).with_connection_name("Office Network"); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let connection = conn.get("connection").unwrap(); + + if let Some(Value::Str(name)) = connection.get("id") { + assert_eq!(name.as_str(), "Office Network"); + } else { + panic!("id field missing or wrong type"); + } + } + + #[test] + fn includes_vlan_flags() { + let config = VlanConfig::new("eth0", 100).with_flags(0x5); + let opts = test_opts(); + + let conn = build_vlan_connection(&config, &opts).unwrap(); + let vlan = conn.get("vlan").unwrap(); + + if let Some(Value::U32(flags)) = vlan.get("flags") { + assert_eq!(*flags, 0x5); + } else { + panic!("flags field missing or wrong type"); + } + } + + #[test] + fn rejects_invalid_vlan_id_zero() { + let config = VlanConfig::new("eth0", 0); + let opts = test_opts(); + + let result = build_vlan_connection(&config, &opts); + assert!(result.is_err()); + } + + #[test] + fn rejects_invalid_vlan_id_too_high() { + let config = VlanConfig::new("eth0", 4095); + let opts = test_opts(); + + let result = build_vlan_connection(&config, &opts); + assert!(result.is_err()); + } + + #[test] + fn rejects_empty_parent() { + let config = VlanConfig::new("", 100); + let opts = test_opts(); + + let result = build_vlan_connection(&config, &opts); + assert!(result.is_err()); + } + + #[test] + fn uuid_is_unique() { + let config = VlanConfig::new("eth0", 100); + let opts = test_opts(); + + let conn1 = build_vlan_connection(&config, &opts).unwrap(); + let conn2 = build_vlan_connection(&config, &opts).unwrap(); + + let uuid1 = conn1 + .get("connection") + .and_then(|c| c.get("uuid")) + .map(|v| match v { + Value::Str(s) => s.as_str(), + _ => "", + }) + .unwrap_or(""); + + let uuid2 = conn2 + .get("connection") + .and_then(|c| c.get("uuid")) + .map(|v| match v { + Value::Str(s) => s.as_str(), + _ => "", + }) + .unwrap_or(""); + + assert_ne!(uuid1, uuid2, "UUIDs should be unique"); + } +} diff --git a/nmrs/src/api/models/device.rs b/nmrs/src/api/models/device.rs index 3a6356eb..a9800aa8 100644 --- a/nmrs/src/api/models/device.rs +++ b/nmrs/src/api/models/device.rs @@ -144,6 +144,8 @@ pub enum DeviceType { Loopback, /// Bluetooth Bluetooth, + /// VLAN (802.1Q) virtual device. + Vlan, /// Unknown or unsupported device type with raw code. /// /// Use the methods on `DeviceType` to query capabilities of unknown device types, @@ -208,6 +210,7 @@ impl DeviceType { Self::WifiP2P => "wifi-p2p", Self::Loopback => "loopback", Self::Bluetooth => "bluetooth", + Self::Vlan => "vlan", Self::Other(code) => { crate::types::device_type_registry::connection_type_for_code(*code) .unwrap_or("generic") @@ -224,6 +227,7 @@ impl DeviceType { Self::WifiP2P => 30, Self::Loopback => 32, Self::Bluetooth => 6, + Self::Vlan => 11, Self::Other(code) => *code, } } @@ -308,6 +312,12 @@ impl Device { pub fn is_loopback(&self) -> bool { matches!(self.device_type, DeviceType::Loopback) } + + /// Returns `true` if this is a VLAN (802.1Q) device. + #[must_use] + pub fn is_vlan(&self) -> bool { + matches!(self.device_type, DeviceType::Vlan) + } } impl Display for Device { @@ -326,6 +336,7 @@ impl From for DeviceType { 1 => DeviceType::Ethernet, 2 => DeviceType::Wifi, 5 => DeviceType::Bluetooth, + 11 => DeviceType::Vlan, 30 => DeviceType::WifiP2P, 32 => DeviceType::Loopback, v => DeviceType::Other(v), @@ -361,6 +372,7 @@ impl Display for DeviceType { DeviceType::WifiP2P => write!(f, "Wi-Fi P2P"), DeviceType::Loopback => write!(f, "Loopback"), DeviceType::Bluetooth => write!(f, "Bluetooth"), + DeviceType::Vlan => write!(f, "VLAN"), DeviceType::Other(v) => write!( f, "{}", diff --git a/nmrs/src/api/models/error.rs b/nmrs/src/api/models/error.rs index 4c724f9a..1c6c0370 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -247,4 +247,20 @@ pub enum ConnectionError { /// The BlueZ Bluetooth stack is unavailable. #[error("bluetooth stack unavailable: {0}")] BluezUnavailable(String), + + /// Invalid VLAN ID (must be 1-4094). + #[error("invalid VLAN ID {id}: must be between 1 and 4094")] + InvalidVlanId { + /// The invalid VLAN ID that was provided. + id: u16, + }, + + /// Invalid input for a configuration field. + #[error("invalid {field}: {reason}")] + InvalidInput { + /// The field that was invalid. + field: String, + /// Why the input was invalid. + reason: String, + }, } diff --git a/nmrs/src/api/models/mod.rs b/nmrs/src/api/models/mod.rs index f5ca15b7..af8cc813 100644 --- a/nmrs/src/api/models/mod.rs +++ b/nmrs/src/api/models/mod.rs @@ -9,6 +9,7 @@ mod openvpn; mod radio; mod saved_connection; mod state_reason; +mod vlan; mod vpn; mod wifi; mod wireguard; @@ -28,6 +29,7 @@ pub use openvpn::*; pub use radio::*; pub use saved_connection::*; pub use state_reason::*; +pub use vlan::*; pub use vpn::*; pub use wifi::*; pub use wireguard::*; diff --git a/nmrs/src/api/models/tests.rs b/nmrs/src/api/models/tests.rs index dea38bab..0ab0d368 100644 --- a/nmrs/src/api/models/tests.rs +++ b/nmrs/src/api/models/tests.rs @@ -18,6 +18,7 @@ use crate::api::models::DeviceType; fn device_type_from_u32_all_variants() { assert_eq!(DeviceType::from(1), DeviceType::Ethernet); assert_eq!(DeviceType::from(2), DeviceType::Wifi); + assert_eq!(DeviceType::from(11), DeviceType::Vlan); assert_eq!(DeviceType::from(30), DeviceType::WifiP2P); assert_eq!(DeviceType::from(32), DeviceType::Loopback); assert_eq!(DeviceType::from(999), DeviceType::Other(999)); @@ -26,7 +27,9 @@ fn device_type_from_u32_all_variants() { #[test] fn device_type_from_u32_registry_types() { - assert_eq!(DeviceType::from(11), DeviceType::Other(11)); + // VLAN is now a first-class variant + assert_eq!(DeviceType::from(11), DeviceType::Vlan); + // These still fall through to Other since they're only in the registry assert_eq!(DeviceType::from(12), DeviceType::Other(12)); assert_eq!(DeviceType::from(13), DeviceType::Other(13)); assert_eq!(DeviceType::from(16), DeviceType::Other(16)); @@ -39,6 +42,7 @@ fn device_type_display() { assert_eq!(format!("{}", DeviceType::Wifi), "Wi-Fi"); assert_eq!(format!("{}", DeviceType::WifiP2P), "Wi-Fi P2P"); assert_eq!(format!("{}", DeviceType::Loopback), "Loopback"); + assert_eq!(format!("{}", DeviceType::Vlan), "VLAN"); assert_eq!(format!("{}", DeviceType::Other(42)), "Other(42)"); } @@ -100,6 +104,7 @@ fn device_type_connection_type_str() { assert_eq!(DeviceType::Wifi.connection_type_str(), "802-11-wireless"); assert_eq!(DeviceType::WifiP2P.connection_type_str(), "wifi-p2p"); assert_eq!(DeviceType::Loopback.connection_type_str(), "loopback"); + assert_eq!(DeviceType::Vlan.connection_type_str(), "vlan"); } #[test] @@ -114,6 +119,7 @@ fn device_type_connection_type_str_registry() { fn device_type_to_code() { assert_eq!(DeviceType::Ethernet.to_code(), 1); assert_eq!(DeviceType::Wifi.to_code(), 2); + assert_eq!(DeviceType::Vlan.to_code(), 11); assert_eq!(DeviceType::WifiP2P.to_code(), 30); assert_eq!(DeviceType::Loopback.to_code(), 32); assert_eq!(DeviceType::Other(999).to_code(), 999); diff --git a/nmrs/src/api/models/vlan.rs b/nmrs/src/api/models/vlan.rs new file mode 100644 index 00000000..752cce2e --- /dev/null +++ b/nmrs/src/api/models/vlan.rs @@ -0,0 +1,334 @@ +//! VLAN (802.1Q) connection configuration. +//! +//! This module provides types for configuring VLAN connections over a parent +//! Ethernet or other interface. + +use super::error::ConnectionError; + +/// VLAN connection configuration. +/// +/// Configures a VLAN (802.1Q) virtual interface on top of a parent device. +/// VLANs allow you to segment network traffic on a single physical interface +/// into multiple logical networks. +/// +/// # Examples +/// +/// ```rust +/// use nmrs::VlanConfig; +/// +/// // Basic VLAN on eth0 with ID 100 +/// let config = VlanConfig::new("eth0", 100); +/// +/// // VLAN with custom interface name and priority mapping +/// let config = VlanConfig::new("eth0", 200) +/// .with_interface_name("vlan200") +/// .with_mtu(1500) +/// .with_connection_name("Office VLAN"); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct VlanConfig { + /// Parent interface name (e.g., "eth0", "enp3s0"). + pub parent: String, + + /// VLAN ID (1-4094). + pub id: u16, + + /// Optional name for the VLAN interface. + /// Defaults to `{parent}.{id}` (e.g., "eth0.100"). + pub interface_name: Option, + + /// Optional human-readable connection name. + /// Defaults to "VLAN {id} on {parent}". + pub connection_name: Option, + + /// Optional MTU for the VLAN interface. + pub mtu: Option, + + /// VLAN flags (bitmask). + /// - 0x1: Reorder headers (default on) + /// - 0x2: GVRP (GARP VLAN Registration Protocol) + /// - 0x4: Loose binding (don't fail if parent is down) + /// - 0x8: MVRP (Multiple VLAN Registration Protocol) + pub flags: Option, + + /// Ingress priority mapping (802.1p to Linux priority). + /// Format: "from:to" pairs, e.g., vec!["0:0", "1:1", "2:2"] + pub ingress_priority_map: Option>, + + /// Egress priority mapping (Linux priority to 802.1p). + /// Format: "from:to" pairs, e.g., vec!["0:0", "1:1", "2:2"] + pub egress_priority_map: Option>, +} + +impl VlanConfig { + /// Creates a new VLAN configuration. + /// + /// # Arguments + /// + /// * `parent` - Parent interface name (e.g., "eth0") + /// * `id` - VLAN ID (1-4094) + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// let config = VlanConfig::new("eth0", 100); + /// assert_eq!(config.parent, "eth0"); + /// assert_eq!(config.id, 100); + /// ``` + #[must_use] + pub fn new(parent: impl Into, id: u16) -> Self { + Self { + parent: parent.into(), + id, + interface_name: None, + connection_name: None, + mtu: None, + flags: None, + ingress_priority_map: None, + egress_priority_map: None, + } + } + + /// Sets a custom interface name for the VLAN device. + /// + /// By default, the interface is named `{parent}.{id}` (e.g., "eth0.100"). + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// let config = VlanConfig::new("eth0", 100) + /// .with_interface_name("office-vlan"); + /// ``` + #[must_use] + pub fn with_interface_name(mut self, name: impl Into) -> Self { + self.interface_name = Some(name.into()); + self + } + + /// Sets a human-readable connection name. + /// + /// This is the name shown in NetworkManager's connection list. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// let config = VlanConfig::new("eth0", 100) + /// .with_connection_name("Office Network"); + /// ``` + #[must_use] + pub fn with_connection_name(mut self, name: impl Into) -> Self { + self.connection_name = Some(name.into()); + self + } + + /// Sets the MTU (Maximum Transmission Unit) for the VLAN interface. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// let config = VlanConfig::new("eth0", 100) + /// .with_mtu(1496); // Account for VLAN header + /// ``` + #[must_use] + pub fn with_mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets VLAN flags. + /// + /// Flags: + /// - `0x1`: Reorder headers (default, recommended) + /// - `0x2`: GVRP (GARP VLAN Registration Protocol) + /// - `0x4`: Loose binding (don't fail if parent is down) + /// - `0x8`: MVRP (Multiple VLAN Registration Protocol) + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// // Enable loose binding (allow VLAN even if parent is down) + /// let config = VlanConfig::new("eth0", 100) + /// .with_flags(0x1 | 0x4); + /// ``` + #[must_use] + pub fn with_flags(mut self, flags: u32) -> Self { + self.flags = Some(flags); + self + } + + /// Sets ingress priority mapping (802.1p priority to Linux skb priority). + /// + /// Each entry maps an incoming 802.1p priority (0-7) to a Linux priority. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// let config = VlanConfig::new("eth0", 100) + /// .with_ingress_priority_map(vec!["0:0", "1:1", "2:2"]); + /// ``` + #[must_use] + pub fn with_ingress_priority_map(mut self, map: Vec>) -> Self { + self.ingress_priority_map = Some(map.into_iter().map(Into::into).collect()); + self + } + + /// Sets egress priority mapping (Linux skb priority to 802.1p priority). + /// + /// Each entry maps an outgoing Linux priority to an 802.1p priority (0-7). + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VlanConfig; + /// + /// let config = VlanConfig::new("eth0", 100) + /// .with_egress_priority_map(vec!["0:0", "1:1", "2:2"]); + /// ``` + #[must_use] + pub fn with_egress_priority_map(mut self, map: Vec>) -> Self { + self.egress_priority_map = Some(map.into_iter().map(Into::into).collect()); + self + } + + /// Returns the effective interface name. + /// + /// Returns the custom interface name if set, otherwise `{parent}.{id}`. + #[must_use] + pub fn effective_interface_name(&self) -> String { + self.interface_name + .clone() + .unwrap_or_else(|| format!("{}.{}", self.parent, self.id)) + } + + /// Returns the effective connection name. + /// + /// Returns the custom connection name if set, otherwise "VLAN {id} on {parent}". + #[must_use] + pub fn effective_connection_name(&self) -> String { + self.connection_name + .clone() + .unwrap_or_else(|| format!("VLAN {} on {}", self.id, self.parent)) + } + + /// Validates the VLAN configuration. + /// + /// # Errors + /// + /// Returns `ConnectionError::InvalidVlanId` if the VLAN ID is out of range (1-4094). + /// Returns `ConnectionError::InvalidInput` if the parent interface name is empty. + pub fn validate(&self) -> Result<(), ConnectionError> { + if self.id == 0 || self.id > 4094 { + return Err(ConnectionError::InvalidVlanId { id: self.id }); + } + if self.parent.is_empty() { + return Err(ConnectionError::InvalidInput { + field: "parent".to_string(), + reason: "parent interface name cannot be empty".to_string(), + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_creates_basic_config() { + let config = VlanConfig::new("eth0", 100); + assert_eq!(config.parent, "eth0"); + assert_eq!(config.id, 100); + assert!(config.interface_name.is_none()); + assert!(config.connection_name.is_none()); + } + + #[test] + fn effective_interface_name_default() { + let config = VlanConfig::new("eth0", 100); + assert_eq!(config.effective_interface_name(), "eth0.100"); + } + + #[test] + fn effective_interface_name_custom() { + let config = VlanConfig::new("eth0", 100).with_interface_name("office-vlan"); + assert_eq!(config.effective_interface_name(), "office-vlan"); + } + + #[test] + fn effective_connection_name_default() { + let config = VlanConfig::new("eth0", 100); + assert_eq!(config.effective_connection_name(), "VLAN 100 on eth0"); + } + + #[test] + fn effective_connection_name_custom() { + let config = VlanConfig::new("eth0", 100).with_connection_name("Office Network"); + assert_eq!(config.effective_connection_name(), "Office Network"); + } + + #[test] + fn builder_methods_chain() { + let config = VlanConfig::new("enp3s0", 200) + .with_interface_name("vlan200") + .with_connection_name("Server VLAN") + .with_mtu(1496) + .with_flags(0x5) + .with_ingress_priority_map(vec!["0:0", "1:1"]) + .with_egress_priority_map(vec!["0:0", "1:1"]); + + assert_eq!(config.parent, "enp3s0"); + assert_eq!(config.id, 200); + assert_eq!(config.interface_name, Some("vlan200".to_string())); + assert_eq!(config.connection_name, Some("Server VLAN".to_string())); + assert_eq!(config.mtu, Some(1496)); + assert_eq!(config.flags, Some(0x5)); + assert_eq!( + config.ingress_priority_map, + Some(vec!["0:0".to_string(), "1:1".to_string()]) + ); + } + + #[test] + fn validate_rejects_zero_id() { + let config = VlanConfig::new("eth0", 0); + assert!(config.validate().is_err()); + } + + #[test] + fn validate_rejects_id_over_4094() { + let config = VlanConfig::new("eth0", 4095); + assert!(config.validate().is_err()); + } + + #[test] + fn validate_rejects_empty_parent() { + let config = VlanConfig::new("", 100); + assert!(config.validate().is_err()); + } + + #[test] + fn validate_accepts_valid_config() { + let config = VlanConfig::new("eth0", 100); + assert!(config.validate().is_ok()); + + let config = VlanConfig::new("eth0", 1); + assert!(config.validate().is_ok()); + + let config = VlanConfig::new("eth0", 4094); + assert!(config.validate().is_ok()); + } +} diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index b2df98f0..861cc6f4 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -354,10 +354,10 @@ pub use api::models::{ EapMethod, EapOptions, Network, NetworkInfo, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, OpenVpnConnectionType, OpenVpnProxy, Phase2, RadioState, SavedConnection, SavedConnectionBrief, SecurityFeatures, SettingsPatch, SettingsSummary, StateReason, - TimeoutConfig, VpnConfig, VpnConfiguration, VpnConnection, VpnConnectionInfo, VpnCredentials, - VpnDetails, VpnKind, VpnRoute, VpnSecretFlags, VpnType, WifiDevice, WifiKeyMgmt, WifiSecurity, - WifiSecuritySummary, WireGuardConfig, WireGuardPeer, connection_state_reason_to_error, - reason_to_error, + TimeoutConfig, VlanConfig, VpnConfig, VpnConfiguration, VpnConnection, VpnConnectionInfo, + VpnCredentials, VpnDetails, VpnKind, VpnRoute, VpnSecretFlags, VpnType, WifiDevice, + WifiKeyMgmt, WifiSecurity, WifiSecuritySummary, WireGuardConfig, WireGuardPeer, + connection_state_reason_to_error, reason_to_error, }; pub use api::network_manager::NetworkManager; pub use api::wifi_scope::WifiScope;