Skip to main content

treetop_bundle/
labels.rs

1use crate::{BundleError, Diagnostic, Result};
2use cedar_policy::{EntityTypeName, Schema};
3use regex::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
4use serde::ser::SerializeStruct;
5use serde::{Deserialize, Serialize, Serializer};
6use serde_json::Value;
7use std::collections::HashSet;
8use std::sync::Arc;
9use treetop_core::{AttrValue, LabelTarget, Labeler, RegexLabeler, Resource};
10
11const MAX_LABEL_RULES: usize = 256;
12const MAX_PATTERNS_PER_RULE: usize = 1_024;
13const MAX_TOTAL_PATTERNS: usize = 4_096;
14const MAX_REGEX_BYTES: usize = 16 * 1024;
15const MAX_TOTAL_REGEX_BYTES: usize = 1024 * 1024;
16const REGEX_SET_SIZE_LIMIT: usize = 2 * 1024 * 1024;
17const REGEX_SET_DFA_SIZE_LIMIT: usize = 1024 * 1024;
18const INDIVIDUAL_REGEX_THRESHOLD: usize = 4;
19const INDIVIDUAL_REGEX_SIZE_LIMIT: usize = REGEX_SET_SIZE_LIMIT / INDIVIDUAL_REGEX_THRESHOLD;
20const INDIVIDUAL_REGEX_DFA_SIZE_LIMIT: usize =
21    REGEX_SET_DFA_SIZE_LIMIT / INDIVIDUAL_REGEX_THRESHOLD;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25struct RawLabelPattern {
26    name: String,
27    regex: String,
28}
29
30/// A validated regular-expression label mapping.
31#[derive(Debug, Clone, Serialize)]
32pub struct LabelPattern {
33    name: String,
34    regex: String,
35}
36
37impl PartialEq for LabelPattern {
38    fn eq(&self, other: &Self) -> bool {
39        self.name == other.name && self.regex == other.regex
40    }
41}
42
43impl Eq for LabelPattern {}
44
45impl LabelPattern {
46    pub fn name(&self) -> &str {
47        &self.name
48    }
49
50    pub fn regex(&self) -> &str {
51        &self.regex
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57struct RawLabelRule {
58    target: RawLabelTarget,
59    field: String,
60    patterns: Vec<RawLabelPattern>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct RawLabelTarget {
66    resource_type: String,
67    attribute: String,
68}
69
70/// A validated label rule.
71#[derive(Clone)]
72pub struct LabelRule {
73    field: String,
74    patterns: Vec<LabelPattern>,
75    runtime: Arc<dyn Labeler>,
76}
77
78impl Serialize for LabelRule {
79    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
80        let mut rule = serializer.serialize_struct("LabelRule", 3)?;
81        rule.serialize_field("target", self.target())?;
82        rule.serialize_field("field", &self.field)?;
83        rule.serialize_field("patterns", &self.patterns)?;
84        rule.end()
85    }
86}
87
88#[derive(Debug, Clone)]
89enum CompiledPatterns {
90    Individual(Arc<Vec<Regex>>),
91    Set(Arc<RegexSet>),
92}
93
94impl std::fmt::Debug for LabelRule {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("LabelRule")
97            .field("target", self.target())
98            .field("field", &self.field)
99            .field("patterns", &self.patterns)
100            .finish_non_exhaustive()
101    }
102}
103
104impl PartialEq for LabelRule {
105    fn eq(&self, other: &Self) -> bool {
106        self.target() == other.target()
107            && self.field == other.field
108            && self.patterns == other.patterns
109    }
110}
111
112impl Eq for LabelRule {}
113
114#[derive(Debug)]
115struct RegexSetLabeler {
116    target: LabelTarget,
117    field: String,
118    names: Vec<String>,
119    compiled: Arc<RegexSet>,
120}
121
122impl Labeler for RegexSetLabeler {
123    fn target(&self) -> &LabelTarget {
124        &self.target
125    }
126
127    fn derive(&self, resource: &Resource) -> Option<AttrValue> {
128        let Some(AttrValue::String(value)) = resource.attributes().get(&self.field) else {
129            return None;
130        };
131        let labels = self
132            .compiled
133            .matches(value)
134            .iter()
135            .map(|index| AttrValue::String(self.names[index].clone()))
136            .collect();
137        Some(AttrValue::Set(labels))
138    }
139}
140
141impl LabelRule {
142    /// Validated exact resource type and derived attribute ownership.
143    ///
144    /// Configuration and application share the runtime's immutable declaration.
145    pub fn target(&self) -> &LabelTarget {
146        self.runtime.target()
147    }
148
149    pub fn field(&self) -> &str {
150        &self.field
151    }
152
153    pub fn patterns(&self) -> &[LabelPattern] {
154        &self.patterns
155    }
156}
157
158/// A validated set of label rules.
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
160#[serde(transparent)]
161pub struct LabelSet(Vec<LabelRule>);
162
163impl LabelSet {
164    /// Parse and strictly validate a JSON label document.
165    pub fn from_json_str(input: &str) -> Result<Self> {
166        let raw: Vec<RawLabelRule> = serde_json::from_str(input).map_err(|error| {
167            let mut diagnostic = Diagnostic::error("labels.invalid_json", error.to_string());
168            diagnostic.line = Some(error.line());
169            diagnostic.column = Some(error.column());
170            BundleError::Validation(vec![diagnostic])
171        })?;
172        Self::from_raw(raw)
173    }
174
175    /// Validate label entity and attribute types against a complete Cedar JSON schema.
176    pub fn validate_schema_json_str(&self, schema_source: &str) -> Result<()> {
177        let schema_json: Value = serde_json::from_str(schema_source).map_err(|error| {
178            BundleError::Validation(vec![Diagnostic::error(
179                "schema.invalid_json",
180                error.to_string(),
181            )])
182        })?;
183        let schema = Schema::from_json_value(schema_json.clone()).map_err(|error| {
184            BundleError::Validation(vec![Diagnostic::error(
185                "schema.aggregate_invalid",
186                error.to_string(),
187            )])
188        })?;
189        let diagnostics = self.validate_schema(&schema, &schema_json);
190        if diagnostics.is_empty() {
191            Ok(())
192        } else {
193            Err(BundleError::Validation(diagnostics))
194        }
195    }
196
197    fn from_raw(raw: Vec<RawLabelRule>) -> Result<Self> {
198        validate_document_limits(&raw)?;
199
200        let mut diagnostics = Vec::new();
201        let mut destinations = HashSet::new();
202        let mut rules = Vec::with_capacity(raw.len());
203
204        for (rule_index, raw_rule) in raw.into_iter().enumerate() {
205            let location = format!("labels[{rule_index}]");
206            let target =
207                match LabelTarget::new(raw_rule.target.resource_type, raw_rule.target.attribute) {
208                    Ok(target) => target,
209                    Err(error) => {
210                        diagnostics.push(Diagnostic::error(
211                            "labels.invalid_target",
212                            format!("{location}.target: {error}"),
213                        ));
214                        continue;
215                    }
216                };
217            if raw_rule.field.trim().is_empty() {
218                diagnostics.push(Diagnostic::error(
219                    "labels.empty_field",
220                    format!("{location}.field must not be empty"),
221                ));
222            }
223            if raw_rule.field == target.attribute() {
224                diagnostics.push(Diagnostic::error(
225                    "labels.input_is_output",
226                    format!("{location}.field and target.attribute must be different"),
227                ));
228            }
229            if !destinations.insert(target.clone()) {
230                diagnostics.push(Diagnostic::error(
231                    "labels.duplicate_destination",
232                    format!(
233                        "duplicate label destination ({}, {})",
234                        target.resource_type(),
235                        target.attribute()
236                    ),
237                ));
238            }
239            if raw_rule.patterns.is_empty() {
240                diagnostics.push(Diagnostic::error(
241                    "labels.empty_patterns",
242                    format!("{location}.patterns must not be empty"),
243                ));
244            }
245            if raw_rule.patterns.len() > MAX_PATTERNS_PER_RULE {
246                diagnostics.push(Diagnostic::error(
247                    "labels.too_many_patterns",
248                    format!(
249                        "{location}.patterns contains more than {MAX_PATTERNS_PER_RULE} entries"
250                    ),
251                ));
252            }
253
254            let mut names = HashSet::new();
255            let mut patterns = Vec::with_capacity(raw_rule.patterns.len());
256            let mut patterns_valid =
257                !raw_rule.patterns.is_empty() && raw_rule.patterns.len() <= MAX_PATTERNS_PER_RULE;
258            for (pattern_index, raw_pattern) in raw_rule.patterns.into_iter().enumerate() {
259                let pattern_location = format!("{location}.patterns[{pattern_index}]");
260                if raw_pattern.name.trim().is_empty() {
261                    diagnostics.push(Diagnostic::error(
262                        "labels.empty_pattern_name",
263                        format!("{pattern_location}.name must not be empty"),
264                    ));
265                }
266                if !names.insert(raw_pattern.name.clone()) {
267                    diagnostics.push(Diagnostic::error(
268                        "labels.duplicate_pattern_name",
269                        format!(
270                            "duplicate pattern name {:?} in {location}",
271                            raw_pattern.name
272                        ),
273                    ));
274                }
275                if raw_pattern.regex.is_empty() {
276                    diagnostics.push(Diagnostic::error(
277                        "labels.empty_regex",
278                        format!("{pattern_location}.regex must not be empty"),
279                    ));
280                    patterns_valid = false;
281                } else if raw_pattern.regex.len() > MAX_REGEX_BYTES {
282                    diagnostics.push(Diagnostic::error(
283                        "labels.regex_too_large",
284                        format!("{pattern_location}.regex exceeds {MAX_REGEX_BYTES} bytes"),
285                    ));
286                    patterns_valid = false;
287                }
288                patterns.push(LabelPattern {
289                    name: raw_pattern.name,
290                    regex: raw_pattern.regex,
291                });
292            }
293
294            if patterns_valid {
295                match compile_patterns(&patterns) {
296                    Ok(compiled) => {
297                        match runtime_labeler(target, &raw_rule.field, &patterns, compiled) {
298                            Ok(runtime) => rules.push(LabelRule {
299                                field: raw_rule.field,
300                                patterns,
301                                runtime,
302                            }),
303                            Err(error) => diagnostics.push(Diagnostic::error(
304                                "labels.invalid_configuration",
305                                format!("{location}: {error}"),
306                            )),
307                        }
308                    }
309                    Err(error) => diagnostics.push(Diagnostic::error(
310                        "labels.invalid_regex",
311                        format!("{location}.patterns cannot be compiled safely: {error}"),
312                    )),
313                }
314            }
315        }
316
317        if diagnostics.is_empty() {
318            Ok(Self(rules))
319        } else {
320            Err(BundleError::Validation(diagnostics))
321        }
322    }
323
324    pub(crate) fn combine(sets: impl IntoIterator<Item = Self>) -> Result<Self> {
325        let rules = sets.into_iter().flat_map(|set| set.0).collect::<Vec<_>>();
326        validate_combined_limits(&rules)?;
327        let mut destinations = HashSet::with_capacity(rules.len());
328        let mut diagnostics = Vec::new();
329        for rule in &rules {
330            if !destinations.insert(rule.target()) {
331                diagnostics.push(Diagnostic::error(
332                    "labels.duplicate_destination",
333                    format!(
334                        "duplicate label destination ({}, {})",
335                        rule.target().resource_type(),
336                        rule.target().attribute()
337                    ),
338                ));
339            }
340        }
341        if diagnostics.is_empty() {
342            Ok(Self(rules))
343        } else {
344            Err(BundleError::Validation(diagnostics))
345        }
346    }
347
348    pub fn rules(&self) -> &[LabelRule] {
349        &self.0
350    }
351
352    pub fn is_empty(&self) -> bool {
353        self.0.is_empty()
354    }
355
356    /// Return one scoped runtime owner per rule in configuration order.
357    ///
358    /// Targets and regex programs were validated at parsing time. Attribute names
359    /// can repeat on disjoint resource types; duplicate tuples are rejected.
360    pub fn to_labelers(&self) -> Vec<Arc<dyn Labeler>> {
361        self.0
362            .iter()
363            .map(|rule| Arc::clone(&rule.runtime))
364            .collect()
365    }
366
367    pub(crate) fn validate_schema(&self, schema: &Schema, schema_json: &Value) -> Vec<Diagnostic> {
368        let known_types = schema
369            .entity_types()
370            .map(ToString::to_string)
371            .collect::<HashSet<_>>();
372        let mut diagnostics = Vec::new();
373        for rule in &self.0 {
374            if !known_types.contains(rule.target().resource_type()) {
375                diagnostics.push(Diagnostic::error(
376                    "labels.unknown_kind",
377                    format!(
378                        "label kind {} is not declared in the schema",
379                        rule.target().resource_type()
380                    ),
381                ));
382                continue;
383            }
384            let Some(attributes) = entity_attributes(schema_json, rule.target().resource_type())
385            else {
386                diagnostics.push(Diagnostic::error(
387                    "labels.missing_shape",
388                    format!(
389                        "label kind {} has no record shape",
390                        rule.target().resource_type()
391                    ),
392                ));
393                continue;
394            };
395            match attributes.get(&rule.field) {
396                Some(value) if is_string_type(value) => {}
397                Some(value) => diagnostics.push(Diagnostic::error(
398                    "labels.field_not_string",
399                    format!(
400                        "{}.{} must have schema type String, found {value}",
401                        rule.target().resource_type(),
402                        rule.field
403                    ),
404                )),
405                None => diagnostics.push(Diagnostic::error(
406                    "labels.field_missing",
407                    format!(
408                        "{}.{} is not declared in the schema",
409                        rule.target().resource_type(),
410                        rule.field
411                    ),
412                )),
413            }
414            match attributes.get(rule.target().attribute()) {
415                Some(value) if is_string_set_type(value) => {}
416                Some(value) => diagnostics.push(Diagnostic::error(
417                    "labels.output_not_string_set",
418                    format!(
419                        "{}.{} must have schema type Set<String>, found {value}",
420                        rule.target().resource_type(),
421                        rule.target().attribute(),
422                    ),
423                )),
424                None => diagnostics.push(Diagnostic::error(
425                    "labels.output_missing",
426                    format!(
427                        "{}.{} is not declared in the schema",
428                        rule.target().resource_type(),
429                        rule.target().attribute()
430                    ),
431                )),
432            }
433        }
434        diagnostics
435    }
436}
437
438fn runtime_labeler(
439    target: LabelTarget,
440    field: &str,
441    patterns: &[LabelPattern],
442    compiled: CompiledPatterns,
443) -> std::result::Result<Arc<dyn Labeler>, treetop_core::PolicyError> {
444    let labeler: Arc<dyn Labeler> = match compiled {
445        CompiledPatterns::Individual(compiled) => Arc::new(RegexLabeler::new(
446            target,
447            field,
448            patterns
449                .iter()
450                .zip(compiled.iter())
451                .map(|(pattern, regex)| (pattern.name.clone(), regex.clone()))
452                .collect(),
453        )?),
454        CompiledPatterns::Set(compiled) => Arc::new(RegexSetLabeler {
455            target,
456            field: field.to_string(),
457            names: patterns
458                .iter()
459                .map(|pattern| pattern.name.clone())
460                .collect(),
461            compiled,
462        }),
463    };
464    Ok(labeler)
465}
466
467fn compile_patterns(
468    patterns: &[LabelPattern],
469) -> std::result::Result<CompiledPatterns, regex::Error> {
470    if patterns.len() <= INDIVIDUAL_REGEX_THRESHOLD {
471        let compiled = patterns
472            .iter()
473            .map(|pattern| {
474                let mut builder = RegexBuilder::new(&pattern.regex);
475                builder
476                    .size_limit(INDIVIDUAL_REGEX_SIZE_LIMIT)
477                    .dfa_size_limit(INDIVIDUAL_REGEX_DFA_SIZE_LIMIT);
478                builder.build()
479            })
480            .collect::<std::result::Result<Vec<_>, _>>()?;
481        Ok(CompiledPatterns::Individual(Arc::new(compiled)))
482    } else {
483        let mut builder =
484            RegexSetBuilder::new(patterns.iter().map(|pattern| pattern.regex.as_str()));
485        builder
486            .size_limit(REGEX_SET_SIZE_LIMIT)
487            .dfa_size_limit(REGEX_SET_DFA_SIZE_LIMIT);
488        builder
489            .build()
490            .map(|compiled| CompiledPatterns::Set(Arc::new(compiled)))
491    }
492}
493
494fn validate_document_limits(raw: &[RawLabelRule]) -> Result<()> {
495    if raw.len() > MAX_LABEL_RULES {
496        return Err(BundleError::Validation(vec![Diagnostic::error(
497            "labels.too_many_rules",
498            format!("label document contains more than {MAX_LABEL_RULES} rules"),
499        )]));
500    }
501    let total_patterns = raw
502        .iter()
503        .try_fold(0usize, |total, rule| total.checked_add(rule.patterns.len()))
504        .unwrap_or(usize::MAX);
505    if total_patterns > MAX_TOTAL_PATTERNS {
506        return Err(BundleError::Validation(vec![Diagnostic::error(
507            "labels.too_many_patterns",
508            format!("label document contains more than {MAX_TOTAL_PATTERNS} patterns"),
509        )]));
510    }
511    let total_regex_bytes = raw
512        .iter()
513        .flat_map(|rule| &rule.patterns)
514        .try_fold(0usize, |total, pattern| {
515            total.checked_add(pattern.regex.len())
516        })
517        .unwrap_or(usize::MAX);
518    if total_regex_bytes > MAX_TOTAL_REGEX_BYTES {
519        return Err(BundleError::Validation(vec![Diagnostic::error(
520            "labels.regex_budget_exceeded",
521            format!("label document regex sources exceed {MAX_TOTAL_REGEX_BYTES} total bytes"),
522        )]));
523    }
524    Ok(())
525}
526
527fn validate_combined_limits(rules: &[LabelRule]) -> Result<()> {
528    if rules.len() > MAX_LABEL_RULES {
529        return Err(BundleError::Validation(vec![Diagnostic::error(
530            "labels.too_many_rules",
531            format!("combined label document contains more than {MAX_LABEL_RULES} rules"),
532        )]));
533    }
534    let total_patterns = rules
535        .iter()
536        .try_fold(0usize, |total, rule| total.checked_add(rule.patterns.len()))
537        .unwrap_or(usize::MAX);
538    if total_patterns > MAX_TOTAL_PATTERNS {
539        return Err(BundleError::Validation(vec![Diagnostic::error(
540            "labels.too_many_patterns",
541            format!("combined label document contains more than {MAX_TOTAL_PATTERNS} patterns"),
542        )]));
543    }
544    let total_regex_bytes = rules
545        .iter()
546        .flat_map(|rule| &rule.patterns)
547        .try_fold(0usize, |total, pattern| {
548            total.checked_add(pattern.regex.len())
549        })
550        .unwrap_or(usize::MAX);
551    if total_regex_bytes > MAX_TOTAL_REGEX_BYTES {
552        return Err(BundleError::Validation(vec![Diagnostic::error(
553            "labels.regex_budget_exceeded",
554            format!(
555                "combined label document regex sources exceed {MAX_TOTAL_REGEX_BYTES} total bytes"
556            ),
557        )]));
558    }
559    Ok(())
560}
561
562fn entity_attributes<'a>(
563    schema_json: &'a Value,
564    kind: &str,
565) -> Option<&'a serde_json::Map<String, Value>> {
566    let parsed = kind.parse::<EntityTypeName>().ok()?;
567    let namespace = parsed.namespace().to_string();
568    let namespace_definition = schema_json.as_object()?.get(&namespace)?;
569    let definition = namespace_definition
570        .get("entityTypes")?
571        .get(parsed.basename())?;
572    record_attributes(
573        schema_json,
574        &namespace,
575        definition.get("shape")?,
576        &mut HashSet::new(),
577    )
578}
579
580fn record_attributes<'a>(
581    schema_json: &'a Value,
582    namespace: &str,
583    shape: &'a Value,
584    visited: &mut HashSet<String>,
585) -> Option<&'a serde_json::Map<String, Value>> {
586    if shape.get("type").and_then(Value::as_str) == Some("Record") {
587        return shape.get("attributes")?.as_object();
588    }
589    if shape.get("type").and_then(Value::as_str) != Some("EntityOrCommon") {
590        return None;
591    }
592    let name = shape.get("name")?.as_str()?;
593    let (common_namespace, basename) = name
594        .rsplit_once("::")
595        .map_or((namespace, name), |(namespace, basename)| {
596            (namespace, basename)
597        });
598    let qualified_name = format!("{common_namespace}::{basename}");
599    if !visited.insert(qualified_name) {
600        return None;
601    }
602    let common = schema_json
603        .as_object()?
604        .get(common_namespace)?
605        .get("commonTypes")?
606        .get(basename)?;
607    record_attributes(schema_json, common_namespace, common, visited)
608}
609
610fn is_string_type(value: &Value) -> bool {
611    value.get("type").and_then(Value::as_str) == Some("String")
612        || (value.get("type").and_then(Value::as_str) == Some("EntityOrCommon")
613            && value.get("name").and_then(Value::as_str) == Some("String"))
614}
615
616fn is_string_set_type(value: &Value) -> bool {
617    value.get("type").and_then(Value::as_str) == Some("Set")
618        && value.get("element").is_some_and(is_string_type)
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use treetop_core::{LabelRegistryBuilder, LabelerApply};
625
626    fn shared_output_rules() -> LabelSet {
627        LabelSet::from_json_str(r#"[
628            {"target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name","patterns":[{"name":"host","regex":"prod"}]},
629            {"target": {"resource_type": "App::Bucket", "attribute": "labels"}, "field": "name","patterns":[{"name":"bucket","regex":"prod"}]}
630        ]"#).unwrap()
631    }
632
633    #[test]
634    fn shared_names_have_distinct_scoped_owners_and_replace_forged_labels() {
635        let labelers = shared_output_rules().to_labelers();
636        assert_eq!(labelers.len(), 2);
637        let mut builder = LabelRegistryBuilder::new();
638        for labeler in labelers {
639            builder = builder.add_labeler(labeler);
640        }
641        let registry = builder.build().unwrap();
642        for (kind, expected) in [("App::Host", "host"), ("App::Bucket", "bucket")] {
643            let mut resource = Resource::new(kind, "one")
644                .unwrap()
645                .with_attr("name", AttrValue::String("prod".into()))
646                .with_attr("labels", AttrValue::String("forged".into()));
647            registry.apply(&mut resource);
648            assert_eq!(
649                resource.attributes().get("labels"),
650                Some(&AttrValue::Set(vec![AttrValue::String(expected.into())]))
651            );
652            let once = resource.clone();
653            registry.apply(&mut resource);
654            assert_eq!(resource, once);
655        }
656    }
657
658    #[test]
659    fn scoped_owners_remove_missing_derivations_and_preserve_other_types() {
660        let mut builder = LabelRegistryBuilder::new();
661        for labeler in shared_output_rules().to_labelers() {
662            builder = builder.add_labeler(labeler);
663        }
664        let registry = builder.build().unwrap();
665        for kind in ["App::Host", "App::Bucket"] {
666            let mut resource = Resource::new(kind, "one")
667                .unwrap()
668                .with_attr("labels", AttrValue::String("forged".into()));
669            registry.apply(&mut resource);
670            assert!(!resource.attributes().contains_key("labels"));
671        }
672        let mut other = Resource::new("App::Other", "one")
673            .unwrap()
674            .with_attr("labels", AttrValue::String("application input".into()));
675        let original = other.clone();
676        registry.apply(&mut other);
677        assert_eq!(other, original);
678    }
679
680    #[test]
681    fn shared_output_preserves_empty_match_set() {
682        let labeler = shared_output_rules().to_labelers().remove(0);
683        let mut resource = Resource::new("App::Host", "one")
684            .unwrap()
685            .with_attr("name", AttrValue::String("development".into()));
686        labeler.apply(&mut resource);
687        assert_eq!(
688            resource.attributes().get("labels"),
689            Some(&AttrValue::Set(vec![]))
690        );
691    }
692
693    #[test]
694    fn combined_label_documents_preserve_distinct_targets_and_reject_duplicates() {
695        let labels = shared_output_rules();
696        let first = LabelSet(vec![labels.0[0].clone()]);
697        let second = LabelSet(vec![labels.0[1].clone()]);
698        assert_eq!(
699            LabelSet::combine([first.clone(), second])
700                .unwrap()
701                .to_labelers()
702                .len(),
703            2
704        );
705        assert!(LabelSet::combine([first.clone(), first]).is_err());
706    }
707
708    #[test]
709    fn both_regex_backends_reject_reserved_output_at_parse_boundary() {
710        for count in [1, 5] {
711            let patterns: Vec<_> = (0..count)
712                .map(|i| {
713                    serde_json::json!({
714                        "name": format!("label-{i}"), "regex": "prod",
715                    })
716                })
717                .collect();
718            let source = serde_json::json!([{
719                "target": {"resource_type": "App::Host", "attribute": "id"}, "field": "name", "patterns":patterns,
720            }])
721            .to_string();
722            let error = LabelSet::from_json_str(&source).unwrap_err();
723            assert!(
724                error
725                    .diagnostics()
726                    .iter()
727                    .any(|d| d.code == "labels.invalid_target")
728            );
729        }
730    }
731
732    #[test]
733    fn old_and_incomplete_target_syntax_is_rejected() {
734        for source in [
735            r#"[{"kind":"App::Host","output":"labels","field":"name","patterns":[]}]"#,
736            r#"[{"target":{"resource_type":"App::Host"},"field":"name","patterns":[]}]"#,
737            r#"[{"target":{"resource_type":"App::Host","attribute":"labels","scope":"*"},"field":"name","patterns":[]}]"#,
738            r#"[{"target":{"resource_type":"App::Host","attribute":"labels"},"output":"labels","field":"name","patterns":[]}]"#,
739        ] {
740            let error = LabelSet::from_json_str(source).unwrap_err();
741            assert_eq!(error.diagnostics()[0].code, "labels.invalid_json");
742        }
743    }
744
745    #[test]
746    fn strict_label_validation_rejects_unknown_fields() {
747        let error = LabelSet::from_json_str(
748            r#"[{"target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name","patterns":[{"name":"prod","regex":"prod","extra":true}]}]"#,
749        )
750        .unwrap_err();
751        assert!(error.diagnostics()[0].message.contains("unknown field"));
752    }
753
754    #[test]
755    fn label_set_converts_to_runtime_labelers() {
756        let labels = LabelSet::from_json_str(
757            r#"[{"target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name","patterns":[{"name":"prod","regex":"^prod"}]}]"#,
758        )
759        .unwrap();
760        assert_eq!(labels.to_labelers().len(), 1);
761    }
762
763    #[test]
764    fn regex_set_labeler_returns_all_matches_and_replaces_untrusted_output() {
765        let labels = LabelSet::from_json_str(
766            r#"[{"target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name","patterns":[{"name":"prod","regex":"^prod"},{"name":"database","regex":"db$"},{"name":"staging","regex":"^staging"},{"name":"cache","regex":"cache$"},{"name":"worker","regex":"worker"}]}]"#,
767        )
768        .unwrap();
769        let labeler = labels.to_labelers().pop().unwrap();
770        let mut resource = Resource::new("App::Host", "one")
771            .unwrap()
772            .with_attr("name", AttrValue::String("prod-db".to_string()))
773            .with_attr(
774                "labels",
775                AttrValue::Set(vec![AttrValue::String("forged".to_string())]),
776            );
777
778        labeler.apply(&mut resource);
779
780        assert_eq!(
781            resource.attributes().get("labels"),
782            Some(&AttrValue::Set(vec![
783                AttrValue::String("prod".to_string()),
784                AttrValue::String("database".to_string()),
785            ]))
786        );
787    }
788
789    #[test]
790    fn label_document_pattern_budget_is_enforced_before_compilation() {
791        let patterns = (0..=MAX_TOTAL_PATTERNS)
792            .map(|index| {
793                serde_json::json!({
794                    "name": format!("pattern-{index}"),
795                    "regex": "a",
796                })
797            })
798            .collect::<Vec<_>>();
799        let source = serde_json::json!([{
800            "target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name",
801            "patterns": patterns,
802        }])
803        .to_string();
804
805        let error = LabelSet::from_json_str(&source).unwrap_err();
806
807        assert_eq!(error.diagnostics()[0].code, "labels.too_many_patterns");
808    }
809
810    #[test]
811    fn combined_label_sets_reapply_document_limits() {
812        let labels = LabelSet::from_json_str(
813            r#"[{"target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name","patterns":[{"name":"prod","regex":"^prod"}]}]"#,
814        )
815        .unwrap();
816
817        let error =
818            LabelSet::combine(std::iter::repeat_n(labels, MAX_LABEL_RULES + 1)).unwrap_err();
819
820        assert_eq!(error.diagnostics()[0].code, "labels.too_many_rules");
821    }
822
823    #[test]
824    fn schema_validation_resolves_common_record_shapes() {
825        let labels = LabelSet::from_json_str(
826            r#"[{"target": {"resource_type": "App::Host", "attribute": "labels"}, "field": "name","patterns":[{"name":"prod","regex":"^prod"}]}]"#,
827        )
828        .unwrap();
829        let schema = r#"{
830          "App": {
831            "commonTypes": {
832              "HostShape": {
833                "type": "Record",
834                "attributes": {
835                  "name": {"type": "String", "required": true},
836                  "labels": {
837                    "type": "Set",
838                    "element": {"type": "String"},
839                    "required": false
840                  }
841                },
842                "additionalAttributes": false
843              }
844            },
845            "entityTypes": {
846              "Host": {
847                "shape": {"type": "EntityOrCommon", "name": "HostShape"}
848              }
849            },
850            "actions": {}
851          }
852        }"#;
853
854        labels.validate_schema_json_str(schema).unwrap();
855    }
856}