Skip to main content

edgeguard/
config.rs

1//! Configuration. Env-first so EdgeGuard drops into any PaaS that injects `$PORT`
2//! with zero edits; an optional TOML file layers richer policy on top.
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::env;
8use std::time::Duration;
9
10#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(default)]
12pub struct Config {
13    pub server: ServerCfg,
14    pub auth: AuthCfg,
15    pub ratelimit: RateLimitCfg,
16    pub validation: ValidationCfg,
17    pub headers: HeadersCfg,
18    pub tls: TlsCfg,
19    pub waf: WafCfg,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23#[serde(default)]
24pub struct ServerCfg {
25    /// Public listen port. Overridden by the `PORT` env var.
26    pub port: u16,
27    /// Internal port the wrapped/upstream app listens on. Overridden by `APP_PORT`.
28    pub app_port: u16,
29    /// Full upstream base URL. Overridden by `UPSTREAM`. If empty, derived from app_port.
30    pub upstream: String,
31    /// Trust the `X-Forwarded-For` header for client identity. Enable ONLY when
32    /// EdgeGuard sits behind a trusted proxy/load balancer that sets it (e.g. a PaaS
33    /// edge). When false (default) the peer socket address is used, so clients can't
34    /// spoof their IP to defeat per-IP rate limiting or forge access-log entries.
35    pub trust_forwarded_for: bool,
36    /// Private listener port for the internal `/__edgeguard/*` ops endpoints (health,
37    /// readiness, metrics). `0` (default) keeps them on the public port. When non-zero,
38    /// EdgeGuard binds a second, plain-HTTP listener on `admin_addr:admin_port` that serves
39    /// those endpoints, and the public port serves only the proxy (plus the browser-facing CSP
40    /// report sink) — so metrics/health aren't exposed on the internet. Overridden by
41    /// `ADMIN_PORT`. (Point your platform's health check at this port when you enable it.)
42    pub admin_port: u16,
43    /// Address the private admin listener binds when `admin_port` is set. Defaults to
44    /// `127.0.0.1` (same-host only — e.g. a sidecar scraper); set to `0.0.0.0` to expose it on
45    /// a private network interface (rely on your network policy to keep it off the internet).
46    pub admin_addr: String,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50#[serde(default)]
51pub struct AuthCfg {
52    /// "none" | "basic" | "apikey" | "jwt". Selects the gate applied to every proxied
53    /// request; the internal `/__edgeguard/*` endpoints are always exempt.
54    pub mode: String,
55    pub realm: String,
56    /// username -> password. Value may be plaintext (dev) or a `$argon2...` PHC hash.
57    /// Used when `mode = "basic"`.
58    pub users: BTreeMap<String, String>,
59    /// Accepted API keys (compared in constant time). Used when `mode = "apikey"`. A request
60    /// may present a key either as `Authorization: Bearer <key>` or in `api_key_header`.
61    /// Overridable from the env via `EDGEGUARD_API_KEYS` (comma-separated) so keys need not
62    /// live in the config file.
63    pub api_keys: Vec<String>,
64    /// Header carrying the API key (in addition to `Authorization: Bearer`), default
65    /// `X-API-Key`. Used when `mode = "apikey"`.
66    pub api_key_header: String,
67    /// JWT verification policy. Used when `mode = "jwt"`.
68    pub jwt: JwtCfg,
69}
70
71/// JWT bearer-token verification. Either a symmetric `secret` (HS*) or an asymmetric key
72/// (RS*/ES*/PS*) supplied as a static `public_key_pem` or fetched from `jwks_url`.
73#[derive(Debug, Clone, Deserialize)]
74#[serde(default)]
75pub struct JwtCfg {
76    /// Expected signature algorithm, e.g. "HS256", "RS256", "ES256". The token's own `alg`
77    /// header must match this (we never trust the token to pick its own algorithm — that is
78    /// the classic JWT downgrade/`alg=none` foot-gun).
79    pub algorithm: String,
80    /// Shared secret for HS* algorithms. Prefer the `EDGEGUARD_JWT_SECRET` env var over
81    /// putting it in the config file.
82    pub secret: String,
83    /// Static PEM public key (SPKI or PKCS#1) for RS*/ES*/PS* verification, as an
84    /// alternative to `jwks_url`.
85    pub public_key_pem: String,
86    /// JWKS endpoint to fetch verification keys from (RS*/ES*/PS*). Keys are cached and
87    /// selected by the token's `kid`.
88    pub jwks_url: String,
89    /// How long (seconds) to cache a fetched JWKS before refetching. Default 300.
90    pub jwks_cache_secs: u64,
91    /// If set, the token's `iss` claim must equal this.
92    pub issuer: String,
93    /// If set, the token's `aud` claim must contain this.
94    pub audience: String,
95    /// Clock-skew leeway (seconds) applied to `exp`/`nbf` validation. Default 60.
96    pub leeway_secs: u64,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100#[serde(default)]
101pub struct RateLimitCfg {
102    pub enabled: bool,
103    /// Default per-client-IP limit, e.g. "60/min", "10/sec", "1000/hour".
104    pub rate: String,
105    pub burst: u32,
106    /// Per-route overrides. A request whose path starts with `path` uses that route's limit
107    /// (still keyed per client IP) instead of the global one; the longest matching prefix
108    /// wins, so `/api/admin/` can be stricter than `/api/`.
109    pub routes: Vec<RouteRateLimit>,
110    /// An additional limit keyed by the authenticated principal (API-key id or JWT subject)
111    /// rather than IP, so a single credential can't fan out across many IPs. Only applies to
112    /// authenticated requests.
113    pub per_key: PerKeyRateLimit,
114    /// Where limiter state lives: `"local"` (default) is the in-process `governor` limiter (fast,
115    /// no dependency, but per-replica). `"redis"` shares GCRA state across replicas via a Redis
116    /// store, so N instances enforce one global limit. `"memory"` uses the same shared-store code
117    /// path backed by an in-process map (a single-replica/testing backend). All three honor the
118    /// same `rate`/`burst`/route/per-key settings above.
119    pub store: String,
120    /// Redis connection URL for `store = "redis"`, e.g. `redis://host:6379` or (TLS)
121    /// `rediss://host:6379`. Prefer the `EDGEGUARD_REDIS_URL` env var over this file.
122    pub redis_url: String,
123    /// Key prefix/namespace for the shared store, so multiple EdgeGuard deployments can share one
124    /// Redis without colliding. Keys look like `<prefix>:ip:<addr>`.
125    pub redis_prefix: String,
126    /// What to do when the shared store is unreachable. `false` (default) fails **closed** — a
127    /// store error returns `503`, so an outage can't silently disable rate limiting. `true` fails
128    /// **open** — a store error allows the request (favor availability over strict limiting).
129    /// Only relevant for `store = "redis"`.
130    pub fail_open: bool,
131}
132
133/// A per-route rate-limit override (matched by path prefix).
134#[derive(Debug, Clone, Deserialize)]
135#[serde(default)]
136pub struct RouteRateLimit {
137    /// Path prefix this limit applies to, e.g. "/api/".
138    pub path: String,
139    pub rate: String,
140    pub burst: u32,
141}
142
143impl Default for RouteRateLimit {
144    fn default() -> Self {
145        RouteRateLimit {
146            path: String::new(),
147            rate: "60/min".into(),
148            burst: 20,
149        }
150    }
151}
152
153/// Per-principal rate limit (keyed by API-key id / JWT subject).
154#[derive(Debug, Clone, Deserialize)]
155#[serde(default)]
156pub struct PerKeyRateLimit {
157    pub enabled: bool,
158    pub rate: String,
159    pub burst: u32,
160}
161
162impl Default for PerKeyRateLimit {
163    fn default() -> Self {
164        PerKeyRateLimit {
165            enabled: false,
166            rate: "1000/hour".into(),
167            burst: 100,
168        }
169    }
170}
171
172#[derive(Debug, Clone, Deserialize)]
173#[serde(default)]
174pub struct ValidationCfg {
175    /// e.g. "2MiB". Requests with a larger body are rejected with 413.
176    pub max_body: String,
177    /// Cap on the upstream response body EdgeGuard buffers, e.g. "16MiB". "0" disables
178    /// the cap (unbounded). Protects against an upstream OOM-ing the proxy; raise it if
179    /// you proxy large downloads.
180    pub max_response_body: String,
181    /// Max time to wait for the upstream response and to read its body, e.g. "30s",
182    /// "500ms", "2m". "0" disables the timeout. Bounds a stalled upstream so it can't pin a
183    /// handler task indefinitely; on elapse the proxy returns 504.
184    pub upstream_timeout: String,
185    /// Cap on the total size of incoming request headers (sum of name + value bytes), e.g.
186    /// "32KiB". "0" disables the cap (default). Requests over the limit get `431`. This is a
187    /// policy limit enforced by EdgeGuard on top of hyper's own transport-level header cap.
188    pub max_header_bytes: String,
189    /// Allowed HTTP methods; empty list means allow all.
190    pub allow_methods: Vec<String>,
191}
192
193#[derive(Debug, Clone, Deserialize)]
194#[serde(default)]
195pub struct HeadersCfg {
196    pub hsts: bool,
197    pub csp: String,
198    /// Send the CSP as `Content-Security-Policy-Report-Only` instead of enforcing it. Lets
199    /// you roll out / tighten a policy by collecting violations first without breaking the
200    /// page.
201    pub csp_report_only: bool,
202    /// If set, a `report-uri <value>` directive is appended to the CSP so browsers POST
203    /// violation reports there. Point it at EdgeGuard's own sink ("/__edgeguard/csp-report")
204    /// to have them logged, or at any external collector.
205    pub csp_report_uri: String,
206    pub referrer_policy: String,
207    pub permissions_policy: String,
208    pub frame_options: String,
209    pub force_secure_cookies: bool,
210    /// Response headers to strip (case-insensitive), e.g. ["Server", "X-Powered-By"].
211    pub strip: Vec<String>,
212}
213
214impl Default for ServerCfg {
215    fn default() -> Self {
216        ServerCfg {
217            port: 8080,
218            app_port: 3000,
219            upstream: String::new(),
220            trust_forwarded_for: false,
221            admin_port: 0,
222            admin_addr: "127.0.0.1".into(),
223        }
224    }
225}
226
227impl Default for AuthCfg {
228    fn default() -> Self {
229        AuthCfg {
230            mode: "none".into(),
231            realm: "EdgeGuard".into(),
232            users: BTreeMap::new(),
233            api_keys: vec![],
234            api_key_header: "X-API-Key".into(),
235            jwt: JwtCfg::default(),
236        }
237    }
238}
239
240impl Default for JwtCfg {
241    fn default() -> Self {
242        JwtCfg {
243            algorithm: "HS256".into(),
244            secret: String::new(),
245            public_key_pem: String::new(),
246            jwks_url: String::new(),
247            jwks_cache_secs: 300,
248            issuer: String::new(),
249            audience: String::new(),
250            leeway_secs: 60,
251        }
252    }
253}
254
255impl Default for RateLimitCfg {
256    fn default() -> Self {
257        RateLimitCfg {
258            enabled: true,
259            rate: "60/min".into(),
260            burst: 20,
261            routes: vec![],
262            per_key: PerKeyRateLimit::default(),
263            store: "local".into(),
264            redis_url: "redis://127.0.0.1:6379".into(),
265            redis_prefix: "edgeguard".into(),
266            fail_open: false,
267        }
268    }
269}
270
271impl Default for ValidationCfg {
272    fn default() -> Self {
273        ValidationCfg {
274            max_body: "2MiB".into(),
275            max_response_body: "0".into(),
276            upstream_timeout: "30s".into(),
277            max_header_bytes: "0".into(),
278            allow_methods: vec![],
279        }
280    }
281}
282
283impl Default for HeadersCfg {
284    fn default() -> Self {
285        HeadersCfg {
286            hsts: true,
287            csp: "default-src 'self'".into(),
288            csp_report_only: false,
289            csp_report_uri: String::new(),
290            referrer_policy: "no-referrer".into(),
291            permissions_policy: "geolocation=(), microphone=(), camera=()".into(),
292            frame_options: "DENY".into(),
293            force_secure_cookies: true,
294            strip: vec!["Server".into(), "X-Powered-By".into()],
295        }
296    }
297}
298
299/// TLS termination. When `enabled`, EdgeGuard serves HTTPS on the public port using a
300/// certificate either loaded from `cert_path`/`key_path` or obtained automatically via ACME.
301/// All-default fields (disabled, empty paths, default ACME) so `Default` is derivable.
302#[derive(Debug, Clone, Default, Deserialize)]
303#[serde(default)]
304pub struct TlsCfg {
305    pub enabled: bool,
306    /// PEM certificate chain (leaf first). When ACME is enabled this is where the obtained
307    /// certificate is written/read.
308    pub cert_path: String,
309    /// PEM private key (PKCS#8/PKCS#1/SEC1).
310    pub key_path: String,
311    pub acme: AcmeCfg,
312}
313
314/// Automatic certificate management (ACME / Let's Encrypt) via the HTTP-01 challenge. The
315/// obtained certificate is written to `TlsCfg::cert_path`/`key_path` and served by the TLS
316/// listener; a background task renews it before expiry.
317#[derive(Debug, Clone, Deserialize)]
318#[serde(default)]
319pub struct AcmeCfg {
320    pub enabled: bool,
321    /// Domains to request a certificate for (the first is the primary CN).
322    pub domains: Vec<String>,
323    /// Contact email for the ACME account (registration + expiry notices).
324    pub email: String,
325    /// ACME directory URL. Defaults to Let's Encrypt **staging** so a misconfiguration can't
326    /// burn the strict production rate limits; switch to production explicitly.
327    pub directory_url: String,
328    /// Directory for the cached ACME account key (so renewals reuse the same account).
329    pub cache_dir: String,
330    /// You must set this to `true` to signify acceptance of the ACME provider's Terms of
331    /// Service; EdgeGuard refuses to register otherwise.
332    pub accept_tos: bool,
333}
334
335impl Default for AcmeCfg {
336    fn default() -> Self {
337        AcmeCfg {
338            enabled: false,
339            domains: vec![],
340            email: String::new(),
341            // Let's Encrypt staging — safe default; see the field doc.
342            directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(),
343            cache_dir: "./acme".into(),
344            accept_tos: false,
345        }
346    }
347}
348
349/// WAF-lite input inspection (Phase 4 / v2). Screens a request for common attack signatures
350/// before it is forwarded, using built-in heuristic rulesets (SQLi/XSS/path-traversal) plus
351/// any operator-defined deny patterns. Disabled by default — these are heuristics, so the
352/// intended rollout is `report` (log + count matches without blocking) until the operator is
353/// confident, then `block` (return `403`). Compiled into a `crate::waf::WafEngine`.
354#[derive(Debug, Clone, Deserialize)]
355#[serde(default)]
356pub struct WafCfg {
357    /// "off" (default) | "report" | "block". `report` evaluates rules and logs/counts matches
358    /// but forwards the request anyway; `block` rejects a matching request with `403`.
359    pub mode: String,
360    /// Enable the built-in SQL-injection heuristic ruleset.
361    pub sqli: bool,
362    /// Enable the built-in cross-site-scripting heuristic ruleset.
363    pub xss: bool,
364    /// Enable the built-in path-traversal heuristic ruleset.
365    pub path_traversal: bool,
366    /// Inspect the request path + query string (matched raw and percent-decoded). Default true.
367    pub inspect_path: bool,
368    /// Inspect request header values. Off by default: header bytes (cookies, tokens, opaque
369    /// blobs) are noisy and prone to false positives.
370    pub inspect_headers: bool,
371    /// Inspect the request body (already capped by `validation.max_body`). Off by default.
372    pub inspect_body: bool,
373    /// Operator-defined deny patterns, evaluated alongside the enabled built-in rulesets.
374    pub rules: Vec<WafRule>,
375}
376
377/// A single operator-defined WAF deny pattern (a `[[waf.rules]]` entry).
378#[derive(Debug, Clone, Deserialize)]
379#[serde(default)]
380pub struct WafRule {
381    /// Identifier reported in logs/metrics when this rule matches (defaults to `custom-<n>`).
382    pub id: String,
383    /// Regular expression (RE2 syntax: linear-time, no backreferences/lookaround, so it can't
384    /// ReDoS the proxy). A request matching it in any targeted location is treated as a hit.
385    pub pattern: String,
386    /// Request location to match against: "path" (path+query, default), "headers", "body", or
387    /// "all". A location is only examined when its `inspect_*` flag above is also enabled.
388    pub target: String,
389}
390
391impl Default for WafCfg {
392    fn default() -> Self {
393        WafCfg {
394            mode: "off".into(),
395            sqli: true,
396            xss: true,
397            path_traversal: true,
398            inspect_path: true,
399            inspect_headers: false,
400            inspect_body: false,
401            rules: vec![],
402        }
403    }
404}
405
406impl Default for WafRule {
407    fn default() -> Self {
408        WafRule {
409            id: String::new(),
410            pattern: String::new(),
411            target: "path".into(),
412        }
413    }
414}
415
416impl Config {
417    /// Load defaults, overlay an optional TOML file, then apply env overrides.
418    pub fn load(path: Option<&str>) -> Result<Config> {
419        let mut cfg = if let Some(p) = path {
420            let raw =
421                std::fs::read_to_string(p).with_context(|| format!("reading config file {p}"))?;
422            toml::from_str::<Config>(&raw).with_context(|| format!("parsing config file {p}"))?
423        } else {
424            Config::default()
425        };
426
427        if let Ok(p) = env::var("PORT") {
428            if let Ok(v) = p.parse() {
429                cfg.server.port = v;
430            }
431        }
432        if let Ok(p) = env::var("APP_PORT") {
433            if let Ok(v) = p.parse() {
434                cfg.server.app_port = v;
435            }
436        }
437        if let Ok(p) = env::var("ADMIN_PORT") {
438            if let Ok(v) = p.parse() {
439                cfg.server.admin_port = v;
440            }
441        }
442        if let Ok(u) = env::var("UPSTREAM") {
443            if !u.is_empty() {
444                cfg.server.upstream = u;
445            }
446        }
447        // Keep secrets out of the config file: let the environment supply them.
448        if let Ok(s) = env::var("EDGEGUARD_JWT_SECRET") {
449            if !s.is_empty() {
450                cfg.auth.jwt.secret = s;
451            }
452        }
453        if let Ok(u) = env::var("EDGEGUARD_REDIS_URL") {
454            if !u.is_empty() {
455                cfg.ratelimit.redis_url = u;
456            }
457        }
458        if let Ok(keys) = env::var("EDGEGUARD_API_KEYS") {
459            let keys: Vec<String> = keys
460                .split(',')
461                .map(|k| k.trim().to_string())
462                .filter(|k| !k.is_empty())
463                .collect();
464            if !keys.is_empty() {
465                cfg.auth.api_keys = keys;
466            }
467        }
468        Ok(cfg)
469    }
470
471    /// The upstream base URL EdgeGuard forwards to, e.g. "http://127.0.0.1:3000".
472    pub fn upstream_base(&self) -> String {
473        if self.server.upstream.is_empty() {
474            format!("http://127.0.0.1:{}", self.server.app_port)
475        } else {
476            self.server.upstream.trim_end_matches('/').to_string()
477        }
478    }
479
480    /// The `(host, port)` EdgeGuard probes for readiness, mirroring [`Self::upstream_base`]:
481    /// co-process mode probes `127.0.0.1:app_port`; an explicit upstream URL is parsed,
482    /// defaulting the port from the scheme. Returns `None` if the URL carries no usable
483    /// host, so the readiness check reports "not ready" rather than panicking.
484    pub fn upstream_probe_addr(&self) -> Option<(String, u16)> {
485        if self.server.upstream.is_empty() {
486            Some(("127.0.0.1".to_string(), self.server.app_port))
487        } else {
488            parse_host_port(&self.server.upstream)
489        }
490    }
491}
492
493/// Extract `(host, port)` from an upstream URL like `http://host:3000/base`. Only the
494/// scheme (for the default port), host, and port are needed — any path is ignored. Handles
495/// bracketed IPv6 literals (`http://[::1]:3000`). This is deliberately small rather than a
496/// full URL parser; the proxy itself is HTTP-only in v0.
497fn parse_host_port(url: &str) -> Option<(String, u16)> {
498    let (default_port, rest) = if let Some(r) = url.strip_prefix("http://") {
499        (80u16, r)
500    } else if let Some(r) = url.strip_prefix("https://") {
501        (443u16, r)
502    } else {
503        (80u16, url)
504    };
505    // Authority is everything up to the first '/'; drop any `user:pass@` userinfo.
506    let authority = rest.split('/').next().unwrap_or(rest);
507    let authority = authority.rsplit('@').next().unwrap_or(authority);
508    if authority.is_empty() {
509        return None;
510    }
511    // Bracketed IPv6 literal: `[::1]` or `[::1]:port`.
512    if let Some(after) = authority.strip_prefix('[') {
513        let (host, tail) = after.split_once(']')?;
514        let port = match tail.strip_prefix(':') {
515            Some(p) => p.parse().ok()?,
516            None => default_port,
517        };
518        return Some((host.to_string(), port));
519    }
520    match authority.rsplit_once(':') {
521        // Reject an empty host (e.g. `http://:3000`) rather than deferring the failure to a
522        // connect call — the "usable host" contract is checked here.
523        Some((host, port)) if !host.is_empty() => Some((host.to_string(), port.parse().ok()?)),
524        Some(_) => None,
525        None => Some((authority.to_string(), default_port)),
526    }
527}
528
529/// Parse a human size like "2MiB", "512KB", "1048576" into bytes.
530pub fn parse_size(s: &str) -> Result<usize> {
531    let s = s.trim();
532    let (num, mult): (&str, usize) = if let Some(n) = s.strip_suffix("GiB") {
533        (n, 1024 * 1024 * 1024)
534    } else if let Some(n) = s.strip_suffix("MiB") {
535        (n, 1024 * 1024)
536    } else if let Some(n) = s.strip_suffix("KiB") {
537        (n, 1024)
538    } else if let Some(n) = s.strip_suffix("GB") {
539        (n, 1_000_000_000)
540    } else if let Some(n) = s.strip_suffix("MB") {
541        (n, 1_000_000)
542    } else if let Some(n) = s.strip_suffix("KB") {
543        (n, 1_000)
544    } else if let Some(n) = s.strip_suffix('B') {
545        (n, 1)
546    } else {
547        (s, 1)
548    };
549    let n: usize = num
550        .trim()
551        .parse()
552        .with_context(|| format!("invalid size: {s}"))?;
553    n.checked_mul(mult)
554        .with_context(|| format!("size too large: {s}"))
555}
556
557/// Parse a rate like "60/min" into (count, period).
558pub fn parse_rate(s: &str) -> Result<(u32, Duration)> {
559    let (n, unit) = s
560        .split_once('/')
561        .with_context(|| format!("invalid rate (expected N/unit): {s}"))?;
562    let count: u32 = n
563        .trim()
564        .parse()
565        .with_context(|| format!("invalid rate count: {s}"))?;
566    let period = match unit.trim() {
567        "s" | "sec" | "second" => Duration::from_secs(1),
568        "m" | "min" | "minute" => Duration::from_secs(60),
569        "h" | "hour" => Duration::from_secs(3600),
570        other => anyhow::bail!("unsupported rate unit: {other}"),
571    };
572    Ok((count, period))
573}
574
575/// Parse a timeout like "30s", "500ms", "2m", or a bare number of seconds ("45"). "0"
576/// yields a zero duration, which callers treat as "disabled".
577pub fn parse_duration(s: &str) -> Result<Duration> {
578    let s = s.trim();
579    // Order matters: check "ms" before the single-char "s"/"m" suffixes.
580    if let Some(n) = s.strip_suffix("ms") {
581        let ms: u64 = n
582            .trim()
583            .parse()
584            .with_context(|| format!("invalid duration: {s}"))?;
585        Ok(Duration::from_millis(ms))
586    } else if let Some(n) = s.strip_suffix('s') {
587        let secs: u64 = n
588            .trim()
589            .parse()
590            .with_context(|| format!("invalid duration: {s}"))?;
591        Ok(Duration::from_secs(secs))
592    } else if let Some(n) = s.strip_suffix('m') {
593        let mins: u64 = n
594            .trim()
595            .parse()
596            .with_context(|| format!("invalid duration: {s}"))?;
597        let secs = mins
598            .checked_mul(60)
599            .with_context(|| format!("duration too large: {s}"))?;
600        Ok(Duration::from_secs(secs))
601    } else {
602        let secs: u64 = s
603            .parse()
604            .with_context(|| format!("invalid duration: {s}"))?;
605        Ok(Duration::from_secs(secs))
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn parse_size_units_and_plain_bytes() {
615        assert_eq!(parse_size("0").unwrap(), 0);
616        assert_eq!(parse_size("1048576").unwrap(), 1_048_576);
617        assert_eq!(parse_size("512B").unwrap(), 512);
618        assert_eq!(parse_size("1KB").unwrap(), 1_000);
619        assert_eq!(parse_size("1KiB").unwrap(), 1_024);
620        assert_eq!(parse_size("2MiB").unwrap(), 2 * 1024 * 1024);
621        assert_eq!(parse_size("16MiB").unwrap(), 16 * 1024 * 1024);
622        assert_eq!(parse_size("1GiB").unwrap(), 1024 * 1024 * 1024);
623        // surrounding / internal whitespace is tolerated
624        assert_eq!(parse_size("  4 MiB ").unwrap(), 4 * 1024 * 1024);
625    }
626
627    #[test]
628    fn parse_size_rejects_garbage_and_overflow() {
629        assert!(parse_size("abc").is_err());
630        assert!(parse_size("MiB").is_err());
631        // would overflow usize -> Err, not a silent wrap
632        assert!(parse_size("99999999999999999999GiB").is_err());
633    }
634
635    #[test]
636    fn parse_rate_counts_and_units() {
637        assert_eq!(parse_rate("60/min").unwrap(), (60, Duration::from_secs(60)));
638        assert_eq!(parse_rate("10/sec").unwrap(), (10, Duration::from_secs(1)));
639        assert_eq!(
640            parse_rate("1000/hour").unwrap(),
641            (1000, Duration::from_secs(3600))
642        );
643        // short and long unit spellings, plus tolerated whitespace
644        assert_eq!(parse_rate(" 5 / m ").unwrap(), (5, Duration::from_secs(60)));
645    }
646
647    #[test]
648    fn parse_rate_rejects_garbage() {
649        assert!(parse_rate("60").is_err()); // no unit
650        assert!(parse_rate("x/min").is_err()); // bad count
651        assert!(parse_rate("60/year").is_err()); // bad unit
652    }
653
654    #[test]
655    fn probe_addr_defaults_to_app_port_in_coprocess_mode() {
656        let cfg = Config::default();
657        assert_eq!(
658            cfg.upstream_probe_addr(),
659            Some(("127.0.0.1".to_string(), cfg.server.app_port))
660        );
661    }
662
663    #[test]
664    fn parse_host_port_handles_schemes_paths_and_ipv6() {
665        assert_eq!(
666            parse_host_port("http://127.0.0.1:3000"),
667            Some(("127.0.0.1".to_string(), 3000))
668        );
669        // a trailing path is ignored
670        assert_eq!(
671            parse_host_port("http://app.internal:8080/health"),
672            Some(("app.internal".to_string(), 8080))
673        );
674        // port defaults from the scheme
675        assert_eq!(
676            parse_host_port("https://example.com"),
677            Some(("example.com".to_string(), 443))
678        );
679        assert_eq!(
680            parse_host_port("http://example.com"),
681            Some(("example.com".to_string(), 80))
682        );
683        // bracketed IPv6 literal, with and without an explicit port
684        assert_eq!(
685            parse_host_port("http://[::1]:3000"),
686            Some(("::1".to_string(), 3000))
687        );
688        assert_eq!(
689            parse_host_port("http://[2001:db8::1]"),
690            Some(("2001:db8::1".to_string(), 80))
691        );
692    }
693
694    #[test]
695    fn parse_host_port_rejects_empty_or_unusable_host() {
696        // empty host (port only) is not a usable probe target
697        assert_eq!(parse_host_port("http://:3000"), None);
698        // non-numeric port
699        assert_eq!(parse_host_port("http://host:notaport"), None);
700    }
701
702    #[test]
703    fn parse_duration_units_and_bare_seconds() {
704        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
705        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
706        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
707        assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
708        // "0" disables (zero duration); callers map it to "no timeout"
709        assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
710        assert_eq!(parse_duration("  10s ").unwrap(), Duration::from_secs(10));
711    }
712
713    #[test]
714    fn parse_duration_rejects_garbage() {
715        assert!(parse_duration("abc").is_err());
716        assert!(parse_duration("10x").is_err());
717        assert!(parse_duration("s").is_err());
718    }
719}