diff --git a/Cargo.toml b/Cargo.toml index 1ed5008..7291b49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "sqm-autorate-rust" version = "0.4.1" edition = "2024" +rust-version = "1.88" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -40,7 +41,7 @@ netlink-bindings = { version = "0.3.3", features = ["rt-link", "tc"] } netlink-socket2 = { version = "0.3.3" } [target.'cfg(unix)'.dependencies] -rust-uci = { git = "https://github.com/Lochnair/rust-uci.git", rev = "fcfedd204fb76928ef617bf9631b041c816e03d4", optional = true, default-features = false, } +rust-uci = { git = "https://github.com/Lochnair/rust-uci.git", rev = "fcfedd204fb76928ef617bf9631b041c816e03d4", optional = true, default-features = false } [features] default = ["toml", "uci-vendored"] diff --git a/README.md b/README.md index 725d217..8d00d8c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ You can install **sqm‑autorate‑rust** in two ways: ### 1. Native Rust build -1. Ensure you have the Rust toolchain (1.70+), Cargo, and `libnl` headers installed on your Linux host. +1. Ensure you have Rust 1.88 or newer, Cargo, and a C toolchain for libuci on your Linux host. 2. Clone and build: ```bash diff --git a/crates/uci-derive/Cargo.toml b/crates/uci-derive/Cargo.toml index ca7ed88..1889a39 100644 --- a/crates/uci-derive/Cargo.toml +++ b/crates/uci-derive/Cargo.toml @@ -2,6 +2,7 @@ name = "uci-derive" version = "0.1.0" edition = "2024" +rust-version = "1.88" publish = false [lib] diff --git a/src/platform/unix/uci.rs b/src/platform/unix/uci.rs index cd48c44..ce14332 100644 --- a/src/platform/unix/uci.rs +++ b/src/platform/unix/uci.rs @@ -114,30 +114,12 @@ impl UciSource { } } - fn mapped_config_key( - &self, - selector: &SectionSelector<'_>, - uci_option: &str, - ) -> Option { - let section_type = match selector { - SectionSelector::Anonymous { - section_type, - index, - } if *index == 0 => *section_type, - - SectionSelector::Anonymous { .. } | SectionSelector::Named(_) => return None, - }; - - self.schema + fn mapped_config_key(section: &UciSectionMapping, uci_option: &str) -> Option { + section + .options .iter() - .filter(|section| section.uci_section == section_type) - .find_map(|section| { - section - .options - .iter() - .find(|option| option.uci_option == uci_option) - .map(|option| format!("{}.{}", section.config_section, option.config_field,)) - }) + .find(|option| option.uci_option == uci_option) + .map(|option| format!("{}.{}", section.config_section, option.config_field)) } fn collect_package( @@ -146,14 +128,35 @@ impl UciSource { package: &UciPackage, values: &mut Map, ) -> Result<(), SourceError> { - let sections = package.sections().map_err(|e| { - SourceError::Message(format!( - "failed to retrieve sections from UCI package \ + let sections: Vec<_> = package + .sections() + .map_err(|e| { + SourceError::Message(format!( + "failed to retrieve sections from UCI package \ {package_name:?}: {e}" - )) - })?; + )) + })? + .collect(); + + for mapping in self.schema { + if sections + .iter() + .filter(|section| section.type_() == mapping.uci_section) + .count() + > 1 + { + return Err(SourceError::Message(format!( + "UCI package {package_name:?} declares multiple `{}` sections; exactly one is expected", + mapping.uci_section + ))); + } + } for section in sections { + let mapping = self + .schema + .iter() + .find(|entry| entry.uci_section == section.type_()); let selector = section.selector().ok_or_else(|| { SourceError::Message(format!( "loaded UCI section of type {:?} in package \ @@ -181,9 +184,14 @@ impl UciSource { let source_key = format!("{package_name}.{config_relative_key}"); - let destination_key = self - .mapped_config_key(&selector, option_name) - .unwrap_or(source_key); + let destination_key = mapping + .and_then(|entry| Self::mapped_config_key(entry, option_name)) + .unwrap_or_else(|| { + if mapping.is_some() { + eprintln!("warning: {uci_key}: unknown option; it will be ignored"); + } + source_key + }); let Some(value) = option.get().map_err(|e| { SourceError::Message(format!( @@ -262,7 +270,7 @@ impl Source for UciSource { let (_uci, packages) = match resolved { Ok(resolved) => resolved, - Err(_) if !self.required => { + Err(SourceError::NotFound(_)) if !self.required => { return Ok(Map::new()); } @@ -285,3 +293,75 @@ impl Source for UciSource { Ok(values) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::Settings; + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT: AtomicUsize = AtomicUsize::new(0); + + struct Fixture(PathBuf); + + impl Fixture { + fn new(contents: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "sqma-uci-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(root.join("config")).unwrap(); + fs::create_dir_all(root.join("save")).unwrap(); + fs::write(root.join("config/sqm-autorate-rust"), contents).unwrap(); + Self(root) + } + + fn collect(&self) -> Result, SourceError> { + Source::collect( + &UciSource::from_schema::() + .with_directories(self.0.join("config"), self.0.join("save")) + .required(false), + ) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + #[test] + fn malformed_uci_is_not_treated_as_absent() { + let fixture = Fixture::new("config network 'unterminated\n"); + assert!(fixture.collect().is_err()); + } + + #[test] + fn named_sections_are_mapped_and_mixed_sections_are_rejected() { + let named = Fixture::new("config network 'primary'\n option download_interface 'ifb0'\n"); + let values = named.collect().unwrap(); + assert_eq!( + values + .get("network.download_interface") + .unwrap() + .clone() + .into_string() + .unwrap(), + "ifb0" + ); + + let mixed = Fixture::new( + "config network 'primary'\n option download_interface 'ifb0'\nconfig network\n option upload_interface 'eth0'\n", + ); + assert!( + mixed + .collect() + .unwrap_err() + .to_string() + .contains("multiple `network` sections") + ); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 15152e9..577ee55 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -192,7 +192,10 @@ impl Settings { })?; match (record.ip_version, record.reflector_ip) { - (4, IpAddr::V4(_)) | (6, IpAddr::V6(_)) => {} + (4, IpAddr::V4(_)) => {} + (6, address @ IpAddr::V6(_)) => { + bail!("reflector {address} is IPv6; only IPv4 reflectors are supported"); + } (version, address) => { bail!("reflector {address} has inconsistent IP version {version}"); } diff --git a/src/settings/validation.rs b/src/settings/validation.rs index 231c85d..475dbbc 100644 --- a/src/settings/validation.rs +++ b/src/settings/validation.rs @@ -153,7 +153,12 @@ impl NetworkSettings { if download_base_valid && download_percent_valid { let minimum = self.download_min_kbits(); - if minimum < WARN_DOWNLOAD_MIN_KBITS { + if minimum < 1.0 { + report.error( + "network.download_min_percent", + "minimum download rate floors to zero", + ); + } else if minimum < WARN_DOWNLOAD_MIN_KBITS { report.warning( "network.download_min_percent", format!( @@ -168,7 +173,12 @@ impl NetworkSettings { if upload_base_valid && upload_percent_valid { let minimum = self.upload_min_kbits(); - if minimum < WARN_UPLOAD_MIN_KBITS { + if minimum < 1.0 { + report.error( + "network.upload_min_percent", + "minimum upload rate floors to zero", + ); + } else if minimum < WARN_UPLOAD_MIN_KBITS { report.warning( "network.upload_min_percent", format!(