Skip to main content

varta_watch/config/
parser.rs

1// All imports below are only used inside `Config::from_args`, which is
2// excluded when `--features compile-time-config` is active.  Gate every
3// use-declaration at the same level to keep `-D warnings` clean.
4#[cfg(not(feature = "compile-time-config"))]
5use std::net::SocketAddr;
6#[cfg(not(feature = "compile-time-config"))]
7use std::path::PathBuf;
8#[cfg(not(feature = "compile-time-config"))]
9use std::time::Duration;
10
11#[cfg(not(feature = "compile-time-config"))]
12use crate::clock::ClockSource;
13#[cfg(not(feature = "compile-time-config"))]
14use crate::tracker::EvictionPolicy;
15// Tracker capacity / eviction-window constants are only referenced from
16// the argv parser (`Config::from_args`) and its bounds checks.  When
17// `--features compile-time-config` is active those callers are excluded
18// from compilation; re-importing the constants would surface as
19// unused-imports under `-D warnings`.
20#[cfg(not(feature = "compile-time-config"))]
21use crate::tracker::{
22    DEFAULT_CAPACITY, DEFAULT_EVICTION_SCAN_WINDOW, MAX_EVICTION_SCAN_WINDOW,
23    MIN_EVICTION_SCAN_WINDOW,
24};
25
26#[cfg(not(feature = "compile-time-config"))]
27use super::parse_helpers::{parse_octal, parse_u16, parse_u64};
28#[cfg(not(feature = "compile-time-config"))]
29use super::types::{
30    Config, ConfigError, DEFAULT_PROM_RATE_LIMIT_BURST, DEFAULT_PROM_RATE_LIMIT_PER_SEC,
31    DEFAULT_READ_TIMEOUT_MS, DEFAULT_RECOVERY_CAPTURE_BYTES, DEFAULT_RECOVERY_DEBOUNCE_MS,
32    DEFAULT_SHUTDOWN_GRACE_MS, DEFAULT_SOCKET_MODE, MAX_ITERATION_BUDGET_MS,
33    MAX_RECOVERY_CAPTURE_BYTES, MAX_SCRAPE_BUDGET_MS, MIN_ITERATION_BUDGET_MS,
34    MIN_SCRAPE_BUDGET_MS, MIN_SHUTDOWN_GRACE_MS, MIN_THRESHOLD_MS,
35};
36
37#[cfg(not(feature = "compile-time-config"))]
38impl Config {
39    /// Parse a token stream (typically `std::env::args().skip(1)`).
40    ///
41    /// Excluded from compilation when `--features compile-time-config` is
42    /// active — the Class-A binary intentionally has no argv parser linked.
43    pub fn from_args(args: impl IntoIterator<Item = String>) -> Result<Config, ConfigError> {
44        let mut socket: Option<PathBuf> = None;
45        let mut threshold_ms: Option<u64> = None;
46        let mut recovery_exec_cmd: Option<String> = None;
47        let mut recovery_exec_file: Option<PathBuf> = None;
48        let mut recovery_debounce_ms: Option<u64> = None;
49        let mut recovery_env: Vec<String> = Vec::new();
50        let mut recovery_inherit_env: bool = false;
51        let mut file_export: Option<PathBuf> = None;
52        let mut export_file_max_bytes: Option<u64> = None;
53        let mut export_file_sync_every: u32 = 0;
54        // `prom_*` locals are mutated only by `--prom-*` arms gated under
55        // `feature = "prometheus-exporter"`.  Without the feature the
56        // arms vanish and the `mut` becomes redundant; allow the lint so
57        // the parser body keeps the same shape across feature
58        // permutations.
59        #[allow(unused_mut)]
60        let mut prom_addr: Option<SocketAddr> = None;
61        #[allow(unused_mut)]
62        let mut prom_token_file: Option<PathBuf> = None;
63        let mut shutdown_after_secs: Option<u64> = None;
64        let mut recovery_timeout_ms: Option<u64> = None;
65        let mut shutdown_grace_ms: Option<u64> = None;
66        let mut socket_mode: Option<u32> = None;
67        let mut read_timeout_ms: Option<u64> = None;
68        let mut tracker_capacity: Option<usize> = None;
69        let mut tracker_eviction_policy: Option<EvictionPolicy> = None;
70        let mut clock_source: Option<ClockSource> = None;
71        let mut eviction_scan_window: Option<usize> = None;
72        let mut udp_port: Option<u16> = None;
73        let mut udp_bind_addr: Option<std::net::IpAddr> = None;
74        let mut secure_key_file: Option<PathBuf> = None;
75        let mut accepted_key_file: Option<PathBuf> = None;
76        let mut master_key_file: Option<PathBuf> = None;
77        let mut max_beat_rate: Option<u32> = None;
78        let mut global_beat_rate: Option<u32> = None;
79        let mut global_beat_burst: Option<u32> = None;
80        let mut uds_rcvbuf_bytes: Option<u32> = None;
81        let mut heartbeat_file: Option<PathBuf> = None;
82        let mut self_watchdog: Option<Duration> = None;
83        let mut hw_watchdog: Option<PathBuf> = None;
84        #[allow(unused_mut)]
85        let mut prom_rate_limit_per_sec: Option<u32> = None;
86        #[allow(unused_mut)]
87        let mut prom_rate_limit_burst: Option<u32> = None;
88        let mut i_accept_plaintext_udp = false;
89        let mut i_accept_recovery_on_secure_udp = false;
90        let mut i_accept_recovery_on_plaintext_udp = false;
91        let mut i_accept_secure_udp_non_loopback = false;
92        let mut allow_cross_namespace_agents = false;
93        let mut strict_namespace_check = false;
94        let mut recovery_audit_file: Option<PathBuf> = None;
95        let mut recovery_audit_max_bytes: Option<u64> = None;
96        let mut recovery_audit_sync_every: Option<u32> = None;
97        let mut recovery_capture_stdio = false;
98        let mut recovery_capture_bytes: Option<u32> = None;
99        let mut iteration_budget_ms: Option<u64> = None;
100        let mut scrape_budget_ms: Option<u64> = None;
101        let mut audit_fsync_budget_ms: Option<u32> = None;
102        let mut audit_sync_interval_ms: Option<u32> = None;
103        let mut audit_rotation_budget_ms: Option<u32> = None;
104        #[cfg(feature = "test-hooks")]
105        let mut inject_wedge_ms: Option<u64> = None;
106        let mut signal_handler_mode: Option<crate::signal_install::SignalHandlerMode> = None;
107
108        let mut iter = args.into_iter();
109        while let Some(tok) = iter.next() {
110            match tok.as_str() {
111                "--help" | "-h" => return Err(ConfigError::HelpRequested),
112                "--socket" => {
113                    let v = iter.next().ok_or(ConfigError::MissingValue("--socket"))?;
114                    socket = Some(PathBuf::from(v));
115                }
116                "--threshold-ms" => {
117                    let v = iter
118                        .next()
119                        .ok_or(ConfigError::MissingValue("--threshold-ms"))?;
120                    threshold_ms = Some(parse_u64("--threshold-ms", &v)?);
121                }
122                "--recovery-cmd" => {
123                    return Err(ConfigError::RemovedFlag {
124                        flag: "--recovery-cmd",
125                        replacement: "--recovery-exec",
126                    });
127                }
128                "--recovery-cmd-file" => {
129                    return Err(ConfigError::RemovedFlag {
130                        flag: "--recovery-cmd-file",
131                        replacement: "--recovery-exec-file",
132                    });
133                }
134                "--i-accept-shell-risk" => {
135                    return Err(ConfigError::RemovedFlag {
136                        flag: "--i-accept-shell-risk",
137                        replacement: "--recovery-exec",
138                    });
139                }
140                "--recovery-exec" => {
141                    let v = iter
142                        .next()
143                        .ok_or(ConfigError::MissingValue("--recovery-exec"))?;
144                    recovery_exec_cmd = Some(v);
145                }
146                "--recovery-exec-file" => {
147                    let v = iter
148                        .next()
149                        .ok_or(ConfigError::MissingValue("--recovery-exec-file"))?;
150                    recovery_exec_file = Some(PathBuf::from(v));
151                }
152                "--recovery-debounce-ms" => {
153                    let v = iter
154                        .next()
155                        .ok_or(ConfigError::MissingValue("--recovery-debounce-ms"))?;
156                    recovery_debounce_ms = Some(parse_u64("--recovery-debounce-ms", &v)?);
157                }
158                "--recovery-env" => {
159                    let v = iter
160                        .next()
161                        .ok_or(ConfigError::MissingValue("--recovery-env"))?;
162                    recovery_env.push(v);
163                }
164                "--recovery-inherit-env" => {
165                    recovery_inherit_env = true;
166                }
167                "--socket-mode" => {
168                    let v = iter
169                        .next()
170                        .ok_or(ConfigError::MissingValue("--socket-mode"))?;
171                    socket_mode = Some(parse_octal(&v)?);
172                }
173                "--export-file" => {
174                    let v = iter
175                        .next()
176                        .ok_or(ConfigError::MissingValue("--export-file"))?;
177                    file_export = Some(PathBuf::from(v));
178                }
179                "--export-file-max-bytes" => {
180                    let v = iter
181                        .next()
182                        .ok_or(ConfigError::MissingValue("--export-file-max-bytes"))?;
183                    export_file_max_bytes = Some(parse_u64("--export-file-max-bytes", &v)?);
184                }
185                "--export-file-sync-every" => {
186                    let v = iter
187                        .next()
188                        .ok_or(ConfigError::MissingValue("--export-file-sync-every"))?;
189                    let parsed = parse_u64("--export-file-sync-every", &v)?;
190                    if parsed > u32::MAX as u64 {
191                        return Err(ConfigError::BadValue {
192                            flag: "--export-file-sync-every",
193                            raw: v,
194                        });
195                    }
196                    export_file_sync_every = parsed as u32;
197                }
198                #[cfg(feature = "prometheus-exporter")]
199                "--prom-addr" => {
200                    let v = iter
201                        .next()
202                        .ok_or(ConfigError::MissingValue("--prom-addr"))?;
203                    prom_addr = Some(
204                        v.parse::<SocketAddr>()
205                            .map_err(|_| ConfigError::BadAddr(v))?,
206                    );
207                }
208                #[cfg(feature = "prometheus-exporter")]
209                "--prom-token-file" => {
210                    let v = iter
211                        .next()
212                        .ok_or(ConfigError::MissingValue("--prom-token-file"))?;
213                    prom_token_file = Some(PathBuf::from(v));
214                }
215                "--recovery-timeout-ms" => {
216                    let v = iter
217                        .next()
218                        .ok_or(ConfigError::MissingValue("--recovery-timeout-ms"))?;
219                    recovery_timeout_ms = Some(parse_u64("--recovery-timeout-ms", &v)?);
220                }
221                "--shutdown-grace-ms" => {
222                    let v = iter
223                        .next()
224                        .ok_or(ConfigError::MissingValue("--shutdown-grace-ms"))?;
225                    shutdown_grace_ms = Some(parse_u64("--shutdown-grace-ms", &v)?);
226                }
227                "--read-timeout-ms" => {
228                    let v = iter
229                        .next()
230                        .ok_or(ConfigError::MissingValue("--read-timeout-ms"))?;
231                    read_timeout_ms = Some(parse_u64("--read-timeout-ms", &v)?);
232                }
233                "--tracker-capacity" => {
234                    let v = iter
235                        .next()
236                        .ok_or(ConfigError::MissingValue("--tracker-capacity"))?;
237                    tracker_capacity =
238                        Some(v.parse::<usize>().map_err(|_| ConfigError::BadInteger {
239                            flag: "--tracker-capacity",
240                            raw: v,
241                        })?);
242                }
243                "--eviction-scan-window" => {
244                    let v = iter
245                        .next()
246                        .ok_or(ConfigError::MissingValue("--eviction-scan-window"))?;
247                    eviction_scan_window =
248                        Some(v.parse::<usize>().map_err(|_| ConfigError::BadInteger {
249                            flag: "--eviction-scan-window",
250                            raw: v,
251                        })?);
252                }
253                "--tracker-eviction-policy" => {
254                    let v = iter
255                        .next()
256                        .ok_or(ConfigError::MissingValue("--tracker-eviction-policy"))?;
257                    tracker_eviction_policy = Some(match v.as_str() {
258                        "strict" => EvictionPolicy::Strict,
259                        "balanced" => EvictionPolicy::Balanced,
260                        _ => {
261                            return Err(ConfigError::BadValue {
262                                flag: "--tracker-eviction-policy",
263                                raw: v,
264                            })
265                        }
266                    });
267                }
268                "--clock-source" => {
269                    let v = iter
270                        .next()
271                        .ok_or(ConfigError::MissingValue("--clock-source"))?;
272                    clock_source =
273                        Some(
274                            v.parse::<ClockSource>()
275                                .map_err(|_| ConfigError::BadValue {
276                                    flag: "--clock-source",
277                                    raw: v,
278                                })?,
279                        );
280                }
281                "--signal-handler-mode" => {
282                    let v = iter
283                        .next()
284                        .ok_or(ConfigError::MissingValue("--signal-handler-mode"))?;
285                    signal_handler_mode = Some(
286                        v.parse::<crate::signal_install::SignalHandlerMode>()
287                            .map_err(|_| ConfigError::BadValue {
288                                flag: "--signal-handler-mode",
289                                raw: v,
290                            })?,
291                    );
292                }
293                "--shutdown-after-secs" => {
294                    let v = iter
295                        .next()
296                        .ok_or(ConfigError::MissingValue("--shutdown-after-secs"))?;
297                    shutdown_after_secs = Some(parse_u64("--shutdown-after-secs", &v)?);
298                }
299                "--udp-port" => {
300                    let v = iter.next().ok_or(ConfigError::MissingValue("--udp-port"))?;
301                    udp_port = Some(parse_u16("--udp-port", &v)?);
302                }
303                "--udp-bind-addr" => {
304                    let v = iter
305                        .next()
306                        .ok_or(ConfigError::MissingValue("--udp-bind-addr"))?;
307                    udp_bind_addr = Some(
308                        v.parse::<std::net::IpAddr>()
309                            .map_err(|_| ConfigError::BadAddr(v))?,
310                    );
311                }
312                "--key-file" => {
313                    let v = iter.next().ok_or(ConfigError::MissingValue("--key-file"))?;
314                    secure_key_file = Some(PathBuf::from(v));
315                }
316                "--accepted-key-file" => {
317                    let v = iter
318                        .next()
319                        .ok_or(ConfigError::MissingValue("--accepted-key-file"))?;
320                    accepted_key_file = Some(PathBuf::from(v));
321                }
322                "--master-key-file" => {
323                    let v = iter
324                        .next()
325                        .ok_or(ConfigError::MissingValue("--master-key-file"))?;
326                    master_key_file = Some(PathBuf::from(v));
327                }
328                "--key-env" | "--master-key-env" | "--accepted-key-env" => {
329                    // Removed for security: env-var keys are exposed via
330                    // /proc/<pid>/environ and `docker inspect`. See
331                    // book/src/architecture/peer-authentication.md.
332                    let flag = match tok.as_str() {
333                        "--key-env" => "--key-env",
334                        "--master-key-env" => "--master-key-env",
335                        _ => "--accepted-key-env",
336                    };
337                    return Err(ConfigError::RemovedFlag {
338                        flag,
339                        replacement: "--key-file (mode 0600, owned by the observer UID)",
340                    });
341                }
342                "--max-beat-rate" => {
343                    let v = iter
344                        .next()
345                        .ok_or(ConfigError::MissingValue("--max-beat-rate"))?;
346                    max_beat_rate =
347                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
348                            flag: "--max-beat-rate",
349                            raw: v,
350                        })?);
351                }
352                "--global-beat-rate" => {
353                    let v = iter
354                        .next()
355                        .ok_or(ConfigError::MissingValue("--global-beat-rate"))?;
356                    global_beat_rate =
357                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
358                            flag: "--global-beat-rate",
359                            raw: v,
360                        })?);
361                }
362                "--global-beat-burst" => {
363                    let v = iter
364                        .next()
365                        .ok_or(ConfigError::MissingValue("--global-beat-burst"))?;
366                    global_beat_burst =
367                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
368                            flag: "--global-beat-burst",
369                            raw: v,
370                        })?);
371                }
372                "--uds-rcvbuf-bytes" => {
373                    let v = iter
374                        .next()
375                        .ok_or(ConfigError::MissingValue("--uds-rcvbuf-bytes"))?;
376                    uds_rcvbuf_bytes =
377                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
378                            flag: "--uds-rcvbuf-bytes",
379                            raw: v,
380                        })?);
381                }
382                "--heartbeat-file" => {
383                    let v = iter
384                        .next()
385                        .ok_or(ConfigError::MissingValue("--heartbeat-file"))?;
386                    heartbeat_file = Some(PathBuf::from(v));
387                }
388                "--self-watchdog-secs" => {
389                    let v = iter
390                        .next()
391                        .ok_or(ConfigError::MissingValue("--self-watchdog-secs"))?;
392                    let secs = v.parse::<u64>().map_err(|_| ConfigError::BadInteger {
393                        flag: "--self-watchdog-secs",
394                        raw: v,
395                    })?;
396                    self_watchdog = Some(Duration::from_secs(secs));
397                }
398                "--hw-watchdog" => {
399                    let v = iter
400                        .next()
401                        .ok_or(ConfigError::MissingValue("--hw-watchdog"))?;
402                    hw_watchdog = Some(PathBuf::from(v));
403                }
404                #[cfg(feature = "test-hooks")]
405                "--inject-wedge-ms" => {
406                    let v = iter
407                        .next()
408                        .ok_or(ConfigError::MissingValue("--inject-wedge-ms"))?;
409                    let ms = v.parse::<u64>().map_err(|_| ConfigError::BadInteger {
410                        flag: "--inject-wedge-ms",
411                        raw: v,
412                    })?;
413                    inject_wedge_ms = Some(ms);
414                }
415                #[cfg(feature = "prometheus-exporter")]
416                "--prom-rate-limit-per-sec" => {
417                    let v = iter
418                        .next()
419                        .ok_or(ConfigError::MissingValue("--prom-rate-limit-per-sec"))?;
420                    prom_rate_limit_per_sec =
421                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
422                            flag: "--prom-rate-limit-per-sec",
423                            raw: v,
424                        })?);
425                }
426                #[cfg(feature = "prometheus-exporter")]
427                "--prom-rate-limit-burst" => {
428                    let v = iter
429                        .next()
430                        .ok_or(ConfigError::MissingValue("--prom-rate-limit-burst"))?;
431                    prom_rate_limit_burst =
432                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
433                            flag: "--prom-rate-limit-burst",
434                            raw: v,
435                        })?);
436                }
437                "--i-accept-plaintext-udp" => {
438                    i_accept_plaintext_udp = true;
439                }
440                "--secure-udp-i-accept-recovery-on-unauthenticated-transport" => {
441                    i_accept_recovery_on_secure_udp = true;
442                }
443                "--plaintext-udp-i-accept-recovery-on-unauthenticated-transport" => {
444                    i_accept_recovery_on_plaintext_udp = true;
445                }
446                "--i-accept-secure-udp-non-loopback" => {
447                    i_accept_secure_udp_non_loopback = true;
448                }
449                "--allow-cross-namespace-agents" => {
450                    allow_cross_namespace_agents = true;
451                }
452                "--strict-namespace-check" => {
453                    strict_namespace_check = true;
454                }
455                "--recovery-audit-file" => {
456                    let v = iter
457                        .next()
458                        .ok_or(ConfigError::MissingValue("--recovery-audit-file"))?;
459                    recovery_audit_file = Some(PathBuf::from(v));
460                }
461                "--recovery-audit-max-bytes" => {
462                    let v = iter
463                        .next()
464                        .ok_or(ConfigError::MissingValue("--recovery-audit-max-bytes"))?;
465                    recovery_audit_max_bytes = Some(parse_u64("--recovery-audit-max-bytes", &v)?);
466                }
467                "--recovery-audit-sync-every" => {
468                    let v = iter
469                        .next()
470                        .ok_or(ConfigError::MissingValue("--recovery-audit-sync-every"))?;
471                    let parsed = v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
472                        flag: "--recovery-audit-sync-every",
473                        raw: v.clone(),
474                    })?;
475                    if parsed == 0 {
476                        return Err(ConfigError::BadInteger {
477                            flag: "--recovery-audit-sync-every",
478                            raw: v,
479                        });
480                    }
481                    recovery_audit_sync_every = Some(parsed);
482                }
483                "--recovery-capture-stdio" => {
484                    recovery_capture_stdio = true;
485                }
486                "--recovery-capture-bytes" => {
487                    let v = iter
488                        .next()
489                        .ok_or(ConfigError::MissingValue("--recovery-capture-bytes"))?;
490                    recovery_capture_bytes =
491                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
492                            flag: "--recovery-capture-bytes",
493                            raw: v,
494                        })?);
495                }
496                "--iteration-budget-ms" => {
497                    let v = iter
498                        .next()
499                        .ok_or(ConfigError::MissingValue("--iteration-budget-ms"))?;
500                    iteration_budget_ms = Some(parse_u64("--iteration-budget-ms", &v)?);
501                }
502                "--scrape-budget-ms" => {
503                    let v = iter
504                        .next()
505                        .ok_or(ConfigError::MissingValue("--scrape-budget-ms"))?;
506                    scrape_budget_ms = Some(parse_u64("--scrape-budget-ms", &v)?);
507                }
508                "--audit-fsync-budget-ms" => {
509                    let v = iter
510                        .next()
511                        .ok_or(ConfigError::MissingValue("--audit-fsync-budget-ms"))?;
512                    let parsed = v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
513                        flag: "--audit-fsync-budget-ms",
514                        raw: v.clone(),
515                    })?;
516                    if parsed == 0 {
517                        return Err(ConfigError::BadInteger {
518                            flag: "--audit-fsync-budget-ms",
519                            raw: v,
520                        });
521                    }
522                    audit_fsync_budget_ms = Some(parsed);
523                }
524                "--audit-sync-interval-ms" => {
525                    let v = iter
526                        .next()
527                        .ok_or(ConfigError::MissingValue("--audit-sync-interval-ms"))?;
528                    audit_sync_interval_ms =
529                        Some(v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
530                            flag: "--audit-sync-interval-ms",
531                            raw: v,
532                        })?);
533                }
534                "--audit-rotation-budget-ms" => {
535                    let v = iter
536                        .next()
537                        .ok_or(ConfigError::MissingValue("--audit-rotation-budget-ms"))?;
538                    let parsed = v.parse::<u32>().map_err(|_| ConfigError::BadInteger {
539                        flag: "--audit-rotation-budget-ms",
540                        raw: v.clone(),
541                    })?;
542                    if parsed == 0 {
543                        return Err(ConfigError::BadInteger {
544                            flag: "--audit-rotation-budget-ms",
545                            raw: v,
546                        });
547                    }
548                    audit_rotation_budget_ms = Some(parsed);
549                }
550                other => return Err(ConfigError::UnknownFlag(other.to_string())),
551            }
552        }
553
554        let socket = socket.ok_or(ConfigError::MissingRequired("--socket"))?;
555        let threshold_ms = threshold_ms.ok_or(ConfigError::MissingRequired("--threshold-ms"))?;
556
557        if threshold_ms < MIN_THRESHOLD_MS {
558            return Err(ConfigError::ThresholdTooLow {
559                value: threshold_ms,
560                min: MIN_THRESHOLD_MS,
561            });
562        }
563
564        // /metrics has no anonymous access.  A reverse proxy doing TLS
565        // termination + auth is fine — but it must still inject the bearer
566        // token on the upstream scrape, which means the operator owns the
567        // token file regardless of the network topology.
568        if prom_addr.is_some() && prom_token_file.is_none() {
569            return Err(ConfigError::PromAddrRequiresToken);
570        }
571        if prom_token_file.is_some() && prom_addr.is_none() {
572            return Err(ConfigError::MutuallyExclusive {
573                a: "--prom-token-file",
574                b: "(missing --prom-addr)",
575            });
576        }
577
578        let shutdown_grace_ms = shutdown_grace_ms.unwrap_or(DEFAULT_SHUTDOWN_GRACE_MS);
579        if shutdown_grace_ms < MIN_SHUTDOWN_GRACE_MS {
580            return Err(ConfigError::ShutdownGraceTooLow {
581                value: shutdown_grace_ms,
582                min: MIN_SHUTDOWN_GRACE_MS,
583            });
584        }
585
586        let recovery_debounce =
587            Duration::from_millis(recovery_debounce_ms.unwrap_or(DEFAULT_RECOVERY_DEBOUNCE_MS));
588
589        let recovery_capture_bytes_resolved =
590            recovery_capture_bytes.unwrap_or(DEFAULT_RECOVERY_CAPTURE_BYTES);
591        if recovery_capture_bytes_resolved > MAX_RECOVERY_CAPTURE_BYTES {
592            return Err(ConfigError::RecoveryCaptureBytesTooLarge {
593                value: recovery_capture_bytes_resolved,
594                max: MAX_RECOVERY_CAPTURE_BYTES,
595            });
596        }
597
598        // Capture is meaningless without a recovery command. Reject the flag
599        // at parse time so a misconfiguration surfaces at startup rather than
600        // hiding silently in a runbook.
601        if recovery_capture_stdio && recovery_exec_cmd.is_none() && recovery_exec_file.is_none() {
602            return Err(ConfigError::RecoveryCaptureRequiresRecovery);
603        }
604
605        // H2 mitigation — recovery commands are operator-controlled actions
606        // (`kill -9 {pid}`, `systemctl restart agent@{pid}.service`).  UDP
607        // transports cannot attest the sending process; a holder of the AEAD
608        // key can forge a beat for any pid, then stop sending to trigger the
609        // recovery command against that pid.  Refuse to start when both a UDP
610        // listener and a recovery command are configured unless the operator
611        // passes the matching per-listener flag.
612        //
613        // Trust is per-listener: --secure-udp-i-accept-recovery-on-
614        // unauthenticated-transport covers the secure-UDP listener only;
615        // --plaintext-udp-i-accept-recovery-on-unauthenticated-transport
616        // covers the plaintext-UDP listener only. They are independent.
617        let any_recovery_configured = recovery_exec_cmd.is_some() || recovery_exec_file.is_some();
618        if any_recovery_configured {
619            if let Some(port) = udp_port {
620                let bind_ip =
621                    udp_bind_addr.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
622                let udp_addr = format!("{bind_ip}:{port}");
623                // Is a secure-UDP listener being configured (any key file)?
624                let secure_udp = secure_key_file.is_some()
625                    || accepted_key_file.is_some()
626                    || master_key_file.is_some();
627                // Is a plaintext-UDP listener being configured (no keys, explicit opt-in)?
628                let plaintext_udp = i_accept_plaintext_udp && !secure_udp;
629
630                if secure_udp && !i_accept_recovery_on_secure_udp {
631                    return Err(ConfigError::RecoveryRequiresAuthenticatedTransport { udp_addr });
632                }
633                if plaintext_udp && !i_accept_recovery_on_plaintext_udp {
634                    return Err(ConfigError::RecoveryRequiresAuthenticatedTransport { udp_addr });
635                }
636            }
637        }
638
639        // H4 mitigation — a secure-UDP listener carries only a 1-deep replay
640        // shadow after capacity-forced eviction.  Acceptable for loopback
641        // (only same-host processes can spoof 127.0.0.0/8 source addresses)
642        // but inadequate for any reachable network.  Refuse non-loopback
643        // binds unless the operator explicitly accepts the risk.  Defers to
644        // the runtime layer for the implicit "no --udp-bind-addr +
645        // secure-UDP keys → loopback default" case (resolved in main.rs).
646        if let Some(port) = udp_port {
647            let secure_udp = secure_key_file.is_some()
648                || accepted_key_file.is_some()
649                || master_key_file.is_some();
650            if secure_udp {
651                if let Some(ip) = udp_bind_addr {
652                    if !ip.is_loopback() && !i_accept_secure_udp_non_loopback {
653                        return Err(ConfigError::SecureUdpRequiresLoopbackBind {
654                            udp_addr: format!("{ip}:{port}"),
655                        });
656                    }
657                }
658            }
659        }
660
661        // --iteration-budget-ms resolution and bounds check.  The default
662        // lives in the exporter so that production builds without metrics
663        // still link the constant for tests.  Bounds reject the noise-floor
664        // case (every iteration overruns) and the never-fires case (budget
665        // overlaps --self-watchdog-secs).
666        let iteration_budget = match iteration_budget_ms {
667            Some(ms) => {
668                if !(MIN_ITERATION_BUDGET_MS..=MAX_ITERATION_BUDGET_MS).contains(&ms) {
669                    return Err(ConfigError::IterationBudgetOutOfRange {
670                        value: ms,
671                        min: MIN_ITERATION_BUDGET_MS,
672                        max: MAX_ITERATION_BUDGET_MS,
673                    });
674                }
675                Duration::from_millis(ms)
676            }
677            None => crate::exporter::DEFAULT_ITERATION_BUDGET,
678        };
679
680        // --scrape-budget-ms — same bounds story as --iteration-budget-ms:
681        // reject the noise-floor case (overlaps serve_pending's own 200 ms
682        // structural cap) and the never-fires case (overlaps the self-
683        // watchdog).  The two budgets are independent: scrape-storm alarms
684        // fire on scrape_budget; beat-path alarms fire on iteration_budget.
685        let scrape_budget = match scrape_budget_ms {
686            Some(ms) => {
687                if !(MIN_SCRAPE_BUDGET_MS..=MAX_SCRAPE_BUDGET_MS).contains(&ms) {
688                    return Err(ConfigError::ScrapeBudgetOutOfRange {
689                        value: ms,
690                        min: MIN_SCRAPE_BUDGET_MS,
691                        max: MAX_SCRAPE_BUDGET_MS,
692                    });
693                }
694                Duration::from_millis(ms)
695            }
696            None => crate::exporter::DEFAULT_SCRAPE_BUDGET,
697        };
698
699        let eviction_scan_window_resolved = match eviction_scan_window {
700            Some(v) => {
701                if !(MIN_EVICTION_SCAN_WINDOW..=MAX_EVICTION_SCAN_WINDOW).contains(&v) {
702                    return Err(ConfigError::EvictionScanWindowOutOfRange {
703                        value: v,
704                        min: MIN_EVICTION_SCAN_WINDOW,
705                        max: MAX_EVICTION_SCAN_WINDOW,
706                    });
707                }
708                v
709            }
710            None => DEFAULT_EVICTION_SCAN_WINDOW,
711        };
712
713        // H7: reject platform-restricted clock sources (`boottime` on
714        // non-Linux, `monotonic-raw` on non-macOS) before any listener
715        // bind so the operator sees the misconfiguration immediately.
716        if let Some(src) = clock_source {
717            if src.clk_id().is_none() {
718                return Err(ConfigError::ClockSourceUnsupported {
719                    source: src,
720                    platform: std::env::consts::OS,
721                });
722            }
723        }
724
725        Ok(Config {
726            socket,
727            threshold: Duration::from_millis(threshold_ms),
728            recovery_exec_cmd,
729            recovery_exec_file,
730            recovery_debounce,
731            recovery_env,
732            recovery_inherit_env,
733            file_export,
734            export_file_max_bytes,
735            export_file_sync_every,
736            prom_addr,
737            prom_token_file,
738            shutdown_after: shutdown_after_secs.map(Duration::from_secs),
739            recovery_timeout: recovery_timeout_ms.map(Duration::from_millis),
740            shutdown_grace: Duration::from_millis(shutdown_grace_ms),
741            socket_mode: socket_mode.unwrap_or(DEFAULT_SOCKET_MODE),
742            read_timeout: Duration::from_millis(read_timeout_ms.unwrap_or(DEFAULT_READ_TIMEOUT_MS)),
743            tracker_capacity: tracker_capacity.unwrap_or(DEFAULT_CAPACITY),
744            tracker_eviction_policy: tracker_eviction_policy.unwrap_or(EvictionPolicy::Strict),
745            eviction_scan_window: eviction_scan_window_resolved,
746            udp_port,
747            udp_bind_addr,
748            secure_key_file,
749            accepted_key_file,
750            master_key_file,
751            max_beat_rate: match max_beat_rate {
752                None => Some(super::types::DEFAULT_MAX_BEAT_RATE),
753                Some(0) => None,
754                Some(n) => Some(n),
755            },
756            global_beat_rate: global_beat_rate.unwrap_or(super::types::DEFAULT_GLOBAL_BEAT_RATE),
757            global_beat_burst: global_beat_burst.unwrap_or(super::types::DEFAULT_GLOBAL_BEAT_BURST),
758            uds_rcvbuf_bytes: uds_rcvbuf_bytes.unwrap_or(super::types::DEFAULT_UDS_RCVBUF_BYTES),
759            heartbeat_file,
760            self_watchdog,
761            hw_watchdog,
762            prom_rate_limit_per_sec: prom_rate_limit_per_sec
763                .unwrap_or(DEFAULT_PROM_RATE_LIMIT_PER_SEC),
764            prom_rate_limit_burst: prom_rate_limit_burst.unwrap_or(DEFAULT_PROM_RATE_LIMIT_BURST),
765            i_accept_plaintext_udp,
766            i_accept_recovery_on_secure_udp,
767            i_accept_recovery_on_plaintext_udp,
768            i_accept_secure_udp_non_loopback,
769            allow_cross_namespace_agents,
770            strict_namespace_check,
771            recovery_audit_file,
772            recovery_audit_max_bytes,
773            recovery_audit_sync_every: recovery_audit_sync_every.unwrap_or(1),
774            recovery_capture_stdio,
775            recovery_capture_bytes: recovery_capture_bytes_resolved,
776            iteration_budget,
777            scrape_budget,
778            audit_fsync_budget_ms: audit_fsync_budget_ms
779                .unwrap_or(super::types::DEFAULT_AUDIT_FSYNC_BUDGET_MS),
780            audit_sync_interval_ms: audit_sync_interval_ms
781                .unwrap_or(super::types::DEFAULT_AUDIT_SYNC_INTERVAL_MS),
782            audit_rotation_budget_ms: audit_rotation_budget_ms
783                .unwrap_or(super::types::DEFAULT_AUDIT_ROTATION_BUDGET_MS),
784            #[cfg(feature = "test-hooks")]
785            inject_wedge_ms,
786            clock_source: clock_source.unwrap_or(ClockSource::Monotonic),
787            signal_handler_mode: signal_handler_mode.unwrap_or_default(),
788        })
789    }
790}