Skip to main content

sentinel_core/config/
validate.rs

1//! Validation of a parsed [`Config`]: bound checks, comfort-zone warnings,
2//! and control-character rejection for every TOML section.
3
4use std::collections::HashMap;
5
6use crate::score::alumet::AlumetConfig;
7use crate::score::cloud_energy::config::{CloudEnergyConfig, ServiceCloudConfig};
8use crate::score::kepler::{KeplerConfig, KeplerMetricKind};
9use crate::score::redfish::{RedfishConfig, RedfishEndpoint};
10use crate::score::scaphandre::ScaphandreConfig;
11
12use super::{Config, RESERVED_DISCLOSE_OUTPUT_PATH_VERSION};
13
14fn check_range<T: PartialOrd + std::fmt::Display>(
15    name: &str,
16    val: &T,
17    min: &T,
18    max: &T,
19) -> Result<(), String> {
20    if val < min {
21        return Err(format!("{name} must be >= {min}, got {val}"));
22    }
23    if val > max {
24        return Err(format!("{name} must be <= {max}, got {val}"));
25    }
26    Ok(())
27}
28
29fn check_min<T: PartialOrd + std::fmt::Display>(
30    name: &str,
31    val: &T,
32    min: &T,
33) -> Result<(), String> {
34    if val < min {
35        return Err(format!("{name} must be >= {min}, got {val}"));
36    }
37    Ok(())
38}
39
40/// Emit a single startup warning when `val` is inside the hard bounds but
41/// outside the recommended "comfort zone" `[comfort_lo, comfort_hi]`.
42///
43/// See design doc 07 > "Comfort-zone warnings" for the rationale and the
44/// list of bands per field.
45fn warn_outside_comfort_zone<T>(
46    name: &str,
47    val: &T,
48    comfort_lo: &T,
49    comfort_hi: &T,
50    note_low: &str,
51    note_high: &str,
52) where
53    T: PartialOrd + std::fmt::Display,
54{
55    if val < comfort_lo {
56        tracing::warn!(
57            field = %name,
58            value = %val,
59            recommended_min = %comfort_lo,
60            "{name} = {val} is below the recommended floor {comfort_lo}; {note_low}"
61        );
62    } else if val > comfort_hi {
63        tracing::warn!(
64            field = %name,
65            value = %val,
66            recommended_max = %comfort_hi,
67            "{name} = {val} is above the recommended ceiling {comfort_hi}; {note_high}"
68        );
69    }
70}
71
72/// `true` if `s` contains any terminal control character: C0 (`< 0x20`),
73/// DEL (`0x7F`), or C1 (`0x80..=0x9F`). The C1 range carries the single-byte
74/// CSI (`U+009B`), ST (`U+009C`) and OSC (`U+009D`) introducers honoured by
75/// VT-family terminals when 8-bit controls are enabled, so a TOML field that
76/// reaches `tracing::warn!` on stderr must reject them at load time the same
77/// way [`crate::text_safety::sanitize_for_terminal`] rejects them at render.
78pub(crate) fn has_control_char(s: &str) -> bool {
79    s.chars().any(|c| {
80        let code = c as u32;
81        code < 0x20 || code == 0x7F || (0x80..=0x9F).contains(&code)
82    })
83}
84
85/// Shared `[green.alumet.database]` field checks, used by the raw TOML
86/// pass (fail loud at load) and the typed pass (defense in depth for
87/// programmatic construction). Control chars are rejected before the
88/// value can reach an error message.
89pub(super) fn validate_alumet_database_fields(
90    label_value: &str,
91    region: Option<&str>,
92) -> Result<(), String> {
93    if has_control_char(label_value) {
94        return Err("[green.alumet.database] label_value contains control characters".to_string());
95    }
96    if label_value.trim().is_empty() || label_value.len() > 256 {
97        return Err(format!(
98            "[green.alumet.database] label_value must be 1-256 chars and not blank, got '{label_value}'"
99        ));
100    }
101    if let Some(region) = region
102        && !crate::score::carbon::is_valid_region_id(region)
103    {
104        return Err(format!(
105            "[green.alumet.database] region '{region}' contains invalid characters; \
106             allowed: ASCII letters, digits, '-' and '_', 1-64 chars"
107        ));
108    }
109    Ok(())
110}
111
112/// Validate the wildcard-mode interactions of `[daemon.cors] allowed_origins`.
113///
114/// - `["*"]` mixed with explicit origins is ambiguous and silently degrades to
115///   wildcard mode in `build_cors_layer`. Reject the mix at config load.
116/// - `["*"]` combined with `[daemon.ack] api_key` lets any browser origin
117///   replay a captured `X-API-Key` header (header-based auth, not blocked by
118///   `allow_credentials = false`). Reject the combination.
119fn validate_cors_wildcard_mode(
120    has_wildcard: bool,
121    origin_count: usize,
122    has_api_key: bool,
123) -> Result<(), String> {
124    if has_wildcard && origin_count > 1 {
125        return Err(
126            "[daemon.cors] allowed_origins cannot mix \"*\" with explicit origins, \
127             either use [\"*\"] for wildcard mode or list every origin explicitly"
128                .to_string(),
129        );
130    }
131    if has_wildcard && has_api_key {
132        return Err(
133            "[daemon.cors] allowed_origins = [\"*\"] is incompatible with \
134             [daemon.ack] api_key, since X-API-Key is sent on every cross-origin \
135             request and would be replayable from any browser tab. \
136             Use an explicit origin list or unset api_key for development"
137                .to_string(),
138        );
139    }
140    Ok(())
141}
142
143/// Validate a single `[daemon.cors] allowed_origins` entry: rejects empty
144/// strings, control characters, missing scheme and trailing slashes. The
145/// literal `"*"` is accepted (wildcard-mode interactions live in
146/// [`validate_cors_wildcard_mode`]).
147fn validate_cors_origin(origin: &str) -> Result<(), String> {
148    if origin.is_empty() {
149        return Err(
150            "[daemon.cors] allowed_origins entry is empty, drop it or set a value".to_string(),
151        );
152    }
153    if has_control_char(origin) {
154        return Err(format!(
155            "[daemon.cors] allowed_origins entry '{origin}' contains control characters"
156        ));
157    }
158    if origin == "*" {
159        return Ok(());
160    }
161    if !(origin.starts_with("http://") || origin.starts_with("https://")) {
162        return Err(format!(
163            "[daemon.cors] allowed_origins entry '{origin}' must start with http:// or https:// (or be \"*\" for wildcard mode)"
164        ));
165    }
166    if origin.ends_with('/') {
167        return Err(format!(
168            "[daemon.cors] allowed_origins entry '{origin}' must not end with a trailing slash, an origin is scheme + host + optional port"
169        ));
170    }
171    Ok(())
172}
173
174/// Validate the authority portion of an HTTP(S) URI.
175/// Rejects credentials, empty host, control characters, and invalid port.
176/// Handles IPv6 bracket notation (`[::1]`, `[::1]:8080`).
177pub(super) fn validate_http_authority(url: &str, label: &str) -> Result<(), String> {
178    let after_scheme = url
179        .strip_prefix("https://")
180        .or_else(|| url.strip_prefix("http://"))
181        .unwrap_or(url);
182    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
183    if authority.is_empty() {
184        return Err(format!("{label} '{url}' has no host"));
185    }
186    if authority.contains('@') {
187        return Err(format!(
188            "{label} must not contain credentials (userinfo): '{url}'"
189        ));
190    }
191    if has_control_char(authority) {
192        return Err(format!("{label} '{url}' contains control characters"));
193    }
194    // Port validation: skip for bare IPv6 without port (`[::1]`), handle
195    // bracketed IPv6 with port (`[::1]:8080`) via the `]:` delimiter.
196    if authority.starts_with('[') {
197        // IPv6 bracket notation: port follows `]:` if present.
198        if let Some(bracket_end) = authority.find(']') {
199            let after_bracket = &authority[bracket_end + 1..];
200            if let Some(port_str) = after_bracket.strip_prefix(':')
201                && !port_str.is_empty()
202                && port_str.parse::<u16>().is_err()
203            {
204                return Err(format!("{label} '{url}' has an invalid port"));
205            }
206        }
207    } else if let Some(port_str) = authority.rsplit(':').next()
208        && authority.contains(':')
209        && port_str.parse::<u16>().is_err()
210    {
211        return Err(format!("{label} '{url}' has an invalid port"));
212    }
213    Ok(())
214}
215
216impl Config {
217    /// Validate that config values are within acceptable bounds.
218    ///
219    /// # Errors
220    ///
221    /// Returns a `String` description of the first invalid value found.
222    /// The caller (`load_from_str`) wraps this in `ConfigError::Validation`.
223    pub fn validate(&self) -> Result<(), String> {
224        self.validate_daemon_limits()?;
225        self.validate_detection_params()?;
226        self.validate_rates()?;
227        self.validate_tls()?;
228        self.validate_green()?;
229        self.validate_daemon_ack()?;
230        self.validate_daemon_cors()?;
231        self.validate_daemon_archive()?;
232        self.validate_reporting()?;
233        self.validate_cross_section_consistency()?;
234        Ok(())
235    }
236
237    /// Emit the non-loopback security advisory if applicable.
238    ///
239    /// The default is `127.0.0.1` (loopback). Advanced users may override
240    /// to `0.0.0.0` for container deployments behind a reverse proxy. We
241    /// warn loudly rather than rejecting, because the user's intent is
242    /// explicit (they changed the config) and a hard reject would force
243    /// workarounds (e.g., iptables) that are harder to audit.
244    ///
245    /// Kept separate from `validate()` because it is the only check
246    /// that depends on CLI overrides (`--listen-address`), so the daemon
247    /// entrypoint calls it a second time after applying the overrides.
248    /// The other advisory warnings inside `validate()` are config-only
249    /// and must be emitted exactly once, at load time, to avoid making
250    /// an operator believe the daemon validates the same config twice.
251    pub fn warn_listen_addr_if_non_loopback(&self) {
252        if self.daemon.listen_addr != "127.0.0.1" && self.daemon.listen_addr != "::1" {
253            tracing::warn!(
254                "Daemon configured to listen on non-loopback address: {}. \
255                 Endpoints have no authentication, use a reverse proxy or \
256                 network policy for security.",
257                self.daemon.listen_addr
258            );
259        }
260    }
261
262    /// Validate `[reporting]` settings. Rejects unknown intent /
263    /// confidentiality values and requires `org_config_path` when
264    /// `intent = "official"`.
265    fn validate_reporting(&self) -> Result<(), String> {
266        if let Some(intent) = &self.reporting.intent {
267            match intent.as_str() {
268                "internal" | "official" | "audited" => {}
269                other => {
270                    return Err(format!(
271                        "[reporting] intent must be one of \"internal\", \"official\", \"audited\", got {other:?}"
272                    ));
273                }
274            }
275        }
276        if let Some(level) = &self.reporting.confidentiality_level {
277            match level.as_str() {
278                "internal" | "public" => {}
279                other => {
280                    return Err(format!(
281                        "[reporting] confidentiality_level must be \"internal\" or \"public\", got {other:?}"
282                    ));
283                }
284            }
285        }
286        if self.reporting.intent.as_deref() == Some("official")
287            && self
288                .reporting
289                .org_config_path
290                .as_deref()
291                .is_none_or(str::is_empty)
292        {
293            return Err(
294                "[reporting] org_config_path is required when intent = \"official\"".to_string(),
295            );
296        }
297        Ok(())
298    }
299
300    /// Reporting-section advisory warnings emitted at load time only.
301    /// Kept separate from `validate_reporting` because the daemon
302    /// entrypoint re-runs `validate()` after applying CLI overrides
303    /// (`--listen-address`, ports), and an advisory not affected by
304    /// those overrides must not be re-emitted, otherwise an operator
305    /// upgrading 0.6.2 -> 0.7.0 sees the same warning twice and
306    /// suspects two daemon instances or a duplicated config layer.
307    pub(super) fn warn_reporting_advisory(&self) {
308        if self
309            .reporting
310            .disclose_output_path
311            .as_deref()
312            .is_some_and(|p| !p.is_empty())
313        {
314            tracing::warn!(
315                "[reporting] disclose_output_path is set but currently unused. \
316                 Reserved for daemon-triggered periodic disclosures (planned for {}). \
317                 Reports today are produced exclusively via `perf-sentinel disclose --output`.",
318                RESERVED_DISCLOSE_OUTPUT_PATH_VERSION
319            );
320        }
321    }
322
323    /// Validate `[daemon.archive]` settings when present.
324    fn validate_daemon_archive(&self) -> Result<(), String> {
325        let Some(archive) = &self.daemon.archive else {
326            return Ok(());
327        };
328        if archive.path.trim().is_empty() {
329            return Err("[daemon.archive] path must not be empty".to_string());
330        }
331        if has_control_char(&archive.path) {
332            return Err("[daemon.archive] path contains control characters".to_string());
333        }
334        if archive.max_size_mb < 1 {
335            return Err("[daemon.archive] max_size_mb must be >= 1".to_string());
336        }
337        if archive.max_files < 1 {
338            return Err("[daemon.archive] max_files must be >= 1".to_string());
339        }
340        Ok(())
341    }
342
343    /// Cross-section consistency checks that no individual section
344    /// can validate alone. Today this is small (CORS-vs-API), but
345    /// `validate` is intentionally extensible: any future "you set X
346    /// but Y is off" trap belongs here.
347    fn validate_cross_section_consistency(&self) -> Result<(), String> {
348        if !self.daemon.api_enabled && !self.daemon.cors.allowed_origins.is_empty() {
349            return Err(
350                "[daemon.cors] allowed_origins is set but [daemon] api_enabled = false. \
351                 The CORS layer would attach to a non-mounted /api/* sub-router and \
352                 silently do nothing, which is almost always a misconfiguration. \
353                 Either remove [daemon.cors] allowed_origins for this environment, or \
354                 enable the API with [daemon] api_enabled = true."
355                    .to_string(),
356            );
357        }
358        if self.daemon.archive.is_some() && !self.green.enabled {
359            return Err(
360                "[daemon.archive] is configured but [green] enabled = false. The archive \
361                 would write windows with zero carbon/energy, making `perf-sentinel disclose` \
362                 produce a meaningless output. Either enable green scoring or remove the \
363                 archive section."
364                    .to_string(),
365            );
366        }
367        Ok(())
368    }
369
370    pub(super) fn validate_daemon_cors(&self) -> Result<(), String> {
371        let has_wildcard = self.daemon.cors.allowed_origins.iter().any(|o| o == "*");
372        validate_cors_wildcard_mode(
373            has_wildcard,
374            self.daemon.cors.allowed_origins.len(),
375            self.daemon.ack.api_key.is_some(),
376        )?;
377        for origin in &self.daemon.cors.allowed_origins {
378            validate_cors_origin(origin)?;
379        }
380        Ok(())
381    }
382
383    /// Validate `[daemon.ack]` settings.
384    pub(super) fn validate_daemon_ack(&self) -> Result<(), String> {
385        if let Some(key) = &self.daemon.ack.api_key {
386            if key.is_empty() {
387                return Err("[daemon.ack] api_key must not be empty".to_string());
388            }
389            if has_control_char(key) {
390                return Err("[daemon.ack] api_key contains control characters".to_string());
391            }
392            // Hard reject obviously-broken keys. The threat model is a
393            // co-resident local attacker hitting the loopback API at
394            // line rate, with no rate limiting on the daemon side.
395            // 36^12 ~= 4.7e18 is well past the brute-force horizon for
396            // any realistic deployment, 16+ remains the recommended
397            // floor for production.
398            if key.len() < 12 {
399                return Err(format!(
400                    "[daemon.ack] api_key is too short ({} chars), \
401                     use at least 12 characters (16 recommended)",
402                    key.len()
403                ));
404            }
405            if key.len() < 16 {
406                tracing::warn!(
407                    len = key.len(),
408                    "[daemon.ack] api_key is shorter than 16 characters, \
409                     consider a longer secret to resist brute-force attempts"
410                );
411            }
412        }
413        if let Some(path) = &self.daemon.ack.storage_path
414            && has_control_char(path)
415        {
416            return Err("[daemon.ack] storage_path contains control characters".to_string());
417        }
418        if let Some(path) = &self.daemon.ack.toml_path
419            && has_control_char(path)
420        {
421            return Err("[daemon.ack] toml_path contains control characters".to_string());
422        }
423        Ok(())
424    }
425
426    /// Validate TLS configuration: both paths must be set or both absent.
427    /// When set, verify the files exist and warn if the key is
428    /// world-readable on Unix.
429    pub(super) fn validate_tls(&self) -> Result<(), String> {
430        match (&self.daemon.tls.cert_path, &self.daemon.tls.key_path) {
431            (Some(cert), Some(key)) => {
432                if has_control_char(cert) {
433                    return Err("[daemon] tls.cert_path contains control characters".to_string());
434                }
435                if has_control_char(key) {
436                    return Err("[daemon] tls.key_path contains control characters".to_string());
437                }
438                if !std::path::Path::new(cert).exists() {
439                    return Err(format!("[daemon] tls.cert_path '{cert}' does not exist"));
440                }
441                if !std::path::Path::new(key).exists() {
442                    return Err(format!("[daemon] tls.key_path '{key}' does not exist"));
443                }
444                #[cfg(unix)]
445                {
446                    use std::os::unix::fs::PermissionsExt;
447                    if let Ok(meta) = std::fs::metadata(key) {
448                        let mode = meta.permissions().mode();
449                        if mode & 0o077 != 0 {
450                            tracing::warn!(
451                                "TLS key file '{key}' is readable by group/others \
452                                 (mode {mode:o}). Consider restricting to owner-only \
453                                 (chmod 600)."
454                            );
455                        }
456                    }
457                }
458                tracing::info!("TLS enabled for daemon OTLP receivers (cert: {cert})");
459                Ok(())
460            }
461            (None, None) => Ok(()),
462            (Some(_), None) => {
463                Err("[daemon] tls.cert_path is set but tls.key_path is missing".to_string())
464            }
465            (None, Some(_)) => {
466                Err("[daemon] tls.key_path is set but tls.cert_path is missing".to_string())
467            }
468        }
469    }
470
471    fn validate_green(&self) -> Result<(), String> {
472        Self::validate_embodied_carbon(self.green.embodied_carbon_per_request_gco2)?;
473        Self::validate_default_region(self.green.default_region.as_deref())?;
474        Self::validate_service_regions(&self.green.service_regions)?;
475        if let Some(cfg) = &self.green.scaphandre {
476            Self::validate_scaphandre(cfg)?;
477        }
478        if let Some(cfg) = &self.green.kepler {
479            Self::validate_kepler(cfg)?;
480        }
481        if let Some(cfg) = &self.green.alumet {
482            Self::validate_alumet(cfg)?;
483        }
484        if let Some(cfg) = &self.green.redfish {
485            Self::validate_redfish(cfg)?;
486        }
487        if let Some(cfg) = &self.green.cloud_energy {
488            Self::validate_cloud_energy(cfg)?;
489        }
490        Self::validate_network_energy(self.green.network_energy_per_byte_kwh)?;
491        self.validate_hourly_profiles_file()?;
492        if let Some(cfg) = &self.green.electricity_maps {
493            Self::validate_electricity_maps(cfg)?;
494        }
495        Ok(())
496    }
497
498    fn validate_embodied_carbon(value: f64) -> Result<(), String> {
499        if !value.is_finite() {
500            return Err(format!(
501                "embodied_carbon_per_request_gco2 must be finite, got {value}"
502            ));
503        }
504        if value < 0.0 {
505            return Err(format!(
506                "embodied_carbon_per_request_gco2 must be >= 0.0, got {value}"
507            ));
508        }
509        Ok(())
510    }
511
512    /// Validate the optional `[green] default_region`. Config is trusted
513    /// input, so typos surface loudly here rather than silently producing
514    /// zeroed COâ‚‚ rows downstream. Same validator used at the OTLP
515    /// ingestion boundary (there, invalid values are silently dropped).
516    fn validate_default_region(region: Option<&str>) -> Result<(), String> {
517        let Some(region) = region else {
518            return Ok(());
519        };
520        if crate::score::carbon::is_valid_region_id(region) {
521            return Ok(());
522        }
523        Err(format!(
524            "[green] default_region '{region}' contains invalid characters; \
525             expected ASCII alphanumeric + '-' or '_', length 1-64"
526        ))
527    }
528
529    /// Validate the `[green.service_regions]` map: cardinality cap, plus
530    /// region-id syntax on every key/value pair.
531    fn validate_service_regions(map: &HashMap<String, String>) -> Result<(), String> {
532        /// Maximum number of entries in `[green.service_regions]`.
533        /// Bounds the config-load memory footprint against fat-finger or
534        /// malicious configs. 1024 is 4× `MAX_REGIONS` (256) and comfortably
535        /// above any realistic multi-cloud deployment size.
536        const MAX_SERVICE_REGIONS: usize = 1024;
537        if map.len() > MAX_SERVICE_REGIONS {
538            return Err(format!(
539                "[green.service_regions] has {} entries; maximum is {MAX_SERVICE_REGIONS}",
540                map.len()
541            ));
542        }
543        for (service, region) in map {
544            if !crate::score::carbon::is_valid_region_id(service) {
545                return Err(format!(
546                    "[green.service_regions] invalid service name '{service}'; \
547                     expected ASCII alphanumeric + '-' or '_', length 1-64"
548                ));
549            }
550            if !crate::score::carbon::is_valid_region_id(region) {
551                return Err(format!(
552                    "[green.service_regions] invalid region '{region}' for service '{service}'; \
553                     expected ASCII alphanumeric + '-' or '_', length 1-64"
554                ));
555            }
556        }
557        Ok(())
558    }
559
560    fn validate_network_energy(value: f64) -> Result<(), String> {
561        if !value.is_finite() || value < 0.0 {
562            return Err(format!(
563                "network_energy_per_byte_kwh must be finite and >= 0.0, got {value}"
564            ));
565        }
566        Ok(())
567    }
568
569    /// Validate `[green] hourly_profiles_file`: reject control characters
570    /// in the path (log injection) and require that the file actually
571    /// loaded when the field is configured.
572    fn validate_hourly_profiles_file(&self) -> Result<(), String> {
573        let Some(path) = &self.green.hourly_profiles_file else {
574            return Ok(());
575        };
576        if has_control_char(path) {
577            return Err("[green] hourly_profiles_file contains control characters".to_string());
578        }
579        if self.green.custom_hourly_profiles.is_none() {
580            return Err(format!(
581                "[green] hourly_profiles_file '{path}' was configured but \
582                 failed to load. Remove the field to use embedded profiles only."
583            ));
584        }
585        Ok(())
586    }
587
588    /// Validate a parsed `[green.electricity_maps]` config section.
589    pub(super) fn validate_electricity_maps(
590        cfg: &crate::score::electricity_maps::ElectricityMapsConfig,
591    ) -> Result<(), String> {
592        if cfg.auth_token.is_empty() {
593            return Err(
594                "[green.electricity_maps] api_key or PERF_SENTINEL_EMAPS_TOKEN is required"
595                    .to_string(),
596            );
597        }
598        if has_control_char(&cfg.auth_token) {
599            return Err(
600                "[green.electricity_maps] auth token contains control characters".to_string(),
601            );
602        }
603        validate_http_authority(&cfg.api_endpoint, "[green.electricity_maps] endpoint")?;
604        // Warn (but do not fail) when a non-empty auth token travels to an
605        // http:// endpoint. The Electricity Maps production API is served
606        // over https in practice; an http:// endpoint usually means a local
607        // test server or a misconfiguration. Flag it so users do not
608        // silently ship credentials in cleartext.
609        if cfg.api_endpoint.starts_with("http://") && !cfg.auth_token.is_empty() {
610            tracing::warn!(
611                "[green.electricity_maps] auth token will be sent over http:// \
612                 (no TLS). Use https:// for production or set the endpoint to \
613                 a loopback/private address if this is intentional."
614            );
615        }
616        let secs = cfg.poll_interval.as_secs();
617        check_range(
618            "[green.electricity_maps] poll_interval_secs",
619            &secs,
620            &60,
621            &86400,
622        )?;
623        if cfg.region_map.is_empty() {
624            return Err(
625                "[green.electricity_maps] region_map must contain at least one entry".to_string(),
626            );
627        }
628        for (region, zone) in &cfg.region_map {
629            if zone.is_empty() {
630                return Err(format!(
631                    "[green.electricity_maps.region_map] zone for '{region}' is empty"
632                ));
633            }
634            if has_control_char(zone)
635                || zone.contains('&')
636                || zone.contains('#')
637                || zone.contains('=')
638                || zone.contains('?')
639                || zone.contains('%')
640                || zone.contains(' ')
641                || zone.contains('+')
642            {
643                return Err(format!(
644                    "[green.electricity_maps.region_map] zone '{zone}' for '{region}' \
645                     contains invalid characters"
646                ));
647            }
648            if has_control_char(region) {
649                return Err(format!(
650                    "[green.electricity_maps.region_map] region key '{region}' \
651                     contains control characters"
652                ));
653            }
654        }
655        Ok(())
656    }
657
658    /// Validate a parsed `[green.scaphandre]` config section.
659    ///
660    /// Rejects: empty endpoint, non-`http://` scheme, credentials in
661    /// authority, control characters, invalid port, `scrape_interval_secs`
662    /// outside [1, 3600], and `process_map` keys/values that are empty,
663    /// >256 chars, or contain control characters.
664    fn validate_scaphandre(cfg: &ScaphandreConfig) -> Result<(), String> {
665        if cfg.endpoint.is_empty() {
666            return Err(
667                "[green.scaphandre] endpoint is required when the section is present".to_string(),
668            );
669        }
670        if !cfg.endpoint.starts_with("http://") && !cfg.endpoint.starts_with("https://") {
671            return Err(format!(
672                "[green.scaphandre] endpoint '{}' must start with 'http://' or 'https://'",
673                cfg.endpoint
674            ));
675        }
676        validate_http_authority(&cfg.endpoint, "[green.scaphandre] endpoint")?;
677        let secs = cfg.scrape_interval.as_secs();
678        if !(1..=3600).contains(&secs) {
679            return Err(format!(
680                "[green.scaphandre] scrape_interval_secs must be in [1, 3600], got {secs}"
681            ));
682        }
683        Self::validate_scaphandre_process_map(cfg)?;
684        // The `AuthHeader` type lives in the `ingest` module, which is
685        // only compiled when hyper is pulled in via one of the daemon /
686        // tempo / jaeger-query features. Bare `cargo publish` builds
687        // `sentinel-core` with no features and must skip the parse.
688        #[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
689        if let Some(auth) = cfg.auth_header.as_deref() {
690            crate::ingest::auth_header::AuthHeader::parse(auth)
691                .map_err(|msg| format!("[green.scaphandre] auth_header: {msg}"))?;
692        }
693        Ok(())
694    }
695
696    /// Validate a parsed `[green.kepler]` config section.
697    ///
698    /// Same shape as [`Self::validate_scaphandre`]: rejects empty
699    /// endpoints, non-`http(s)` schemes, embedded credentials, control
700    /// chars, invalid ports, `scrape_interval_secs` outside [1, 3600],
701    /// and `service_mappings` keys/values outside [1, 256] chars or with
702    /// control chars.
703    pub(super) fn validate_kepler(cfg: &KeplerConfig) -> Result<(), String> {
704        if cfg.endpoint.is_empty() {
705            return Err(
706                "[green.kepler] endpoint is required when the section is present".to_string(),
707            );
708        }
709        if !cfg.endpoint.starts_with("http://") && !cfg.endpoint.starts_with("https://") {
710            return Err(format!(
711                "[green.kepler] endpoint '{}' must start with 'http://' or 'https://'",
712                cfg.endpoint
713            ));
714        }
715        validate_http_authority(&cfg.endpoint, "[green.kepler] endpoint")?;
716        let secs = cfg.scrape_interval.as_secs();
717        if !(1..=3600).contains(&secs) {
718            return Err(format!(
719                "[green.kepler] scrape_interval_secs must be in [1, 3600], got {secs}"
720            ));
721        }
722        Self::validate_kepler_service_mappings(cfg)?;
723        #[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
724        if let Some(auth) = cfg.auth_header.as_deref() {
725            crate::ingest::auth_header::AuthHeader::parse(auth)
726                .map_err(|msg| format!("[green.kepler] auth_header: {msg}"))?;
727        }
728        Ok(())
729    }
730
731    /// Validate `[green.kepler].service_mappings` keys and values.
732    /// Label cap depends on `metric_kind`: 256 for `Container` (full
733    /// `container_name`), 15 for `Process` since the kernel truncates
734    /// `comm` at `TASK_COMM_LEN - 1`. The cap is `len()` bytes, not
735    /// chars, matching the kernel's byte-bounded truncation.
736    fn validate_kepler_service_mappings(cfg: &KeplerConfig) -> Result<(), String> {
737        /// Memory-footprint cap, mirrors `MAX_SERVICE_REGIONS`.
738        const MAX_KEPLER_SERVICE_MAPPINGS: usize = 1024;
739        if cfg.service_mappings.len() > MAX_KEPLER_SERVICE_MAPPINGS {
740            return Err(format!(
741                "[green.kepler] service_mappings has {} entries; maximum is {MAX_KEPLER_SERVICE_MAPPINGS}",
742                cfg.service_mappings.len()
743            ));
744        }
745        let (max_label_len, label_hint) = match cfg.metric_kind {
746            KeplerMetricKind::Container => (256_usize, ""),
747            KeplerMetricKind::Process => (
748                15_usize,
749                " (the Linux kernel truncates `comm` to 15 bytes, \
750                  provide the truncated value, not the full binary path)",
751            ),
752        };
753        for (service, label) in &cfg.service_mappings {
754            // Reject control chars first so an ANSI-laden label is not
755            // echoed back to stderr via the length-error `format!`.
756            if has_control_char(service) {
757                return Err("[green.kepler] service_mappings has a service name \
758                     that contains control characters"
759                    .to_string());
760            }
761            if has_control_char(label) {
762                return Err(format!(
763                    "[green.kepler] service_mappings has a label \
764                     for service '{service}' that contains control characters"
765                ));
766            }
767            if service.is_empty() || service.len() > 256 {
768                return Err(format!(
769                    "[green.kepler] service_mappings service name '{service}' must be 1-256 chars"
770                ));
771            }
772            if label.is_empty() || label.len() > max_label_len {
773                return Err(format!(
774                    "[green.kepler] service_mappings label for service '{service}' \
775                     must be 1-{max_label_len} chars, got '{label}'{label_hint}"
776                ));
777            }
778        }
779        Ok(())
780    }
781
782    /// Validate a parsed `[green.alumet]` config section.
783    ///
784    /// Beyond the shared endpoint and interval checks, this guards the
785    /// two operator-supplied parser inputs (`metric_name`, `label_key`)
786    /// and `energy_interval_secs`, the value that silently rescales
787    /// every reading when it drifts from the Alumet-side
788    /// `poll_interval`. Neither string reaches a Prometheus label, they
789    /// are matched against the scraped body, so there is no cardinality
790    /// exposure here.
791    pub(super) fn validate_alumet(cfg: &AlumetConfig) -> Result<(), String> {
792        if cfg.endpoint.is_empty() {
793            return Err(
794                "[green.alumet] endpoint is required when the section is present".to_string(),
795            );
796        }
797        if !cfg.endpoint.starts_with("http://") && !cfg.endpoint.starts_with("https://") {
798            return Err(format!(
799                "[green.alumet] endpoint '{}' must start with 'http://' or 'https://'",
800                cfg.endpoint
801            ));
802        }
803        validate_http_authority(&cfg.endpoint, "[green.alumet] endpoint")?;
804        let secs = cfg.scrape_interval.as_secs();
805        if !(1..=3600).contains(&secs) {
806            return Err(format!(
807                "[green.alumet] scrape_interval_secs must be in [1, 3600], got {secs}"
808            ));
809        }
810        Self::validate_alumet_parser_field(&cfg.metric_name, "metric_name")?;
811        Self::validate_alumet_parser_field(&cfg.label_key, "label_key")?;
812        let interval = cfg.energy_interval_secs;
813        if !interval.is_finite() || interval <= 0.0 || interval > 3600.0 {
814            return Err(format!(
815                "[green.alumet] energy_interval_secs must be a finite value in (0, 3600], \
816                 got {interval}"
817            ));
818        }
819        Self::validate_alumet_service_mappings(cfg)?;
820        if let Some(db) = &cfg.database {
821            validate_alumet_database_fields(&db.label_value, db.region.as_deref())?;
822            if cfg.service_mappings.values().any(|v| v == &db.label_value) {
823                return Err(format!(
824                    "[green.alumet.database] label_value '{}' also appears in \
825                     service_mappings; one cgroup cannot feed both the energy \
826                     totals and the database waste figure",
827                    db.label_value
828                ));
829            }
830            // Charset-valid but unknown regions are legitimate (custom
831            // ids covered by Electricity Maps), so warn instead of
832            // rejecting: without any intensity the gCO2 stays absent.
833            if let Some(region) = db.region.as_deref()
834                && crate::score::carbon::lookup_region_lower(&region.to_ascii_lowercase()).is_none()
835            {
836                tracing::warn!(
837                    region,
838                    "[green.alumet.database] region is not in the embedded \
839                     intensity table: waste_gco2 will be absent unless \
840                     Electricity Maps real-time intensity covers it. Check \
841                     for a typo (e.g. eu-west-3)."
842                );
843            }
844        }
845        #[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
846        if let Some(auth) = cfg.auth_header.as_deref() {
847            crate::ingest::auth_header::AuthHeader::parse(auth)
848                .map_err(|msg| format!("[green.alumet] auth_header: {msg}"))?;
849        }
850        Ok(())
851    }
852
853    /// Bound one of the two operator-supplied parser inputs. Control
854    /// chars are rejected before the value reaches an error message.
855    fn validate_alumet_parser_field(value: &str, field: &str) -> Result<(), String> {
856        /// Prometheus metric and label names are far shorter than this
857        /// in practice, the cap only bounds the memory a hostile config
858        /// can pin per scrape.
859        const MAX_PARSER_FIELD_LEN: usize = 256;
860        if has_control_char(value) {
861            return Err(format!(
862                "[green.alumet] {field} contains control characters"
863            ));
864        }
865        if value.is_empty() || value.len() > MAX_PARSER_FIELD_LEN {
866            return Err(format!(
867                "[green.alumet] {field} must be 1-{MAX_PARSER_FIELD_LEN} chars, got '{value}'"
868            ));
869        }
870        Ok(())
871    }
872
873    /// Validate `[green.alumet].service_mappings` keys and values.
874    /// Label values are Alumet label values (a pod name, a cgroup id, a
875    /// RAPL domain), all well under the shared 256-byte cap.
876    fn validate_alumet_service_mappings(cfg: &AlumetConfig) -> Result<(), String> {
877        /// Memory-footprint cap, mirrors `MAX_KEPLER_SERVICE_MAPPINGS`.
878        const MAX_ALUMET_SERVICE_MAPPINGS: usize = 1024;
879        if cfg.service_mappings.len() > MAX_ALUMET_SERVICE_MAPPINGS {
880            return Err(format!(
881                "[green.alumet] service_mappings has {} entries; maximum is {MAX_ALUMET_SERVICE_MAPPINGS}",
882                cfg.service_mappings.len()
883            ));
884        }
885        for (service, label) in &cfg.service_mappings {
886            // Reject control chars first so an ANSI-laden label is not
887            // echoed back to stderr via the length-error `format!`.
888            if has_control_char(service) {
889                return Err("[green.alumet] service_mappings has a service name \
890                     that contains control characters"
891                    .to_string());
892            }
893            if has_control_char(label) {
894                return Err(format!(
895                    "[green.alumet] service_mappings has a label \
896                     for service '{service}' that contains control characters"
897                ));
898            }
899            if service.is_empty() || service.len() > 256 {
900                return Err(format!(
901                    "[green.alumet] service_mappings service name '{service}' must be 1-256 chars"
902                ));
903            }
904            if label.is_empty() || label.len() > 256 {
905                return Err(format!(
906                    "[green.alumet] service_mappings label for service '{service}' \
907                     must be 1-256 chars, got '{label}'"
908                ));
909            }
910        }
911        Ok(())
912    }
913
914    /// Validate a parsed `[green.redfish]` config section.
915    ///
916    /// Enforces the BMC-specific scrape-interval lower bound
917    /// (`MIN_SCRAPE_INTERVAL_SECS`), checks every endpoint URL, walks
918    /// the service mapping for control chars + length bounds, ensures
919    /// every mapped chassis exists in `endpoints`, and confirms that
920    /// the `ca_bundle_path` file is readable when set.
921    pub(super) fn validate_redfish(cfg: &RedfishConfig) -> Result<(), String> {
922        use crate::score::redfish::config::{MAX_SCRAPE_INTERVAL_SECS, MIN_SCRAPE_INTERVAL_SECS};
923        if cfg.endpoints.is_empty() {
924            return Err(
925                "[green.redfish] endpoints must contain at least one chassis when the section is present"
926                    .to_string(),
927            );
928        }
929        Self::validate_redfish_endpoints(&cfg.endpoints)?;
930        let secs = cfg.scrape_interval.as_secs();
931        if !(MIN_SCRAPE_INTERVAL_SECS..=MAX_SCRAPE_INTERVAL_SECS).contains(&secs) {
932            return Err(format!(
933                "[green.redfish] scrape_interval_secs must be in [{MIN_SCRAPE_INTERVAL_SECS}, {MAX_SCRAPE_INTERVAL_SECS}], got {secs}. \
934                 The lower bound defends against BMC rate-limit retaliation."
935            ));
936        }
937        Self::validate_redfish_service_mappings(&cfg.service_mappings, &cfg.endpoints)?;
938        if let Some(bundle) = cfg.ca_bundle_path.as_deref()
939            && bundle.is_empty()
940        {
941            return Err("[green.redfish] ca_bundle_path must be non-empty when set".to_string());
942        }
943        // No filesystem probe on `ca_bundle_path`: the scraper task
944        // refuses to start the moment the field is set (see
945        // `score/redfish/scraper.rs`), so a metadata() check here would
946        // only add a path-probe attack surface for no operator benefit
947        // until custom-CA TLS lands.
948        #[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
949        if let Some(auth) = cfg.auth_header.as_deref() {
950            crate::ingest::auth_header::AuthHeader::parse(auth)
951                .map_err(|msg| format!("[green.redfish] auth_header: {msg}"))?;
952        }
953        Ok(())
954    }
955
956    /// Validate each `chassis_id -> RedfishEndpoint` pair in
957    /// `[green.redfish.endpoints]`. The `schema` field is type-checked
958    /// by serde at deserialization, so only the URL needs runtime
959    /// validation here.
960    fn validate_redfish_endpoints(
961        endpoints: &HashMap<String, RedfishEndpoint>,
962    ) -> Result<(), String> {
963        for (chassis_id, endpoint) in endpoints {
964            if chassis_id.is_empty() || chassis_id.len() > 256 {
965                return Err(format!(
966                    "[green.redfish] endpoints chassis id '{chassis_id}' must be 1-256 chars"
967                ));
968            }
969            if has_control_char(chassis_id) {
970                return Err(format!(
971                    "[green.redfish] endpoints chassis id '{chassis_id}' contains control characters"
972                ));
973            }
974            let url = &endpoint.url;
975            if !url.starts_with("http://") && !url.starts_with("https://") {
976                return Err(format!(
977                    "[green.redfish] endpoint URL for chassis '{chassis_id}' must start with 'http://' or 'https://', got '{url}'"
978                ));
979            }
980            validate_http_authority(
981                url,
982                &format!("[green.redfish] endpoint URL for chassis '{chassis_id}'"),
983            )?;
984        }
985        Ok(())
986    }
987
988    /// Validate each `service -> chassis_id` pair in `[green.redfish.service_mappings]`.
989    /// Every mapped chassis must already be declared in `endpoints`.
990    fn validate_redfish_service_mappings(
991        service_mappings: &HashMap<String, String>,
992        endpoints: &HashMap<String, RedfishEndpoint>,
993    ) -> Result<(), String> {
994        for (service, chassis_id) in service_mappings {
995            if service.is_empty() || service.len() > 256 {
996                return Err(format!(
997                    "[green.redfish] service_mappings service name '{service}' must be 1-256 chars"
998                ));
999            }
1000            if has_control_char(service) {
1001                return Err(format!(
1002                    "[green.redfish] service_mappings service name '{service}' contains control characters"
1003                ));
1004            }
1005            if !endpoints.contains_key(chassis_id) {
1006                return Err(format!(
1007                    "[green.redfish] service '{service}' maps to chassis '{chassis_id}' which is not declared in [green.redfish.endpoints]"
1008                ));
1009            }
1010        }
1011        Ok(())
1012    }
1013
1014    /// Validate `[green.scaphandre].process_map` keys and values.
1015    ///
1016    /// Service names (keys), `exe_contains` substrings and optional
1017    /// `cmdline_contains` substrings must be 1 to 256 chars and free
1018    /// of control characters. Service names are intentionally NOT run
1019    /// through `is_valid_region_id` because they may legitimately
1020    /// contain dots, slashes and similar.
1021    fn validate_scaphandre_process_map(cfg: &ScaphandreConfig) -> Result<(), String> {
1022        for (service, matcher) in &cfg.process_map {
1023            Self::validate_scaphandre_substring(service, "service name", service)?;
1024            Self::validate_scaphandre_substring(&matcher.exe_contains, "exe_contains", service)?;
1025            if let Some(cmdline) = matcher.cmdline_contains.as_deref() {
1026                Self::validate_scaphandre_substring(cmdline, "cmdline_contains", service)?;
1027            }
1028        }
1029        Ok(())
1030    }
1031
1032    /// Length and control-char validation for one `process_map` string
1033    /// field. Extracted so [`validate_scaphandre_process_map`] stays
1034    /// below the cognitive-complexity ceiling. `kind` is the field
1035    /// label inserted into the error message (e.g. `"exe_contains"`),
1036    /// `service` is the surrounding service name used for operator
1037    /// context.
1038    fn validate_scaphandre_substring(value: &str, kind: &str, service: &str) -> Result<(), String> {
1039        if value.is_empty() || value.len() > 256 {
1040            return Err(format!(
1041                "[green.scaphandre] process_map {kind} for service '{service}' \
1042                 must be 1-256 chars, got '{value}'"
1043            ));
1044        }
1045        if has_control_char(value) {
1046            return Err(format!(
1047                "[green.scaphandre] process_map {kind} for service '{service}' \
1048                 contains control characters"
1049            ));
1050        }
1051        Ok(())
1052    }
1053
1054    /// Validate a parsed `[green.cloud]` config section.
1055    fn validate_cloud_energy(cfg: &CloudEnergyConfig) -> Result<(), String> {
1056        Self::validate_cloud_endpoint(cfg)?;
1057        Self::validate_cloud_services(cfg)?;
1058        // See the twin note in `validate_scaphandre`: the `AuthHeader`
1059        // type is feature-gated, so bare no-features builds skip it.
1060        #[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
1061        if let Some(auth) = cfg.auth_header.as_deref() {
1062            crate::ingest::auth_header::AuthHeader::parse(auth)
1063                .map_err(|msg| format!("[green.cloud] auth_header: {msg}"))?;
1064        }
1065        Ok(())
1066    }
1067
1068    /// Validate `[green.cloud]` endpoint, scrape interval, provider, and instance type.
1069    fn validate_cloud_endpoint(cfg: &CloudEnergyConfig) -> Result<(), String> {
1070        if cfg.prometheus_endpoint.is_empty() {
1071            return Err(
1072                "[green.cloud] prometheus_endpoint is required when the section is present"
1073                    .to_string(),
1074            );
1075        }
1076        if !cfg.prometheus_endpoint.starts_with("http://")
1077            && !cfg.prometheus_endpoint.starts_with("https://")
1078        {
1079            return Err(format!(
1080                "[green.cloud] prometheus_endpoint '{}' must start with 'http://' or 'https://'",
1081                cfg.prometheus_endpoint
1082            ));
1083        }
1084        validate_http_authority(
1085            &cfg.prometheus_endpoint,
1086            "[green.cloud] prometheus_endpoint",
1087        )?;
1088        let secs = cfg.scrape_interval.as_secs();
1089        if !(1..=3600).contains(&secs) {
1090            return Err(format!(
1091                "[green.cloud] scrape_interval_secs must be in [1, 3600], got {secs}"
1092            ));
1093        }
1094        if let Some(ref p) = cfg.default_provider
1095            && !matches!(p.as_str(), "aws" | "gcp" | "azure")
1096        {
1097            return Err(format!(
1098                "[green.cloud] default_provider must be 'aws', 'gcp', or 'azure', got '{p}'"
1099            ));
1100        }
1101        if let Some(ref it) = cfg.default_instance_type
1102            && !crate::score::cloud_energy::table::is_known_instance_type(it)
1103        {
1104            tracing::warn!(
1105                instance_type = %it,
1106                "[green.cloud] default_instance_type is not in the embedded \
1107                 SPECpower table; the provider default watts will be used"
1108            );
1109        }
1110        if let Some(ref m) = cfg.cpu_metric
1111            && has_control_char(m)
1112        {
1113            return Err("[green.cloud] cpu_metric contains control characters".to_string());
1114        }
1115        Ok(())
1116    }
1117
1118    /// Validate per-service entries in `[green.cloud.services]`: cardinality
1119    /// cap, name/control-char checks, watts ranges, instance type lookup.
1120    fn validate_cloud_services(cfg: &CloudEnergyConfig) -> Result<(), String> {
1121        const MAX_CLOUD_SERVICES: usize = 256;
1122        if cfg.services.len() > MAX_CLOUD_SERVICES {
1123            return Err(format!(
1124                "[green.cloud.services] has {} entries; maximum is {MAX_CLOUD_SERVICES}",
1125                cfg.services.len()
1126            ));
1127        }
1128        for (service, svc_cfg) in &cfg.services {
1129            Self::validate_cloud_service_name(service)?;
1130            Self::validate_cloud_service_cpu_query(service, svc_cfg)?;
1131            match svc_cfg {
1132                ServiceCloudConfig::ManualWatts {
1133                    idle_watts,
1134                    max_watts,
1135                    ..
1136                } => Self::validate_manual_watts(service, *idle_watts, *max_watts)?,
1137                ServiceCloudConfig::InstanceType {
1138                    provider,
1139                    instance_type,
1140                    ..
1141                } => Self::validate_instance_type_variant(
1142                    service,
1143                    provider.as_deref(),
1144                    instance_type,
1145                )?,
1146            }
1147        }
1148        Ok(())
1149    }
1150
1151    /// Shape + control-char check on a cloud service name.
1152    fn validate_cloud_service_name(service: &str) -> Result<(), String> {
1153        if service.is_empty() || service.len() > 256 {
1154            return Err(format!(
1155                "[green.cloud.services] service name '{service}' must be 1-256 chars"
1156            ));
1157        }
1158        if has_control_char(service) {
1159            return Err(format!(
1160                "[green.cloud.services] service name '{service}' contains control characters"
1161            ));
1162        }
1163        Ok(())
1164    }
1165
1166    /// Reject control characters in a service's optional per-service
1167    /// `cpu_query` override (log-injection / Prometheus-label-injection
1168    /// guard).
1169    fn validate_cloud_service_cpu_query(
1170        service: &str,
1171        svc_cfg: &ServiceCloudConfig,
1172    ) -> Result<(), String> {
1173        let Some(q) = svc_cfg.cpu_query() else {
1174            return Ok(());
1175        };
1176        if has_control_char(q) {
1177            return Err(format!(
1178                "[green.cloud.services.{service}] cpu_query contains control characters"
1179            ));
1180        }
1181        Ok(())
1182    }
1183
1184    /// Validate a [`ServiceCloudConfig::ManualWatts`] arm: both values
1185    /// finite and non-negative, and `max_watts >= idle_watts`.
1186    fn validate_manual_watts(service: &str, idle_watts: f64, max_watts: f64) -> Result<(), String> {
1187        if !idle_watts.is_finite() || idle_watts < 0.0 {
1188            return Err(format!(
1189                "[green.cloud.services.{service}] idle_watts must be finite and >= 0, \
1190                 got {idle_watts}"
1191            ));
1192        }
1193        if !max_watts.is_finite() || max_watts < 0.0 {
1194            return Err(format!(
1195                "[green.cloud.services.{service}] max_watts must be finite and >= 0, \
1196                 got {max_watts}"
1197            ));
1198        }
1199        if max_watts < idle_watts {
1200            return Err(format!(
1201                "[green.cloud.services.{service}] max_watts ({max_watts}) must be \
1202                 >= idle_watts ({idle_watts})"
1203            ));
1204        }
1205        Ok(())
1206    }
1207
1208    /// Validate a [`ServiceCloudConfig::InstanceType`] arm: provider
1209    /// allow-list, control-char rejection on `instance_type`, and a
1210    /// soft warning when the type is not in the embedded `SPECpower`
1211    /// table (not an error, the provider default is used instead).
1212    fn validate_instance_type_variant(
1213        service: &str,
1214        provider: Option<&str>,
1215        instance_type: &str,
1216    ) -> Result<(), String> {
1217        if let Some(p) = provider
1218            && !matches!(p, "aws" | "gcp" | "azure")
1219        {
1220            return Err(format!(
1221                "[green.cloud.services.{service}] provider must be 'aws', 'gcp', \
1222                 or 'azure', got '{p}'"
1223            ));
1224        }
1225        if has_control_char(instance_type) {
1226            return Err(format!(
1227                "[green.cloud.services.{service}] instance_type contains control characters"
1228            ));
1229        }
1230        if !instance_type.is_empty()
1231            && !crate::score::cloud_energy::table::is_known_instance_type(instance_type)
1232        {
1233            tracing::warn!(
1234                service = %service,
1235                instance_type = %instance_type,
1236                "[green.cloud.services] instance_type is not in the embedded \
1237                 SPECpower table; provider default watts will be used"
1238            );
1239        }
1240        Ok(())
1241    }
1242
1243    fn validate_daemon_limits(&self) -> Result<(), String> {
1244        check_range(
1245            "max_payload_size",
1246            &self.daemon.max_payload_size,
1247            &1024,
1248            &(100 * 1024 * 1024),
1249        )?;
1250        check_range(
1251            "max_active_traces",
1252            &self.daemon.max_active_traces,
1253            &1,
1254            &1_000_000,
1255        )?;
1256        check_range(
1257            "max_events_per_trace",
1258            &self.daemon.max_events_per_trace,
1259            &1,
1260            &100_000,
1261        )?;
1262        // 0 is documented as "disable the findings store entirely". Cap
1263        // the upper end at 10M so a typo can't OOM the daemon.
1264        check_range(
1265            "max_retained_findings",
1266            &self.daemon.max_retained_findings,
1267            &0,
1268            &10_000_000,
1269        )?;
1270        check_range("trace_ttl_ms", &self.daemon.trace_ttl_ms, &100, &3_600_000)?;
1271        check_range(
1272            "ingest_queue_capacity",
1273            &self.daemon.ingest_queue_capacity,
1274            &1,
1275            &1_048_576,
1276        )?;
1277        check_range(
1278            "analysis_queue_capacity",
1279            &self.daemon.analysis_queue_capacity,
1280            &1,
1281            &1_048_576,
1282        )?;
1283        // 0 disables the memory-pressure admission guard; otherwise the
1284        // percentage must clear the 5-point hysteresis band, else the
1285        // flag's low-water bound would sit at or below zero and the
1286        // guard could never un-reject once tripped.
1287        if self.daemon.memory_high_water_pct != 0 {
1288            check_range(
1289                "memory_high_water_pct",
1290                &self.daemon.memory_high_water_pct,
1291                &6,
1292                &100,
1293            )
1294            .map_err(|e| format!("{e} (0 disables the guard; 1..=5 would make the 5-point hysteresis low bound unreachable)"))?;
1295        }
1296        check_range("listen_port_http", &self.daemon.listen_port, &1, &65535)?;
1297        check_range(
1298            "listen_port_grpc",
1299            &self.daemon.listen_port_grpc,
1300            &1,
1301            &65535,
1302        )?;
1303        self.warn_unusual_daemon_limits();
1304        Ok(())
1305    }
1306
1307    /// Soft startup warnings for daemon-limit values inside the hard
1308    /// bounds but outside their recommended comfort zone.
1309    ///
1310    /// See design doc 07 > "Comfort-zone warnings" for the band table
1311    /// and the rationale.
1312    fn warn_unusual_daemon_limits(&self) {
1313        // The 16 MiB ceiling intentionally matches the `max_payload_size`
1314        // default value (see line 205). Default-at-ceiling is inclusive
1315        // (`..=`), so the canonical config emits no warning. A future
1316        // bump of the default must also raise this ceiling, otherwise
1317        // every fresh daemon would log a startup warning.
1318        warn_outside_comfort_zone(
1319            "max_payload_size",
1320            &self.daemon.max_payload_size,
1321            &(256 * 1024),
1322            &(16 * 1024 * 1024),
1323            "tiny payloads may reject legitimate OTLP batches",
1324            "large payloads increase ingest latency and memory pressure",
1325        );
1326        warn_outside_comfort_zone(
1327            "max_active_traces",
1328            &self.daemon.max_active_traces,
1329            &1_000,
1330            &100_000,
1331            "aggressive LRU eviction is likely under load",
1332            "memory footprint grows roughly linearly with this cap",
1333        );
1334        warn_outside_comfort_zone(
1335            "max_events_per_trace",
1336            &self.daemon.max_events_per_trace,
1337            &100,
1338            &10_000,
1339            "complex traces will be truncated by the per-trace ring buffer",
1340            "very wide ring buffers rarely improve detection quality",
1341        );
1342        // Skip the comfort-zone check when the store is intentionally
1343        // disabled (max_retained_findings == 0); warning on that would
1344        // be noise.
1345        if self.daemon.max_retained_findings > 0 {
1346            warn_outside_comfort_zone(
1347                "max_retained_findings",
1348                &self.daemon.max_retained_findings,
1349                &100,
1350                &100_000,
1351                "old findings will be evicted before /api/findings can serve them",
1352                "the findings store will hold a large in-memory backlog",
1353            );
1354        }
1355        warn_outside_comfort_zone(
1356            "trace_ttl_ms",
1357            &self.daemon.trace_ttl_ms,
1358            &1_000,
1359            &600_000,
1360            "TTL below 1s flushes traces before slow spans land",
1361            "TTL above 10min keeps near-dead traces in the active set",
1362        );
1363    }
1364
1365    fn validate_detection_params(&self) -> Result<(), String> {
1366        check_min(
1367            "n_plus_one_threshold",
1368            &self.detection.n_plus_one_threshold,
1369            &1,
1370        )?;
1371        check_min("window_duration_ms", &self.detection.window_duration_ms, &1)?;
1372        check_min(
1373            "slow_query_threshold_ms",
1374            &self.detection.slow_query_threshold_ms,
1375            &1,
1376        )?;
1377        check_min(
1378            "slow_query_min_occurrences",
1379            &self.detection.slow_query_min_occurrences,
1380            &1,
1381        )?;
1382        check_range("max_fanout", &self.detection.max_fanout, &1, &100_000)?;
1383        warn_outside_comfort_zone(
1384            "max_fanout",
1385            &self.detection.max_fanout,
1386            &5,
1387            &1_000,
1388            "very low fanout floods the findings store with noise",
1389            "very high fanout suppresses most fan-out detections",
1390        );
1391        check_min(
1392            "chatty_service_min_calls",
1393            &self.detection.chatty_service_min_calls,
1394            &1,
1395        )?;
1396        check_min(
1397            "pool_saturation_concurrent_threshold",
1398            &self.detection.pool_saturation_concurrent_threshold,
1399            &2,
1400        )?;
1401        check_min(
1402            "serialized_min_sequential",
1403            &self.detection.serialized_min_sequential,
1404            &2,
1405        )?;
1406        Ok(())
1407    }
1408
1409    fn validate_rates(&self) -> Result<(), String> {
1410        if !(0.0..=1.0).contains(&self.daemon.sampling_rate) {
1411            return Err(format!(
1412                "sampling_rate must be in [0.0, 1.0], got {}",
1413                self.daemon.sampling_rate
1414            ));
1415        }
1416        if !(0.0..=1.0).contains(&self.thresholds.io_waste_ratio_max) {
1417            return Err(format!(
1418                "io_waste_ratio_max must be in [0.0, 1.0], got {}",
1419                self.thresholds.io_waste_ratio_max
1420            ));
1421        }
1422        Ok(())
1423    }
1424}