use std::collections::HashMap;
use crate::score::cloud_energy::config::{CloudEnergyConfig, ServiceCloudConfig};
use crate::score::kepler::{KeplerConfig, KeplerMetricKind};
use crate::score::redfish::{RedfishConfig, RedfishEndpoint};
use crate::score::scaphandre::ScaphandreConfig;
use super::{Config, RESERVED_DISCLOSE_OUTPUT_PATH_VERSION};
fn check_range<T: PartialOrd + std::fmt::Display>(
name: &str,
val: &T,
min: &T,
max: &T,
) -> Result<(), String> {
if val < min {
return Err(format!("{name} must be >= {min}, got {val}"));
}
if val > max {
return Err(format!("{name} must be <= {max}, got {val}"));
}
Ok(())
}
fn check_min<T: PartialOrd + std::fmt::Display>(
name: &str,
val: &T,
min: &T,
) -> Result<(), String> {
if val < min {
return Err(format!("{name} must be >= {min}, got {val}"));
}
Ok(())
}
fn warn_outside_comfort_zone<T>(
name: &str,
val: &T,
comfort_lo: &T,
comfort_hi: &T,
note_low: &str,
note_high: &str,
) where
T: PartialOrd + std::fmt::Display,
{
if val < comfort_lo {
tracing::warn!(
field = %name,
value = %val,
recommended_min = %comfort_lo,
"{name} = {val} is below the recommended floor {comfort_lo}; {note_low}"
);
} else if val > comfort_hi {
tracing::warn!(
field = %name,
value = %val,
recommended_max = %comfort_hi,
"{name} = {val} is above the recommended ceiling {comfort_hi}; {note_high}"
);
}
}
pub(crate) fn has_control_char(s: &str) -> bool {
s.chars().any(|c| {
let code = c as u32;
code < 0x20 || code == 0x7F || (0x80..=0x9F).contains(&code)
})
}
fn validate_cors_wildcard_mode(
has_wildcard: bool,
origin_count: usize,
has_api_key: bool,
) -> Result<(), String> {
if has_wildcard && origin_count > 1 {
return Err(
"[daemon.cors] allowed_origins cannot mix \"*\" with explicit origins, \
either use [\"*\"] for wildcard mode or list every origin explicitly"
.to_string(),
);
}
if has_wildcard && has_api_key {
return Err(
"[daemon.cors] allowed_origins = [\"*\"] is incompatible with \
[daemon.ack] api_key, since X-API-Key is sent on every cross-origin \
request and would be replayable from any browser tab. \
Use an explicit origin list or unset api_key for development"
.to_string(),
);
}
Ok(())
}
fn validate_cors_origin(origin: &str) -> Result<(), String> {
if origin.is_empty() {
return Err(
"[daemon.cors] allowed_origins entry is empty, drop it or set a value".to_string(),
);
}
if has_control_char(origin) {
return Err(format!(
"[daemon.cors] allowed_origins entry '{origin}' contains control characters"
));
}
if origin == "*" {
return Ok(());
}
if !(origin.starts_with("http://") || origin.starts_with("https://")) {
return Err(format!(
"[daemon.cors] allowed_origins entry '{origin}' must start with http:// or https:// (or be \"*\" for wildcard mode)"
));
}
if origin.ends_with('/') {
return Err(format!(
"[daemon.cors] allowed_origins entry '{origin}' must not end with a trailing slash, an origin is scheme + host + optional port"
));
}
Ok(())
}
pub(super) fn validate_http_authority(url: &str, label: &str) -> Result<(), String> {
let after_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
if authority.is_empty() {
return Err(format!("{label} '{url}' has no host"));
}
if authority.contains('@') {
return Err(format!(
"{label} must not contain credentials (userinfo): '{url}'"
));
}
if has_control_char(authority) {
return Err(format!("{label} '{url}' contains control characters"));
}
if authority.starts_with('[') {
if let Some(bracket_end) = authority.find(']') {
let after_bracket = &authority[bracket_end + 1..];
if let Some(port_str) = after_bracket.strip_prefix(':')
&& !port_str.is_empty()
&& port_str.parse::<u16>().is_err()
{
return Err(format!("{label} '{url}' has an invalid port"));
}
}
} else if let Some(port_str) = authority.rsplit(':').next()
&& authority.contains(':')
&& port_str.parse::<u16>().is_err()
{
return Err(format!("{label} '{url}' has an invalid port"));
}
Ok(())
}
impl Config {
pub fn validate(&self) -> Result<(), String> {
self.validate_daemon_limits()?;
self.validate_detection_params()?;
self.validate_rates()?;
self.validate_tls()?;
self.validate_green()?;
self.validate_daemon_ack()?;
self.validate_daemon_cors()?;
self.validate_daemon_archive()?;
self.validate_reporting()?;
self.validate_cross_section_consistency()?;
Ok(())
}
pub fn warn_listen_addr_if_non_loopback(&self) {
if self.daemon.listen_addr != "127.0.0.1" && self.daemon.listen_addr != "::1" {
tracing::warn!(
"Daemon configured to listen on non-loopback address: {}. \
Endpoints have no authentication, use a reverse proxy or \
network policy for security.",
self.daemon.listen_addr
);
}
}
fn validate_reporting(&self) -> Result<(), String> {
if let Some(intent) = &self.reporting.intent {
match intent.as_str() {
"internal" | "official" | "audited" => {}
other => {
return Err(format!(
"[reporting] intent must be one of \"internal\", \"official\", \"audited\", got {other:?}"
));
}
}
}
if let Some(level) = &self.reporting.confidentiality_level {
match level.as_str() {
"internal" | "public" => {}
other => {
return Err(format!(
"[reporting] confidentiality_level must be \"internal\" or \"public\", got {other:?}"
));
}
}
}
if self.reporting.intent.as_deref() == Some("official")
&& self
.reporting
.org_config_path
.as_deref()
.is_none_or(str::is_empty)
{
return Err(
"[reporting] org_config_path is required when intent = \"official\"".to_string(),
);
}
Ok(())
}
pub(super) fn warn_reporting_advisory(&self) {
if self
.reporting
.disclose_output_path
.as_deref()
.is_some_and(|p| !p.is_empty())
{
tracing::warn!(
"[reporting] disclose_output_path is set but currently unused. \
Reserved for daemon-triggered periodic disclosures (planned for {}). \
Reports today are produced exclusively via `perf-sentinel disclose --output`.",
RESERVED_DISCLOSE_OUTPUT_PATH_VERSION
);
}
}
fn validate_daemon_archive(&self) -> Result<(), String> {
let Some(archive) = &self.daemon.archive else {
return Ok(());
};
if archive.path.trim().is_empty() {
return Err("[daemon.archive] path must not be empty".to_string());
}
if has_control_char(&archive.path) {
return Err("[daemon.archive] path contains control characters".to_string());
}
if archive.max_size_mb < 1 {
return Err("[daemon.archive] max_size_mb must be >= 1".to_string());
}
if archive.max_files < 1 {
return Err("[daemon.archive] max_files must be >= 1".to_string());
}
Ok(())
}
fn validate_cross_section_consistency(&self) -> Result<(), String> {
if !self.daemon.api_enabled && !self.daemon.cors.allowed_origins.is_empty() {
return Err(
"[daemon.cors] allowed_origins is set but [daemon] api_enabled = false. \
The CORS layer would attach to a non-mounted /api/* sub-router and \
silently do nothing, which is almost always a misconfiguration. \
Either remove [daemon.cors] allowed_origins for this environment, or \
enable the API with [daemon] api_enabled = true."
.to_string(),
);
}
if self.daemon.archive.is_some() && !self.green.enabled {
return Err(
"[daemon.archive] is configured but [green] enabled = false. The archive \
would write windows with zero carbon/energy, making `perf-sentinel disclose` \
produce a meaningless output. Either enable green scoring or remove the \
archive section."
.to_string(),
);
}
Ok(())
}
pub(super) fn validate_daemon_cors(&self) -> Result<(), String> {
let has_wildcard = self.daemon.cors.allowed_origins.iter().any(|o| o == "*");
validate_cors_wildcard_mode(
has_wildcard,
self.daemon.cors.allowed_origins.len(),
self.daemon.ack.api_key.is_some(),
)?;
for origin in &self.daemon.cors.allowed_origins {
validate_cors_origin(origin)?;
}
Ok(())
}
pub(super) fn validate_daemon_ack(&self) -> Result<(), String> {
if let Some(key) = &self.daemon.ack.api_key {
if key.is_empty() {
return Err("[daemon.ack] api_key must not be empty".to_string());
}
if has_control_char(key) {
return Err("[daemon.ack] api_key contains control characters".to_string());
}
if key.len() < 12 {
return Err(format!(
"[daemon.ack] api_key is too short ({} chars), \
use at least 12 characters (16 recommended)",
key.len()
));
}
if key.len() < 16 {
tracing::warn!(
len = key.len(),
"[daemon.ack] api_key is shorter than 16 characters, \
consider a longer secret to resist brute-force attempts"
);
}
}
if let Some(path) = &self.daemon.ack.storage_path
&& has_control_char(path)
{
return Err("[daemon.ack] storage_path contains control characters".to_string());
}
if let Some(path) = &self.daemon.ack.toml_path
&& has_control_char(path)
{
return Err("[daemon.ack] toml_path contains control characters".to_string());
}
Ok(())
}
pub(super) fn validate_tls(&self) -> Result<(), String> {
match (&self.daemon.tls.cert_path, &self.daemon.tls.key_path) {
(Some(cert), Some(key)) => {
if has_control_char(cert) {
return Err("[daemon] tls.cert_path contains control characters".to_string());
}
if has_control_char(key) {
return Err("[daemon] tls.key_path contains control characters".to_string());
}
if !std::path::Path::new(cert).exists() {
return Err(format!("[daemon] tls.cert_path '{cert}' does not exist"));
}
if !std::path::Path::new(key).exists() {
return Err(format!("[daemon] tls.key_path '{key}' does not exist"));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(key) {
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
tracing::warn!(
"TLS key file '{key}' is readable by group/others \
(mode {mode:o}). Consider restricting to owner-only \
(chmod 600)."
);
}
}
}
tracing::info!("TLS enabled for daemon OTLP receivers (cert: {cert})");
Ok(())
}
(None, None) => Ok(()),
(Some(_), None) => {
Err("[daemon] tls.cert_path is set but tls.key_path is missing".to_string())
}
(None, Some(_)) => {
Err("[daemon] tls.key_path is set but tls.cert_path is missing".to_string())
}
}
}
fn validate_green(&self) -> Result<(), String> {
Self::validate_embodied_carbon(self.green.embodied_carbon_per_request_gco2)?;
Self::validate_default_region(self.green.default_region.as_deref())?;
Self::validate_service_regions(&self.green.service_regions)?;
if let Some(cfg) = &self.green.scaphandre {
Self::validate_scaphandre(cfg)?;
}
if let Some(cfg) = &self.green.kepler {
Self::validate_kepler(cfg)?;
}
if let Some(cfg) = &self.green.redfish {
Self::validate_redfish(cfg)?;
}
if let Some(cfg) = &self.green.cloud_energy {
Self::validate_cloud_energy(cfg)?;
}
Self::validate_network_energy(self.green.network_energy_per_byte_kwh)?;
self.validate_hourly_profiles_file()?;
if let Some(cfg) = &self.green.electricity_maps {
Self::validate_electricity_maps(cfg)?;
}
Ok(())
}
fn validate_embodied_carbon(value: f64) -> Result<(), String> {
if !value.is_finite() {
return Err(format!(
"embodied_carbon_per_request_gco2 must be finite, got {value}"
));
}
if value < 0.0 {
return Err(format!(
"embodied_carbon_per_request_gco2 must be >= 0.0, got {value}"
));
}
Ok(())
}
fn validate_default_region(region: Option<&str>) -> Result<(), String> {
let Some(region) = region else {
return Ok(());
};
if crate::score::carbon::is_valid_region_id(region) {
return Ok(());
}
Err(format!(
"[green] default_region '{region}' contains invalid characters; \
expected ASCII alphanumeric + '-' or '_', length 1-64"
))
}
fn validate_service_regions(map: &HashMap<String, String>) -> Result<(), String> {
const MAX_SERVICE_REGIONS: usize = 1024;
if map.len() > MAX_SERVICE_REGIONS {
return Err(format!(
"[green.service_regions] has {} entries; maximum is {MAX_SERVICE_REGIONS}",
map.len()
));
}
for (service, region) in map {
if !crate::score::carbon::is_valid_region_id(service) {
return Err(format!(
"[green.service_regions] invalid service name '{service}'; \
expected ASCII alphanumeric + '-' or '_', length 1-64"
));
}
if !crate::score::carbon::is_valid_region_id(region) {
return Err(format!(
"[green.service_regions] invalid region '{region}' for service '{service}'; \
expected ASCII alphanumeric + '-' or '_', length 1-64"
));
}
}
Ok(())
}
fn validate_network_energy(value: f64) -> Result<(), String> {
if !value.is_finite() || value < 0.0 {
return Err(format!(
"network_energy_per_byte_kwh must be finite and >= 0.0, got {value}"
));
}
Ok(())
}
fn validate_hourly_profiles_file(&self) -> Result<(), String> {
let Some(path) = &self.green.hourly_profiles_file else {
return Ok(());
};
if has_control_char(path) {
return Err("[green] hourly_profiles_file contains control characters".to_string());
}
if self.green.custom_hourly_profiles.is_none() {
return Err(format!(
"[green] hourly_profiles_file '{path}' was configured but \
failed to load. Remove the field to use embedded profiles only."
));
}
Ok(())
}
pub(super) fn validate_electricity_maps(
cfg: &crate::score::electricity_maps::ElectricityMapsConfig,
) -> Result<(), String> {
if cfg.auth_token.is_empty() {
return Err(
"[green.electricity_maps] api_key or PERF_SENTINEL_EMAPS_TOKEN is required"
.to_string(),
);
}
if has_control_char(&cfg.auth_token) {
return Err(
"[green.electricity_maps] auth token contains control characters".to_string(),
);
}
validate_http_authority(&cfg.api_endpoint, "[green.electricity_maps] endpoint")?;
if cfg.api_endpoint.starts_with("http://") && !cfg.auth_token.is_empty() {
tracing::warn!(
"[green.electricity_maps] auth token will be sent over http:// \
(no TLS). Use https:// for production or set the endpoint to \
a loopback/private address if this is intentional."
);
}
let secs = cfg.poll_interval.as_secs();
check_range(
"[green.electricity_maps] poll_interval_secs",
&secs,
&60,
&86400,
)?;
if cfg.region_map.is_empty() {
return Err(
"[green.electricity_maps] region_map must contain at least one entry".to_string(),
);
}
for (region, zone) in &cfg.region_map {
if zone.is_empty() {
return Err(format!(
"[green.electricity_maps.region_map] zone for '{region}' is empty"
));
}
if has_control_char(zone)
|| zone.contains('&')
|| zone.contains('#')
|| zone.contains('=')
|| zone.contains('?')
|| zone.contains('%')
|| zone.contains(' ')
|| zone.contains('+')
{
return Err(format!(
"[green.electricity_maps.region_map] zone '{zone}' for '{region}' \
contains invalid characters"
));
}
if has_control_char(region) {
return Err(format!(
"[green.electricity_maps.region_map] region key '{region}' \
contains control characters"
));
}
}
Ok(())
}
fn validate_scaphandre(cfg: &ScaphandreConfig) -> Result<(), String> {
if cfg.endpoint.is_empty() {
return Err(
"[green.scaphandre] endpoint is required when the section is present".to_string(),
);
}
if !cfg.endpoint.starts_with("http://") && !cfg.endpoint.starts_with("https://") {
return Err(format!(
"[green.scaphandre] endpoint '{}' must start with 'http://' or 'https://'",
cfg.endpoint
));
}
validate_http_authority(&cfg.endpoint, "[green.scaphandre] endpoint")?;
let secs = cfg.scrape_interval.as_secs();
if !(1..=3600).contains(&secs) {
return Err(format!(
"[green.scaphandre] scrape_interval_secs must be in [1, 3600], got {secs}"
));
}
Self::validate_scaphandre_process_map(cfg)?;
#[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
if let Some(auth) = cfg.auth_header.as_deref() {
crate::ingest::auth_header::AuthHeader::parse(auth)
.map_err(|msg| format!("[green.scaphandre] auth_header: {msg}"))?;
}
Ok(())
}
pub(super) fn validate_kepler(cfg: &KeplerConfig) -> Result<(), String> {
if cfg.endpoint.is_empty() {
return Err(
"[green.kepler] endpoint is required when the section is present".to_string(),
);
}
if !cfg.endpoint.starts_with("http://") && !cfg.endpoint.starts_with("https://") {
return Err(format!(
"[green.kepler] endpoint '{}' must start with 'http://' or 'https://'",
cfg.endpoint
));
}
validate_http_authority(&cfg.endpoint, "[green.kepler] endpoint")?;
let secs = cfg.scrape_interval.as_secs();
if !(1..=3600).contains(&secs) {
return Err(format!(
"[green.kepler] scrape_interval_secs must be in [1, 3600], got {secs}"
));
}
Self::validate_kepler_service_mappings(cfg)?;
#[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
if let Some(auth) = cfg.auth_header.as_deref() {
crate::ingest::auth_header::AuthHeader::parse(auth)
.map_err(|msg| format!("[green.kepler] auth_header: {msg}"))?;
}
Ok(())
}
fn validate_kepler_service_mappings(cfg: &KeplerConfig) -> Result<(), String> {
const MAX_KEPLER_SERVICE_MAPPINGS: usize = 1024;
if cfg.service_mappings.len() > MAX_KEPLER_SERVICE_MAPPINGS {
return Err(format!(
"[green.kepler] service_mappings has {} entries; maximum is {MAX_KEPLER_SERVICE_MAPPINGS}",
cfg.service_mappings.len()
));
}
let (max_label_len, label_hint) = match cfg.metric_kind {
KeplerMetricKind::Container => (256_usize, ""),
KeplerMetricKind::Process => (
15_usize,
" (the Linux kernel truncates `comm` to 15 bytes, \
provide the truncated value, not the full binary path)",
),
};
for (service, label) in &cfg.service_mappings {
if has_control_char(service) {
return Err("[green.kepler] service_mappings has a service name \
that contains control characters"
.to_string());
}
if has_control_char(label) {
return Err(format!(
"[green.kepler] service_mappings has a label \
for service '{service}' that contains control characters"
));
}
if service.is_empty() || service.len() > 256 {
return Err(format!(
"[green.kepler] service_mappings service name '{service}' must be 1-256 chars"
));
}
if label.is_empty() || label.len() > max_label_len {
return Err(format!(
"[green.kepler] service_mappings label for service '{service}' \
must be 1-{max_label_len} chars, got '{label}'{label_hint}"
));
}
}
Ok(())
}
pub(super) fn validate_redfish(cfg: &RedfishConfig) -> Result<(), String> {
use crate::score::redfish::config::{MAX_SCRAPE_INTERVAL_SECS, MIN_SCRAPE_INTERVAL_SECS};
if cfg.endpoints.is_empty() {
return Err(
"[green.redfish] endpoints must contain at least one chassis when the section is present"
.to_string(),
);
}
Self::validate_redfish_endpoints(&cfg.endpoints)?;
let secs = cfg.scrape_interval.as_secs();
if !(MIN_SCRAPE_INTERVAL_SECS..=MAX_SCRAPE_INTERVAL_SECS).contains(&secs) {
return Err(format!(
"[green.redfish] scrape_interval_secs must be in [{MIN_SCRAPE_INTERVAL_SECS}, {MAX_SCRAPE_INTERVAL_SECS}], got {secs}. \
The lower bound defends against BMC rate-limit retaliation."
));
}
Self::validate_redfish_service_mappings(&cfg.service_mappings, &cfg.endpoints)?;
if let Some(bundle) = cfg.ca_bundle_path.as_deref()
&& bundle.is_empty()
{
return Err("[green.redfish] ca_bundle_path must be non-empty when set".to_string());
}
#[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
if let Some(auth) = cfg.auth_header.as_deref() {
crate::ingest::auth_header::AuthHeader::parse(auth)
.map_err(|msg| format!("[green.redfish] auth_header: {msg}"))?;
}
Ok(())
}
fn validate_redfish_endpoints(
endpoints: &HashMap<String, RedfishEndpoint>,
) -> Result<(), String> {
for (chassis_id, endpoint) in endpoints {
if chassis_id.is_empty() || chassis_id.len() > 256 {
return Err(format!(
"[green.redfish] endpoints chassis id '{chassis_id}' must be 1-256 chars"
));
}
if has_control_char(chassis_id) {
return Err(format!(
"[green.redfish] endpoints chassis id '{chassis_id}' contains control characters"
));
}
let url = &endpoint.url;
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(format!(
"[green.redfish] endpoint URL for chassis '{chassis_id}' must start with 'http://' or 'https://', got '{url}'"
));
}
validate_http_authority(
url,
&format!("[green.redfish] endpoint URL for chassis '{chassis_id}'"),
)?;
}
Ok(())
}
fn validate_redfish_service_mappings(
service_mappings: &HashMap<String, String>,
endpoints: &HashMap<String, RedfishEndpoint>,
) -> Result<(), String> {
for (service, chassis_id) in service_mappings {
if service.is_empty() || service.len() > 256 {
return Err(format!(
"[green.redfish] service_mappings service name '{service}' must be 1-256 chars"
));
}
if has_control_char(service) {
return Err(format!(
"[green.redfish] service_mappings service name '{service}' contains control characters"
));
}
if !endpoints.contains_key(chassis_id) {
return Err(format!(
"[green.redfish] service '{service}' maps to chassis '{chassis_id}' which is not declared in [green.redfish.endpoints]"
));
}
}
Ok(())
}
fn validate_scaphandre_process_map(cfg: &ScaphandreConfig) -> Result<(), String> {
for (service, matcher) in &cfg.process_map {
Self::validate_scaphandre_substring(service, "service name", service)?;
Self::validate_scaphandre_substring(&matcher.exe_contains, "exe_contains", service)?;
if let Some(cmdline) = matcher.cmdline_contains.as_deref() {
Self::validate_scaphandre_substring(cmdline, "cmdline_contains", service)?;
}
}
Ok(())
}
fn validate_scaphandre_substring(value: &str, kind: &str, service: &str) -> Result<(), String> {
if value.is_empty() || value.len() > 256 {
return Err(format!(
"[green.scaphandre] process_map {kind} for service '{service}' \
must be 1-256 chars, got '{value}'"
));
}
if has_control_char(value) {
return Err(format!(
"[green.scaphandre] process_map {kind} for service '{service}' \
contains control characters"
));
}
Ok(())
}
fn validate_cloud_energy(cfg: &CloudEnergyConfig) -> Result<(), String> {
Self::validate_cloud_endpoint(cfg)?;
Self::validate_cloud_services(cfg)?;
#[cfg(any(feature = "daemon", feature = "tempo", feature = "jaeger-query"))]
if let Some(auth) = cfg.auth_header.as_deref() {
crate::ingest::auth_header::AuthHeader::parse(auth)
.map_err(|msg| format!("[green.cloud] auth_header: {msg}"))?;
}
Ok(())
}
fn validate_cloud_endpoint(cfg: &CloudEnergyConfig) -> Result<(), String> {
if cfg.prometheus_endpoint.is_empty() {
return Err(
"[green.cloud] prometheus_endpoint is required when the section is present"
.to_string(),
);
}
if !cfg.prometheus_endpoint.starts_with("http://")
&& !cfg.prometheus_endpoint.starts_with("https://")
{
return Err(format!(
"[green.cloud] prometheus_endpoint '{}' must start with 'http://' or 'https://'",
cfg.prometheus_endpoint
));
}
validate_http_authority(
&cfg.prometheus_endpoint,
"[green.cloud] prometheus_endpoint",
)?;
let secs = cfg.scrape_interval.as_secs();
if !(1..=3600).contains(&secs) {
return Err(format!(
"[green.cloud] scrape_interval_secs must be in [1, 3600], got {secs}"
));
}
if let Some(ref p) = cfg.default_provider
&& !matches!(p.as_str(), "aws" | "gcp" | "azure")
{
return Err(format!(
"[green.cloud] default_provider must be 'aws', 'gcp', or 'azure', got '{p}'"
));
}
if let Some(ref it) = cfg.default_instance_type
&& !crate::score::cloud_energy::table::is_known_instance_type(it)
{
tracing::warn!(
instance_type = %it,
"[green.cloud] default_instance_type is not in the embedded \
SPECpower table; the provider default watts will be used"
);
}
if let Some(ref m) = cfg.cpu_metric
&& has_control_char(m)
{
return Err("[green.cloud] cpu_metric contains control characters".to_string());
}
Ok(())
}
fn validate_cloud_services(cfg: &CloudEnergyConfig) -> Result<(), String> {
const MAX_CLOUD_SERVICES: usize = 256;
if cfg.services.len() > MAX_CLOUD_SERVICES {
return Err(format!(
"[green.cloud.services] has {} entries; maximum is {MAX_CLOUD_SERVICES}",
cfg.services.len()
));
}
for (service, svc_cfg) in &cfg.services {
Self::validate_cloud_service_name(service)?;
Self::validate_cloud_service_cpu_query(service, svc_cfg)?;
match svc_cfg {
ServiceCloudConfig::ManualWatts {
idle_watts,
max_watts,
..
} => Self::validate_manual_watts(service, *idle_watts, *max_watts)?,
ServiceCloudConfig::InstanceType {
provider,
instance_type,
..
} => Self::validate_instance_type_variant(
service,
provider.as_deref(),
instance_type,
)?,
}
}
Ok(())
}
fn validate_cloud_service_name(service: &str) -> Result<(), String> {
if service.is_empty() || service.len() > 256 {
return Err(format!(
"[green.cloud.services] service name '{service}' must be 1-256 chars"
));
}
if has_control_char(service) {
return Err(format!(
"[green.cloud.services] service name '{service}' contains control characters"
));
}
Ok(())
}
fn validate_cloud_service_cpu_query(
service: &str,
svc_cfg: &ServiceCloudConfig,
) -> Result<(), String> {
let Some(q) = svc_cfg.cpu_query() else {
return Ok(());
};
if has_control_char(q) {
return Err(format!(
"[green.cloud.services.{service}] cpu_query contains control characters"
));
}
Ok(())
}
fn validate_manual_watts(service: &str, idle_watts: f64, max_watts: f64) -> Result<(), String> {
if !idle_watts.is_finite() || idle_watts < 0.0 {
return Err(format!(
"[green.cloud.services.{service}] idle_watts must be finite and >= 0, \
got {idle_watts}"
));
}
if !max_watts.is_finite() || max_watts < 0.0 {
return Err(format!(
"[green.cloud.services.{service}] max_watts must be finite and >= 0, \
got {max_watts}"
));
}
if max_watts < idle_watts {
return Err(format!(
"[green.cloud.services.{service}] max_watts ({max_watts}) must be \
>= idle_watts ({idle_watts})"
));
}
Ok(())
}
fn validate_instance_type_variant(
service: &str,
provider: Option<&str>,
instance_type: &str,
) -> Result<(), String> {
if let Some(p) = provider
&& !matches!(p, "aws" | "gcp" | "azure")
{
return Err(format!(
"[green.cloud.services.{service}] provider must be 'aws', 'gcp', \
or 'azure', got '{p}'"
));
}
if has_control_char(instance_type) {
return Err(format!(
"[green.cloud.services.{service}] instance_type contains control characters"
));
}
if !instance_type.is_empty()
&& !crate::score::cloud_energy::table::is_known_instance_type(instance_type)
{
tracing::warn!(
service = %service,
instance_type = %instance_type,
"[green.cloud.services] instance_type is not in the embedded \
SPECpower table; provider default watts will be used"
);
}
Ok(())
}
fn validate_daemon_limits(&self) -> Result<(), String> {
check_range(
"max_payload_size",
&self.daemon.max_payload_size,
&1024,
&(100 * 1024 * 1024),
)?;
check_range(
"max_active_traces",
&self.daemon.max_active_traces,
&1,
&1_000_000,
)?;
check_range(
"max_events_per_trace",
&self.daemon.max_events_per_trace,
&1,
&100_000,
)?;
check_range(
"max_retained_findings",
&self.daemon.max_retained_findings,
&0,
&10_000_000,
)?;
check_range("trace_ttl_ms", &self.daemon.trace_ttl_ms, &100, &3_600_000)?;
check_range(
"ingest_queue_capacity",
&self.daemon.ingest_queue_capacity,
&1,
&1_048_576,
)?;
check_range(
"analysis_queue_capacity",
&self.daemon.analysis_queue_capacity,
&1,
&1_048_576,
)?;
if self.daemon.memory_high_water_pct != 0 {
check_range(
"memory_high_water_pct",
&self.daemon.memory_high_water_pct,
&6,
&100,
)
.map_err(|e| format!("{e} (0 disables the guard; 1..=5 would make the 5-point hysteresis low bound unreachable)"))?;
}
check_range("listen_port_http", &self.daemon.listen_port, &1, &65535)?;
check_range(
"listen_port_grpc",
&self.daemon.listen_port_grpc,
&1,
&65535,
)?;
self.warn_unusual_daemon_limits();
Ok(())
}
fn warn_unusual_daemon_limits(&self) {
warn_outside_comfort_zone(
"max_payload_size",
&self.daemon.max_payload_size,
&(256 * 1024),
&(16 * 1024 * 1024),
"tiny payloads may reject legitimate OTLP batches",
"large payloads increase ingest latency and memory pressure",
);
warn_outside_comfort_zone(
"max_active_traces",
&self.daemon.max_active_traces,
&1_000,
&100_000,
"aggressive LRU eviction is likely under load",
"memory footprint grows roughly linearly with this cap",
);
warn_outside_comfort_zone(
"max_events_per_trace",
&self.daemon.max_events_per_trace,
&100,
&10_000,
"complex traces will be truncated by the per-trace ring buffer",
"very wide ring buffers rarely improve detection quality",
);
if self.daemon.max_retained_findings > 0 {
warn_outside_comfort_zone(
"max_retained_findings",
&self.daemon.max_retained_findings,
&100,
&100_000,
"old findings will be evicted before /api/findings can serve them",
"the findings store will hold a large in-memory backlog",
);
}
warn_outside_comfort_zone(
"trace_ttl_ms",
&self.daemon.trace_ttl_ms,
&1_000,
&600_000,
"TTL below 1s flushes traces before slow spans land",
"TTL above 10min keeps near-dead traces in the active set",
);
}
fn validate_detection_params(&self) -> Result<(), String> {
check_min(
"n_plus_one_threshold",
&self.detection.n_plus_one_threshold,
&1,
)?;
check_min("window_duration_ms", &self.detection.window_duration_ms, &1)?;
check_min(
"slow_query_threshold_ms",
&self.detection.slow_query_threshold_ms,
&1,
)?;
check_min(
"slow_query_min_occurrences",
&self.detection.slow_query_min_occurrences,
&1,
)?;
check_range("max_fanout", &self.detection.max_fanout, &1, &100_000)?;
warn_outside_comfort_zone(
"max_fanout",
&self.detection.max_fanout,
&5,
&1_000,
"very low fanout floods the findings store with noise",
"very high fanout suppresses most fan-out detections",
);
check_min(
"chatty_service_min_calls",
&self.detection.chatty_service_min_calls,
&1,
)?;
check_min(
"pool_saturation_concurrent_threshold",
&self.detection.pool_saturation_concurrent_threshold,
&2,
)?;
check_min(
"serialized_min_sequential",
&self.detection.serialized_min_sequential,
&2,
)?;
Ok(())
}
fn validate_rates(&self) -> Result<(), String> {
if !(0.0..=1.0).contains(&self.daemon.sampling_rate) {
return Err(format!(
"sampling_rate must be in [0.0, 1.0], got {}",
self.daemon.sampling_rate
));
}
if !(0.0..=1.0).contains(&self.thresholds.io_waste_ratio_max) {
return Err(format!(
"io_waste_ratio_max must be in [0.0, 1.0], got {}",
self.thresholds.io_waste_ratio_max
));
}
Ok(())
}
}