From 39add2b2e0c4ea93eac9d3ce65e56ae21022c51d Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 7 Aug 2026 12:33:56 +0700 Subject: [PATCH] feat(uci): add generalized UCI configuration source Introduce the UciSource abstraction as a config-rs Source that reads UCI packages and maps them into the configuration tree. Supports package selection, required/optional behavior, and custom config/save directories. Move rust-uci dependency to cfg(unix) to enable UCI support on all Unix systems for development and testing, while Linux/OpenWrt remains the production-supported platform. --- Cargo.lock | 8 +- Cargo.toml | 2 + build.rs | 4 +- src/platform.rs | 3 + src/platform/unix/mod.rs | 2 + src/platform/unix/uci.rs | 237 +++++++++++++++++++++++++++++++++++++++ src/settings/mod.rs | 6 + 7 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 src/platform/unix/mod.rs create mode 100644 src/platform/unix/uci.rs diff --git a/Cargo.lock b/Cargo.lock index f9cddc3..5b8aff7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,9 +540,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "serde_core", "serde_spanned", @@ -562,9 +562,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 3dcf053..fdb8782 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ version = "0.4" [target.'cfg(target_os = "linux")'.dependencies] 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/namib-project/rust-uci.git", rev = "3e7b6153b6df92b45428c32bb6461756563deecb", optional = true } [features] diff --git a/build.rs b/build.rs index fd0ee4e..dba8fb6 100644 --- a/build.rs +++ b/build.rs @@ -7,8 +7,8 @@ fn main() { if uci_enabled && target_os != "linux" { println!( - "cargo::warning=feature `uci` is unsupported for target `{target}`; \ - UCI integration will not be compiled" + "cargo:warning=UCI support on {target} is intended for development \ + and testing; Linux/OpenWrt remains the production-supported platform" ); } diff --git a/src/platform.rs b/src/platform.rs index 170d41a..b5e959a 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -42,6 +42,9 @@ mod linux; #[cfg(target_os = "macos")] mod macos; +#[cfg(unix)] +pub mod unix; + #[cfg(not(any(target_os = "freebsd", target_os = "linux", target_os = "macos")))] mod unsupported; diff --git a/src/platform/unix/mod.rs b/src/platform/unix/mod.rs new file mode 100644 index 0000000..a3f1153 --- /dev/null +++ b/src/platform/unix/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "uci")] +pub mod uci; diff --git a/src/platform/unix/uci.rs b/src/platform/unix/uci.rs new file mode 100644 index 0000000..24f3737 --- /dev/null +++ b/src/platform/unix/uci.rs @@ -0,0 +1,237 @@ +use std::path::{Path, PathBuf}; + +use config::{ConfigError as SourceError, Map, Source, Value, ValueKind}; + +use rust_uci::config::{ + Config as UciConfig, Package as UciPackage, SectionSelector, Value as UciValue, +}; + +#[derive(Clone, Debug)] +enum PackageSelection { + Selected(Vec), + All, +} + +#[derive(Clone, Debug)] +pub(crate) struct UciSource { + packages: PackageSelection, + required: bool, + config_dir: Option, + save_dir: Option, +} + +impl UciSource { + fn with_selection(packages: PackageSelection) -> Self { + Self { + packages, + required: true, + config_dir: None, + save_dir: None, + } + } + + pub(crate) fn new(packages: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::with_selection(PackageSelection::Selected( + packages.into_iter().map(Into::into).collect(), + )) + } + + #[allow(dead_code)] + pub(crate) fn all() -> Self { + Self::with_selection(PackageSelection::All) + } + + pub(crate) fn required(mut self, required: bool) -> Self { + self.required = required; + self + } + + #[allow(dead_code)] + pub(crate) fn with_directories( + mut self, + config_dir: impl AsRef, + save_dir: impl AsRef, + ) -> Self { + self.config_dir = Some(config_dir.as_ref().to_owned()); + self.save_dir = Some(save_dir.as_ref().to_owned()); + self + } + + fn open(&self) -> Result { + let mut uci = rust_uci::Uci::new() + .map_err(|e| SourceError::Message(format!("failed to open UCI: {e}")))?; + + if let Some(config_dir) = &self.config_dir { + uci.set_config_dir(config_dir).map_err(|e| { + SourceError::Message(format!( + "failed to set UCI config directory {config_dir:?}: {e}" + )) + })?; + } + + if let Some(save_dir) = &self.save_dir { + uci.set_save_dir(save_dir).map_err(|e| { + SourceError::Message(format!( + "failed to set UCI save directory {save_dir:?}: {e}" + )) + })?; + } + + Ok(uci.into()) + } + + fn config_selector(selector: &SectionSelector<'_>) -> String { + match selector { + SectionSelector::Named(name) => (*name).to_owned(), + + SectionSelector::Anonymous { + section_type, + index, + } => format!("{section_type}[{index}]"), + } + } + + fn collect_package( + &self, + package_name: &str, + package: &UciPackage, + values: &mut Map, + ) -> Result<(), SourceError> { + let sections = package.sections().map_err(|e| { + SourceError::Message(format!( + "failed to retrieve sections from UCI package \ + {package_name:?}: {e}" + )) + })?; + + for section in sections { + let selector = section.selector().ok_or_else(|| { + SourceError::Message(format!( + "loaded UCI section of type {:?} in package \ + {package_name:?} has no selector", + section.type_() + )) + })?; + + let options = section.options().map_err(|e| { + SourceError::Message(format!( + "failed to retrieve options from UCI section \ + {package_name}.{selector}: {e}" + )) + })?; + + for option in options { + let option_name = option.name(); + + let config_relative_key = + format!("{}.{option_name}", Self::config_selector(&selector),); + + let destination_key = format!("{package_name}.{config_relative_key}"); + + let uci_relative_key = format!("{selector}.{option_name}"); + let uci_key = format!("{package_name}.{uci_relative_key}"); + + let Some(value) = option.get().map_err(|e| { + SourceError::Message(format!( + "failed to retrieve UCI option \ + {uci_key}: {e}" + )) + })? + else { + continue; + }; + + let origin = format!("UCI option {uci_key}"); + + let value = match value { + UciValue::String(value) => Value::new(Some(&origin), ValueKind::String(value)), + + UciValue::List(items) => { + let items = items + .into_iter() + .map(|item| Value::new(Some(&origin), ValueKind::String(item))) + .collect(); + + Value::new(Some(&origin), ValueKind::Array(items)) + } + }; + + if values.insert(destination_key.clone(), value).is_some() { + return Err(SourceError::Message(format!( + "duplicate UCI destination key \ + {destination_key:?} while mapping {uci_key:?}" + ))); + } + } + } + + Ok(()) + } +} + +impl Source for UciSource { + fn clone_into_box(&self) -> Box { + Box::new(self.clone()) + } + + fn collect(&self) -> Result, SourceError> { + let resolved = (|| { + let uci = self.open()?; + + let packages: Vec = match &self.packages { + PackageSelection::Selected(package_names) => package_names + .iter() + .map(|package_name| { + uci.package(package_name) + .map_err(|e| { + SourceError::Message(format!( + "failed to open UCI package {package_name:?}: {e}" + )) + })? + .ok_or_else(|| { + SourceError::NotFound(format!("UCI package {package_name:?}")) + }) + }) + .collect::>()?, + + PackageSelection::All => uci + .packages() + .map_err(|e| { + SourceError::Message(format!("failed to enumerate UCI packages: {e}")) + })? + .collect(), + }; + + Ok::<_, SourceError>((uci, packages)) + })(); + + let (_uci, packages) = match resolved { + Ok(resolved) => resolved, + + Err(_) if !self.required => { + return Ok(Map::new()); + } + + Err(error) => { + return Err(error); + } + }; + + let mut values = Map::new(); + + for package in packages { + let package_name = package + .name() + .map_err(|e| SourceError::Message(format!("failed to read UCI package name: {e}")))? + .to_owned(); + + self.collect_package(&package_name, &package, &mut values)?; + } + + Ok(values) + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index fecb8c4..001c90e 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Deserializer, de}; use std::fmt::{self, Display}; use std::str::FromStr; +#[cfg(all(feature = "uci", unix))] +use crate::platform::unix::uci::UciSource; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum MeasurementType { Icmp = 1, @@ -76,6 +79,9 @@ impl Settings { pub(crate) fn load() -> Result { let builder = Config::builder(); + #[cfg(all(feature = "uci", unix))] + let builder = builder.add_source(UciSource::new(["sqm-autorate-rust"]).required(false)); + #[cfg(feature = "toml")] let builder = builder.add_source( config::File::with_name("/etc/sqm-autorate-rust/config.toml").required(false),