Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions libdd-sampling/src/sampling_rule_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ pub struct SamplingRuleConfig {
#[serde(default)]
pub resource: Option<String>,

/// Tags that must match (key-value pairs)
#[serde(default)]
/// Tags that must match (key-value pairs).
///
/// Accepts either the map shape `{"env": "prod"}` or the Remote Config
/// wire shape `[{"key": "env", "value_glob": "prod"}]`. Internally both
/// normalize to the map shape; the list-shape entries are required to
/// have both `key` and `value_glob` (missing either rejects the rule).
#[serde(default, deserialize_with = "deserialize_tags")]
pub tags: HashMap<String, String>,

/// Where this rule comes from (customer, dynamic, default).
Expand Down Expand Up @@ -61,6 +66,61 @@ fn default_provenance() -> String {
"default".to_string()
}

/// Deserializes the `tags` field, accepting either:
/// - map shape: `{"env": "prod", "region": "us-east-1"}`
/// - list shape: `[{"key": "env", "value_glob": "prod"}, ...]`
///
/// A list entry missing `key` or `value_glob` produces a deserialization
/// error; we never silently drop entries because that could broaden a
/// tag-constrained sampling rule.
fn deserialize_tags<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;

#[derive(serde::Deserialize)]
struct ListEntry {
key: String,
value_glob: String,
}

struct TagsVisitor;

impl<'de> Visitor<'de> for TagsVisitor {
type Value = HashMap<String, String>;

fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map of string to string or a list of {key, value_glob} objects")
}

fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut map = HashMap::with_capacity(access.size_hint().unwrap_or(0));
while let Some((k, v)) = access.next_entry::<String, String>()? {
map.insert(k, v);
}
Ok(map)
}

fn visit_seq<S>(self, mut access: S) -> Result<Self::Value, S::Error>
where
S: SeqAccess<'de>,
{
let mut map = HashMap::with_capacity(access.size_hint().unwrap_or(0));
while let Some(entry) = access.next_element::<ListEntry>()? {
map.insert(entry.key, entry.value_glob);
}
Ok(map)
}
}

deserializer.deserialize_any(TagsVisitor)
}

#[derive(Debug, Default, Clone, PartialEq)]
pub struct ParsedSamplingRules {
pub rules: Vec<SamplingRuleConfig>,
Expand Down Expand Up @@ -181,6 +241,65 @@ mod tests {
assert_eq!(original, restored);
}

#[test]
fn test_sampling_rule_config_tags_accepts_map_shape() {
// Already supported — guard against regression.
let json = r#"{
"sample_rate": 0.5,
"service": "svc",
"tags": {"env": "prod", "region": "us-east-1"}
}"#;
let cfg: SamplingRuleConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.tags.get("env").map(String::as_str), Some("prod"));
assert_eq!(
cfg.tags.get("region").map(String::as_str),
Some("us-east-1")
);
}

#[test]
fn test_sampling_rule_config_tags_accepts_rc_list_shape() {
// Remote Config wire shape: list of {key, value_glob} entries.
let json = r#"{
"sample_rate": 0.5,
"service": "svc",
"tags": [
{"key": "env", "value_glob": "prod"},
{"key": "region", "value_glob": "us-east-1"}
]
}"#;
let cfg: SamplingRuleConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.tags.get("env").map(String::as_str), Some("prod"));
assert_eq!(
cfg.tags.get("region").map(String::as_str),
Some("us-east-1")
);
}

#[test]
fn test_sampling_rule_config_tags_list_with_malformed_entry_rejects() {
// A list entry missing `value_glob` must reject the whole rule rather
// than silently dropping the entry — silently dropping a constraint could
// broaden a tag-constrained rule and produce a security-relevant change
// in sampling decisions.
let json = r#"{
"sample_rate": 0.5,
"tags": [
{"key": "env", "value_glob": "prod"},
{"key": "region"}
]
}"#;
let result: Result<SamplingRuleConfig, _> = serde_json::from_str(json);
assert!(result.is_err(), "expected deserialization to fail");
}

#[test]
fn test_sampling_rule_config_tags_absent_defaults_to_empty() {
let json = r#"{"sample_rate": 0.5}"#;
let cfg: SamplingRuleConfig = serde_json::from_str(json).unwrap();
assert!(cfg.tags.is_empty());
}

#[test]
fn test_sampling_rule_config_display() {
let config = SamplingRuleConfig {
Expand Down
Loading