1use cedar_policy::{EntityTypeName, RestrictedExpression};
2use regex::Regex;
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use std::collections::{HashMap, HashSet};
5use std::fmt::{Display, Formatter, Result as FmtResult};
6use std::hash::{Hash, Hasher};
7use std::sync::Arc;
8use utoipa::{PartialSchema, ToSchema};
9
10use crate::error::PolicyError;
11use crate::traits::CedarAtom;
12use crate::types::{AttrValue, Resource};
13
14#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)]
16#[serde(transparent)]
17pub struct LabelSetVersion(Arc<str>);
18
19impl LabelSetVersion {
20 fn new(value: impl AsRef<str>) -> Result<Self, PolicyError> {
21 let value = value.as_ref();
22 if value.trim().is_empty() {
23 return Err(PolicyError::LabelConfigError(
24 "label-set version must not be empty".to_string(),
25 ));
26 }
27 Ok(Self(value.into()))
28 }
29
30 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34}
35
36impl Display for LabelSetVersion {
37 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
38 f.write_str(self.as_str())
39 }
40}
41
42impl<'de> Deserialize<'de> for LabelSetVersion {
43 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
44 where
45 D: Deserializer<'de>,
46 {
47 let value = String::deserialize(deserializer)?;
48 Self::new(value).map_err(D::Error::custom)
49 }
50}
51
52impl PartialSchema for LabelSetVersion {
53 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
54 <String as PartialSchema>::schema()
55 }
56}
57
58impl ToSchema for LabelSetVersion {}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
78#[serde(deny_unknown_fields)]
79pub struct LabelTarget {
80 resource_type: String,
81 attribute: String,
82 #[serde(skip)]
83 entity_type: EntityTypeName,
84}
85
86impl Hash for LabelTarget {
87 fn hash<H: Hasher>(&self, state: &mut H) {
88 self.entity_type.hash(state);
89 self.attribute.hash(state);
90 }
91}
92
93impl LabelTarget {
94 pub fn new(
99 resource_type: impl AsRef<str>,
100 attribute: impl Into<String>,
101 ) -> Result<Self, PolicyError> {
102 let resource_type = resource_type.as_ref();
103 let entity_type: EntityTypeName = resource_type.parse().map_err(|error| {
104 PolicyError::LabelConfigError(format!(
105 "invalid label resource type '{resource_type}': {error}"
106 ))
107 })?;
108 let attribute = attribute.into();
109 validate_output(&attribute)?;
110 Ok(Self {
111 resource_type: entity_type.to_string(),
112 attribute,
113 entity_type,
114 })
115 }
116
117 pub fn resource_type(&self) -> &str {
119 &self.resource_type
120 }
121
122 pub fn attribute(&self) -> &str {
124 &self.attribute
125 }
126
127 fn matches(&self, resource: &Resource) -> bool {
128 resource.cedar_entity_uid().type_name() == &self.entity_type
129 }
130}
131
132impl<'de> Deserialize<'de> for LabelTarget {
133 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
134 where
135 D: Deserializer<'de>,
136 {
137 #[derive(Deserialize)]
138 #[serde(deny_unknown_fields)]
139 struct WireTarget {
140 resource_type: String,
141 attribute: String,
142 }
143 let wire = WireTarget::deserialize(deserializer)?;
144 Self::new(wire.resource_type, wire.attribute).map_err(D::Error::custom)
145 }
146}
147
148pub trait Labeler: Send + Sync {
154 fn target(&self) -> &LabelTarget;
156
157 fn derive(&self, resource: &Resource) -> Option<AttrValue>;
163}
164
165pub trait LabelerApply: Labeler {
170 fn apply(&self, resource: &mut Resource) {
172 let target = self.target();
173 if !target.matches(resource) {
174 return;
175 }
176 resource.attrs().remove(target.attribute());
177 if let Some(value) = self.derive(resource) {
178 resource
179 .attrs()
180 .insert(target.attribute().to_string(), value);
181 }
182 }
183}
184
185impl<T: Labeler + ?Sized> LabelerApply for T {}
186
187#[derive(Debug, Clone)]
189pub struct RegexLabeler {
190 target: LabelTarget,
191 field: String,
193 table: Vec<(String, Regex)>,
195}
196
197impl RegexLabeler {
198 pub fn new(
211 target: LabelTarget,
212 field: impl Into<String>,
213 table: Vec<(String, Regex)>,
214 ) -> Result<Self, PolicyError> {
215 let field = field.into();
216 if field.trim().is_empty() {
217 return Err(PolicyError::LabelConfigError(
218 "regex labeler input field must not be empty".to_string(),
219 ));
220 }
221 if field == target.attribute() {
222 return Err(PolicyError::LabelConfigError(format!(
223 "regex labeler input and output must differ ('{field}')"
224 )));
225 }
226 Ok(Self {
227 target,
228 field,
229 table,
230 })
231 }
232}
233
234impl Labeler for RegexLabeler {
235 fn target(&self) -> &LabelTarget {
236 &self.target
237 }
238
239 fn derive(&self, resource: &Resource) -> Option<AttrValue> {
240 let Some(AttrValue::String(value)) = resource.attributes().get(&self.field) else {
241 return None;
242 };
243 let out = self
244 .table
245 .iter()
246 .filter(|(_, re)| re.is_match(value))
247 .map(|(label, _)| AttrValue::String(label.clone()))
248 .collect();
249
250 Some(AttrValue::Set(out))
251 }
252}
253
254fn validate_output(output: &str) -> Result<(), PolicyError> {
255 if output.trim().is_empty() {
256 return Err(PolicyError::LabelConfigError(
257 "labeler output must not be empty".to_string(),
258 ));
259 }
260 if output == "id" {
261 return Err(PolicyError::LabelConfigError(
262 "labeler output 'id' is reserved for the canonical resource ID".to_string(),
263 ));
264 }
265 RestrictedExpression::new_record([(output.to_string(), RestrictedExpression::new_bool(true))])
269 .map_err(|error| {
270 PolicyError::LabelConfigError(format!(
271 "invalid Cedar attribute name '{output}': {error}"
272 ))
273 })?;
274 Ok(())
275}
276
277#[derive(Clone)]
283pub struct LabelRegistry {
284 version: Option<LabelSetVersion>,
285 scopes: Arc<HashMap<EntityTypeName, Vec<RegisteredLabeler>>>,
286}
287
288struct RegisteredLabeler {
289 target: LabelTarget,
290 labeler: Arc<dyn Labeler>,
291}
292
293impl RegisteredLabeler {
294 fn apply(&self, resource: &mut Resource) {
295 resource.attrs().remove(self.target.attribute());
296 if let Some(value) = self.labeler.derive(resource) {
297 resource
298 .attrs()
299 .insert(self.target.attribute().to_string(), value);
300 }
301 }
302}
303
304impl LabelRegistry {
305 pub fn version(&self) -> Option<&LabelSetVersion> {
307 self.version.as_ref()
308 }
309
310 pub(crate) fn apply_to_clone_if_applicable(&self, resource: &Resource) -> Option<Resource> {
312 let labelers = self.scopes.get(resource.cedar_entity_uid().type_name())?;
313 let mut labelled = resource.clone();
314 Self::apply_scope(labelers, &mut labelled);
315 Some(labelled)
316 }
317
318 pub fn apply(&self, resource: &mut Resource) {
323 if let Some(labelers) = self.scopes.get(resource.cedar_entity_uid().type_name()) {
324 Self::apply_scope(labelers, resource);
325 }
326 }
327
328 fn apply_scope(labelers: &[RegisteredLabeler], resource: &mut Resource) {
329 for labeler in labelers {
330 resource.attrs().remove(labeler.target.attribute());
331 }
332 for labeler in labelers {
333 labeler.apply(resource);
334 }
335 }
336}
337
338#[derive(Default)]
361pub struct LabelRegistryBuilder {
362 version: Option<String>,
363 labelers: Vec<Arc<dyn Labeler>>,
364}
365
366impl LabelRegistryBuilder {
367 pub fn new() -> Self {
373 Self::default()
374 }
375
376 pub fn versioned(version: impl Into<String>) -> Self {
378 Self {
379 version: Some(version.into()),
380 labelers: Vec::new(),
381 }
382 }
383
384 pub fn add_labeler(mut self, labeler: Arc<dyn Labeler>) -> Self {
388 self.labelers.push(labeler);
389 self
390 }
391
392 pub fn build(self) -> Result<LabelRegistry, PolicyError> {
399 let version = self.version.map(LabelSetVersion::new).transpose()?;
400 let mut targets = HashSet::with_capacity(self.labelers.len());
401 let mut scopes: HashMap<EntityTypeName, Vec<RegisteredLabeler>> = HashMap::new();
402 for labeler in &self.labelers {
403 let target = labeler.target();
404 if !targets.insert(target) {
405 return Err(PolicyError::LabelConfigError(format!(
406 "multiple labelers own target ({}, {})",
407 target.resource_type(),
408 target.attribute()
409 )));
410 }
411 let target = target.clone();
412 scopes
413 .entry(target.entity_type.clone())
414 .or_default()
415 .push(RegisteredLabeler {
416 target,
417 labeler: Arc::clone(labeler),
418 });
419 }
420 Ok(LabelRegistry {
421 version,
422 scopes: Arc::new(scopes),
423 })
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use std::collections::BTreeSet;
431 use yare::parameterized;
432
433 #[test]
434 fn targets_validate_and_round_trip_through_the_same_boundary() {
435 let target = LabelTarget::new("App::Host", "labels").unwrap();
436 let json = serde_json::json!({"resource_type": "App::Host", "attribute": "labels"});
437 assert_eq!(serde_json::to_value(&target).unwrap(), json);
438 assert_eq!(serde_json::from_value::<LabelTarget>(json).unwrap(), target);
439 for resource_type in ["", "*", "App::*", "App::", "App::Host::\"one\""] {
440 assert!(LabelTarget::new(resource_type, "labels").is_err());
441 assert!(
442 serde_json::from_value::<LabelTarget>(serde_json::json!({
443 "resource_type": resource_type, "attribute": "labels"
444 }))
445 .is_err()
446 );
447 }
448 for attribute in ["", " ", "id"] {
449 assert!(LabelTarget::new("App::Host", attribute).is_err());
450 assert!(
451 serde_json::from_value::<LabelTarget>(serde_json::json!({
452 "resource_type": "App::Host", "attribute": attribute
453 }))
454 .is_err()
455 );
456 }
457 for json in [
458 serde_json::json!({"kind": "App::Host", "output": "labels"}),
459 serde_json::json!({"resource_type": "App::Host"}),
460 serde_json::json!({"resource_type": "App::Host", "attribute": "labels", "extra": true}),
461 ] {
462 assert!(serde_json::from_value::<LabelTarget>(json).is_err());
463 }
464 }
465
466 #[test]
467 fn identical_attribute_names_have_independent_qualified_type_owners() {
468 let mut builder = LabelRegistryBuilder::new();
469 let pattern = Regex::new("prod").unwrap();
470 for resource_type in ["App::Host", "Other::Host"] {
471 builder = builder.add_labeler(Arc::new(
472 RegexLabeler::new(
473 LabelTarget::new(resource_type, "labels").unwrap(),
474 "name",
475 vec![(resource_type.to_string(), pattern.clone())],
476 )
477 .unwrap(),
478 ));
479 }
480 let registry = builder.build().unwrap();
481 for resource_type in ["App::Host", "Other::Host"] {
482 let mut resource = Resource::new(resource_type, "one")
483 .unwrap()
484 .with_attr("name", AttrValue::String("prod".into()))
485 .with_attr("labels", AttrValue::String("forged".into()));
486 registry.apply(&mut resource);
487 assert_eq!(
488 resource.attributes().get("labels"),
489 Some(&AttrValue::Set(vec![AttrValue::String(
490 resource_type.to_string()
491 )]))
492 );
493 let first = resource.clone();
494 registry.apply(&mut resource);
495 assert_eq!(resource, first);
496 }
497 }
498
499 #[test]
500 fn direct_application_enforces_the_declared_scope() {
501 let labeler = EchoOwnedOutput(LabelTarget::new("App::Host", "labels").unwrap());
502 let mut other = Resource::new("Other::Host", "one")
503 .unwrap()
504 .with_attr("labels", AttrValue::String("application input".into()));
505 let original = other.clone();
506 labeler.apply(&mut other);
507 assert_eq!(other, original);
508 let mut matching = Resource::new("App::Host", "one")
509 .unwrap()
510 .with_attr("labels", AttrValue::String("forged".into()));
511 labeler.apply(&mut matching);
512 assert!(!matching.attributes().contains_key("labels"));
513 }
514
515 #[test]
516 fn registry_freezes_targets_at_construction() {
517 use std::sync::atomic::{AtomicBool, Ordering};
518 struct ChangingDeclaration {
519 original: LabelTarget,
520 replacement: LabelTarget,
521 changed: AtomicBool,
522 }
523 impl Labeler for ChangingDeclaration {
524 fn target(&self) -> &LabelTarget {
525 if self.changed.load(Ordering::Relaxed) {
526 &self.replacement
527 } else {
528 &self.original
529 }
530 }
531 fn derive(&self, _: &Resource) -> Option<AttrValue> {
532 Some(AttrValue::Bool(true))
533 }
534 }
535 let labeler = Arc::new(ChangingDeclaration {
536 original: LabelTarget::new("App::Host", "labels").unwrap(),
537 replacement: LabelTarget::new("Other::Host", "other").unwrap(),
538 changed: AtomicBool::new(false),
539 });
540 let registry = LabelRegistryBuilder::new()
541 .add_labeler(labeler.clone())
542 .build()
543 .unwrap();
544 labeler.changed.store(true, Ordering::Relaxed);
545 let mut original = Resource::new("App::Host", "one").unwrap();
546 registry.apply(&mut original);
547 assert_eq!(
548 original.attributes().get("labels"),
549 Some(&AttrValue::Bool(true))
550 );
551 assert!(!original.attributes().contains_key("other"));
552 let other = Resource::new("Other::Host", "one").unwrap();
553 assert!(registry.apply_to_clone_if_applicable(&other).is_none());
554 }
555
556 struct EchoOwnedOutput(LabelTarget);
557
558 impl Labeler for EchoOwnedOutput {
559 fn target(&self) -> &LabelTarget {
560 &self.0
561 }
562 fn derive(&self, resource: &Resource) -> Option<AttrValue> {
563 resource
564 .attributes()
565 .get(self.target().attribute())
566 .cloned()
567 }
568 }
569
570 struct CopyAttribute {
571 target: LabelTarget,
572 input: &'static str,
573 }
574
575 impl Labeler for CopyAttribute {
576 fn target(&self) -> &LabelTarget {
577 &self.target
578 }
579 fn derive(&self, resource: &Resource) -> Option<AttrValue> {
580 resource.attributes().get(self.input).cloned()
581 }
582 }
583
584 fn compile(rules: Vec<(&str, &str)>) -> Vec<(String, Regex)> {
585 rules
586 .into_iter()
587 .map(|(l, p)| (l.to_string(), Regex::new(p).unwrap()))
588 .collect()
589 }
590
591 fn get_label_strings(res: &mut Resource, key: &str) -> BTreeSet<String> {
592 match res.attrs().get(key) {
593 Some(AttrValue::Set(v)) => v
594 .iter()
595 .filter_map(|a| {
596 if let AttrValue::String(s) = a {
597 Some(s.clone())
598 } else {
599 None
600 }
601 })
602 .collect(),
603 _ => BTreeSet::new(),
604 }
605 }
606
607 #[parameterized(
608 simple_match = {
609 "Host", "name", "nameLabels",
610 vec![("prod", r"(^|\.)prod\.example\.com$")],
611 "db12.prod.example.com",
612 &["prod"]
613 },
614 no_match = {
615 "Host", "name", "nameLabels",
616 vec![("corp", r"(^|\.)corp\.example\.com$")],
617 "web.dev.example.com",
618 &[]
619 },
620 multi_match = {
621 "Host", "name", "nameLabels",
622 vec![("prod", r"(^|\.)prod\."), ("db", r"(^|\.)db\d+\.")],
623 "db42.prod.example.com",
624 &["db","prod"]
625 }
626 )]
627 fn regex_labeler_apply_basic(
628 kind: &str,
629 field: &str,
630 output: &str,
631 rules: Vec<(&str, &str)>,
632 input: &str,
633 expected: &[&str],
634 ) {
635 let labeler = RegexLabeler::new(
636 LabelTarget::new(kind, output).unwrap(),
637 field,
638 compile(rules),
639 )
640 .unwrap();
641
642 let mut res = Resource::new(kind, input).unwrap();
643 res.attrs()
644 .insert(field.to_string(), AttrValue::String(input.to_string()));
645
646 labeler.apply(&mut res);
647
648 let got = get_label_strings(&mut res, output);
649 let want: BTreeSet<String> = expected.iter().map(|s| s.to_string()).collect();
650 assert_eq!(got, want);
651 }
652
653 #[test]
654 fn regex_labeler_missing_input_field_is_noop() {
655 let labeler = RegexLabeler::new(
656 LabelTarget::new("Host", "nameLabels").unwrap(),
657 "name",
658 compile(vec![("prod", r"(^|\.)prod\.")]),
659 )
660 .unwrap();
661
662 let mut res = Resource::new("Host", "db99.prod.example.com").unwrap();
663 labeler.apply(&mut res);
666 assert!(res.attrs().get("nameLabels").is_none());
667 }
668
669 #[test]
670 fn regex_labeler_replaces_untrusted_existing_set() {
671 let labeler = RegexLabeler::new(
672 LabelTarget::new("Host", "nameLabels").unwrap(),
673 "name",
674 compile(vec![("prod", r"(^|\.)prod\."), ("db", r"(^|\.)db\d+\.")]),
675 )
676 .unwrap();
677
678 let mut res = Resource::new("Host", "db99.prod.example.com").unwrap();
679 res.attrs().insert(
680 "name".into(),
681 AttrValue::String("db99.prod.example.com".into()),
682 );
683 res.attrs().insert(
684 "nameLabels".into(),
685 AttrValue::Set(vec![AttrValue::String("pre".into())]),
686 );
687
688 labeler.apply(&mut res);
689
690 let labels = get_label_strings(&mut res, "nameLabels");
691 assert!(!labels.contains("pre"));
692 assert!(labels.contains("prod"));
693 assert!(labels.contains("db"));
694 }
695
696 #[test]
697 fn regex_labeler_replaces_untrusted_set_when_no_rule_matches() {
698 let labeler = RegexLabeler::new(
699 LabelTarget::new("Host", "nameLabels").unwrap(),
700 "name",
701 compile(vec![("prod", r"(^|\.)prod\.")]),
702 )
703 .unwrap();
704 let mut res = Resource::new("Host", "public.example.com")
705 .unwrap()
706 .with_attr("name", AttrValue::String("public.example.com".into()))
707 .with_attr(
708 "nameLabels",
709 AttrValue::Set(vec![AttrValue::String("prod".into())]),
710 );
711
712 labeler.apply(&mut res);
713
714 assert_eq!(
715 res.attributes().get("nameLabels"),
716 Some(&AttrValue::Set(Vec::new()))
717 );
718 }
719
720 #[test]
721 fn regex_labeler_removes_untrusted_output_when_input_is_missing() {
722 let labeler = RegexLabeler::new(
723 LabelTarget::new("Host", "nameLabels").unwrap(),
724 "name",
725 compile(vec![("prod", r"(^|\.)prod\.")]),
726 )
727 .unwrap();
728 let mut res = Resource::new("Host", "public.example.com")
729 .unwrap()
730 .with_attr(
731 "nameLabels",
732 AttrValue::Set(vec![AttrValue::String("prod".into())]),
733 );
734
735 labeler.apply(&mut res);
736
737 assert!(!res.attributes().contains_key("nameLabels"));
738 }
739
740 #[test]
741 fn custom_labeler_cannot_observe_or_preserve_untrusted_owned_output() {
742 let mut resource = Resource::new("Host", "attacker.invalid")
743 .unwrap()
744 .with_attr("labels", AttrValue::String("forged".into()));
745
746 EchoOwnedOutput(LabelTarget::new("Host", "labels").unwrap()).apply(&mut resource);
747
748 assert!(!resource.attributes().contains_key("labels"));
749 }
750
751 #[test]
752 fn registry_preserves_other_types_attributes_without_cloning() {
753 let registry = LabelRegistryBuilder::new()
754 .add_labeler(Arc::new(EchoOwnedOutput(
755 LabelTarget::new("Host", "labels").unwrap(),
756 )))
757 .build()
758 .unwrap();
759 let resource = Resource::new("Document", "report")
760 .unwrap()
761 .with_attr("labels", AttrValue::String("forged".into()));
762
763 assert!(registry.apply_to_clone_if_applicable(&resource).is_none());
764 let mut applied = resource.clone();
765 registry.apply(&mut applied);
766 assert_eq!(applied, resource);
767 assert_eq!(
768 resource.attributes().get("labels"),
769 Some(&AttrValue::String("forged".into()))
770 );
771 }
772
773 #[test]
774 fn registry_clears_later_owned_output_before_earlier_derivation() {
775 let registry = LabelRegistryBuilder::new()
776 .add_labeler(Arc::new(CopyAttribute {
777 target: LabelTarget::new("Host", "labels").unwrap(),
778 input: "laterLabels",
779 }))
780 .add_labeler(Arc::new(CopyAttribute {
781 target: LabelTarget::new("Host", "laterLabels").unwrap(),
782 input: "unused",
783 }))
784 .build()
785 .unwrap();
786 let mut resource = Resource::new("Host", "attacker.invalid")
787 .unwrap()
788 .with_attr("laterLabels", AttrValue::String("forged".into()));
789
790 registry.apply(&mut resource);
791
792 assert!(!resource.attributes().contains_key("labels"));
793 assert!(!resource.attributes().contains_key("laterLabels"));
794 }
795
796 #[test]
797 fn registry_rejects_ambiguous_or_reserved_outputs() {
798 let first = RegexLabeler::new(
799 LabelTarget::new("Host", "labels").unwrap(),
800 "name",
801 Vec::new(),
802 )
803 .unwrap();
804 let second = RegexLabeler::new(
805 LabelTarget::new("Host", "labels").unwrap(),
806 "owner",
807 Vec::new(),
808 )
809 .unwrap();
810 let duplicate = LabelRegistryBuilder::new()
811 .add_labeler(Arc::new(first))
812 .add_labeler(Arc::new(second))
813 .build();
814 assert!(matches!(duplicate, Err(PolicyError::LabelConfigError(_))));
815
816 assert!(LabelTarget::new("Host", "id").is_err());
817 assert!(
818 RegexLabeler::new(
819 LabelTarget::new("Host", "name").unwrap(),
820 "name",
821 Vec::new()
822 )
823 .is_err()
824 );
825 assert!(LabelRegistryBuilder::versioned("").build().is_err());
826 }
827
828 #[test]
829 fn output_validation_preserves_cedar_record_key_semantics() {
830 for output in [
833 "labels",
834 "has space",
835 "hyphen-name",
836 "名前",
837 "quote\"",
838 "a\0b",
839 "id ",
840 ] {
841 assert!(
842 cedar_policy::Context::from_pairs([(
843 output.to_string(),
844 RestrictedExpression::new_bool(true),
845 )])
846 .is_ok()
847 );
848 assert!(validate_output(output).is_ok(), "{output:?}");
849 assert!(
850 RegexLabeler::new(
851 LabelTarget::new("Host", output).unwrap(),
852 "name",
853 Vec::new()
854 )
855 .is_ok()
856 );
857 }
858 for output in ["", " ", "\t\n", "id"] {
859 assert!(matches!(
860 validate_output(output),
861 Err(PolicyError::LabelConfigError(_))
862 ));
863 }
864 }
865
866 #[test]
867 fn controlled_apply_is_idempotent() {
868 let labeler = RegexLabeler::new(
869 LabelTarget::new("Host", "labels").unwrap(),
870 "name",
871 compile(vec![("prod", "prod")]),
872 )
873 .unwrap();
874 let mut resource = Resource::new("Host", "prod")
875 .unwrap()
876 .with_attr("name", AttrValue::String("prod".into()))
877 .with_attr(
878 "labels",
879 AttrValue::Set(vec![AttrValue::String("forged".into())]),
880 );
881
882 labeler.apply(&mut resource);
883 let once = resource.clone();
884 labeler.apply(&mut resource);
885 assert_eq!(resource, once);
886 }
887}