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