Skip to main content

treetop_bundle/
labels.rs

1use crate::{BundleError, Diagnostic, Result};
2use cedar_policy::{EntityTypeName, Schema};
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashSet;
7use std::sync::Arc;
8use treetop_core::{Labeler, RegexLabeler};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12struct RawLabelPattern {
13    name: String,
14    regex: String,
15}
16
17/// A validated regular-expression label mapping.
18#[derive(Debug, Clone, Serialize)]
19pub struct LabelPattern {
20    name: String,
21    regex: String,
22    #[serde(skip)]
23    compiled: Regex,
24}
25
26impl PartialEq for LabelPattern {
27    fn eq(&self, other: &Self) -> bool {
28        self.name == other.name && self.regex == other.regex
29    }
30}
31
32impl Eq for LabelPattern {}
33
34impl LabelPattern {
35    pub fn name(&self) -> &str {
36        &self.name
37    }
38
39    pub fn regex(&self) -> &str {
40        &self.regex
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46struct RawLabelRule {
47    kind: String,
48    field: String,
49    output: String,
50    patterns: Vec<RawLabelPattern>,
51}
52
53/// A validated label rule.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct LabelRule {
56    kind: String,
57    field: String,
58    output: String,
59    patterns: Vec<LabelPattern>,
60}
61
62impl LabelRule {
63    pub fn kind(&self) -> &str {
64        &self.kind
65    }
66
67    pub fn field(&self) -> &str {
68        &self.field
69    }
70
71    pub fn output(&self) -> &str {
72        &self.output
73    }
74
75    pub fn patterns(&self) -> &[LabelPattern] {
76        &self.patterns
77    }
78}
79
80/// A validated set of label rules.
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
82#[serde(transparent)]
83pub struct LabelSet(Vec<LabelRule>);
84
85impl LabelSet {
86    /// Parse and strictly validate a JSON label document.
87    pub fn from_json_str(input: &str) -> Result<Self> {
88        let raw: Vec<RawLabelRule> = serde_json::from_str(input).map_err(|error| {
89            let mut diagnostic = Diagnostic::error("labels.invalid_json", error.to_string());
90            diagnostic.line = Some(error.line());
91            diagnostic.column = Some(error.column());
92            BundleError::Validation(vec![diagnostic])
93        })?;
94        Self::from_raw(raw)
95    }
96
97    /// Validate label entity and attribute types against a complete Cedar JSON schema.
98    pub fn validate_schema_json_str(&self, schema_source: &str) -> Result<()> {
99        let schema_json: Value = serde_json::from_str(schema_source).map_err(|error| {
100            BundleError::Validation(vec![Diagnostic::error(
101                "schema.invalid_json",
102                error.to_string(),
103            )])
104        })?;
105        let schema = Schema::from_json_value(schema_json.clone()).map_err(|error| {
106            BundleError::Validation(vec![Diagnostic::error(
107                "schema.aggregate_invalid",
108                error.to_string(),
109            )])
110        })?;
111        let diagnostics = self.validate_schema(&schema, &schema_json);
112        if diagnostics.is_empty() {
113            Ok(())
114        } else {
115            Err(BundleError::Validation(diagnostics))
116        }
117    }
118
119    fn from_raw(raw: Vec<RawLabelRule>) -> Result<Self> {
120        let mut diagnostics = Vec::new();
121        let mut destinations = HashSet::new();
122        let mut rules = Vec::with_capacity(raw.len());
123
124        for (rule_index, raw_rule) in raw.into_iter().enumerate() {
125            let location = format!("labels[{rule_index}]");
126            if raw_rule.kind.trim().is_empty() {
127                diagnostics.push(Diagnostic::error(
128                    "labels.empty_kind",
129                    format!("{location}.kind must not be empty"),
130                ));
131            } else if raw_rule.kind.parse::<EntityTypeName>().is_err() {
132                diagnostics.push(Diagnostic::error(
133                    "labels.invalid_kind",
134                    format!("{location}.kind is not a Cedar entity type"),
135                ));
136            }
137            if raw_rule.field.trim().is_empty() {
138                diagnostics.push(Diagnostic::error(
139                    "labels.empty_field",
140                    format!("{location}.field must not be empty"),
141                ));
142            }
143            if raw_rule.output.trim().is_empty() {
144                diagnostics.push(Diagnostic::error(
145                    "labels.empty_output",
146                    format!("{location}.output must not be empty"),
147                ));
148            }
149            if raw_rule.field == raw_rule.output {
150                diagnostics.push(Diagnostic::error(
151                    "labels.input_is_output",
152                    format!("{location}.field and output must be different"),
153                ));
154            }
155            if !destinations.insert((raw_rule.kind.clone(), raw_rule.output.clone())) {
156                diagnostics.push(Diagnostic::error(
157                    "labels.duplicate_destination",
158                    format!(
159                        "duplicate label destination ({}, {})",
160                        raw_rule.kind, raw_rule.output
161                    ),
162                ));
163            }
164            if raw_rule.patterns.is_empty() {
165                diagnostics.push(Diagnostic::error(
166                    "labels.empty_patterns",
167                    format!("{location}.patterns must not be empty"),
168                ));
169            }
170
171            let mut names = HashSet::new();
172            let mut patterns = Vec::with_capacity(raw_rule.patterns.len());
173            for (pattern_index, raw_pattern) in raw_rule.patterns.into_iter().enumerate() {
174                let pattern_location = format!("{location}.patterns[{pattern_index}]");
175                if raw_pattern.name.trim().is_empty() {
176                    diagnostics.push(Diagnostic::error(
177                        "labels.empty_pattern_name",
178                        format!("{pattern_location}.name must not be empty"),
179                    ));
180                }
181                if !names.insert(raw_pattern.name.clone()) {
182                    diagnostics.push(Diagnostic::error(
183                        "labels.duplicate_pattern_name",
184                        format!(
185                            "duplicate pattern name {:?} in {location}",
186                            raw_pattern.name
187                        ),
188                    ));
189                }
190                let compiled = if raw_pattern.regex.is_empty() {
191                    diagnostics.push(Diagnostic::error(
192                        "labels.empty_regex",
193                        format!("{pattern_location}.regex must not be empty"),
194                    ));
195                    None
196                } else {
197                    match Regex::new(&raw_pattern.regex) {
198                        Ok(regex) => Some(regex),
199                        Err(error) => {
200                            diagnostics.push(Diagnostic::error(
201                                "labels.invalid_regex",
202                                format!("{pattern_location}.regex is invalid: {error}"),
203                            ));
204                            None
205                        }
206                    }
207                };
208                if let Some(compiled) = compiled {
209                    patterns.push(LabelPattern {
210                        name: raw_pattern.name,
211                        regex: raw_pattern.regex,
212                        compiled,
213                    });
214                }
215            }
216
217            rules.push(LabelRule {
218                kind: raw_rule.kind,
219                field: raw_rule.field,
220                output: raw_rule.output,
221                patterns,
222            });
223        }
224
225        if diagnostics.is_empty() {
226            Ok(Self(rules))
227        } else {
228            Err(BundleError::Validation(diagnostics))
229        }
230    }
231
232    pub(crate) fn combine(sets: impl IntoIterator<Item = Self>) -> Result<Self> {
233        let rules = sets.into_iter().flat_map(|set| set.0).collect::<Vec<_>>();
234        let mut destinations = HashSet::with_capacity(rules.len());
235        let mut diagnostics = Vec::new();
236        for rule in &rules {
237            if !destinations.insert((&rule.kind, &rule.output)) {
238                diagnostics.push(Diagnostic::error(
239                    "labels.duplicate_destination",
240                    format!(
241                        "duplicate label destination ({}, {})",
242                        rule.kind, rule.output
243                    ),
244                ));
245            }
246        }
247        if diagnostics.is_empty() {
248            Ok(Self(rules))
249        } else {
250            Err(BundleError::Validation(diagnostics))
251        }
252    }
253
254    pub fn rules(&self) -> &[LabelRule] {
255        &self.0
256    }
257
258    pub fn is_empty(&self) -> bool {
259        self.0.is_empty()
260    }
261
262    /// Convert this validated set into Treetop's existing runtime labelers.
263    pub fn to_labelers(&self) -> Vec<Arc<dyn Labeler>> {
264        self.0
265            .iter()
266            .map(|rule| {
267                let patterns = rule
268                    .patterns
269                    .iter()
270                    .map(|pattern| (pattern.name.clone(), pattern.compiled.clone()))
271                    .collect();
272                Arc::new(RegexLabeler::new(
273                    rule.kind.clone(),
274                    rule.field.clone(),
275                    rule.output.clone(),
276                    patterns,
277                )) as Arc<dyn Labeler>
278            })
279            .collect()
280    }
281
282    pub(crate) fn validate_schema(&self, schema: &Schema, schema_json: &Value) -> Vec<Diagnostic> {
283        let known_types = schema
284            .entity_types()
285            .map(ToString::to_string)
286            .collect::<HashSet<_>>();
287        let mut diagnostics = Vec::new();
288        for rule in &self.0 {
289            if !known_types.contains(&rule.kind) {
290                diagnostics.push(Diagnostic::error(
291                    "labels.unknown_kind",
292                    format!("label kind {} is not declared in the schema", rule.kind),
293                ));
294                continue;
295            }
296            let Some(attributes) = entity_attributes(schema_json, &rule.kind) else {
297                diagnostics.push(Diagnostic::error(
298                    "labels.missing_shape",
299                    format!("label kind {} has no record shape", rule.kind),
300                ));
301                continue;
302            };
303            match attributes.get(&rule.field) {
304                Some(value) if is_string_type(value) => {}
305                Some(value) => diagnostics.push(Diagnostic::error(
306                    "labels.field_not_string",
307                    format!(
308                        "{}.{} must have schema type String, found {value}",
309                        rule.kind, rule.field
310                    ),
311                )),
312                None => diagnostics.push(Diagnostic::error(
313                    "labels.field_missing",
314                    format!("{}.{} is not declared in the schema", rule.kind, rule.field),
315                )),
316            }
317            match attributes.get(&rule.output) {
318                Some(value) if is_string_set_type(value) => {}
319                Some(value) => diagnostics.push(Diagnostic::error(
320                    "labels.output_not_string_set",
321                    format!(
322                        "{}.{} must have schema type Set<String>, found {value}",
323                        rule.kind, rule.output,
324                    ),
325                )),
326                None => diagnostics.push(Diagnostic::error(
327                    "labels.output_missing",
328                    format!(
329                        "{}.{} is not declared in the schema",
330                        rule.kind, rule.output
331                    ),
332                )),
333            }
334        }
335        diagnostics
336    }
337}
338
339fn entity_attributes<'a>(
340    schema_json: &'a Value,
341    kind: &str,
342) -> Option<&'a serde_json::Map<String, Value>> {
343    let parsed = kind.parse::<EntityTypeName>().ok()?;
344    let namespace = parsed.namespace().to_string();
345    let namespace_definition = schema_json.as_object()?.get(&namespace)?;
346    let definition = namespace_definition
347        .get("entityTypes")?
348        .get(parsed.basename())?;
349    record_attributes(
350        schema_json,
351        &namespace,
352        definition.get("shape")?,
353        &mut HashSet::new(),
354    )
355}
356
357fn record_attributes<'a>(
358    schema_json: &'a Value,
359    namespace: &str,
360    shape: &'a Value,
361    visited: &mut HashSet<String>,
362) -> Option<&'a serde_json::Map<String, Value>> {
363    if shape.get("type").and_then(Value::as_str) == Some("Record") {
364        return shape.get("attributes")?.as_object();
365    }
366    if shape.get("type").and_then(Value::as_str) != Some("EntityOrCommon") {
367        return None;
368    }
369    let name = shape.get("name")?.as_str()?;
370    let (common_namespace, basename) = name
371        .rsplit_once("::")
372        .map_or((namespace, name), |(namespace, basename)| {
373            (namespace, basename)
374        });
375    let qualified_name = format!("{common_namespace}::{basename}");
376    if !visited.insert(qualified_name) {
377        return None;
378    }
379    let common = schema_json
380        .as_object()?
381        .get(common_namespace)?
382        .get("commonTypes")?
383        .get(basename)?;
384    record_attributes(schema_json, common_namespace, common, visited)
385}
386
387fn is_string_type(value: &Value) -> bool {
388    value.get("type").and_then(Value::as_str) == Some("String")
389        || (value.get("type").and_then(Value::as_str) == Some("EntityOrCommon")
390            && value.get("name").and_then(Value::as_str) == Some("String"))
391}
392
393fn is_string_set_type(value: &Value) -> bool {
394    value.get("type").and_then(Value::as_str) == Some("Set")
395        && value.get("element").is_some_and(is_string_type)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn strict_label_validation_rejects_unknown_fields() {
404        let error = LabelSet::from_json_str(
405            r#"[{"kind":"App::Host","field":"name","output":"labels","patterns":[{"name":"prod","regex":"prod","extra":true}]}]"#,
406        )
407        .unwrap_err();
408        assert!(error.diagnostics()[0].message.contains("unknown field"));
409    }
410
411    #[test]
412    fn label_set_converts_to_runtime_labelers() {
413        let labels = LabelSet::from_json_str(
414            r#"[{"kind":"App::Host","field":"name","output":"labels","patterns":[{"name":"prod","regex":"^prod"}]}]"#,
415        )
416        .unwrap();
417        assert_eq!(labels.to_labelers().len(), 1);
418    }
419
420    #[test]
421    fn schema_validation_resolves_common_record_shapes() {
422        let labels = LabelSet::from_json_str(
423            r#"[{"kind":"App::Host","field":"name","output":"labels","patterns":[{"name":"prod","regex":"^prod"}]}]"#,
424        )
425        .unwrap();
426        let schema = r#"{
427          "App": {
428            "commonTypes": {
429              "HostShape": {
430                "type": "Record",
431                "attributes": {
432                  "name": {"type": "String", "required": true},
433                  "labels": {
434                    "type": "Set",
435                    "element": {"type": "String"},
436                    "required": false
437                  }
438                },
439                "additionalAttributes": false
440              }
441            },
442            "entityTypes": {
443              "Host": {
444                "shape": {"type": "EntityOrCommon", "name": "HostShape"}
445              }
446            },
447            "actions": {}
448          }
449        }"#;
450
451        labels.validate_schema_json_str(schema).unwrap();
452    }
453}