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#[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#[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
160#[serde(transparent)]
161pub struct LabelSet(Vec<LabelRule>);
162
163impl LabelSet {
164 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 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 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}