@@ -25,8 +25,13 @@ pub struct SamplingRuleConfig {
2525 #[ serde( default ) ]
2626 pub resource : Option < String > ,
2727
28- /// Tags that must match (key-value pairs)
29- #[ serde( default ) ]
28+ /// Tags that must match (key-value pairs).
29+ ///
30+ /// Accepts either the map shape `{"env": "prod"}` or the Remote Config
31+ /// wire shape `[{"key": "env", "value_glob": "prod"}]`. Internally both
32+ /// normalize to the map shape; the list-shape entries are required to
33+ /// have both `key` and `value_glob` (missing either rejects the rule).
34+ #[ serde( default , deserialize_with = "deserialize_tags" ) ]
3035 pub tags : HashMap < String , String > ,
3136
3237 /// Where this rule comes from (customer, dynamic, default).
@@ -61,6 +66,61 @@ fn default_provenance() -> String {
6166 "default" . to_string ( )
6267}
6368
69+ /// Deserializes the `tags` field, accepting either:
70+ /// - map shape: `{"env": "prod", "region": "us-east-1"}`
71+ /// - list shape: `[{"key": "env", "value_glob": "prod"}, ...]`
72+ ///
73+ /// A list entry missing `key` or `value_glob` produces a deserialization
74+ /// error; we never silently drop entries because that could broaden a
75+ /// tag-constrained sampling rule.
76+ fn deserialize_tags < ' de , D > ( deserializer : D ) -> Result < HashMap < String , String > , D :: Error >
77+ where
78+ D : serde:: Deserializer < ' de > ,
79+ {
80+ use serde:: de:: { MapAccess , SeqAccess , Visitor } ;
81+ use std:: fmt;
82+
83+ #[ derive( serde:: Deserialize ) ]
84+ struct ListEntry {
85+ key : String ,
86+ value_glob : String ,
87+ }
88+
89+ struct TagsVisitor ;
90+
91+ impl < ' de > Visitor < ' de > for TagsVisitor {
92+ type Value = HashMap < String , String > ;
93+
94+ fn expecting ( & self , f : & mut fmt:: Formatter ) -> fmt:: Result {
95+ f. write_str ( "a map of string to string or a list of {key, value_glob} objects" )
96+ }
97+
98+ fn visit_map < M > ( self , mut access : M ) -> Result < Self :: Value , M :: Error >
99+ where
100+ M : MapAccess < ' de > ,
101+ {
102+ let mut map = HashMap :: with_capacity ( access. size_hint ( ) . unwrap_or ( 0 ) ) ;
103+ while let Some ( ( k, v) ) = access. next_entry :: < String , String > ( ) ? {
104+ map. insert ( k, v) ;
105+ }
106+ Ok ( map)
107+ }
108+
109+ fn visit_seq < S > ( self , mut access : S ) -> Result < Self :: Value , S :: Error >
110+ where
111+ S : SeqAccess < ' de > ,
112+ {
113+ let mut map = HashMap :: with_capacity ( access. size_hint ( ) . unwrap_or ( 0 ) ) ;
114+ while let Some ( entry) = access. next_element :: < ListEntry > ( ) ? {
115+ map. insert ( entry. key , entry. value_glob ) ;
116+ }
117+ Ok ( map)
118+ }
119+ }
120+
121+ deserializer. deserialize_any ( TagsVisitor )
122+ }
123+
64124#[ derive( Debug , Default , Clone , PartialEq ) ]
65125pub struct ParsedSamplingRules {
66126 pub rules : Vec < SamplingRuleConfig > ,
@@ -181,6 +241,65 @@ mod tests {
181241 assert_eq ! ( original, restored) ;
182242 }
183243
244+ #[ test]
245+ fn test_sampling_rule_config_tags_accepts_map_shape ( ) {
246+ // Already supported — guard against regression.
247+ let json = r#"{
248+ "sample_rate": 0.5,
249+ "service": "svc",
250+ "tags": {"env": "prod", "region": "us-east-1"}
251+ }"# ;
252+ let cfg: SamplingRuleConfig = serde_json:: from_str ( json) . unwrap ( ) ;
253+ assert_eq ! ( cfg. tags. get( "env" ) . map( String :: as_str) , Some ( "prod" ) ) ;
254+ assert_eq ! (
255+ cfg. tags. get( "region" ) . map( String :: as_str) ,
256+ Some ( "us-east-1" )
257+ ) ;
258+ }
259+
260+ #[ test]
261+ fn test_sampling_rule_config_tags_accepts_rc_list_shape ( ) {
262+ // Remote Config wire shape: list of {key, value_glob} entries.
263+ let json = r#"{
264+ "sample_rate": 0.5,
265+ "service": "svc",
266+ "tags": [
267+ {"key": "env", "value_glob": "prod"},
268+ {"key": "region", "value_glob": "us-east-1"}
269+ ]
270+ }"# ;
271+ let cfg: SamplingRuleConfig = serde_json:: from_str ( json) . unwrap ( ) ;
272+ assert_eq ! ( cfg. tags. get( "env" ) . map( String :: as_str) , Some ( "prod" ) ) ;
273+ assert_eq ! (
274+ cfg. tags. get( "region" ) . map( String :: as_str) ,
275+ Some ( "us-east-1" )
276+ ) ;
277+ }
278+
279+ #[ test]
280+ fn test_sampling_rule_config_tags_list_with_malformed_entry_rejects ( ) {
281+ // A list entry missing `value_glob` must reject the whole rule rather
282+ // than silently dropping the entry — silently dropping a constraint could
283+ // broaden a tag-constrained rule and produce a security-relevant change
284+ // in sampling decisions.
285+ let json = r#"{
286+ "sample_rate": 0.5,
287+ "tags": [
288+ {"key": "env", "value_glob": "prod"},
289+ {"key": "region"}
290+ ]
291+ }"# ;
292+ let result: Result < SamplingRuleConfig , _ > = serde_json:: from_str ( json) ;
293+ assert ! ( result. is_err( ) , "expected deserialization to fail" ) ;
294+ }
295+
296+ #[ test]
297+ fn test_sampling_rule_config_tags_absent_defaults_to_empty ( ) {
298+ let json = r#"{"sample_rate": 0.5}"# ;
299+ let cfg: SamplingRuleConfig = serde_json:: from_str ( json) . unwrap ( ) ;
300+ assert ! ( cfg. tags. is_empty( ) ) ;
301+ }
302+
184303 #[ test]
185304 fn test_sampling_rule_config_display ( ) {
186305 let config = SamplingRuleConfig {
0 commit comments