From 8bdec9784223fb3e95bb482b64cc7934bdd85cc3 Mon Sep 17 00:00:00 2001 From: "Claude (sandbox)" Date: Wed, 12 Aug 2026 13:41:57 +0000 Subject: [PATCH] feat(config): optional TOML config loading behind `config-file` feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file-based way to set tuning knobs without recompiling. urus is a library, so it never presumes a config path or reads the environment — the binary hands the text in: - Config::with_toml_str(&str) -> Result: sparse overlay onto an existing Config (built with the addr the binary chose). Only keys present are applied; durations are integer seconds; unknown keys are a hard error (deny_unknown_fields) so a typo is loud, not a silent no-op. - Scope: the four slowloris knobs (head_timeout_secs, body_timeout_secs, body_burst_bytes, body_stall_timeout_secs). Migrating the rest of Config into the file is a separate, additive job — TomlOverrides just grows. - Feature `config-file = ["dep:serde", "dep:toml"]`; the optional serde dep gains the derive feature. The default build is unchanged (deps + code are all gated). - examples/serve_toml.rs (required-features = ["config-file"]): a `--config PATH` demo with no presumed default location. plain_serve and its env vars are left untouched. Tests (feature-gated): empty keeps defaults, partial overrides only named, full overrides all, unknown key errors, malformed errors. 84 lib with the feature / 79 without; clippy --lib clean both ways; e2e smoke serves 200 from a file and rejects an unknown key loudly. --- Cargo.toml | 9 ++- examples/serve_toml.rs | 48 +++++++++++++++ src/lib.rs | 2 + src/serve.rs | 131 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 examples/serve_toml.rs diff --git a/Cargo.toml b/Cargo.toml index 4f4a4c3..903a7b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,14 +14,17 @@ sha1_smol = "1" # dep #4, ratified 2026-06-12: serde/serde_json behind the opt-in # "phoenix" feature only — the "channels" core stays dependency-free. -serde = { version = "1", optional = true } +serde = { version = "1", optional = true, features = ["derive"] } serde_json = { version = "1", optional = true } +# config-file feature: TOML loader for tuning knobs (dep #5, 2026-08-12) +toml = { version = "0.8", optional = true } [features] smarm-trace = ["smarm/smarm-trace"] smarm-causal = ["smarm/smarm-causal"] channels = [] phoenix = ["channels", "dep:serde", "dep:serde_json"] +config-file = ["dep:serde", "dep:toml"] [dev-dependencies] serde = { version = "1", features = ["derive"] } @@ -47,3 +50,7 @@ path = "examples/crud.rs" name = "channels_chat" path = "examples/channels_chat.rs" required-features = ["phoenix"] + +[[example]] +name = "serve_toml" +required-features = ["config-file"] diff --git a/examples/serve_toml.rs b/examples/serve_toml.rs new file mode 100644 index 0000000..adbac16 --- /dev/null +++ b/examples/serve_toml.rs @@ -0,0 +1,48 @@ +//! Serve with an optional TOML config overlay. +//! +//! urus is a library and never presumes a config path or reads the +//! environment for one — the binary decides where the file lives and hands +//! the text to `Config::with_toml_str`. Here that's a `--config PATH` flag; +//! with no flag, the compiled defaults are used unchanged. +//! +//! Requires the `config-file` feature: +//! cargo run --example serve_toml --features config-file -- --config urus.toml +//! +//! Example urus.toml (all keys optional, sparse override; seconds): +//! head_timeout_secs = 15 +//! body_timeout_secs = 300 +//! body_burst_bytes = 4096 +//! body_stall_timeout_secs = 20 + +use std::net::SocketAddr; + +use urus::{serve_with, Config, Conn, Next, Pipeline, Router}; + +fn json_id(conn: Conn, _next: Next) -> Conn { + let id: u64 = conn.params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0); + conn.put_status(200) + .put_header("content-type", "application/json") + .put_body(format!("{{\"id\":{id}}}")) +} + +fn main() { + let addr: SocketAddr = "0.0.0.0:8080".parse().expect("addr"); + let mut cfg = Config::new(addr); + + // Minimal flag scan: `--config PATH`. No presumed default location. + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--config" { + let path = args.next().expect("--config needs a PATH"); + let toml = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("reading config {path}: {e}")); + cfg = cfg + .with_toml_str(&toml) + .unwrap_or_else(|e| panic!("invalid config {path}: {e}")); + eprintln!("serve_toml: loaded config from {path}"); + } + } + + let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id)); + serve_with(cfg, pipe).expect("serve"); +} diff --git a/src/lib.rs b/src/lib.rs index e71d74c..0b7d88c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,3 +44,5 @@ pub use ws::{Message, WsClosed, WsHandler, WsSender}; pub use serve::{ serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal, }; +#[cfg(feature = "config-file")] +pub use serve::ConfigError; diff --git a/src/serve.rs b/src/serve.rs index 2a0a75f..b06e12e 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -135,6 +135,79 @@ impl Config { } } +// --------------------------------------------------------------------------- +// config-file: TOML overlay for tuning knobs +// --------------------------------------------------------------------------- +// +// urus is a library, so it never presumes a config-file path or reads the +// environment — the embedding binary decides where a file lives and hands +// the text here. This overlays a sparse TOML document onto an existing +// `Config` (built with an addr the binary chose): only the keys present are +// applied, everything else keeps the compiled default. Durations are +// integer seconds. Unknown keys are a hard error so a typo is loud, not a +// silent no-op. +// +// Scope for now: the slowloris-tuning knobs only. Migrating the rest of the +// Config surface into the file is a separate, additive job (the loader +// mechanism is general — it just extends `TomlOverrides`). + +/// Error from [`Config::with_toml_str`]: the TOML failed to parse or carried +/// an unknown/mistyped key. +#[cfg(feature = "config-file")] +#[derive(Debug)] +pub enum ConfigError { + Toml(String), +} + +#[cfg(feature = "config-file")] +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::Toml(m) => write!(f, "config TOML error: {m}"), + } + } +} + +#[cfg(feature = "config-file")] +impl std::error::Error for ConfigError {} + +#[cfg(feature = "config-file")] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TomlOverrides { + head_timeout_secs: Option, + body_timeout_secs: Option, + body_burst_bytes: Option, + body_stall_timeout_secs: Option, +} + +#[cfg(feature = "config-file")] +impl Config { + /// Overlay a TOML document of tuning knobs onto this config (sparse: + /// only the keys present are applied). Durations are integer seconds. + /// + /// Recognized keys: `head_timeout_secs`, `body_timeout_secs`, + /// `body_burst_bytes`, `body_stall_timeout_secs`. Unknown keys error. + /// Other `Config` knobs are not yet file-configurable. + pub fn with_toml_str(mut self, s: &str) -> Result { + let o: TomlOverrides = + toml::from_str(s).map_err(|e| ConfigError::Toml(e.to_string()))?; + if let Some(v) = o.head_timeout_secs { + self.head_timeout = Duration::from_secs(v); + } + if let Some(v) = o.body_timeout_secs { + self.body_timeout = Duration::from_secs(v); + } + if let Some(v) = o.body_burst_bytes { + self.body_burst_bytes = v; + } + if let Some(v) = o.body_stall_timeout_secs { + self.body_stall_timeout = Duration::from_secs(v); + } + Ok(self) + } +} + // --------------------------------------------------------------------------- // dup helper // --------------------------------------------------------------------------- @@ -470,3 +543,61 @@ pub fn serve(addr: impl ToSocketAddrs, pipeline: Pipeline) -> io::Result<()> { .ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "no addresses resolved"))?; serve_with(Config::new(addr), pipeline) } + +#[cfg(all(test, feature = "config-file"))] +mod config_file_tests { + use super::*; + + fn base() -> Config { + Config::new("127.0.0.1:0".parse().unwrap()) + } + + #[test] + fn toml_empty_keeps_defaults() { + let d = base(); + let c = base().with_toml_str("").unwrap(); + assert_eq!(c.head_timeout, d.head_timeout); + assert_eq!(c.body_timeout, d.body_timeout); + assert_eq!(c.body_burst_bytes, d.body_burst_bytes); + assert_eq!(c.body_stall_timeout, d.body_stall_timeout); + } + + #[test] + fn toml_partial_overrides_only_named() { + let d = base(); + let c = base().with_toml_str("head_timeout_secs = 5").unwrap(); + assert_eq!(c.head_timeout, Duration::from_secs(5)); // overridden + assert_eq!(c.body_timeout, d.body_timeout); // default kept + assert_eq!(c.body_burst_bytes, d.body_burst_bytes); // default kept + assert_eq!(c.body_stall_timeout, d.body_stall_timeout); + } + + #[test] + fn toml_full_overrides_all() { + let c = base() + .with_toml_str( + "head_timeout_secs = 10\n\ + body_timeout_secs = 120\n\ + body_burst_bytes = 8192\n\ + body_stall_timeout_secs = 15\n", + ) + .unwrap(); + assert_eq!(c.head_timeout, Duration::from_secs(10)); + assert_eq!(c.body_timeout, Duration::from_secs(120)); + assert_eq!(c.body_burst_bytes, 8192); + assert_eq!(c.body_stall_timeout, Duration::from_secs(15)); + } + + #[test] + fn toml_unknown_key_errors() { + // A mistyped/unknown key is a hard error, not a silent no-op. + let e = base().with_toml_str("body_timeout_sec = 120"); // typo: missing 's' + assert!(e.is_err(), "unknown key should error"); + } + + #[test] + fn toml_malformed_errors() { + let e = base().with_toml_str("this is not = valid = toml"); + assert!(e.is_err(), "malformed TOML should error"); + } +}