use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct ShadowConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub target: Option<String>,
#[serde(default = "default_sample_rate")]
pub sample_rate: f64,
#[serde(default)]
pub routes: Vec<String>,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_max_in_flight")]
pub max_in_flight: usize,
#[serde(default = "default_max_body_bytes")]
pub max_body_bytes: usize,
#[serde(default = "default_max_records")]
pub max_records: usize,
#[serde(default = "default_max_sample_bytes")]
pub max_sample_bytes: usize,
}
impl Default for ShadowConfig {
fn default() -> Self {
Self {
enabled: false,
target: None,
sample_rate: default_sample_rate(),
routes: Vec::new(),
timeout_ms: default_timeout_ms(),
max_in_flight: default_max_in_flight(),
max_body_bytes: default_max_body_bytes(),
max_records: default_max_records(),
max_sample_bytes: default_max_sample_bytes(),
}
}
}
impl ShadowConfig {
pub fn validate(&self) -> Result<(), String> {
if !self.enabled {
return Ok(());
}
let Some(target) = self
.target
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
else {
return Err(
"shadow.target is required when shadow.enabled = true — set it to the \
candidate build's base URL, e.g. \"http://127.0.0.1:9091\""
.to_owned(),
);
};
let parsed = url::Url::parse(target)
.map_err(|error| format!("shadow.target is not a valid URL ({error}): {target:?}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(format!(
"shadow.target must be an absolute http(s) URL, got {target:?}"
));
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err(format!(
"shadow.target must name a host to dial, got {target:?}"
));
}
if parsed.query().is_some() {
return Err(format!(
"shadow.target must not carry a query string — the request target is \
appended to it, so the mirrored path would fold into the query: {target:?}"
));
}
if parsed.fragment().is_some() {
return Err(format!(
"shadow.target must not carry a fragment — a fragment is never sent over \
the wire: {target:?}"
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(
"shadow.target must not embed credentials — the value is published by the \
shadow actuator endpoint and logged at startup"
.to_owned(),
);
}
for route in &self.routes {
if route.trim().is_empty() {
return Err("shadow.routes must not contain a blank pattern".to_owned());
}
if !route.starts_with('/') {
return Err(format!(
"shadow.routes patterns must start with '/', got {route:?} — a pattern that \
cannot match any request path would silently disable mirroring"
));
}
}
if !self.sample_rate.is_finite() || !(0.0..=1.0).contains(&self.sample_rate) {
return Err(format!(
"shadow.sample_rate must be between 0.0 and 1.0, got {}",
self.sample_rate
));
}
for (name, is_zero) in [
("shadow.timeout_ms", self.timeout_ms == 0),
("shadow.max_in_flight", self.max_in_flight == 0),
("shadow.max_body_bytes", self.max_body_bytes == 0),
("shadow.max_records", self.max_records == 0),
("shadow.max_sample_bytes", self.max_sample_bytes == 0),
] {
if is_zero {
return Err(format!("{name} must be greater than zero"));
}
}
Ok(())
}
#[must_use]
pub fn target_base(&self) -> Option<&str> {
self.target
.as_deref()
.map(str::trim)
.map(|target| target.strip_suffix('/').unwrap_or(target))
.filter(|target| !target.is_empty())
}
#[must_use]
pub fn is_active(&self) -> bool {
self.enabled && self.target_base().is_some()
}
}
const fn default_sample_rate() -> f64 {
1.0
}
const fn default_timeout_ms() -> u64 {
2000
}
const fn default_max_in_flight() -> usize {
8
}
const fn default_max_body_bytes() -> usize {
256 * 1024
}
const fn default_max_records() -> usize {
50
}
const fn default_max_sample_bytes() -> usize {
2048
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_by_default_with_no_target() {
let config = ShadowConfig::default();
assert!(!config.enabled);
assert!(config.target.is_none());
assert!(config.routes.is_empty());
assert!(config.validate().is_ok());
}
#[test]
fn defaults_are_bounded() {
let config = ShadowConfig::default();
assert!(config.timeout_ms > 0);
assert!(config.max_in_flight > 0);
assert!(config.max_body_bytes > 0);
assert!(config.max_records > 0);
assert!(config.max_sample_bytes > 0);
assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn enabling_without_a_target_is_rejected() {
let config = ShadowConfig {
enabled: true,
..ShadowConfig::default()
};
let error = config.validate().expect_err("must reject");
assert!(error.contains("shadow.target"), "{error}");
}
#[test]
fn target_must_be_an_absolute_http_url() {
for bad in ["localhost:9091", "ftp://host", "/relative", " "] {
let config = ShadowConfig {
enabled: true,
target: Some(bad.to_owned()),
..ShadowConfig::default()
};
assert!(
config.validate().is_err(),
"{bad} must be rejected as a shadow target"
);
}
let config = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091".to_owned()),
..ShadowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn sample_rate_must_be_a_probability() {
for bad in [-0.1, 1.1, f64::NAN] {
let config = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091".to_owned()),
sample_rate: bad,
..ShadowConfig::default()
};
assert!(
config.validate().is_err(),
"sample_rate {bad} must be rejected"
);
}
}
#[test]
fn zero_bounds_are_rejected() {
let base = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091".to_owned()),
..ShadowConfig::default()
};
for mutate in [
(|c: &mut ShadowConfig| c.timeout_ms = 0) as fn(&mut ShadowConfig),
|c: &mut ShadowConfig| c.max_in_flight = 0,
|c: &mut ShadowConfig| c.max_body_bytes = 0,
|c: &mut ShadowConfig| c.max_records = 0,
|c: &mut ShadowConfig| c.max_sample_bytes = 0,
] {
let mut config = base.clone();
mutate(&mut config);
assert!(config.validate().is_err(), "a zero bound must be rejected");
}
}
#[test]
fn validation_is_skipped_while_disabled() {
let config = ShadowConfig {
enabled: false,
target: Some("not-a-url".to_owned()),
sample_rate: 9.0,
..ShadowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn deserializes_from_toml_with_partial_tables() {
let config: ShadowConfig = toml::from_str(
r#"
enabled = true
target = "http://127.0.0.1:9091"
sample_rate = 0.25
routes = ["/api/*"]
"#,
)
.expect("must parse");
assert!(config.enabled);
assert_eq!(config.target.as_deref(), Some("http://127.0.0.1:9091"));
assert!((config.sample_rate - 0.25).abs() < f64::EPSILON);
assert_eq!(config.routes, vec!["/api/*".to_owned()]);
assert_eq!(config.timeout_ms, ShadowConfig::default().timeout_ms);
}
#[test]
fn a_target_with_no_host_to_dial_is_rejected() {
for bad in ["http://", "https://"] {
let config = ShadowConfig {
enabled: true,
target: Some(bad.to_owned()),
..ShadowConfig::default()
};
let error = config
.validate()
.expect_err(&format!("{bad} must be rejected"));
assert!(error.contains("shadow.target"), "{error}");
}
}
#[test]
fn a_target_carrying_a_query_or_fragment_is_rejected() {
for bad in [
"http://candidate/base?token=x",
"http://candidate#fragment",
"http://candidate/base?a=1#f",
] {
let config = ShadowConfig {
enabled: true,
target: Some(bad.to_owned()),
..ShadowConfig::default()
};
let error = config
.validate()
.expect_err(&format!("{bad} must be rejected"));
assert!(error.contains("shadow.target"), "{error}");
}
let config = ShadowConfig {
enabled: true,
target: Some("http://candidate/base".to_owned()),
..ShadowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn a_target_carrying_credentials_is_rejected() {
let config = ShadowConfig {
enabled: true,
target: Some("http://user:pass@candidate.internal".to_owned()),
..ShadowConfig::default()
};
let error = config.validate().expect_err("must reject");
assert!(error.contains("credentials"), "{error}");
}
#[test]
fn route_patterns_that_can_never_match_are_rejected() {
for bad in ["api/*", " ", ""] {
let config = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091".to_owned()),
routes: vec![bad.to_owned()],
..ShadowConfig::default()
};
assert!(
config.validate().is_err(),
"{bad:?} must be rejected as a route pattern"
);
}
let config = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091".to_owned()),
routes: vec!["/api/*".to_owned(), "/status".to_owned()],
..ShadowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn validation_errors_name_the_key_that_is_wrong() {
let base = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091".to_owned()),
..ShadowConfig::default()
};
for (name, mutate) in [
(
"shadow.timeout_ms",
(|c: &mut ShadowConfig| c.timeout_ms = 0) as fn(&mut ShadowConfig),
),
("shadow.max_in_flight", |c: &mut ShadowConfig| {
c.max_in_flight = 0;
}),
("shadow.max_body_bytes", |c: &mut ShadowConfig| {
c.max_body_bytes = 0;
}),
("shadow.max_records", |c: &mut ShadowConfig| {
c.max_records = 0;
}),
("shadow.max_sample_bytes", |c: &mut ShadowConfig| {
c.max_sample_bytes = 0;
}),
("shadow.sample_rate", |c: &mut ShadowConfig| {
c.sample_rate = 2.0;
}),
] {
let mut config = base.clone();
mutate(&mut config);
let error = config.validate().expect_err("must reject");
assert!(
error.contains(name),
"the error for {name} must name it, got: {error}"
);
}
}
#[test]
fn target_base_trims_a_trailing_slash() {
let config = ShadowConfig {
enabled: true,
target: Some("http://127.0.0.1:9091/".to_owned()),
..ShadowConfig::default()
};
assert_eq!(config.target_base(), Some("http://127.0.0.1:9091"));
}
}