use std::borrow::Cow;
use std::collections::HashMap;
use serde::Deserialize;
use std::time::Duration;
use crate::detect::Confidence;
use crate::score::carbon::DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2;
use crate::score::cloud_energy::config::{CloudEnergyConfig, ServiceCloudConfig};
use crate::score::kepler::{KeplerConfig, KeplerMetricKind};
use crate::score::redfish::{RedfishConfig, RedfishEndpoint};
use crate::score::scaphandre::{ProcessMatcher, ScaphandreConfig};
#[derive(Debug, Clone, Default)]
pub struct Config {
pub thresholds: ThresholdsConfig,
pub detection: DetectionConfig,
pub green: GreenConfig,
pub daemon: DaemonConfig,
pub reporting: ReportingConfig,
}
#[derive(Debug, Clone, Default)]
pub struct ReportingConfig {
pub intent: Option<String>,
pub confidentiality_level: Option<String>,
pub org_config_path: Option<String>,
pub disclose_output_path: Option<String>,
pub disclose_period: Option<String>,
pub sigstore: SigstoreConfig,
}
#[derive(Debug, Clone)]
pub struct SigstoreConfig {
pub rekor_url: String,
pub fulcio_url: String,
}
impl Default for SigstoreConfig {
fn default() -> Self {
Self {
rekor_url: DEFAULT_REKOR_URL.to_string(),
fulcio_url: DEFAULT_FULCIO_URL.to_string(),
}
}
}
pub const DEFAULT_REKOR_URL: &str = "https://rekor.sigstore.dev";
pub const DEFAULT_FULCIO_URL: &str = "https://fulcio.sigstore.dev";
const RESERVED_DISCLOSE_OUTPUT_PATH_VERSION: &str = "0.8.0";
#[derive(Debug, Clone)]
pub struct DaemonArchiveConfig {
pub path: String,
pub max_size_mb: u64,
pub max_files: u32,
}
impl Default for DaemonArchiveConfig {
fn default() -> Self {
Self {
path: String::new(),
max_size_mb: 100,
max_files: 12,
}
}
}
#[derive(Debug, Clone)]
pub struct ThresholdsConfig {
pub n_plus_one_sql_critical_max: u32,
pub n_plus_one_http_warning_max: u32,
pub io_waste_ratio_max: f64,
}
#[derive(Debug, Clone)]
pub struct DetectionConfig {
pub n_plus_one_threshold: u32,
pub window_duration_ms: u64,
pub slow_query_threshold_ms: u64,
pub slow_query_min_occurrences: u32,
pub max_fanout: u32,
pub chatty_service_min_calls: u32,
pub pool_saturation_concurrent_threshold: u32,
pub serialized_min_sequential: u32,
pub sanitizer_aware_classification: crate::detect::sanitizer_aware::SanitizerAwareMode,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)] pub struct GreenConfig {
pub enabled: bool,
pub default_region: Option<String>,
pub service_regions: HashMap<String, String>,
pub embodied_carbon_per_request_gco2: f64,
pub use_hourly_profiles: bool,
pub scaphandre: Option<ScaphandreConfig>,
pub kepler: Option<KeplerConfig>,
pub redfish: Option<RedfishConfig>,
pub cloud_energy: Option<CloudEnergyConfig>,
pub per_operation_coefficients: bool,
pub include_network_transport: bool,
pub network_energy_per_byte_kwh: f64,
pub hourly_profiles_file: Option<String>,
pub custom_hourly_profiles:
Option<std::sync::Arc<HashMap<String, crate::score::carbon::HourlyProfile>>>,
pub calibration_file: Option<String>,
pub calibration: Option<crate::calibrate::CalibrationData>,
pub electricity_maps: Option<crate::score::electricity_maps::ElectricityMapsConfig>,
}
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub listen_addr: String,
pub listen_port: u16,
pub listen_port_grpc: u16,
pub json_socket: String,
pub max_active_traces: usize,
pub trace_ttl_ms: u64,
pub sampling_rate: f64,
pub max_events_per_trace: usize,
pub max_payload_size: usize,
pub environment: DaemonEnvironment,
pub max_retained_findings: usize,
pub api_enabled: bool,
pub tls: DaemonTlsConfig,
pub ack: DaemonAckConfig,
pub cors: DaemonCorsConfig,
pub correlation: crate::detect::correlate_cross::CorrelationConfig,
pub archive: Option<DaemonArchiveConfig>,
}
#[derive(Debug, Clone, Default)]
pub struct DaemonTlsConfig {
pub cert_path: Option<String>,
pub key_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DaemonAckConfig {
pub enabled: bool,
pub storage_path: Option<String>,
pub api_key: Option<String>,
pub toml_path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct DaemonCorsConfig {
pub allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DaemonEnvironment {
#[default]
Staging,
Production,
}
impl DaemonEnvironment {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Staging => "staging",
Self::Production => "production",
}
}
}
impl Default for ThresholdsConfig {
fn default() -> Self {
Self {
n_plus_one_sql_critical_max: 0,
n_plus_one_http_warning_max: 3,
io_waste_ratio_max: 0.30,
}
}
}
impl Default for DetectionConfig {
fn default() -> Self {
Self {
n_plus_one_threshold: 5,
window_duration_ms: 500,
slow_query_threshold_ms: 500,
slow_query_min_occurrences: 3,
max_fanout: 20,
chatty_service_min_calls: 15,
pool_saturation_concurrent_threshold: 10,
serialized_min_sequential: 3,
sanitizer_aware_classification:
crate::detect::sanitizer_aware::SanitizerAwareMode::default(),
}
}
}
impl Default for GreenConfig {
fn default() -> Self {
Self {
enabled: true,
default_region: None,
service_regions: HashMap::new(),
embodied_carbon_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
use_hourly_profiles: true,
scaphandre: None,
kepler: None,
redfish: None,
cloud_energy: None,
per_operation_coefficients: true,
include_network_transport: false,
network_energy_per_byte_kwh: crate::score::carbon::DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH,
hourly_profiles_file: None,
custom_hourly_profiles: None,
calibration_file: None,
calibration: None,
electricity_maps: None,
}
}
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
listen_addr: "127.0.0.1".to_string(),
listen_port: 4318,
listen_port_grpc: 4317,
json_socket: "/tmp/perf-sentinel.sock".to_string(),
max_active_traces: 10_000,
trace_ttl_ms: 30_000,
sampling_rate: 1.0,
max_events_per_trace: 1_000,
max_payload_size: 16 * 1024 * 1024,
environment: DaemonEnvironment::Staging,
max_retained_findings: 10_000,
api_enabled: true,
tls: DaemonTlsConfig::default(),
ack: DaemonAckConfig::default(),
cors: DaemonCorsConfig::default(),
correlation: crate::detect::correlate_cross::CorrelationConfig::default(),
archive: None,
}
}
}
impl Default for DaemonAckConfig {
fn default() -> Self {
Self {
enabled: true,
storage_path: None,
api_key: None,
toml_path: None,
}
}
}
impl Config {
#[must_use]
pub const fn confidence(&self) -> Confidence {
match self.daemon.environment {
DaemonEnvironment::Staging => Confidence::DaemonStaging,
DaemonEnvironment::Production => Confidence::DaemonProduction,
}
}
#[must_use]
pub fn carbon_context(&self) -> crate::score::carbon::CarbonContext {
let scoring_config = self
.green
.electricity_maps
.as_ref()
.map(crate::score::carbon::ScoringConfig::from_electricity_maps);
crate::score::carbon::CarbonContext {
default_region: self.green.default_region.clone(),
service_regions: self.green.service_regions.clone(),
embodied_per_request_gco2: self.green.embodied_carbon_per_request_gco2,
use_hourly_profiles: self.green.use_hourly_profiles,
energy_snapshot: None,
per_operation_coefficients: self.green.per_operation_coefficients,
include_network_transport: self.green.include_network_transport,
network_energy_per_byte_kwh: self.green.network_energy_per_byte_kwh,
custom_hourly_profiles: self.green.custom_hourly_profiles.clone(),
calibration: self.green.calibration.clone(),
real_time_intensity: None, scoring_config,
}
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawConfig {
thresholds: ThresholdsSection,
detection: DetectionSection,
green: GreenSection,
daemon: DaemonSection,
reporting: ReportingSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ReportingSection {
intent: Option<String>,
confidentiality_level: Option<String>,
org_config_path: Option<String>,
disclose_output_path: Option<String>,
disclose_period: Option<String>,
sigstore: SigstoreSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct SigstoreSection {
rekor_url: Option<String>,
fulcio_url: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ArchiveSection {
path: Option<String>,
max_size_mb: Option<u64>,
max_files: Option<u32>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
#[allow(clippy::struct_field_names)] struct ThresholdsSection {
n_plus_one_sql_critical_max: Option<u32>,
n_plus_one_http_warning_max: Option<u32>,
io_waste_ratio_max: Option<f64>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DetectionSection {
window_duration_ms: Option<u64>,
n_plus_one_min_occurrences: Option<u32>,
slow_query_threshold_ms: Option<u64>,
slow_query_min_occurrences: Option<u32>,
max_fanout: Option<u32>,
chatty_service_min_calls: Option<u32>,
pool_saturation_concurrent_threshold: Option<u32>,
serialized_min_sequential: Option<u32>,
sanitizer_aware_classification: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct GreenSection {
enabled: Option<bool>,
default_region: Option<String>,
service_regions: HashMap<String, String>,
embodied_carbon_per_request_gco2: Option<f64>,
use_hourly_profiles: Option<bool>,
scaphandre: ScaphandreSection,
kepler: KeplerSection,
redfish: RedfishSection,
cloud: CloudSection,
per_operation_coefficients: Option<bool>,
include_network_transport: Option<bool>,
network_energy_per_byte_kwh: Option<f64>,
hourly_profiles_file: Option<String>,
calibration_file: Option<String>,
electricity_maps: ElectricityMapsSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ScaphandreSection {
endpoint: Option<String>,
scrape_interval_secs: Option<u64>,
process_map: HashMap<String, ProcessMatcher>,
auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct KeplerSection {
endpoint: Option<String>,
scrape_interval_secs: Option<u64>,
metric_kind: Option<String>,
service_mappings: HashMap<String, String>,
auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct RedfishSection {
endpoints: HashMap<String, RedfishEndpoint>,
scrape_interval_secs: Option<u64>,
service_mappings: HashMap<String, String>,
ca_bundle_path: Option<String>,
auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct CloudSection {
prometheus_endpoint: Option<String>,
scrape_interval_secs: Option<u64>,
default_provider: Option<String>,
default_instance_type: Option<String>,
cpu_metric: Option<String>,
services: HashMap<String, CloudServiceRaw>,
auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct CloudServiceRaw {
provider: Option<String>,
instance_type: Option<String>,
idle_watts: Option<f64>,
max_watts: Option<f64>,
cpu_query: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ElectricityMapsSection {
api_key: Option<String>,
endpoint: Option<String>,
poll_interval_secs: Option<u64>,
region_map: HashMap<String, String>,
emission_factor_type: Option<String>,
temporal_granularity: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DaemonSection {
listen_address: Option<String>,
listen_port_http: Option<u16>,
listen_port_grpc: Option<u16>,
json_socket: Option<String>,
max_active_traces: Option<usize>,
trace_ttl_ms: Option<u64>,
sampling_rate: Option<f64>,
max_events_per_trace: Option<usize>,
max_payload_size: Option<usize>,
environment: Option<String>,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
max_retained_findings: Option<usize>,
api_enabled: Option<bool>,
correlation: CorrelationSection,
ack: DaemonAckSection,
cors: DaemonCorsSection,
archive: ArchiveSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct CorrelationSection {
enabled: Option<bool>,
window_minutes: Option<u64>,
lag_threshold_ms: Option<u64>,
min_co_occurrences: Option<u32>,
min_confidence: Option<f64>,
max_tracked_pairs: Option<usize>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DaemonAckSection {
enabled: Option<bool>,
storage_path: Option<String>,
api_key: Option<String>,
toml_path: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DaemonCorsSection {
allowed_origins: Vec<String>,
}
const TOML_PATH_STRING_KEYS: &[&str] = &[
"hourly_profiles_file",
"calibration_file",
"json_socket",
"tls_cert_path",
"tls_key_path",
"storage_path",
"toml_path",
];
fn normalize_toml_path_strings(content: &str) -> Cow<'_, str> {
let mut changed = false;
let mut normalized = String::with_capacity(content.len());
for line in content.split_inclusive('\n') {
let rewritten = normalize_toml_path_line(line);
changed |= matches!(rewritten, Cow::Owned(_));
normalized.push_str(rewritten.as_ref());
}
if changed {
Cow::Owned(normalized)
} else {
Cow::Borrowed(content)
}
}
fn normalize_toml_path_line(line: &str) -> Cow<'_, str> {
let leading_ws = line.len() - line.trim_start_matches([' ', '\t']).len();
let trimmed = &line[leading_ws..];
let Some(eq_idx) = trimmed.find('=') else {
return Cow::Borrowed(line);
};
let key = trimmed[..eq_idx].trim();
if !TOML_PATH_STRING_KEYS.contains(&key) {
return Cow::Borrowed(line);
}
let after_eq = &trimmed[eq_idx + 1..];
let value_ws = after_eq.len() - after_eq.trim_start_matches([' ', '\t']).len();
let value_start = leading_ws + eq_idx + 1 + value_ws;
let value = &line[value_start..];
if !value.starts_with('"') {
return Cow::Borrowed(line);
}
let Some(closing_quote) = find_basic_string_end(value) else {
return Cow::Borrowed(line);
};
let inner = &value[1..closing_quote];
let Cow::Owned(normalized_inner) = escape_toml_path_backslashes(inner) else {
return Cow::Borrowed(line);
};
let mut out =
String::with_capacity(line.len() + normalized_inner.len().saturating_sub(inner.len()));
out.push_str(&line[..value_start]);
out.push('"');
out.push_str(&normalized_inner);
out.push_str(&value[closing_quote..]);
Cow::Owned(out)
}
fn find_basic_string_end(value: &str) -> Option<usize> {
debug_assert!(value.starts_with('"'));
let bytes = value.as_bytes();
let mut run: usize = 0;
let mut idx = 1;
while idx < bytes.len() {
match bytes[idx] {
b'"' if run.is_multiple_of(2) => return Some(idx),
b'\\' => run += 1,
_ => run = 0,
}
idx += 1;
}
None
}
fn escape_toml_path_backslashes(inner: &str) -> Cow<'_, str> {
if !inner.contains('\\') {
return Cow::Borrowed(inner);
}
let bytes = inner.as_bytes();
let mut out = String::with_capacity(inner.len() + 4);
let mut changed = false;
let mut idx = 0;
while idx < bytes.len() {
if bytes[idx] != b'\\' {
idx = copy_until_backslash(inner, bytes, idx, &mut out);
continue;
}
let run_start = idx;
idx = skip_backslash_run(bytes, idx);
let run_len = idx - run_start;
let emit_len = backslash_emit_len(run_start, run_len, bytes.get(idx).copied());
changed |= emit_len != run_len;
for _ in 0..emit_len {
out.push('\\');
}
}
if changed {
Cow::Owned(out)
} else {
Cow::Borrowed(inner)
}
}
fn copy_until_backslash(inner: &str, bytes: &[u8], start: usize, out: &mut String) -> usize {
let mut idx = start;
while idx < bytes.len() && bytes[idx] != b'\\' {
idx += 1;
}
out.push_str(&inner[start..idx]);
idx
}
fn skip_backslash_run(bytes: &[u8], start: usize) -> usize {
let mut idx = start;
while idx < bytes.len() && bytes[idx] == b'\\' {
idx += 1;
}
idx
}
fn backslash_emit_len(run_start: usize, run_len: usize, next_byte: Option<u8>) -> usize {
let raw_unc_prefix = run_start == 0 && run_len == 2 && next_byte != Some(b'\\');
if raw_unc_prefix {
4
} else if run_len == 1 {
2
} else {
run_len
}
}
impl From<RawConfig> for Config {
#[allow(clippy::too_many_lines)] fn from(raw: RawConfig) -> Self {
let thresholds_defaults = ThresholdsConfig::default();
let detection_defaults = DetectionConfig::default();
let green_defaults = GreenConfig::default();
let daemon_defaults = DaemonConfig::default();
let correlation_defaults = crate::detect::correlate_cross::CorrelationConfig::default();
let ack_defaults = DaemonAckConfig::default();
Self {
thresholds: ThresholdsConfig {
n_plus_one_sql_critical_max: raw
.thresholds
.n_plus_one_sql_critical_max
.unwrap_or(thresholds_defaults.n_plus_one_sql_critical_max),
n_plus_one_http_warning_max: raw
.thresholds
.n_plus_one_http_warning_max
.unwrap_or(thresholds_defaults.n_plus_one_http_warning_max),
io_waste_ratio_max: raw
.thresholds
.io_waste_ratio_max
.unwrap_or(thresholds_defaults.io_waste_ratio_max),
},
detection: DetectionConfig {
n_plus_one_threshold: raw
.detection
.n_plus_one_min_occurrences
.unwrap_or(detection_defaults.n_plus_one_threshold),
window_duration_ms: raw
.detection
.window_duration_ms
.unwrap_or(detection_defaults.window_duration_ms),
slow_query_threshold_ms: raw
.detection
.slow_query_threshold_ms
.unwrap_or(detection_defaults.slow_query_threshold_ms),
slow_query_min_occurrences: raw
.detection
.slow_query_min_occurrences
.unwrap_or(detection_defaults.slow_query_min_occurrences),
max_fanout: raw
.detection
.max_fanout
.unwrap_or(detection_defaults.max_fanout),
chatty_service_min_calls: raw
.detection
.chatty_service_min_calls
.unwrap_or(detection_defaults.chatty_service_min_calls),
pool_saturation_concurrent_threshold: raw
.detection
.pool_saturation_concurrent_threshold
.unwrap_or(detection_defaults.pool_saturation_concurrent_threshold),
serialized_min_sequential: raw
.detection
.serialized_min_sequential
.unwrap_or(detection_defaults.serialized_min_sequential),
sanitizer_aware_classification:
crate::detect::sanitizer_aware::SanitizerAwareMode::from_config(
raw.detection.sanitizer_aware_classification.as_deref(),
),
},
green: GreenConfig {
enabled: raw.green.enabled.unwrap_or(green_defaults.enabled),
default_region: raw.green.default_region.map(|s| s.to_ascii_lowercase()),
service_regions: raw
.green
.service_regions
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect(),
embodied_carbon_per_request_gco2: raw
.green
.embodied_carbon_per_request_gco2
.unwrap_or(green_defaults.embodied_carbon_per_request_gco2),
use_hourly_profiles: raw
.green
.use_hourly_profiles
.unwrap_or(green_defaults.use_hourly_profiles),
scaphandre: convert_scaphandre_section(&raw.green.scaphandre),
kepler: convert_kepler_section(&raw.green.kepler),
redfish: convert_redfish_section(&raw.green.redfish),
cloud_energy: convert_cloud_section(&raw.green.cloud),
per_operation_coefficients: raw
.green
.per_operation_coefficients
.unwrap_or(green_defaults.per_operation_coefficients),
include_network_transport: raw
.green
.include_network_transport
.unwrap_or(green_defaults.include_network_transport),
network_energy_per_byte_kwh: raw
.green
.network_energy_per_byte_kwh
.unwrap_or(green_defaults.network_energy_per_byte_kwh),
hourly_profiles_file: raw.green.hourly_profiles_file.clone(),
custom_hourly_profiles: raw.green.hourly_profiles_file.as_ref().and_then(|path| {
if has_control_char(path) {
tracing::warn!(
"hourly_profiles_file path contains control characters, skipping"
);
return None;
}
let p = std::path::Path::new(path);
match crate::score::carbon::load_custom_profiles(p) {
Ok(profiles) => Some(std::sync::Arc::new(profiles)),
Err(e) => {
tracing::debug!(
error = %e,
"Custom hourly profiles failed to load"
);
None
}
}
}),
calibration_file: raw.green.calibration_file.clone(),
calibration: raw.green.calibration_file.as_ref().and_then(|path| {
if has_control_char(path) {
tracing::warn!(
"calibration_file path contains control characters, skipping"
);
return None;
}
match crate::calibrate::load_calibration_file(path) {
Ok(data) => Some(data),
Err(e) => {
tracing::debug!(
error = %e,
"Calibration file failed to load"
);
None
}
}
}),
electricity_maps: convert_electricity_maps_section(&raw.green.electricity_maps),
},
daemon: DaemonConfig {
listen_addr: raw
.daemon
.listen_address
.unwrap_or(daemon_defaults.listen_addr),
listen_port: raw
.daemon
.listen_port_http
.unwrap_or(daemon_defaults.listen_port),
listen_port_grpc: raw
.daemon
.listen_port_grpc
.unwrap_or(daemon_defaults.listen_port_grpc),
json_socket: raw
.daemon
.json_socket
.unwrap_or(daemon_defaults.json_socket),
max_active_traces: raw
.daemon
.max_active_traces
.unwrap_or(daemon_defaults.max_active_traces),
trace_ttl_ms: raw
.daemon
.trace_ttl_ms
.unwrap_or(daemon_defaults.trace_ttl_ms),
sampling_rate: raw
.daemon
.sampling_rate
.unwrap_or(daemon_defaults.sampling_rate),
max_events_per_trace: raw
.daemon
.max_events_per_trace
.unwrap_or(daemon_defaults.max_events_per_trace),
max_payload_size: raw
.daemon
.max_payload_size
.unwrap_or(daemon_defaults.max_payload_size),
environment: match raw.daemon.environment.as_deref() {
None => daemon_defaults.environment,
Some(s) => parse_daemon_environment(s).unwrap_or(DaemonEnvironment::Staging),
},
max_retained_findings: raw
.daemon
.max_retained_findings
.unwrap_or(daemon_defaults.max_retained_findings),
api_enabled: raw
.daemon
.api_enabled
.unwrap_or(daemon_defaults.api_enabled),
tls: DaemonTlsConfig {
cert_path: raw.daemon.tls_cert_path,
key_path: raw.daemon.tls_key_path,
},
ack: DaemonAckConfig {
enabled: raw.daemon.ack.enabled.unwrap_or(ack_defaults.enabled),
storage_path: raw.daemon.ack.storage_path,
api_key: raw.daemon.ack.api_key,
toml_path: raw.daemon.ack.toml_path,
},
cors: DaemonCorsConfig {
allowed_origins: raw.daemon.cors.allowed_origins,
},
correlation: {
let c = &raw.daemon.correlation;
crate::detect::correlate_cross::CorrelationConfig {
enabled: c.enabled.unwrap_or(correlation_defaults.enabled),
window_ms: c
.window_minutes
.map_or(correlation_defaults.window_ms, |m| m.saturating_mul(60_000)),
lag_threshold_ms: c
.lag_threshold_ms
.unwrap_or(correlation_defaults.lag_threshold_ms),
min_co_occurrences: c
.min_co_occurrences
.unwrap_or(correlation_defaults.min_co_occurrences),
min_confidence: c
.min_confidence
.unwrap_or(correlation_defaults.min_confidence),
max_tracked_pairs: c
.max_tracked_pairs
.unwrap_or(correlation_defaults.max_tracked_pairs),
}
},
archive: convert_archive_section(&raw.daemon.archive),
},
reporting: ReportingConfig {
intent: raw.reporting.intent,
confidentiality_level: raw.reporting.confidentiality_level,
org_config_path: raw.reporting.org_config_path,
disclose_output_path: raw.reporting.disclose_output_path,
disclose_period: raw.reporting.disclose_period,
sigstore: SigstoreConfig {
rekor_url: raw
.reporting
.sigstore
.rekor_url
.unwrap_or_else(|| DEFAULT_REKOR_URL.to_string()),
fulcio_url: raw
.reporting
.sigstore
.fulcio_url
.unwrap_or_else(|| DEFAULT_FULCIO_URL.to_string()),
},
},
}
}
}
fn convert_archive_section(raw: &ArchiveSection) -> Option<DaemonArchiveConfig> {
let path = raw.path.clone()?;
let defaults = DaemonArchiveConfig::default();
Some(DaemonArchiveConfig {
path,
max_size_mb: raw.max_size_mb.unwrap_or(defaults.max_size_mb),
max_files: raw.max_files.unwrap_or(defaults.max_files),
})
}
fn parse_daemon_environment(value: &str) -> Option<DaemonEnvironment> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("staging") {
Some(DaemonEnvironment::Staging)
} else if trimmed.eq_ignore_ascii_case("production") {
Some(DaemonEnvironment::Production)
} else {
None
}
}
fn convert_cloud_section(raw: &CloudSection) -> Option<CloudEnergyConfig> {
convert_cloud_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_CLOUD_AUTH_HEADER").ok()
})
}
fn convert_cloud_section_with_env(
raw: &CloudSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<CloudEnergyConfig> {
let endpoint = raw.prometheus_endpoint.as_ref()?;
let mut services = HashMap::with_capacity(raw.services.len());
for (name, svc) in &raw.services {
let config = if svc.idle_watts.is_some() || svc.max_watts.is_some() {
ServiceCloudConfig::ManualWatts {
idle_watts: svc.idle_watts.unwrap_or(0.0),
max_watts: svc.max_watts.unwrap_or(0.0),
cpu_query: svc.cpu_query.clone(),
}
} else {
ServiceCloudConfig::InstanceType {
provider: svc.provider.clone(),
instance_type: svc.instance_type.clone().unwrap_or_default(),
cpu_query: svc.cpu_query.clone(),
}
};
services.insert(name.clone(), config);
}
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.cloud] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_CLOUD_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(CloudEnergyConfig {
prometheus_endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(15)),
default_provider: raw.default_provider.clone(),
default_instance_type: raw.default_instance_type.clone(),
cpu_metric: raw.cpu_metric.clone(),
services,
auth_header,
})
}
fn convert_scaphandre_section(raw: &ScaphandreSection) -> Option<ScaphandreConfig> {
convert_scaphandre_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_SCAPHANDRE_AUTH_HEADER").ok()
})
}
fn convert_scaphandre_section_with_env(
raw: &ScaphandreSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<ScaphandreConfig> {
let endpoint = raw.endpoint.as_ref()?;
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.scaphandre] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_SCAPHANDRE_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(ScaphandreConfig {
endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(5)),
process_map: raw.process_map.clone(),
auth_header,
})
}
fn parse_kepler_metric_kind(raw: Option<&str>) -> Result<KeplerMetricKind, String> {
let Some(literal) = raw else {
return Ok(KeplerMetricKind::Container);
};
if has_control_char(literal) {
return Err("[green.kepler] metric_kind contains control characters".to_string());
}
let trimmed = literal.trim();
if trimmed.is_empty() {
return Err(format!(
"[green.kepler] metric_kind '{literal}' is empty; \
remove the field for the default or set it to 'container' or 'process'"
));
}
if trimmed.eq_ignore_ascii_case("container") {
return Ok(KeplerMetricKind::Container);
}
if trimmed.eq_ignore_ascii_case("process") {
return Ok(KeplerMetricKind::Process);
}
if trimmed.eq_ignore_ascii_case("process_package")
|| trimmed.eq_ignore_ascii_case("process_dram")
{
return Err(format!(
"[green.kepler] metric_kind '{literal}' was removed in v0.7.5. \
Kepler v2 only exposes per-process CPU joules, use 'process' instead."
));
}
Err(format!(
"[green.kepler] metric_kind '{literal}' is not recognized \
(expected 'container' or 'process')"
))
}
fn convert_kepler_section(raw: &KeplerSection) -> Option<KeplerConfig> {
convert_kepler_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_KEPLER_AUTH_HEADER").ok()
})
}
fn convert_kepler_section_with_env(
raw: &KeplerSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<KeplerConfig> {
let endpoint = raw.endpoint.as_ref()?;
let metric_kind = match parse_kepler_metric_kind(raw.metric_kind.as_deref()) {
Ok(k) => k,
Err(msg) => {
tracing::error!("{msg}");
return None;
}
};
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.kepler] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_KEPLER_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(KeplerConfig {
endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(5)),
metric_kind,
service_mappings: raw.service_mappings.clone(),
auth_header,
})
}
fn convert_redfish_section(raw: &RedfishSection) -> Option<RedfishConfig> {
convert_redfish_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_REDFISH_AUTH_HEADER").ok()
})
}
fn convert_redfish_section_with_env(
raw: &RedfishSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<RedfishConfig> {
if raw.endpoints.is_empty() {
return None;
}
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.redfish] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_REDFISH_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(RedfishConfig {
endpoints: raw.endpoints.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(60)),
service_mappings: raw.service_mappings.clone(),
ca_bundle_path: raw.ca_bundle_path.clone(),
auth_header,
})
}
fn convert_electricity_maps_section(
raw: &ElectricityMapsSection,
) -> Option<crate::score::electricity_maps::ElectricityMapsConfig> {
convert_electricity_maps_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_EMAPS_TOKEN").ok()
})
}
fn convert_electricity_maps_section_with_env(
raw: &ElectricityMapsSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<crate::score::electricity_maps::ElectricityMapsConfig> {
let from_env = env_lookup();
let token = from_env.clone().or_else(|| raw.api_key.clone())?;
if token.is_empty() {
return None;
}
if from_env.is_none() && raw.api_key.is_some() {
tracing::warn!(
"[green.electricity_maps] api_key is set in the config file. \
Prefer the PERF_SENTINEL_EMAPS_TOKEN environment variable \
to avoid committing secrets to version control."
);
}
let poll_secs = raw.poll_interval_secs.unwrap_or(300);
let api_endpoint = raw
.endpoint
.clone()
.unwrap_or_else(|| {
crate::score::electricity_maps::config::DEFAULT_ELECTRICITY_MAPS_ENDPOINT.to_string()
})
.trim_end_matches('/')
.to_string();
let emission_factor_type =
crate::score::electricity_maps::config::EmissionFactorType::from_config(
raw.emission_factor_type.as_deref(),
);
let temporal_granularity =
crate::score::electricity_maps::config::TemporalGranularity::from_config(
raw.temporal_granularity.as_deref(),
);
Some(crate::score::electricity_maps::ElectricityMapsConfig {
api_endpoint,
auth_token: token,
poll_interval: Duration::from_secs(poll_secs),
region_map: raw
.region_map
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
.collect(),
emission_factor_type,
temporal_granularity,
})
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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("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(())
}
}
const REMOVED_LEGACY_TOP_LEVEL_KEYS: &[(&str, &str)] = &[
(
"n_plus_one_threshold",
"[detection] n_plus_one_min_occurrences",
),
("window_duration_ms", "[detection] window_duration_ms"),
("listen_addr", "[daemon] listen_address"),
("listen_port", "[daemon] listen_port_http"),
("max_active_traces", "[daemon] max_active_traces"),
("trace_ttl_ms", "[daemon] trace_ttl_ms"),
("max_events_per_trace", "[daemon] max_events_per_trace"),
("max_payload_size", "[daemon] max_payload_size"),
];
fn reject_legacy_top_level_keys(content: &str) -> Result<(), ConfigError> {
let value: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
let toml::Value::Table(table) = value else {
return Ok(());
};
for (legacy, replacement) in REMOVED_LEGACY_TOP_LEVEL_KEYS {
if table.contains_key(*legacy) {
return Err(ConfigError::Validation(format!(
"config: top-level '{legacy}' was removed in 0.6.0; \
use '{replacement}' instead. \
See the 0.6.0 migration notes for the full list of renamed keys."
)));
}
}
Ok(())
}
pub fn load_from_str(content: &str) -> Result<Config, ConfigError> {
let normalized = normalize_toml_path_strings(content);
reject_legacy_top_level_keys(normalized.as_ref())?;
let raw: RawConfig = match toml::from_str(normalized.as_ref()) {
Ok(raw) => raw,
Err(norm_err) => {
if matches!(normalized, Cow::Owned(_)) {
tracing::debug!(
normalized_error = %norm_err,
"path normalization produced invalid TOML; retrying with original input"
);
toml::from_str(content).map_err(ConfigError::Parse)?
} else {
return Err(ConfigError::Parse(norm_err));
}
}
};
if let Some(env_str) = raw.daemon.environment.as_deref()
&& parse_daemon_environment(env_str).is_none()
{
return Err(ConfigError::Validation(format!(
"[daemon] environment '{env_str}' is invalid; \
expected 'staging' or 'production' (case-insensitive)"
)));
}
parse_kepler_metric_kind(raw.green.kepler.metric_kind.as_deref())
.map_err(ConfigError::Validation)?;
let config = Config::from(raw);
config.validate().map_err(ConfigError::Validation)?;
config.warn_listen_addr_if_non_loopback();
config.warn_reporting_advisory();
Ok(config)
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("config parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("config validation error: {0}")]
Validation(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_safe_defaults() {
let config = Config::default();
assert_eq!(config.daemon.max_payload_size, 16 * 1024 * 1024);
assert_eq!(config.daemon.listen_addr, "127.0.0.1");
assert_eq!(config.detection.n_plus_one_threshold, 5);
assert_eq!(config.detection.window_duration_ms, 500);
assert_eq!(config.daemon.trace_ttl_ms, 30_000);
assert_eq!(config.daemon.max_active_traces, 10_000);
assert_eq!(config.daemon.max_events_per_trace, 1_000);
}
#[test]
fn parse_empty_toml_gives_defaults() {
let config = load_from_str("").unwrap();
assert_eq!(config.daemon.max_payload_size, 16 * 1024 * 1024);
}
#[test]
fn parse_partial_toml() {
let config = load_from_str("[detection]\nn_plus_one_min_occurrences = 10").unwrap();
assert_eq!(config.detection.n_plus_one_threshold, 10);
assert_eq!(config.daemon.max_payload_size, 16 * 1024 * 1024); }
#[test]
fn parse_window_config() {
let config = load_from_str(
"[detection]\nwindow_duration_ms = 1000\n\
[daemon]\ntrace_ttl_ms = 60000\nmax_active_traces = 5000",
)
.unwrap();
assert_eq!(config.detection.window_duration_ms, 1000);
assert_eq!(config.daemon.trace_ttl_ms, 60_000);
assert_eq!(config.daemon.max_active_traces, 5000);
}
#[test]
fn parse_sectioned_format() {
let toml = r#"
[thresholds]
n_plus_one_sql_critical_max = 2
n_plus_one_http_warning_max = 5
io_waste_ratio_max = 0.50
[detection]
window_duration_ms = 1000
n_plus_one_min_occurrences = 10
[green]
enabled = false
[daemon]
listen_address = "0.0.0.0"
listen_port_http = 9418
listen_port_grpc = 9417
json_socket = "/var/run/perf-sentinel.sock"
max_active_traces = 20000
trace_ttl_ms = 60000
sampling_rate = 0.5
max_events_per_trace = 500
max_payload_size = 2097152
"#;
let config = load_from_str(toml).unwrap();
assert_eq!(config.thresholds.n_plus_one_sql_critical_max, 2);
assert_eq!(config.thresholds.n_plus_one_http_warning_max, 5);
assert!((config.thresholds.io_waste_ratio_max - 0.50).abs() < f64::EPSILON);
assert_eq!(config.detection.n_plus_one_threshold, 10);
assert_eq!(config.detection.window_duration_ms, 1000);
assert!(!config.green.enabled);
assert_eq!(config.daemon.listen_addr, "0.0.0.0");
assert_eq!(config.daemon.listen_port, 9418);
assert_eq!(config.daemon.listen_port_grpc, 9417);
assert_eq!(config.daemon.json_socket, "/var/run/perf-sentinel.sock");
assert_eq!(config.daemon.max_active_traces, 20_000);
assert_eq!(config.daemon.trace_ttl_ms, 60_000);
assert!((config.daemon.sampling_rate - 0.5).abs() < f64::EPSILON);
assert_eq!(config.daemon.max_events_per_trace, 500);
assert_eq!(config.daemon.max_payload_size, 2_097_152);
}
fn assert_legacy_top_level_rejected(toml: &str, key: &str, replacement: &str) {
let err = load_from_str(toml).expect_err("legacy top-level key must be rejected");
let msg = err.to_string();
assert!(
msg.contains(key),
"error must name the legacy key '{key}': {msg}"
);
assert!(
msg.contains(replacement),
"error must point at '{replacement}': {msg}"
);
assert!(
msg.contains("0.6.0"),
"error must tag the breaking-change version: {msg}"
);
}
#[test]
fn legacy_flat_n_plus_one_threshold_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"n_plus_one_threshold = 7\n",
"n_plus_one_threshold",
"[detection] n_plus_one_min_occurrences",
);
}
#[test]
fn legacy_flat_window_duration_ms_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"window_duration_ms = 1500\n",
"window_duration_ms",
"[detection] window_duration_ms",
);
}
#[test]
fn legacy_flat_listen_addr_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"listen_addr = \"0.0.0.0\"\n",
"listen_addr",
"[daemon] listen_address",
);
}
#[test]
fn legacy_flat_listen_port_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"listen_port = 9418\n",
"listen_port",
"[daemon] listen_port_http",
);
}
#[test]
fn legacy_flat_max_active_traces_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"max_active_traces = 5000\n",
"max_active_traces",
"[daemon] max_active_traces",
);
}
#[test]
fn legacy_flat_trace_ttl_ms_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"trace_ttl_ms = 60000\n",
"trace_ttl_ms",
"[daemon] trace_ttl_ms",
);
}
#[test]
fn legacy_flat_max_events_per_trace_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"max_events_per_trace = 250\n",
"max_events_per_trace",
"[daemon] max_events_per_trace",
);
}
#[test]
fn legacy_flat_max_payload_size_rejected_with_migration_hint() {
assert_legacy_top_level_rejected(
"max_payload_size = 1048576\n",
"max_payload_size",
"[daemon] max_payload_size",
);
}
#[test]
fn empty_config_yields_defaults_after_legacy_removal() {
let config = load_from_str("").unwrap();
let d = Config::default();
assert_eq!(
config.detection.n_plus_one_threshold,
d.detection.n_plus_one_threshold
);
assert_eq!(
config.detection.window_duration_ms,
d.detection.window_duration_ms
);
assert_eq!(config.daemon.listen_addr, d.daemon.listen_addr);
assert_eq!(config.daemon.listen_port, d.daemon.listen_port);
assert_eq!(config.daemon.max_active_traces, d.daemon.max_active_traces);
assert_eq!(config.daemon.trace_ttl_ms, d.daemon.trace_ttl_ms);
assert_eq!(
config.daemon.max_events_per_trace,
d.daemon.max_events_per_trace
);
assert_eq!(config.daemon.max_payload_size, d.daemon.max_payload_size);
}
#[test]
fn parse_sanitizer_aware_classification_modes() {
use crate::detect::sanitizer_aware::SanitizerAwareMode;
let default_config = load_from_str("").unwrap();
assert_eq!(
default_config.detection.sanitizer_aware_classification,
SanitizerAwareMode::Auto
);
for (value, expected) in [
("auto", SanitizerAwareMode::Auto),
("always", SanitizerAwareMode::Always),
("never", SanitizerAwareMode::Never),
("ALWAYS", SanitizerAwareMode::Always),
("strict", SanitizerAwareMode::Strict),
("STRICT", SanitizerAwareMode::Strict),
] {
let toml = format!("[detection]\nsanitizer_aware_classification = \"{value}\"\n");
let config = load_from_str(&toml).unwrap();
assert_eq!(
config.detection.sanitizer_aware_classification, expected,
"value: {value}"
);
}
let unknown =
load_from_str("[detection]\nsanitizer_aware_classification = \"unknown\"\n").unwrap();
assert_eq!(
unknown.detection.sanitizer_aware_classification,
SanitizerAwareMode::Auto,
"unknown value should fall back to Auto"
);
}
#[test]
fn parse_windows_style_json_socket_path_in_basic_string() {
let config = load_from_str(
r#"
[daemon]
json_socket = "C:\temp\perf-sentinel.sock"
"#,
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"C:\temp\perf-sentinel.sock");
}
#[test]
fn parse_escaped_windows_style_json_socket_path_stays_stable() {
let config = load_from_str(
r#"
[daemon]
json_socket = "C:\\temp\\perf-sentinel.sock"
"#,
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"C:\temp\perf-sentinel.sock");
}
#[test]
fn parse_windows_style_json_socket_path_with_trailing_comment() {
let config = load_from_str(
"[daemon]\n\
json_socket = \"C:\\temp\\sock\" # inline note\n",
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"C:\temp\sock");
}
#[test]
fn parse_unc_json_socket_path_preserves_double_leading_backslash() {
let config = load_from_str(
r#"
[daemon]
json_socket = "\\server\share\sock"
"#,
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"\\server\share\sock");
}
#[test]
fn parse_pre_escaped_unc_json_socket_path_is_stable() {
let config = load_from_str(
r#"
[daemon]
json_socket = "\\\\server\\share\\sock"
"#,
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"\\server\share\sock");
}
#[test]
fn literal_string_windows_path_bypasses_normalization() {
let config = load_from_str(
r"
[daemon]
json_socket = 'C:\temp\sock'
",
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"C:\temp\sock");
}
#[test]
fn normalization_applies_to_tls_cert_and_key_paths() {
let err = load_from_str(
r#"
[daemon]
tls_cert_path = "C:\certs\server.crt"
tls_key_path = "C:\certs\server.key"
"#,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(r"C:\certs\server.crt") || msg.contains(r"C:\certs\server.key"),
"expected normalized TLS path in error, got: {msg}"
);
}
#[test]
fn normalization_applies_to_all_registered_path_keys() {
for key in TOML_PATH_STRING_KEYS {
let line = format!("{key} = \"C:\\temp\\x\"\n");
let rewritten = normalize_toml_path_strings(&line);
assert!(
matches!(rewritten, Cow::Owned(_)),
"{key}: expected normalization to rewrite bare Windows path"
);
assert!(
rewritten.as_ref().contains(r#""C:\\temp\\x""#),
"{key}: normalized output missing escaped path, got {rewritten}"
);
}
}
#[test]
fn normalization_leaves_toml_escape_sequences_literal_in_path_keys() {
let config = load_from_str(
r#"
[daemon]
json_socket = "C:\new\tmp\sock"
"#,
)
.unwrap();
assert_eq!(config.daemon.json_socket, r"C:\new\tmp\sock");
}
#[test]
fn load_from_str_falls_back_when_original_error_is_unrelated_to_path() {
let err = load_from_str(
r#"
[daemon]
json_socket = "C:\temp\sock"
sampling_rate = "not a number"
"#,
)
.unwrap_err();
assert!(
matches!(err, ConfigError::Parse(_)),
"expected ConfigError::Parse, got {err:?}"
);
}
#[test]
fn find_basic_string_end_handles_escaped_inner_quote() {
let value = r#""a\"b""#;
assert_eq!(find_basic_string_end(value), Some(5));
}
#[test]
fn find_basic_string_end_survives_very_long_backslash_run() {
let mut input = String::from("\"");
input.extend(std::iter::repeat_n('\\', 10_000));
input.push('"');
assert_eq!(find_basic_string_end(&input), Some(10_001));
}
#[test]
fn detection_section_drives_thresholds() {
let toml = r"
[detection]
n_plus_one_min_occurrences = 12
window_duration_ms = 800
";
let config = load_from_str(toml).unwrap();
assert_eq!(config.detection.n_plus_one_threshold, 12);
assert_eq!(config.detection.window_duration_ms, 800);
}
#[test]
fn new_fields_have_correct_defaults() {
let config = Config::default();
assert_eq!(config.thresholds.n_plus_one_sql_critical_max, 0);
assert_eq!(config.thresholds.n_plus_one_http_warning_max, 3);
assert!((config.thresholds.io_waste_ratio_max - 0.30).abs() < f64::EPSILON);
assert!(config.green.enabled);
assert_eq!(config.daemon.listen_port_grpc, 4317);
assert_eq!(config.daemon.json_socket, "/tmp/perf-sentinel.sock");
assert!((config.daemon.sampling_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn default_config_validates() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn rejects_sampling_rate_above_one() {
let result = load_from_str("[daemon]\nsampling_rate = 5.0");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("sampling_rate"), "got: {err}");
}
#[test]
fn rejects_negative_sampling_rate() {
let result = load_from_str("[daemon]\nsampling_rate = -0.1");
assert!(result.is_err());
}
#[test]
fn rejects_io_waste_ratio_max_above_one() {
let result = load_from_str("[thresholds]\nio_waste_ratio_max = 1.5");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("io_waste_ratio_max"), "got: {err}");
}
#[test]
fn rejects_zero_max_payload_size() {
let result = load_from_str("[daemon]\nmax_payload_size = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_n_plus_one_threshold() {
let result = load_from_str("n_plus_one_threshold = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_max_active_traces() {
let result = load_from_str("max_active_traces = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_max_events_per_trace() {
let result = load_from_str("max_events_per_trace = 0");
assert!(result.is_err());
}
#[test]
fn slow_query_defaults() {
let config = Config::default();
assert_eq!(config.detection.slow_query_threshold_ms, 500);
assert_eq!(config.detection.slow_query_min_occurrences, 3);
assert!(config.green.default_region.is_none());
assert!(config.green.service_regions.is_empty());
assert!(
(config.green.embodied_carbon_per_request_gco2
- DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2)
.abs()
< f64::EPSILON
);
}
#[test]
fn parse_slow_query_config() {
let toml = r"
[detection]
slow_query_threshold_ms = 1000
slow_query_min_occurrences = 5
";
let config = load_from_str(toml).unwrap();
assert_eq!(config.detection.slow_query_threshold_ms, 1000);
assert_eq!(config.detection.slow_query_min_occurrences, 5);
}
#[test]
fn parse_green_default_region() {
let toml = r#"
[green]
enabled = true
default_region = "eu-west-3"
"#;
let config = load_from_str(toml).unwrap();
assert_eq!(config.green.default_region.as_deref(), Some("eu-west-3"));
}
#[test]
fn parse_green_service_regions() {
let toml = r#"
[green]
enabled = true
default_region = "eu-west-3"
[green.service_regions]
"order-svc" = "us-east-1"
"chat-svc" = "ap-southeast-1"
"#;
let config = load_from_str(toml).unwrap();
assert_eq!(config.green.service_regions.len(), 2);
assert_eq!(
config
.green
.service_regions
.get("order-svc")
.map(String::as_str),
Some("us-east-1")
);
assert_eq!(
config
.green
.service_regions
.get("chat-svc")
.map(String::as_str),
Some("ap-southeast-1")
);
}
#[test]
fn parse_green_embodied_carbon_override() {
let toml = r"
[green]
enabled = true
embodied_carbon_per_request_gco2 = 0.005
";
let config = load_from_str(toml).unwrap();
assert!((config.green.embodied_carbon_per_request_gco2 - 0.005).abs() < f64::EPSILON);
}
#[test]
fn rejects_negative_embodied_carbon() {
let result = load_from_str("[green]\nembodied_carbon_per_request_gco2 = -0.001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("embodied_carbon_per_request_gco2"),
"got: {err}"
);
}
#[test]
fn accepts_zero_embodied_carbon() {
let toml = r"
[green]
embodied_carbon_per_request_gco2 = 0.0
";
let config = load_from_str(toml).unwrap();
assert!((config.green.embodied_carbon_per_request_gco2 - 0.0).abs() < f64::EPSILON);
}
#[test]
fn empty_service_regions_default() {
let toml = r#"
[green]
default_region = "eu-west-3"
"#;
let config = load_from_str(toml).unwrap();
assert!(config.green.service_regions.is_empty());
}
#[test]
fn rejects_invalid_default_region_characters() {
let result = load_from_str("[green]\ndefault_region = \"eu west 3\"");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("default_region"),
"error should mention default_region, got: {err}"
);
}
#[test]
fn rejects_oversized_default_region() {
let long_region = "a".repeat(65);
let toml = format!("[green]\ndefault_region = \"{long_region}\"");
let result = load_from_str(&toml);
assert!(result.is_err());
}
#[test]
fn rejects_default_region_with_newline_escape() {
let result = load_from_str("[green]\ndefault_region = \"eu-west-3\\n\"");
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("default_region"),
"error should mention default_region"
);
}
#[test]
fn rejects_default_region_with_literal_newline() {
let result = load_from_str("[green]\ndefault_region = \"\"\"eu-west-3\n\"\"\"");
assert!(result.is_err());
}
#[test]
fn accepts_known_regions() {
for region in ["eu-west-3", "us-east-1", "fr", "mars-1", "unknown"] {
let toml = format!("[green]\ndefault_region = \"{region}\"");
let config = load_from_str(&toml)
.unwrap_or_else(|e| panic!("region '{region}' should be accepted, got error: {e}"));
assert_eq!(config.green.default_region.as_deref(), Some(region));
}
}
#[test]
fn rejects_invalid_service_regions_service_name() {
let toml = r#"
[green.service_regions]
"bad service" = "us-east-1"
"#;
let result = load_from_str(toml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("service_regions"),
"error should mention service_regions, got: {err}"
);
}
#[test]
fn rejects_invalid_service_regions_region_value() {
let toml = r#"
[green.service_regions]
"order-svc" = "us east 1"
"#;
let result = load_from_str(toml);
assert!(result.is_err());
}
#[test]
fn rejects_oversized_service_regions_map() {
use std::fmt::Write as _;
let mut toml = String::from("[green.service_regions]\n");
for i in 0..1025 {
let _ = writeln!(toml, "\"svc-{i:04}\" = \"eu-west-3\"");
}
let result = load_from_str(&toml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("service_regions") && err.contains("1025"),
"error should mention service_regions and the count, got: {err}"
);
}
#[test]
fn accepts_service_regions_at_exactly_the_cap() {
use std::fmt::Write as _;
let mut toml = String::from("[green.service_regions]\n");
for i in 0..1024 {
let _ = writeln!(toml, "\"svc-{i:04}\" = \"eu-west-3\"");
}
let config = load_from_str(&toml).expect("1024 entries should be accepted");
assert_eq!(config.green.service_regions.len(), 1024);
}
#[test]
fn service_regions_keys_are_lowercased_on_load() {
let toml = r#"
[green.service_regions]
"Order-Svc" = "us-east-1"
"CHAT-SVC" = "ap-southeast-1"
"#;
let config = load_from_str(toml).unwrap();
assert_eq!(config.green.service_regions.len(), 2);
assert_eq!(
config
.green
.service_regions
.get("order-svc")
.map(String::as_str),
Some("us-east-1")
);
assert_eq!(
config
.green
.service_regions
.get("chat-svc")
.map(String::as_str),
Some("ap-southeast-1")
);
assert!(!config.green.service_regions.contains_key("Order-Svc"));
}
#[test]
fn rejects_zero_slow_query_threshold() {
let result = load_from_str("[detection]\nslow_query_threshold_ms = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_slow_query_min_occurrences() {
let result = load_from_str("[detection]\nslow_query_min_occurrences = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_max_fanout() {
let result = load_from_str("[detection]\nmax_fanout = 0");
assert!(result.is_err());
}
#[test]
fn rejects_max_fanout_over_100k() {
let result = load_from_str("[detection]\nmax_fanout = 100001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_fanout"), "got: {err}");
}
#[test]
fn accepts_max_fanout_at_100k() {
let result = load_from_str("[detection]\nmax_fanout = 100000");
assert!(result.is_ok());
}
#[test]
fn rejects_max_payload_size_over_100mb() {
let result = load_from_str("[daemon]\nmax_payload_size = 104857601");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_payload_size"), "got: {err}");
}
#[test]
fn accepts_max_payload_size_at_100mb() {
let result = load_from_str("[daemon]\nmax_payload_size = 104857600");
assert!(result.is_ok());
}
#[test]
fn rejects_max_active_traces_over_1m() {
let result = load_from_str("[daemon]\nmax_active_traces = 1000001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_active_traces"), "got: {err}");
}
#[test]
fn accepts_max_active_traces_at_1m() {
let result = load_from_str("[daemon]\nmax_active_traces = 1000000");
assert!(result.is_ok());
}
#[test]
fn rejects_max_events_per_trace_over_100k() {
let result = load_from_str("[daemon]\nmax_events_per_trace = 100001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_events_per_trace"), "got: {err}");
}
#[test]
fn accepts_max_events_per_trace_at_100k() {
let result = load_from_str("[daemon]\nmax_events_per_trace = 100000");
assert!(result.is_ok());
}
#[test]
fn config_defaults_sit_inside_every_comfort_zone() {
let cfg = Config::default();
assert!(
(256 * 1024..=16 * 1024 * 1024).contains(&cfg.daemon.max_payload_size),
"default max_payload_size {} is outside its comfort zone",
cfg.daemon.max_payload_size
);
assert!(
(1_000..=100_000).contains(&cfg.daemon.max_active_traces),
"default max_active_traces {} is outside its comfort zone",
cfg.daemon.max_active_traces
);
assert!(
(100..=10_000).contains(&cfg.daemon.max_events_per_trace),
"default max_events_per_trace {} is outside its comfort zone",
cfg.daemon.max_events_per_trace
);
assert!(
(100..=100_000).contains(&cfg.daemon.max_retained_findings),
"default max_retained_findings {} is outside its comfort zone",
cfg.daemon.max_retained_findings
);
assert!(
(1_000..=600_000).contains(&cfg.daemon.trace_ttl_ms),
"default trace_ttl_ms {} is outside its comfort zone",
cfg.daemon.trace_ttl_ms
);
assert!(
(5..=1_000).contains(&cfg.detection.max_fanout),
"default max_fanout {} is outside its comfort zone",
cfg.detection.max_fanout
);
}
#[test]
fn accepts_max_active_traces_below_comfort_floor_with_warning() {
let result = load_from_str("[daemon]\nmax_active_traces = 500");
assert!(result.is_ok(), "expected parse OK, got {result:?}");
}
#[test]
fn accepts_max_active_traces_above_comfort_ceiling_with_warning() {
let result = load_from_str("[daemon]\nmax_active_traces = 500000");
assert!(result.is_ok(), "expected parse OK, got {result:?}");
}
#[test]
fn accepts_max_events_per_trace_outside_comfort_zone() {
for value in [10, 50_000] {
let result = load_from_str(&format!("[daemon]\nmax_events_per_trace = {value}\n"));
assert!(result.is_ok(), "expected {value} to parse, got {result:?}");
}
}
#[test]
fn accepts_trace_ttl_outside_comfort_but_inside_hard_bounds() {
for value in [200_u64, 1_800_000_u64] {
let result = load_from_str(&format!("[daemon]\ntrace_ttl_ms = {value}\n"));
assert!(result.is_ok(), "expected {value} to parse, got {result:?}");
}
}
#[test]
fn accepts_max_fanout_outside_comfort_but_inside_hard_bounds() {
for value in [2, 5_000] {
let result = load_from_str(&format!("[detection]\nmax_fanout = {value}\n"));
assert!(result.is_ok(), "expected {value} to parse, got {result:?}");
}
}
#[test]
fn accepts_max_payload_size_outside_comfort_but_inside_hard_bounds() {
for value in [64 * 1024_u64, 32 * 1024 * 1024_u64] {
let result = load_from_str(&format!("[daemon]\nmax_payload_size = {value}\n"));
assert!(result.is_ok(), "expected {value} to parse, got {result:?}");
}
}
#[test]
fn accepts_zero_max_retained_findings_disables_store() {
let result = load_from_str("[daemon]\nmax_retained_findings = 0");
assert!(result.is_ok(), "expected 0 to parse, got {result:?}");
}
#[test]
fn rejects_max_retained_findings_above_10m() {
let result = load_from_str("[daemon]\nmax_retained_findings = 10000001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_retained_findings"), "got: {err}");
}
#[test]
fn accepts_max_retained_findings_at_10m_hard_ceiling() {
let result = load_from_str("[daemon]\nmax_retained_findings = 10000000");
assert!(result.is_ok());
}
#[test]
fn accepts_max_retained_findings_outside_comfort_but_inside_hard_bounds() {
for value in [50, 500_000] {
let result = load_from_str(&format!("[daemon]\nmax_retained_findings = {value}\n"));
assert!(result.is_ok(), "expected {value} to parse, got {result:?}");
}
}
#[test]
fn rejects_trace_ttl_below_100() {
let result = load_from_str("[daemon]\ntrace_ttl_ms = 50");
assert!(result.is_err());
}
#[test]
fn rejects_zero_window_duration() {
let result = load_from_str("[detection]\nwindow_duration_ms = 0");
assert!(result.is_err());
}
#[test]
fn green_disabled_parses() {
let config = load_from_str("[green]\nenabled = false").unwrap();
assert!(!config.green.enabled);
}
#[test]
fn rejects_port_zero() {
let result = load_from_str("[daemon]\nlisten_port_http = 0");
assert!(result.is_err());
}
#[test]
fn accepts_port_one() {
let config = load_from_str("[daemon]\nlisten_port_http = 1").unwrap();
assert_eq!(config.daemon.listen_port, 1);
}
#[test]
fn accepts_port_65535() {
let config = load_from_str("[daemon]\nlisten_port_http = 65535").unwrap();
assert_eq!(config.daemon.listen_port, 65535);
}
#[test]
fn rejects_grpc_port_zero() {
let result = load_from_str("[daemon]\nlisten_port_grpc = 0");
assert!(result.is_err());
}
#[test]
fn rejects_trace_ttl_above_1h() {
let result = load_from_str("[daemon]\ntrace_ttl_ms = 3600001");
assert!(result.is_err());
}
#[test]
fn accepts_trace_ttl_at_1h() {
let config = load_from_str("[daemon]\ntrace_ttl_ms = 3600000").unwrap();
assert_eq!(config.daemon.trace_ttl_ms, 3_600_000);
}
#[test]
fn accepts_trace_ttl_at_100ms() {
let config = load_from_str("[daemon]\ntrace_ttl_ms = 100").unwrap();
assert_eq!(config.daemon.trace_ttl_ms, 100);
}
#[test]
fn accepts_sampling_rate_zero() {
let config = load_from_str("[daemon]\nsampling_rate = 0.0").unwrap();
assert!((config.daemon.sampling_rate - 0.0).abs() < f64::EPSILON);
}
#[test]
fn accepts_sampling_rate_one() {
let config = load_from_str("[daemon]\nsampling_rate = 1.0").unwrap();
assert!((config.daemon.sampling_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn daemon_environment_defaults_to_staging() {
let config = Config::default();
assert_eq!(config.daemon.environment, DaemonEnvironment::Staging);
assert_eq!(config.confidence(), Confidence::DaemonStaging);
}
#[test]
fn daemon_environment_omitted_uses_default() {
let config = load_from_str("[daemon]\nmax_active_traces = 100").unwrap();
assert_eq!(config.daemon.environment, DaemonEnvironment::Staging);
}
#[test]
fn daemon_environment_staging() {
let config = load_from_str("[daemon]\nenvironment = \"staging\"").unwrap();
assert_eq!(config.daemon.environment, DaemonEnvironment::Staging);
assert_eq!(config.confidence(), Confidence::DaemonStaging);
}
#[test]
fn daemon_environment_production() {
let config = load_from_str("[daemon]\nenvironment = \"production\"").unwrap();
assert_eq!(config.daemon.environment, DaemonEnvironment::Production);
assert_eq!(config.confidence(), Confidence::DaemonProduction);
}
#[test]
fn daemon_environment_case_insensitive() {
let config = load_from_str("[daemon]\nenvironment = \"PRODUCTION\"").unwrap();
assert_eq!(config.daemon.environment, DaemonEnvironment::Production);
let config = load_from_str("[daemon]\nenvironment = \"Staging\"").unwrap();
assert_eq!(config.daemon.environment, DaemonEnvironment::Staging);
}
#[test]
fn daemon_environment_rejects_unknown() {
let result = load_from_str("[daemon]\nenvironment = \"prod\"");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("environment"), "got: {err}");
assert!(err.contains("staging"), "error should mention valid values");
assert!(
err.contains("production"),
"error should mention valid values"
);
}
#[test]
fn daemon_environment_rejects_empty() {
let result = load_from_str("[daemon]\nenvironment = \"\"");
assert!(result.is_err());
}
#[test]
fn daemon_environment_rejects_dev() {
let result = load_from_str("[daemon]\nenvironment = \"dev\"");
assert!(result.is_err());
}
#[test]
fn daemon_environment_as_str() {
assert_eq!(DaemonEnvironment::Staging.as_str(), "staging");
assert_eq!(DaemonEnvironment::Production.as_str(), "production");
}
#[test]
fn green_use_hourly_profiles_defaults_to_true() {
let config = Config::default();
assert!(config.green.use_hourly_profiles);
}
#[test]
fn green_use_hourly_profiles_omitted_uses_default() {
let config = load_from_str("[green]\nenabled = true\n").unwrap();
assert!(config.green.use_hourly_profiles);
}
#[test]
fn green_use_hourly_profiles_explicit_false() {
let config = load_from_str("[green]\nuse_hourly_profiles = false\n").unwrap();
assert!(!config.green.use_hourly_profiles);
}
#[test]
fn green_use_hourly_profiles_explicit_true() {
let config = load_from_str("[green]\nuse_hourly_profiles = true\n").unwrap();
assert!(config.green.use_hourly_profiles);
}
#[test]
fn hourly_profiles_file_absent_by_default() {
let config = Config::default();
assert!(config.green.hourly_profiles_file.is_none());
assert!(config.green.custom_hourly_profiles.is_none());
}
#[test]
fn hourly_profiles_file_control_chars_rejected() {
let config = load_from_str("[green]\nhourly_profiles_file = \"/tmp/profiles\\n.json\"\n");
if let Ok(c) = config {
let err = c.validate().unwrap_err();
assert!(
err.contains("control characters") || err.contains("failed to load"),
"expected control char or load failure error, got: {err}"
);
} else {
}
}
#[test]
fn hourly_profiles_file_nonexistent_path_rejected() {
let result =
load_from_str("[green]\nhourly_profiles_file = \"/nonexistent/profiles.json\"\n");
let err = result.unwrap_err();
assert!(
format!("{err}").contains("failed to load"),
"expected load failure error, got: {err}"
);
}
#[test]
fn hourly_profiles_windows_path_reports_load_failure_not_parse_error() {
let err = load_from_str(
r#"
[green]
hourly_profiles_file = "C:\temp\profiles.json"
"#,
)
.unwrap_err();
assert!(
format!("{err}").contains("failed to load"),
"expected load failure error, got: {err}"
);
}
#[test]
fn scaphandre_absent_by_default() {
let config = Config::default();
assert!(config.green.scaphandre.is_none());
}
#[test]
fn scaphandre_empty_section_parses_to_none() {
let config = load_from_str("[green.scaphandre]\n").unwrap();
assert!(config.green.scaphandre.is_none());
}
#[test]
fn scaphandre_endpoint_only() {
let config =
load_from_str("[green.scaphandre]\nendpoint = \"http://localhost:8080/metrics\"\n")
.unwrap();
let cfg = config.green.scaphandre.unwrap();
assert_eq!(cfg.endpoint, "http://localhost:8080/metrics");
assert_eq!(cfg.scrape_interval.as_secs(), 5);
assert!(cfg.process_map.is_empty());
}
#[test]
fn scaphandre_full_config() {
let toml = r#"
[green.scaphandre]
endpoint = "http://localhost:9090/metrics"
scrape_interval_secs = 10
[green.scaphandre.process_map."order-svc"]
exe_contains = "bin/java"
cmdline_contains = "order-svc.jar"
[green.scaphandre.process_map."chat-svc"]
exe_contains = "bin/dotnet"
cmdline_contains = "chat-svc.dll"
"#;
let config = load_from_str(toml).unwrap();
let cfg = config.green.scaphandre.unwrap();
assert_eq!(cfg.endpoint, "http://localhost:9090/metrics");
assert_eq!(cfg.scrape_interval.as_secs(), 10);
let order = cfg.process_map.get("order-svc").unwrap();
assert_eq!(order.exe_contains, "bin/java");
assert_eq!(order.cmdline_contains.as_deref(), Some("order-svc.jar"));
let chat = cfg.process_map.get("chat-svc").unwrap();
assert_eq!(chat.exe_contains, "bin/dotnet");
assert_eq!(chat.cmdline_contains.as_deref(), Some("chat-svc.dll"));
}
#[test]
fn scaphandre_rejects_unknown_field_in_process_matcher() {
let toml = r#"
[green.scaphandre]
endpoint = "http://localhost/metrics"
[green.scaphandre.process_map."order-svc"]
exe_contains = "bin/java"
cmdline_containss = "order-svc.jar"
"#;
let result = load_from_str(toml);
assert!(result.is_err(), "typo in matcher field must be rejected");
}
#[test]
fn scaphandre_rejects_legacy_string_form() {
let toml = r#"
[green.scaphandre]
endpoint = "http://localhost/metrics"
[green.scaphandre.process_map]
"order-svc" = "java"
"#;
let result = load_from_str(toml);
assert!(result.is_err(), "legacy string form must be rejected");
}
#[test]
fn scaphandre_accepts_exe_only_matcher() {
let toml = r#"
[green.scaphandre]
endpoint = "http://localhost/metrics"
[green.scaphandre.process_map."native-svc"]
exe_contains = "/opt/native/bin/svc"
"#;
let config = load_from_str(toml).unwrap();
let cfg = config.green.scaphandre.unwrap();
let native = cfg.process_map.get("native-svc").unwrap();
assert_eq!(native.exe_contains, "/opt/native/bin/svc");
assert!(native.cmdline_contains.is_none());
}
#[test]
fn scaphandre_accepts_https_endpoint() {
let result =
load_from_str("[green.scaphandre]\nendpoint = \"https://secure:8080/metrics\"\n");
assert!(result.is_ok(), "HTTPS endpoints should be accepted");
}
#[test]
fn scaphandre_rejects_zero_interval() {
let result = load_from_str(
"[green.scaphandre]\nendpoint = \"http://localhost/metrics\"\nscrape_interval_secs = 0\n",
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("scrape_interval_secs"), "got: {err}");
}
#[test]
fn scaphandre_rejects_huge_interval() {
let result = load_from_str(
"[green.scaphandre]\nendpoint = \"http://localhost/metrics\"\nscrape_interval_secs = 99999\n",
);
assert!(result.is_err());
}
#[test]
fn scaphandre_rejects_empty_exe_in_process_map() {
let toml = r#"
[green.scaphandre]
endpoint = "http://localhost/metrics"
[green.scaphandre.process_map."order-svc"]
exe_contains = ""
"#;
let result = load_from_str(toml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("process_map"), "got: {err}");
}
#[test]
fn scaphandre_accepts_interval_at_boundary_1s() {
let config = load_from_str(
"[green.scaphandre]\nendpoint = \"http://localhost/metrics\"\nscrape_interval_secs = 1\n",
)
.unwrap();
assert_eq!(
config
.green
.scaphandre
.as_ref()
.unwrap()
.scrape_interval
.as_secs(),
1
);
}
#[test]
fn scaphandre_accepts_interval_at_boundary_3600s() {
let config = load_from_str(
"[green.scaphandre]\nendpoint = \"http://localhost/metrics\"\nscrape_interval_secs = 3600\n",
)
.unwrap();
assert_eq!(
config
.green
.scaphandre
.as_ref()
.unwrap()
.scrape_interval
.as_secs(),
3600
);
}
#[test]
fn cloud_section_absent_yields_none() {
let toml = "[green]\nenabled = true\n";
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
assert!(cfg.green.cloud_energy.is_none());
}
#[test]
fn cloud_section_endpoint_only_parses_with_defaults() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "http://prom:9090"
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let cloud = cfg.green.cloud_energy.unwrap();
assert_eq!(cloud.prometheus_endpoint, "http://prom:9090");
assert_eq!(cloud.scrape_interval.as_secs(), 15);
assert!(cloud.default_provider.is_none());
assert!(cloud.services.is_empty());
}
#[test]
fn cloud_section_full_config_with_both_service_types() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "http://prom:9090"
scrape_interval_secs = 30
default_provider = "aws"
[green.cloud.services.svc-a]
provider = "gcp"
instance_type = "n2-standard-8"
[green.cloud.services.svc-b]
idle_watts = 45
max_watts = 120
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
assert!(cfg.validate().is_ok());
let cloud = cfg.green.cloud_energy.as_ref().unwrap();
assert_eq!(cloud.scrape_interval.as_secs(), 30);
assert_eq!(cloud.default_provider.as_deref(), Some("aws"));
assert_eq!(cloud.services.len(), 2);
}
#[test]
fn cloud_accepts_https_endpoint() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "https://prom:9090"
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
assert!(cfg.validate().is_ok(), "HTTPS endpoints should be accepted");
}
#[test]
fn cloud_rejects_credentials_in_endpoint() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "http://user:pass@prom:9090"
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let err = cfg.validate().unwrap_err();
assert!(err.contains("credentials"), "error: {err}");
}
#[test]
fn cloud_rejects_invalid_scrape_interval() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "http://prom:9090"
scrape_interval_secs = 0
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let err = cfg.validate().unwrap_err();
assert!(err.contains("scrape_interval"), "error: {err}");
}
#[test]
fn cloud_rejects_invalid_provider() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "http://prom:9090"
default_provider = "alibaba"
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let err = cfg.validate().unwrap_err();
assert!(err.contains("default_provider"), "error: {err}");
}
#[test]
fn cloud_rejects_max_watts_less_than_idle() {
let toml = r#"
[green.cloud]
prometheus_endpoint = "http://prom:9090"
[green.cloud.services.bad-svc]
idle_watts = 100
max_watts = 50
"#;
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let err = cfg.validate().unwrap_err();
assert!(err.contains("max_watts"), "error: {err}");
}
#[test]
fn cloud_rejects_service_name_with_control_chars() {
let toml = "
[green.cloud]
prometheus_endpoint = \"http://prom:9090\"
[green.cloud.services.\"bad\\nsvc\"]
idle_watts = 10
max_watts = 50
";
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let err = cfg.validate().unwrap_err();
assert!(err.contains("control characters"), "error: {err}");
}
#[test]
fn config_per_operation_coefficients_default_true() {
let cfg = Config::default();
assert!(cfg.green.per_operation_coefficients);
}
#[test]
fn config_include_network_transport_default_false() {
let cfg = Config::default();
assert!(!cfg.green.include_network_transport);
}
#[test]
fn config_network_energy_per_byte_kwh_default() {
let cfg = Config::default();
assert!(
(cfg.green.network_energy_per_byte_kwh
- crate::score::carbon::DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH)
.abs()
< f64::EPSILON
);
}
#[test]
fn config_network_energy_per_byte_kwh_rejects_negative() {
let toml = r"
[green]
network_energy_per_byte_kwh = -0.001
";
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
let err = cfg.validate().unwrap_err();
assert!(err.contains("network_energy_per_byte_kwh"), "error: {err}");
}
#[test]
fn config_network_energy_per_byte_kwh_rejects_nan() {
let cfg = Config {
green: GreenConfig {
network_energy_per_byte_kwh: f64::NAN,
..GreenConfig::default()
},
..Config::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("network_energy_per_byte_kwh"), "error: {err}");
}
#[test]
fn config_per_operation_coefficients_from_toml() {
let toml = r"
[green]
per_operation_coefficients = false
";
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
assert!(!cfg.green.per_operation_coefficients);
}
#[test]
fn config_include_network_transport_from_toml() {
let toml = r"
[green]
include_network_transport = true
network_energy_per_byte_kwh = 0.00000000008
";
let cfg: Config = toml::from_str::<RawConfig>(toml).unwrap().into();
assert!(cfg.green.include_network_transport);
assert!((cfg.green.network_energy_per_byte_kwh - 0.000_000_000_08).abs() < f64::EPSILON);
}
#[test]
fn validate_http_authority_rejects_empty_host() {
assert!(validate_http_authority("http://", "test").is_err());
}
#[test]
fn validate_http_authority_rejects_credentials() {
let err = validate_http_authority("http://user:pass@host/", "test").unwrap_err();
assert!(err.contains("credentials"));
}
#[test]
fn validate_http_authority_rejects_control_char() {
let err = validate_http_authority("http://bad\thost/", "test").unwrap_err();
assert!(err.contains("control"));
}
#[test]
fn has_control_char_rejects_c1_range() {
assert!(has_control_char("path\u{009b}[31m"));
assert!(has_control_char("path\u{009c}suffix"));
assert!(has_control_char("path\u{009d}OSC"));
assert!(has_control_char("\u{0080}"));
assert!(has_control_char("\u{009f}"));
assert!(!has_control_char("\u{00a0}plain"));
}
#[test]
fn validate_http_authority_rejects_invalid_ipv4_port() {
let err = validate_http_authority("http://host:abc/", "test").unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn validate_http_authority_accepts_bare_ipv6() {
assert!(validate_http_authority("http://[::1]/metrics", "test").is_ok());
}
#[test]
fn validate_http_authority_accepts_ipv6_with_port() {
assert!(validate_http_authority("http://[::1]:8080/metrics", "test").is_ok());
}
#[test]
fn validate_http_authority_rejects_ipv6_with_invalid_port() {
let err = validate_http_authority("http://[::1]:abc/metrics", "test").unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn validate_http_authority_accepts_https_scheme() {
assert!(validate_http_authority("https://host:443/", "test").is_ok());
}
#[test]
fn validate_green_rejects_nonfinite_embodied_carbon() {
let toml = "[green]\nembodied_carbon_per_request_gco2 = nan\n";
let err = load_from_str(toml).unwrap_err();
assert!(format!("{err:?}").contains("finite"));
}
#[test]
fn validate_green_rejects_hourly_profiles_file_that_fails_to_load() {
let toml = r#"
[green]
hourly_profiles_file = "/tmp/does-not-exist-perfsentinel-test.json"
"#;
let err = load_from_str(toml).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("hourly_profiles_file") || msg.contains("failed to load"));
}
use crate::score::electricity_maps::ElectricityMapsConfig;
use crate::score::electricity_maps::config::{EmissionFactorType, TemporalGranularity};
#[test]
fn electricity_maps_empty_api_key_returns_none() {
let raw = ElectricityMapsSection {
api_key: Some(String::new()),
endpoint: None,
poll_interval_secs: None,
region_map: HashMap::new(),
emission_factor_type: None,
temporal_granularity: None,
};
assert!(convert_electricity_maps_section_with_env(&raw, || None).is_none());
}
#[test]
fn electricity_maps_warn_when_api_key_in_config_file() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
let raw = ElectricityMapsSection {
api_key: Some("file-token".to_string()),
endpoint: None,
poll_interval_secs: Some(600),
region_map,
emission_factor_type: None,
temporal_granularity: None,
};
let cfg = convert_electricity_maps_section_with_env(&raw, || None).expect("should convert");
assert_eq!(cfg.auth_token, "file-token");
assert_eq!(cfg.poll_interval, Duration::from_mins(10));
assert_eq!(cfg.api_endpoint, "https://api.electricitymaps.com/v4");
assert!(cfg.region_map.contains_key("eu-west-3"));
}
#[test]
fn electricity_maps_legacy_v3_endpoint_loads_cleanly() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
let raw = ElectricityMapsSection {
api_key: Some("tok".to_string()),
endpoint: Some("https://api.electricitymaps.com/v3".to_string()),
poll_interval_secs: Some(300),
region_map,
emission_factor_type: None,
temporal_granularity: None,
};
let cfg = convert_electricity_maps_section_with_env(&raw, || None).expect("should convert");
assert_eq!(cfg.api_endpoint, "https://api.electricitymaps.com/v3");
}
#[test]
fn electricity_maps_endpoint_trailing_slash_is_normalized() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
let raw = ElectricityMapsSection {
api_key: Some("tok".to_string()),
endpoint: Some("https://api.electricitymaps.com/v4/".to_string()),
poll_interval_secs: Some(300),
region_map,
emission_factor_type: None,
temporal_granularity: None,
};
let cfg = convert_electricity_maps_section_with_env(&raw, || None).expect("should convert");
assert_eq!(cfg.api_endpoint, "https://api.electricitymaps.com/v4");
}
#[test]
fn electricity_maps_endpoint_strips_multiple_trailing_slashes() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
let raw = ElectricityMapsSection {
api_key: Some("tok".to_string()),
endpoint: Some("https://api.electricitymaps.com/v4///".to_string()),
poll_interval_secs: Some(300),
region_map,
emission_factor_type: None,
temporal_granularity: None,
};
let cfg = convert_electricity_maps_section_with_env(&raw, || None).expect("should convert");
assert_eq!(cfg.api_endpoint, "https://api.electricitymaps.com/v4");
}
#[test]
fn electricity_maps_region_map_keys_lowercased() {
let mut region_map = HashMap::new();
region_map.insert("EU-WEST-3".to_string(), "FR".to_string());
region_map.insert("Us-East-1".to_string(), "US-MIDA-PJM".to_string());
let raw = ElectricityMapsSection {
api_key: Some("tok".to_string()),
endpoint: Some("https://custom.api/v3".to_string()),
poll_interval_secs: Some(120),
region_map,
emission_factor_type: None,
temporal_granularity: None,
};
let cfg = convert_electricity_maps_section_with_env(&raw, || None).expect("should convert");
assert!(cfg.region_map.contains_key("eu-west-3"));
assert!(cfg.region_map.contains_key("us-east-1"));
assert_eq!(cfg.api_endpoint, "https://custom.api/v3");
}
#[test]
fn electricity_maps_env_var_takes_precedence_over_config_file() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
let raw = ElectricityMapsSection {
api_key: Some("from-file".to_string()),
endpoint: None,
poll_interval_secs: None,
region_map,
emission_factor_type: None,
temporal_granularity: None,
};
let cfg = convert_electricity_maps_section_with_env(&raw, || Some("from-env".to_string()))
.expect("env-supplied token should produce a valid config");
assert_eq!(cfg.auth_token, "from-env");
}
#[test]
fn cloud_auth_header_env_var_takes_precedence_over_config_file() {
let raw = CloudSection {
prometheus_endpoint: Some("http://prometheus:9090".to_string()),
scrape_interval_secs: None,
default_provider: None,
default_instance_type: None,
cpu_metric: None,
services: HashMap::new(),
auth_header: Some("Authorization: Bearer from-file".to_string()),
};
let cfg = convert_cloud_section_with_env(&raw, || {
Some("Authorization: Bearer from-env".to_string())
})
.expect("cloud section should convert");
assert_eq!(
cfg.auth_header.as_deref(),
Some("Authorization: Bearer from-env"),
"env var must take precedence over the config file value"
);
}
#[test]
fn cloud_auth_header_falls_back_to_config_when_env_unset() {
let raw = CloudSection {
prometheus_endpoint: Some("http://prometheus:9090".to_string()),
scrape_interval_secs: None,
default_provider: None,
default_instance_type: None,
cpu_metric: None,
services: HashMap::new(),
auth_header: Some("Authorization: Bearer from-file".to_string()),
};
let cfg =
convert_cloud_section_with_env(&raw, || None).expect("cloud section should convert");
assert_eq!(
cfg.auth_header.as_deref(),
Some("Authorization: Bearer from-file"),
"config value is used when the env var is unset"
);
}
#[test]
fn scaphandre_auth_header_env_var_takes_precedence_over_config_file() {
let raw = ScaphandreSection {
endpoint: Some("http://localhost:8080/metrics".to_string()),
scrape_interval_secs: None,
process_map: HashMap::new(),
auth_header: Some("Authorization: Bearer from-file".to_string()),
};
let cfg = convert_scaphandre_section_with_env(&raw, || {
Some("Authorization: Bearer from-env".to_string())
})
.expect("scaphandre section should convert");
assert_eq!(
cfg.auth_header.as_deref(),
Some("Authorization: Bearer from-env"),
"env var must take precedence over the config file value"
);
}
#[test]
fn scaphandre_auth_header_falls_back_to_config_when_env_unset() {
let raw = ScaphandreSection {
endpoint: Some("http://localhost:8080/metrics".to_string()),
scrape_interval_secs: None,
process_map: HashMap::new(),
auth_header: Some("Authorization: Bearer from-file".to_string()),
};
let cfg = convert_scaphandre_section_with_env(&raw, || None)
.expect("scaphandre section should convert");
assert_eq!(
cfg.auth_header.as_deref(),
Some("Authorization: Bearer from-file"),
"config value is used when the env var is unset"
);
}
#[test]
fn validate_electricity_maps_rejects_control_char_in_token() {
let cfg = ElectricityMapsConfig {
api_endpoint: "https://api.electricitymaps.com/v4".to_string(),
auth_token: "tok\x07en".to_string(), poll_interval: Duration::from_mins(5),
region_map: {
let mut m = HashMap::new();
m.insert("eu-west-3".to_string(), "FR".to_string());
m
},
emission_factor_type: EmissionFactorType::default(),
temporal_granularity: TemporalGranularity::default(),
};
let err = Config::validate_electricity_maps(&cfg).unwrap_err();
assert!(err.contains("control"));
}
#[test]
fn validate_electricity_maps_rejects_empty_region_map() {
let cfg = ElectricityMapsConfig {
api_endpoint: "https://api.electricitymaps.com/v4".to_string(),
auth_token: "tok".to_string(),
poll_interval: Duration::from_mins(5),
region_map: HashMap::new(),
emission_factor_type: EmissionFactorType::default(),
temporal_granularity: TemporalGranularity::default(),
};
let err = Config::validate_electricity_maps(&cfg).unwrap_err();
assert!(err.contains("region_map"));
}
#[test]
fn validate_electricity_maps_rejects_empty_zone() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), String::new());
let cfg = ElectricityMapsConfig {
api_endpoint: "https://api.electricitymaps.com/v4".to_string(),
auth_token: "tok".to_string(),
poll_interval: Duration::from_mins(5),
region_map,
emission_factor_type: EmissionFactorType::default(),
temporal_granularity: TemporalGranularity::default(),
};
let err = Config::validate_electricity_maps(&cfg).unwrap_err();
assert!(err.contains("empty"));
}
#[test]
fn validate_electricity_maps_rejects_invalid_poll_interval() {
let mut region_map = HashMap::new();
region_map.insert("eu-west-3".to_string(), "FR".to_string());
let cfg = ElectricityMapsConfig {
api_endpoint: "https://api.electricitymaps.com/v4".to_string(),
auth_token: "tok".to_string(),
poll_interval: Duration::from_secs(10), region_map,
emission_factor_type: EmissionFactorType::default(),
temporal_granularity: TemporalGranularity::default(),
};
let err = Config::validate_electricity_maps(&cfg).unwrap_err();
assert!(err.contains("poll_interval"));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_accepts_both_absent() {
let cfg = Config::default();
assert!(cfg.validate_tls().is_ok());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_rejects_cert_without_key() {
let mut cfg = Config::default();
cfg.daemon.tls.cert_path = Some("/tmp/cert.pem".to_string());
let err = cfg.validate_tls().unwrap_err();
assert!(err.contains("tls.key_path is missing"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_rejects_key_without_cert() {
let mut cfg = Config::default();
cfg.daemon.tls.key_path = Some("/tmp/key.pem".to_string());
let err = cfg.validate_tls().unwrap_err();
assert!(err.contains("tls.cert_path is missing"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_rejects_nonexistent_cert() {
let mut cfg = Config::default();
cfg.daemon.tls.cert_path = Some("/nonexistent/cert.pem".to_string());
cfg.daemon.tls.key_path = Some("/nonexistent/key.pem".to_string());
let err = cfg.validate_tls().unwrap_err();
assert!(err.contains("does not exist"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_accepts_existing_files() {
let dir = tempfile::tempdir().unwrap();
let cert = dir.path().join("cert.pem");
let key = dir.path().join("key.pem");
std::fs::write(&cert, b"fake cert").unwrap();
std::fs::write(&key, b"fake key").unwrap();
let mut cfg = Config::default();
cfg.daemon.tls.cert_path = Some(cert.to_str().unwrap().to_string());
cfg.daemon.tls.key_path = Some(key.to_str().unwrap().to_string());
assert!(cfg.validate_tls().is_ok());
}
#[test]
fn tls_config_fields_round_trip_through_toml() {
let dir = tempfile::tempdir().unwrap();
let cert = dir.path().join("cert.pem");
let key = dir.path().join("key.pem");
std::fs::write(&cert, b"fake cert").unwrap();
std::fs::write(&key, b"fake key").unwrap();
let toml = format!(
"[daemon]\ntls_cert_path = \"{}\"\ntls_key_path = \"{}\"",
cert.display(),
key.display()
);
let cfg = load_from_str(&toml).unwrap();
assert_eq!(
cfg.daemon.tls.cert_path.as_deref(),
Some(cert.to_str().unwrap())
);
assert_eq!(
cfg.daemon.tls.key_path.as_deref(),
Some(key.to_str().unwrap())
);
}
#[test]
fn tls_config_defaults_to_none() {
let cfg = load_from_str("").unwrap();
assert!(cfg.daemon.tls.cert_path.is_none());
assert!(cfg.daemon.tls.key_path.is_none());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_rejects_control_chars_in_cert_path() {
let mut cfg = Config::default();
cfg.daemon.tls.cert_path = Some("/tmp/cert\x00.pem".to_string());
cfg.daemon.tls.key_path = Some("/tmp/key.pem".to_string());
let err = cfg.validate_tls().unwrap_err();
assert!(err.contains("control characters"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_tls_rejects_control_chars_in_key_path() {
let mut cfg = Config::default();
cfg.daemon.tls.cert_path = Some("/tmp/cert.pem".to_string());
cfg.daemon.tls.key_path = Some("/tmp/key\n.pem".to_string());
let err = cfg.validate_tls().unwrap_err();
assert!(err.contains("control characters"), "{err}");
}
#[test]
fn default_daemon_ack_is_enabled_with_no_secrets() {
let cfg = Config::default();
assert!(cfg.daemon.ack.enabled);
assert!(cfg.daemon.ack.storage_path.is_none());
assert!(cfg.daemon.ack.api_key.is_none());
assert!(cfg.daemon.ack.toml_path.is_none());
}
#[test]
fn parse_daemon_ack_section_overrides() {
let toml = "
[daemon.ack]
enabled = false
storage_path = \"/var/lib/perf-sentinel/acks.jsonl\"
api_key = \"a-long-enough-secret-key\"
toml_path = \"/etc/perf-sentinel/acknowledgments.toml\"
";
let cfg = load_from_str(toml).unwrap();
assert!(!cfg.daemon.ack.enabled);
assert_eq!(
cfg.daemon.ack.storage_path.as_deref(),
Some("/var/lib/perf-sentinel/acks.jsonl")
);
assert_eq!(
cfg.daemon.ack.api_key.as_deref(),
Some("a-long-enough-secret-key")
);
assert_eq!(
cfg.daemon.ack.toml_path.as_deref(),
Some("/etc/perf-sentinel/acknowledgments.toml")
);
}
#[test]
fn validate_daemon_ack_rejects_empty_api_key() {
let toml = "
[daemon.ack]
api_key = \"\"
";
let err = load_from_str(toml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("must not be empty"), "{msg}");
}
#[test]
fn validate_daemon_ack_rejects_short_api_key() {
let toml = "
[daemon.ack]
api_key = \"shortish\"
";
let err = load_from_str(toml).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("too short"), "{msg}");
}
#[test]
fn validate_daemon_ack_accepts_twelve_char_api_key() {
let toml = "
[daemon.ack]
api_key = \"short-enough\"
";
let cfg = load_from_str(toml).unwrap();
assert_eq!(cfg.daemon.ack.api_key.as_deref(), Some("short-enough"));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_ack_rejects_control_chars_in_storage_path() {
let mut cfg = Config::default();
cfg.daemon.ack.storage_path = Some("/var/lib/acks\x00.jsonl".to_string());
let err = cfg.validate_daemon_ack().unwrap_err();
assert!(err.contains("control characters"), "{err}");
}
#[test]
fn validate_daemon_cors_accepts_empty_default() {
let cfg = Config::default();
assert!(cfg.validate_daemon_cors().is_ok());
assert!(cfg.daemon.cors.allowed_origins.is_empty());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_accepts_wildcard() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["*".to_string()];
assert!(cfg.validate_daemon_cors().is_ok());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_accepts_https_origin() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["https://reports.example.com".to_string()];
assert!(cfg.validate_daemon_cors().is_ok());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_rejects_origin_without_scheme() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["reports.example.com".to_string()];
let err = cfg.validate_daemon_cors().unwrap_err();
assert!(err.contains("must start with http://"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_rejects_trailing_slash() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["https://reports.example.com/".to_string()];
let err = cfg.validate_daemon_cors().unwrap_err();
assert!(err.contains("trailing slash"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_rejects_empty_entry() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec![String::new()];
let err = cfg.validate_daemon_cors().unwrap_err();
assert!(err.contains("is empty"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_rejects_control_chars() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["https://example.com\nattacker".to_string()];
let err = cfg.validate_daemon_cors().unwrap_err();
assert!(err.contains("control characters"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_rejects_wildcard_mixed_with_explicit_origins() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["*".to_string(), "https://example.com".to_string()];
let err = cfg.validate_daemon_cors().unwrap_err();
assert!(err.contains("cannot mix"), "{err}");
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn validate_daemon_cors_rejects_wildcard_with_api_key() {
let mut cfg = Config::default();
cfg.daemon.cors.allowed_origins = vec!["*".to_string()];
cfg.daemon.ack.api_key = Some("test-token-12chars".to_string());
let err = cfg.validate_daemon_cors().unwrap_err();
assert!(
err.contains("incompatible with") && err.contains("api_key"),
"{err}"
);
}
#[test]
fn cors_section_round_trips_via_toml() {
let toml = r#"
[daemon.cors]
allowed_origins = ["https://reports.example.com", "https://gitlab.example.com"]
"#;
let cfg = load_from_str(toml).expect("valid TOML");
assert_eq!(
cfg.daemon.cors.allowed_origins,
vec![
"https://reports.example.com".to_string(),
"https://gitlab.example.com".to_string(),
]
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn cors_with_api_disabled_is_rejected() {
let mut cfg = Config::default();
cfg.daemon.api_enabled = false;
cfg.daemon.cors.allowed_origins = vec!["https://reports.example.com".to_string()];
let err = cfg.validate().unwrap_err();
assert!(
err.contains("api_enabled = false"),
"expected mismatch error, got: {err}"
);
}
#[test]
fn cors_disabled_with_api_disabled_is_accepted() {
let cfg = Config {
daemon: DaemonConfig {
api_enabled: false,
..DaemonConfig::default()
},
..Config::default()
};
assert!(cfg.daemon.cors.allowed_origins.is_empty());
assert!(cfg.validate().is_ok());
}
#[test]
fn cors_section_rejects_mixed_wildcard_via_toml_load() {
let toml = r#"
[daemon.cors]
allowed_origins = ["*", "https://reports.example.com"]
"#;
let err = load_from_str(toml).expect_err("mixed wildcard must be rejected");
let msg = err.to_string();
assert!(
msg.contains("cannot mix"),
"expected validation error to mention mixing: {msg}"
);
}
#[test]
fn reporting_section_parses_and_validates() {
let toml = r#"
[reporting]
intent = "official"
confidentiality_level = "public"
org_config_path = "/etc/perf-sentinel/org.toml"
disclose_output_path = "/var/lib/perf-sentinel/last.json"
disclose_period = "calendar-quarter"
"#;
let cfg = load_from_str(toml).expect("valid reporting section");
assert_eq!(cfg.reporting.intent.as_deref(), Some("official"));
assert_eq!(
cfg.reporting.confidentiality_level.as_deref(),
Some("public")
);
assert_eq!(
cfg.reporting.org_config_path.as_deref(),
Some("/etc/perf-sentinel/org.toml")
);
}
#[test]
fn reporting_unknown_intent_rejected() {
let toml = r#"
[reporting]
intent = "draft"
"#;
let err = load_from_str(toml).expect_err("unknown intent must be rejected");
assert!(err.to_string().contains("intent must be one of"));
}
#[test]
fn reporting_unknown_confidentiality_rejected() {
let toml = r#"
[reporting]
confidentiality_level = "restricted"
"#;
let err = load_from_str(toml).expect_err("unknown confidentiality must be rejected");
assert!(err.to_string().contains("confidentiality_level"));
}
#[test]
fn reporting_official_requires_org_config_path() {
let toml = r#"
[reporting]
intent = "official"
"#;
let err = load_from_str(toml).expect_err("missing org_config_path must be rejected");
assert!(err.to_string().contains("org_config_path"));
}
#[test]
fn reporting_disclose_output_path_accepted_but_unused() {
let toml = r#"
[reporting]
disclose_output_path = "/var/lib/perf-sentinel/last.json"
"#;
let cfg = load_from_str(toml).expect("disclose_output_path must parse without error");
assert_eq!(
cfg.reporting.disclose_output_path.as_deref(),
Some("/var/lib/perf-sentinel/last.json")
);
}
#[test]
fn reporting_sigstore_absent_defaults_to_public() {
let cfg = load_from_str("[reporting]\n").expect("valid empty reporting");
assert_eq!(cfg.reporting.sigstore.rekor_url, DEFAULT_REKOR_URL);
assert_eq!(cfg.reporting.sigstore.fulcio_url, DEFAULT_FULCIO_URL);
}
#[test]
fn reporting_sigstore_section_overrides_defaults() {
let toml = r#"
[reporting.sigstore]
rekor_url = "https://rekor.internal.example.fr"
fulcio_url = "https://fulcio.internal.example.fr"
"#;
let cfg = load_from_str(toml).expect("valid sigstore section");
assert_eq!(
cfg.reporting.sigstore.rekor_url,
"https://rekor.internal.example.fr"
);
assert_eq!(
cfg.reporting.sigstore.fulcio_url,
"https://fulcio.internal.example.fr"
);
}
#[test]
fn daemon_archive_section_parses_with_defaults() {
let toml = r#"
[daemon.archive]
path = "/var/lib/perf-sentinel/reports.ndjson"
"#;
let cfg = load_from_str(toml).expect("valid archive section");
let archive = cfg.daemon.archive.expect("archive must be Some");
assert_eq!(archive.path, "/var/lib/perf-sentinel/reports.ndjson");
assert_eq!(archive.max_size_mb, 100);
assert_eq!(archive.max_files, 12);
}
#[test]
fn daemon_archive_zero_size_rejected() {
let toml = r#"
[daemon.archive]
path = "/tmp/a.ndjson"
max_size_mb = 0
"#;
let err = load_from_str(toml).expect_err("zero size must be rejected");
assert!(err.to_string().contains("max_size_mb"));
}
#[test]
fn daemon_archive_zero_files_rejected() {
let toml = r#"
[daemon.archive]
path = "/tmp/a.ndjson"
max_files = 0
"#;
let err = load_from_str(toml).expect_err("zero files must be rejected");
assert!(err.to_string().contains("max_files"));
}
#[test]
fn daemon_archive_absent_section_yields_none() {
let cfg = load_from_str("").expect("empty config parses");
assert!(cfg.daemon.archive.is_none());
}
#[test]
fn parse_kepler_metric_kind_defaults_and_aliases() {
use crate::score::kepler::config::KeplerMetricKind;
assert_eq!(
parse_kepler_metric_kind(None).unwrap(),
KeplerMetricKind::Container
);
assert_eq!(
parse_kepler_metric_kind(Some("container")).unwrap(),
KeplerMetricKind::Container
);
assert_eq!(
parse_kepler_metric_kind(Some("process")).unwrap(),
KeplerMetricKind::Process
);
assert_eq!(
parse_kepler_metric_kind(Some(" process ")).unwrap(),
KeplerMetricKind::Process
);
}
#[test]
fn parse_kepler_metric_kind_is_case_insensitive() {
use crate::score::kepler::config::KeplerMetricKind;
for raw in ["Container", "CONTAINER", " Container "] {
assert_eq!(
parse_kepler_metric_kind(Some(raw)).unwrap(),
KeplerMetricKind::Container,
"case-insensitive match expected for {raw:?}"
);
}
for raw in ["Process", "PROCESS", "ProCess"] {
assert_eq!(
parse_kepler_metric_kind(Some(raw)).unwrap(),
KeplerMetricKind::Process,
"case-insensitive match expected for {raw:?}"
);
}
}
#[test]
fn parse_kepler_metric_kind_empty_string_rejected() {
for raw in ["", " "] {
let err = parse_kepler_metric_kind(Some(raw))
.expect_err("explicit empty must error, not silently default");
assert!(err.contains("is empty"));
assert!(err.contains("'container' or 'process'"));
}
}
#[test]
fn parse_kepler_metric_kind_unknown_value_errors() {
let err = parse_kepler_metric_kind(Some("rapl")).expect_err("unknown variant must error");
assert!(err.contains("metric_kind 'rapl'"));
assert!(err.contains("'container'"));
assert!(err.contains("'process'"));
}
#[test]
fn parse_kepler_metric_kind_legacy_values_rejected() {
for legacy in ["process_package", "process_dram"] {
let err = parse_kepler_metric_kind(Some(legacy))
.expect_err("legacy variant must error with migration guidance");
assert!(err.contains(legacy));
assert!(err.contains("removed in v0.7.5"));
assert!(err.contains("'process'"));
}
}
#[test]
fn parse_kepler_metric_kind_error_preserves_raw_whitespace() {
let err = parse_kepler_metric_kind(Some(" process_package "))
.expect_err("legacy variant must error");
assert!(
err.contains("' process_package '"),
"expected raw value in error, got: {err}"
);
}
#[test]
fn parse_kepler_metric_kind_rejects_control_characters() {
let raw = "container\u{001b}[31m";
let err =
parse_kepler_metric_kind(Some(raw)).expect_err("control characters must be rejected");
assert!(err.contains("contains control characters"));
assert!(!err.contains('\u{001b}'));
}
#[test]
fn load_from_str_rejects_legacy_kepler_metric_kind_loudly() {
let toml = r#"
[green.kepler]
endpoint = "http://kepler:9102/metrics"
metric_kind = "process_package"
"#;
let err = load_from_str(toml)
.expect_err("load_from_str must surface the migration error, not silently drop");
let msg = err.to_string();
assert!(msg.contains("process_package"));
assert!(msg.contains("removed in v0.7.5"));
}
#[test]
fn convert_kepler_section_without_endpoint_yields_none() {
let raw = KeplerSection::default();
assert!(convert_kepler_section_with_env(&raw, || None).is_none());
}
#[test]
fn convert_kepler_section_env_overrides_file_auth_header() {
let raw = KeplerSection {
endpoint: Some("http://kepler:9102/metrics".to_string()),
auth_header: Some("Bearer file-token".to_string()),
..Default::default()
};
let cfg = convert_kepler_section_with_env(&raw, || Some("Bearer env-token".to_string()))
.expect("endpoint set, expected Some");
assert_eq!(cfg.auth_header.as_deref(), Some("Bearer env-token"));
assert_eq!(cfg.endpoint, "http://kepler:9102/metrics");
}
#[test]
fn convert_kepler_section_file_auth_used_when_env_absent() {
let raw = KeplerSection {
endpoint: Some("http://kepler:9102/metrics".to_string()),
auth_header: Some("Bearer file".to_string()),
..Default::default()
};
let cfg = convert_kepler_section_with_env(&raw, || None).expect("endpoint set");
assert_eq!(cfg.auth_header.as_deref(), Some("Bearer file"));
}
#[test]
fn convert_kepler_section_unknown_metric_kind_yields_none() {
let raw = KeplerSection {
endpoint: Some("http://kepler:9102/metrics".to_string()),
metric_kind: Some("rapl".to_string()),
..Default::default()
};
assert!(convert_kepler_section_with_env(&raw, || None).is_none());
}
#[test]
fn convert_kepler_section_uses_default_scrape_interval() {
let raw = KeplerSection {
endpoint: Some("http://kepler:9102/metrics".to_string()),
..Default::default()
};
let cfg = convert_kepler_section_with_env(&raw, || None).expect("endpoint set");
assert_eq!(cfg.scrape_interval, Duration::from_secs(5));
}
#[test]
fn convert_redfish_section_empty_endpoints_yields_none() {
let raw = RedfishSection::default();
assert!(convert_redfish_section_with_env(&raw, || None).is_none());
}
fn sample_redfish_endpoint() -> RedfishEndpoint {
use crate::score::redfish::RedfishSchema;
RedfishEndpoint {
url: "https://bmc.local".to_string(),
schema: RedfishSchema::LegacyPower,
}
}
#[test]
fn convert_redfish_section_env_overrides_file_auth_header() {
let mut endpoints = HashMap::new();
endpoints.insert("rack1".to_string(), sample_redfish_endpoint());
let raw = RedfishSection {
endpoints,
auth_header: Some("Basic file".to_string()),
..Default::default()
};
let cfg = convert_redfish_section_with_env(&raw, || Some("Basic env".to_string()))
.expect("endpoints set, expected Some");
assert_eq!(cfg.auth_header.as_deref(), Some("Basic env"));
assert_eq!(cfg.endpoints.len(), 1);
}
#[test]
fn convert_redfish_section_file_auth_used_when_env_absent() {
let mut endpoints = HashMap::new();
endpoints.insert("rack1".to_string(), sample_redfish_endpoint());
let raw = RedfishSection {
endpoints,
auth_header: Some("Basic file".to_string()),
..Default::default()
};
let cfg =
convert_redfish_section_with_env(&raw, || None).expect("endpoints set, expected Some");
assert_eq!(cfg.auth_header.as_deref(), Some("Basic file"));
}
#[test]
fn convert_redfish_section_applies_default_interval() {
let mut endpoints = HashMap::new();
endpoints.insert("rack1".to_string(), sample_redfish_endpoint());
let raw = RedfishSection {
endpoints,
..Default::default()
};
let cfg =
convert_redfish_section_with_env(&raw, || None).expect("endpoints set, expected Some");
assert_eq!(cfg.scrape_interval, Duration::from_mins(1));
}
fn minimal_kepler_config() -> KeplerConfig {
use crate::score::kepler::config::KeplerMetricKind;
KeplerConfig {
endpoint: "http://kepler:9102/metrics".to_string(),
scrape_interval: Duration::from_secs(5),
metric_kind: KeplerMetricKind::Container,
service_mappings: HashMap::new(),
auth_header: None,
}
}
#[test]
fn validate_kepler_accepts_minimal_config() {
let cfg = minimal_kepler_config();
assert!(Config::validate_kepler(&cfg).is_ok());
}
#[test]
fn validate_kepler_rejects_empty_endpoint() {
let mut cfg = minimal_kepler_config();
cfg.endpoint = String::new();
let err = Config::validate_kepler(&cfg).expect_err("empty endpoint must error");
assert!(err.contains("endpoint is required"));
}
#[test]
fn validate_kepler_rejects_non_http_scheme() {
let mut cfg = minimal_kepler_config();
cfg.endpoint = "ftp://kepler/metrics".to_string();
let err = Config::validate_kepler(&cfg).expect_err("non-http scheme must error");
assert!(err.contains("must start with 'http://' or 'https://'"));
}
#[test]
fn validate_kepler_rejects_scrape_interval_zero() {
let mut cfg = minimal_kepler_config();
cfg.scrape_interval = Duration::from_secs(0);
let err = Config::validate_kepler(&cfg).expect_err("zero interval must error");
assert!(err.contains("scrape_interval_secs must be in [1, 3600]"));
}
#[test]
fn validate_kepler_rejects_scrape_interval_above_max() {
let mut cfg = minimal_kepler_config();
cfg.scrape_interval = Duration::from_secs(3601);
let err = Config::validate_kepler(&cfg).expect_err("interval above 3600 must error");
assert!(err.contains("3601"));
}
#[test]
fn validate_kepler_rejects_empty_service_name() {
let mut cfg = minimal_kepler_config();
cfg.service_mappings
.insert(String::new(), "label".to_string());
let err = Config::validate_kepler(&cfg).expect_err("empty service name must error");
assert!(err.contains("service name") && err.contains("1-256"));
}
#[test]
fn validate_kepler_rejects_control_char_in_service_name() {
let mut cfg = minimal_kepler_config();
cfg.service_mappings
.insert("svc\u{0007}".to_string(), "label".to_string());
let err = Config::validate_kepler(&cfg).expect_err("control char must error");
assert!(err.contains("control characters"));
}
#[test]
fn validate_kepler_rejects_empty_label() {
let mut cfg = minimal_kepler_config();
cfg.service_mappings
.insert("svc".to_string(), String::new());
let err = Config::validate_kepler(&cfg).expect_err("empty label must error");
assert!(err.contains("label for service") && err.contains("1-256"));
}
#[test]
fn validate_kepler_rejects_control_char_in_label() {
let mut cfg = minimal_kepler_config();
cfg.service_mappings
.insert("svc".to_string(), "lab\u{0007}el".to_string());
let err = Config::validate_kepler(&cfg).expect_err("control char in label must error");
assert!(err.contains("control characters"));
}
#[test]
fn validate_kepler_process_caps_label_at_kernel_truncation() {
use crate::score::kepler::config::KeplerMetricKind;
let mut cfg = minimal_kepler_config();
cfg.metric_kind = KeplerMetricKind::Process;
cfg.service_mappings
.insert("svc".to_string(), "my-long-checkout".to_string()); let err = Config::validate_kepler(&cfg)
.expect_err("16-char label must be rejected for Process metric_kind");
assert!(err.contains("must be 1-15 chars"));
assert!(err.contains("kernel truncates"));
}
#[test]
fn validate_kepler_process_accepts_15_char_label() {
use crate::score::kepler::config::KeplerMetricKind;
let mut cfg = minimal_kepler_config();
cfg.metric_kind = KeplerMetricKind::Process;
cfg.service_mappings
.insert("svc".to_string(), "exactly-15char!".to_string()); assert!(Config::validate_kepler(&cfg).is_ok());
}
#[test]
fn validate_kepler_service_mappings_caps_cardinality() {
let mut cfg = minimal_kepler_config();
for i in 0..1025 {
cfg.service_mappings
.insert(format!("svc-{i}"), format!("label-{i}"));
}
let err =
Config::validate_kepler(&cfg).expect_err("1025 mappings must exceed the 1024 cap");
assert!(err.contains("1025 entries"));
assert!(err.contains("maximum is 1024"));
}
#[test]
fn convert_kepler_section_with_process_metric_kind() {
use crate::score::kepler::config::KeplerMetricKind;
let raw = KeplerSection {
endpoint: Some("http://kepler:9102/metrics".to_string()),
metric_kind: Some("process".to_string()),
..Default::default()
};
let cfg = convert_kepler_section_with_env(&raw, || None)
.expect("endpoint set + valid metric_kind, expected Some");
assert_eq!(cfg.metric_kind, KeplerMetricKind::Process);
}
fn redfish_endpoint(url: &str) -> RedfishEndpoint {
use crate::score::redfish::RedfishSchema;
RedfishEndpoint {
url: url.to_string(),
schema: RedfishSchema::LegacyPower,
}
}
fn minimal_redfish_config() -> RedfishConfig {
let mut endpoints = HashMap::new();
endpoints.insert(
"rack1".to_string(),
redfish_endpoint("https://bmc.local/Power"),
);
RedfishConfig {
endpoints,
scrape_interval: Duration::from_mins(1),
service_mappings: HashMap::new(),
ca_bundle_path: None,
auth_header: None,
}
}
#[test]
fn validate_redfish_accepts_minimal_config() {
let cfg = minimal_redfish_config();
assert!(Config::validate_redfish(&cfg).is_ok());
}
#[test]
fn validate_redfish_rejects_empty_endpoints() {
let mut cfg = minimal_redfish_config();
cfg.endpoints.clear();
let err = Config::validate_redfish(&cfg).expect_err("empty endpoints must error");
assert!(err.contains("endpoints must contain at least one chassis"));
}
#[test]
fn validate_redfish_rejects_empty_chassis_id() {
let mut cfg = minimal_redfish_config();
cfg.endpoints.clear();
cfg.endpoints
.insert(String::new(), redfish_endpoint("https://bmc/Power"));
let err = Config::validate_redfish(&cfg).expect_err("empty chassis id must error");
assert!(err.contains("chassis id") && err.contains("1-256"));
}
#[test]
fn validate_redfish_rejects_control_char_in_chassis_id() {
let mut cfg = minimal_redfish_config();
cfg.endpoints.clear();
cfg.endpoints.insert(
"rack\u{0007}".to_string(),
redfish_endpoint("https://bmc/Power"),
);
let err = Config::validate_redfish(&cfg).expect_err("control char must error");
assert!(err.contains("control characters"));
}
#[test]
fn validate_redfish_rejects_non_http_endpoint() {
let mut cfg = minimal_redfish_config();
cfg.endpoints.clear();
cfg.endpoints
.insert("rack1".to_string(), redfish_endpoint("ftp://bmc/Power"));
let err = Config::validate_redfish(&cfg).expect_err("non-http endpoint must error");
assert!(err.contains("must start with 'http://' or 'https://'"));
}
#[test]
fn validate_redfish_rejects_scrape_interval_below_min() {
let mut cfg = minimal_redfish_config();
cfg.scrape_interval = Duration::from_secs(5);
let err = Config::validate_redfish(&cfg).expect_err("scrape_interval below 15 must error");
assert!(err.contains("scrape_interval_secs"));
assert!(err.contains("rate-limit"));
}
#[test]
fn validate_redfish_rejects_scrape_interval_above_max() {
let mut cfg = minimal_redfish_config();
cfg.scrape_interval = Duration::from_secs(4000);
let err = Config::validate_redfish(&cfg).expect_err("scrape_interval above MAX must error");
assert!(err.contains("4000"));
}
#[test]
fn validate_redfish_rejects_unknown_chassis_in_mapping() {
let mut cfg = minimal_redfish_config();
cfg.service_mappings
.insert("svc".to_string(), "rack-missing".to_string());
let err = Config::validate_redfish(&cfg).expect_err("unknown chassis must error");
assert!(err.contains("rack-missing"));
assert!(err.contains("not declared"));
}
#[test]
fn validate_redfish_rejects_empty_service_name_in_mapping() {
let mut cfg = minimal_redfish_config();
cfg.service_mappings
.insert(String::new(), "rack1".to_string());
let err = Config::validate_redfish(&cfg).expect_err("empty service name must error");
assert!(err.contains("service name") && err.contains("1-256"));
}
#[test]
fn validate_redfish_rejects_control_char_in_service_name() {
let mut cfg = minimal_redfish_config();
cfg.service_mappings
.insert("svc\u{0001}".to_string(), "rack1".to_string());
let err = Config::validate_redfish(&cfg).expect_err("control char must error");
assert!(err.contains("control characters"));
}
#[test]
fn validate_redfish_rejects_empty_ca_bundle_path() {
let mut cfg = minimal_redfish_config();
cfg.ca_bundle_path = Some(String::new());
let err = Config::validate_redfish(&cfg).expect_err("empty ca_bundle_path must error");
assert!(err.contains("ca_bundle_path must be non-empty"));
}
#[test]
fn load_toml_with_kepler_and_redfish_sections() {
use crate::score::redfish::RedfishSchema;
let toml = r#"
[green.kepler]
endpoint = "http://kepler:9102/metrics"
scrape_interval_secs = 10
metric_kind = "container"
[green.redfish]
scrape_interval_secs = 60
[green.redfish.endpoints."rack-legacy"]
url = "https://bmc-legacy.local/redfish/v1/Chassis/1/Power"
schema = "legacy_power"
[green.redfish.endpoints."rack-modern"]
url = "https://bmc-modern.local/redfish/v1/Chassis/1/EnvironmentMetrics"
schema = "environment_metrics"
"#;
let cfg = load_from_str(toml).expect("kepler+redfish toml parses and validates");
let kepler = cfg.green.kepler.expect("kepler section produced a config");
assert_eq!(kepler.endpoint, "http://kepler:9102/metrics");
assert_eq!(kepler.scrape_interval, Duration::from_secs(10));
let redfish = cfg
.green
.redfish
.expect("redfish section produced a config");
assert_eq!(redfish.endpoints.len(), 2);
assert_eq!(
redfish.endpoints.get("rack-legacy").unwrap().schema,
RedfishSchema::LegacyPower
);
assert_eq!(
redfish.endpoints.get("rack-modern").unwrap().schema,
RedfishSchema::EnvironmentMetrics
);
}
#[test]
fn load_toml_rejects_legacy_flat_endpoint_string() {
let toml = r#"
[green.redfish.endpoints]
"rack1" = "https://bmc.local/Power"
"#;
let result = load_from_str(toml);
assert!(
result.is_err(),
"legacy flat endpoint form must be rejected"
);
}
#[test]
fn load_toml_rejects_unknown_redfish_schema() {
let toml = r#"
[green.redfish.endpoints."rack1"]
url = "https://bmc.local/Power"
schema = "oem_custom"
"#;
let result = load_from_str(toml);
assert!(
result.is_err(),
"unknown schema variant must be rejected by serde"
);
}
#[test]
fn load_toml_rejects_legacy_top_level_power_path() {
let toml = r#"
[green.redfish]
power_path = "/PowerControl/0/PowerConsumedWatts"
[green.redfish.endpoints."rack1"]
url = "https://bmc.local/Power"
schema = "legacy_power"
"#;
let result = load_from_str(toml);
assert!(
result.is_err(),
"stale top-level power_path must be rejected"
);
}
#[test]
fn load_toml_rejects_invalid_kepler_endpoint() {
let toml = r#"
[green.kepler]
endpoint = "ftp://kepler/metrics"
"#;
let err = load_from_str(toml).expect_err("invalid scheme must error at validate()");
assert!(err.to_string().contains("[green.kepler]"));
}
#[test]
fn load_toml_rejects_invalid_redfish_scrape_interval() {
let toml = r#"
[green.redfish]
scrape_interval_secs = 5
[green.redfish.endpoints."rack1"]
url = "https://bmc/Power"
schema = "legacy_power"
"#;
let err = load_from_str(toml).expect_err("below-min interval must error at validate()");
assert!(err.to_string().contains("[green.redfish]"));
}
}