1use 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
40fn 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
72pub(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
85pub(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
112fn 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
143fn 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
174pub(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 if authority.starts_with('[') {
197 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 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 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 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 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 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 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 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 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 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 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 fn validate_service_regions(map: &HashMap<String, String>) -> Result<(), String> {
532 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 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 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 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 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 #[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 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 fn validate_kepler_service_mappings(cfg: &KeplerConfig) -> Result<(), String> {
737 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 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 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 if let Some(region) = db.region.as_deref()
834 && crate::score::carbon::lookup_region_lower(®ion.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 fn validate_alumet_parser_field(value: &str, field: &str) -> Result<(), String> {
856 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 fn validate_alumet_service_mappings(cfg: &AlumetConfig) -> Result<(), String> {
877 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 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 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 #[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 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 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 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 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 fn validate_cloud_energy(cfg: &CloudEnergyConfig) -> Result<(), String> {
1056 Self::validate_cloud_endpoint(cfg)?;
1057 Self::validate_cloud_services(cfg)?;
1058 #[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 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 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 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 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 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 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 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 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 fn warn_unusual_daemon_limits(&self) {
1313 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 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}