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}