Skip to main content

treetop_bundle/
validation.rs

1use crate::manifest::namespace_owns;
2use crate::{
3    BundleError, BundleManifest, Diagnostic, DiagnosticSeverity, LabelSet, ModuleManifest,
4    ModuleRole, Result,
5};
6use cedar_policy::pst::{
7    ActionConstraint as PstActionConstraint, Clause, EntityOrSlot, Expr, Literal,
8    PrincipalConstraint as PstPrincipalConstraint, ResourceConstraint as PstResourceConstraint,
9};
10use cedar_policy::{Policy, PolicySet, Schema, SchemaFragment, ValidationMode, Validator};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13use std::collections::{BTreeMap, BTreeSet};
14use std::fs;
15use std::path::Path;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub(crate) struct ModuleRecord {
20    pub name: String,
21    pub namespace: String,
22    pub imports: Vec<String>,
23    pub role: ModuleRole,
24    pub policy_ids: Vec<String>,
25}
26
27#[derive(Debug)]
28pub(crate) struct BundleParts {
29    pub name: String,
30    pub modules: Vec<ModuleRecord>,
31    pub policies: String,
32    pub schema_json: Option<Value>,
33    pub labels: LabelSet,
34    pub policy_ids: Vec<String>,
35    pub diagnostics: Vec<Diagnostic>,
36}
37
38/// Result of checking a standalone Cedar policy document.
39#[derive(Debug, Clone, Serialize)]
40pub struct PolicyCheck {
41    pub(crate) diagnostics: Vec<Diagnostic>,
42}
43
44impl PolicyCheck {
45    pub fn diagnostics(&self) -> &[Diagnostic] {
46        &self.diagnostics
47    }
48
49    pub fn is_valid(&self, deny_warnings: bool) -> bool {
50        !self.diagnostics.iter().any(|diagnostic| {
51            diagnostic.severity == DiagnosticSeverity::Error
52                || (deny_warnings && diagnostic.severity == DiagnosticSeverity::Warning)
53        })
54    }
55}
56
57/// Check policy syntax and optionally validate it against a complete schema and labels.
58pub fn check_policy(
59    policy_source: &str,
60    schema_source: Option<&str>,
61    labels_source: Option<&str>,
62) -> Result<PolicyCheck> {
63    let mut diagnostics = Vec::new();
64    let policy_set = match policy_source.parse::<PolicySet>() {
65        Ok(policy_set) => Some(policy_set),
66        Err(error) => {
67            diagnostics.push(Diagnostic::error("policy.syntax", error.to_string()));
68            None
69        }
70    };
71    let labels = match labels_source {
72        Some(source) => match LabelSet::from_json_str(source) {
73            Ok(labels) => Some(labels),
74            Err(BundleError::Validation(mut label_diagnostics)) => {
75                diagnostics.append(&mut label_diagnostics);
76                None
77            }
78            Err(error) => return Err(error),
79        },
80        None => None,
81    };
82    let schema = match schema_source {
83        Some(source) => match parse_schema_fragment(source) {
84            Ok((fragment, schema_warnings)) => {
85                diagnostics.extend(schema_warnings);
86                match Schema::from_schema_fragments([fragment.clone()]) {
87                    Ok(schema) => {
88                        let json = fragment
89                            .to_json_value()
90                            .map_err(|error| BundleError::Serialization(error.to_string()))?;
91                        Some((schema, json))
92                    }
93                    Err(error) => {
94                        diagnostics.push(Diagnostic::error("schema.invalid", error.to_string()));
95                        None
96                    }
97                }
98            }
99            Err(error) => {
100                diagnostics.push(Diagnostic::error("schema.syntax", error));
101                None
102            }
103        },
104        None => None,
105    };
106
107    if let (Some(policy_set), Some((schema, _))) = (&policy_set, &schema) {
108        validate_policy_set(policy_set, schema, &mut diagnostics);
109    }
110    if let (Some(labels), Some((schema, schema_json))) = (&labels, &schema) {
111        diagnostics.extend(labels.validate_schema(schema, schema_json));
112    } else if labels.is_some() && schema.is_none() {
113        diagnostics.push(Diagnostic::warning(
114            "labels.schema_check_skipped",
115            "label/schema compatibility was not checked because no schema was provided",
116        ));
117    }
118
119    Ok(PolicyCheck { diagnostics })
120}
121
122/// Check one module in isolation. Declared imports are accepted but need not be present.
123pub fn check_module(path: impl AsRef<Path>) -> Result<PolicyCheck> {
124    let module = ModuleManifest::from_path(path)?;
125    let manifest = BundleManifest::for_single_module(module);
126    let parts = compile_manifest(&manifest)?;
127    Ok(PolicyCheck {
128        diagnostics: parts.diagnostics,
129    })
130}
131
132pub(crate) fn compile_manifest(manifest: &BundleManifest) -> Result<BundleParts> {
133    let mut diagnostics = Vec::new();
134    let mut policy_text = String::new();
135    let mut policy_ids = BTreeSet::new();
136    let mut modules = Vec::with_capacity(manifest.modules().len());
137    let mut schema_json = Value::Object(Map::new());
138    let mut has_schema = false;
139    let mut label_sets = Vec::new();
140
141    for selected in manifest.modules() {
142        let module = selected.manifest();
143        let mut module_record = ModuleRecord {
144            name: module.name().to_string(),
145            namespace: module.namespace().to_string(),
146            imports: module.imports().to_vec(),
147            role: selected.role(),
148            policy_ids: Vec::new(),
149        };
150        let mut module_policy_ids = Vec::new();
151        for relative_path in module.policies() {
152            let path = module.input_path(relative_path);
153            let source = read_utf8(&path)?;
154            let normalized = normalize_text(&source);
155            let parsed = match normalized.parse::<PolicySet>() {
156                Ok(parsed) => parsed,
157                Err(error) => {
158                    diagnostics.push(
159                        Diagnostic::error("policy.syntax", error.to_string())
160                            .in_module(module.name())
161                            .at_path(relative_path),
162                    );
163                    continue;
164                }
165            };
166            validate_module_policies(
167                &parsed,
168                &module_record,
169                relative_path,
170                &mut module_policy_ids,
171                &mut policy_ids,
172                &mut diagnostics,
173            );
174            policy_text.push_str("// treetop-module: ");
175            policy_text.push_str(&single_line(module.name()));
176            policy_text.push_str("; path: ");
177            policy_text.push_str(&single_line(relative_path));
178            policy_text.push('\n');
179            policy_text.push_str(&normalized);
180        }
181
182        for relative_path in module.schemas() {
183            has_schema = true;
184            let path = module.input_path(relative_path);
185            let source = read_utf8(&path)?;
186            match parse_schema_fragment(&source) {
187                Ok((fragment, warnings)) => {
188                    diagnostics.extend(warnings.into_iter().map(|diagnostic| {
189                        diagnostic.in_module(module.name()).at_path(relative_path)
190                    }));
191                    let fragment_json = fragment
192                        .to_json_value()
193                        .map_err(|error| BundleError::Serialization(error.to_string()))?;
194                    validate_schema_ownership(
195                        &fragment_json,
196                        module.name(),
197                        module.namespace(),
198                        relative_path,
199                        &mut diagnostics,
200                    );
201                    merge_schema_fragment(
202                        &mut schema_json,
203                        fragment_json,
204                        module.name(),
205                        relative_path,
206                        &mut diagnostics,
207                    );
208                }
209                Err(error) => diagnostics.push(
210                    Diagnostic::error("schema.syntax", error)
211                        .in_module(module.name())
212                        .at_path(relative_path),
213                ),
214            }
215        }
216
217        for relative_path in module.labels() {
218            let path = module.input_path(relative_path);
219            let source = read_utf8(&path)?;
220            match LabelSet::from_json_str(&source) {
221                Ok(labels) => {
222                    for rule in labels.rules() {
223                        if !namespace_owns(module.namespace(), rule.kind()) {
224                            diagnostics.push(
225                                Diagnostic::error(
226                                    "labels.namespace_violation",
227                                    format!(
228                                        "label kind {} is outside namespace {}",
229                                        rule.kind(),
230                                        module.namespace()
231                                    ),
232                                )
233                                .in_module(module.name())
234                                .at_path(relative_path),
235                            );
236                        }
237                    }
238                    label_sets.push(labels);
239                }
240                Err(BundleError::Validation(label_diagnostics)) => {
241                    diagnostics.extend(label_diagnostics.into_iter().map(|diagnostic| {
242                        diagnostic.in_module(module.name()).at_path(relative_path)
243                    }));
244                }
245                Err(error) => return Err(error),
246            }
247        }
248
249        module_record.policy_ids = module_policy_ids;
250        modules.push(module_record);
251    }
252
253    let labels = match LabelSet::combine(label_sets) {
254        Ok(labels) => labels,
255        Err(BundleError::Validation(mut label_diagnostics)) => {
256            diagnostics.append(&mut label_diagnostics);
257            LabelSet::default()
258        }
259        Err(error) => return Err(error),
260    };
261
262    let aggregate_policy_set = match policy_text.parse::<PolicySet>() {
263        Ok(policy_set) => Some(policy_set),
264        Err(error) => {
265            diagnostics.push(Diagnostic::error(
266                "policy.aggregate_syntax",
267                error.to_string(),
268            ));
269            None
270        }
271    };
272
273    let schema_json = if has_schema {
274        match Schema::from_json_value(schema_json.clone()) {
275            Ok(schema) => {
276                if let Some(policy_set) = &aggregate_policy_set {
277                    validate_policy_set(policy_set, &schema, &mut diagnostics);
278                }
279                diagnostics.extend(labels.validate_schema(&schema, &schema_json));
280                Some(schema_json)
281            }
282            Err(error) => {
283                diagnostics.push(Diagnostic::error(
284                    "schema.aggregate_invalid",
285                    error.to_string(),
286                ));
287                None
288            }
289        }
290    } else {
291        diagnostics.push(Diagnostic::warning(
292            "schema.compatibility_skipped",
293            "policy and label schema compatibility checks were skipped because the bundle has no schema",
294        ));
295        None
296    };
297
298    if diagnostics
299        .iter()
300        .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
301    {
302        return Err(BundleError::Validation(diagnostics));
303    }
304
305    Ok(BundleParts {
306        name: manifest.name().to_string(),
307        modules,
308        policies: policy_text,
309        schema_json,
310        labels,
311        policy_ids: policy_ids.into_iter().collect(),
312        diagnostics,
313    })
314}
315
316pub(crate) fn validate_archive_parts(
317    name: String,
318    modules: Vec<ModuleRecord>,
319    policies: String,
320    schema_json: Option<Value>,
321    labels: LabelSet,
322    declared_policy_ids: &[String],
323) -> Result<BundleParts> {
324    let mut diagnostics = Vec::new();
325    let policy_set = policies.parse::<PolicySet>().map_err(|error| {
326        BundleError::Validation(vec![Diagnostic::error("policy.syntax", error.to_string())])
327    })?;
328    if policy_set.num_of_templates() != 0 || policy_set.policies().any(|policy| !policy.is_static())
329    {
330        diagnostics.push(Diagnostic::error(
331            "policy.templates_unsupported",
332            "deployable bundles may contain only static policies",
333        ));
334    }
335
336    let mut actual_ids = BTreeSet::new();
337    let module_by_policy = modules
338        .iter()
339        .flat_map(|module| {
340            module
341                .policy_ids
342                .iter()
343                .map(move |policy_id| (policy_id.as_str(), module))
344        })
345        .collect::<BTreeMap<_, _>>();
346    for policy in policy_set.policies() {
347        let Some(id) = policy.annotation("id").filter(|id| !id.is_empty()) else {
348            diagnostics.push(Diagnostic::error(
349                "policy.missing_id",
350                "every bundled policy requires a non-empty @id annotation",
351            ));
352            continue;
353        };
354        if !actual_ids.insert(id.to_string()) {
355            diagnostics.push(Diagnostic::error(
356                "policy.duplicate_id",
357                format!("duplicate policy @id {id:?}"),
358            ));
359        }
360        let Some(module) = module_by_policy.get(id) else {
361            diagnostics.push(Diagnostic::error(
362                "archive.policy_module_missing",
363                format!("policy {id:?} is not assigned to a module"),
364            ));
365            continue;
366        };
367        if !id.starts_with(&format!("{}.", module.name)) {
368            diagnostics.push(Diagnostic::warning(
369                "policy.id_prefix",
370                format!(
371                    "policy @id {id:?} should start with {:?} followed by '.'",
372                    module.name
373                ),
374            ));
375        }
376        validate_policy_ownership(policy, id, module, &mut diagnostics);
377    }
378
379    let declared = declared_policy_ids.iter().cloned().collect::<BTreeSet<_>>();
380    if actual_ids != declared {
381        diagnostics.push(Diagnostic::error(
382            "archive.policy_ids_mismatch",
383            "manifest policy IDs do not match policies.cedar",
384        ));
385    }
386
387    if let Some(schema_json) = &schema_json {
388        validate_aggregate_schema_ownership(schema_json, &modules, &mut diagnostics);
389        match Schema::from_json_value(schema_json.clone()) {
390            Ok(schema) => {
391                validate_policy_set(&policy_set, &schema, &mut diagnostics);
392                diagnostics.extend(labels.validate_schema(&schema, schema_json));
393            }
394            Err(error) => diagnostics.push(Diagnostic::error(
395                "schema.aggregate_invalid",
396                error.to_string(),
397            )),
398        }
399    } else {
400        diagnostics.push(Diagnostic::warning(
401            "schema.compatibility_skipped",
402            "policy and label schema compatibility checks were skipped because the bundle has no schema",
403        ));
404    }
405    for rule in labels.rules() {
406        if !modules
407            .iter()
408            .any(|module| namespace_owns(&module.namespace, rule.kind()))
409        {
410            diagnostics.push(Diagnostic::error(
411                "labels.namespace_violation",
412                format!("label kind {} is not owned by any module", rule.kind()),
413            ));
414        }
415    }
416
417    if diagnostics
418        .iter()
419        .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
420    {
421        Err(BundleError::Validation(diagnostics))
422    } else {
423        Ok(BundleParts {
424            name,
425            modules,
426            policies,
427            schema_json,
428            labels,
429            policy_ids: actual_ids.into_iter().collect(),
430            diagnostics,
431        })
432    }
433}
434
435fn validate_module_policies(
436    policy_set: &PolicySet,
437    module: &ModuleRecord,
438    relative_path: &str,
439    module_policy_ids: &mut Vec<String>,
440    all_policy_ids: &mut BTreeSet<String>,
441    diagnostics: &mut Vec<Diagnostic>,
442) {
443    if policy_set.num_of_templates() != 0 || policy_set.policies().any(|policy| !policy.is_static())
444    {
445        diagnostics.push(
446            Diagnostic::error(
447                "policy.templates_unsupported",
448                "deployable bundles may contain only static policies",
449            )
450            .in_module(&module.name)
451            .at_path(relative_path),
452        );
453    }
454    for policy in policy_set.policies() {
455        let Some(id) = policy.annotation("id").filter(|id| !id.is_empty()) else {
456            diagnostics.push(
457                Diagnostic::error(
458                    "policy.missing_id",
459                    "every bundled policy requires a non-empty @id annotation",
460                )
461                .in_module(&module.name)
462                .at_path(relative_path),
463            );
464            continue;
465        };
466        if !all_policy_ids.insert(id.to_string()) {
467            diagnostics.push(
468                Diagnostic::error(
469                    "policy.duplicate_id",
470                    format!("duplicate policy @id {id:?}"),
471                )
472                .in_module(&module.name)
473                .at_path(relative_path),
474            );
475        }
476        module_policy_ids.push(id.to_string());
477        if !id.starts_with(&format!("{}.", module.name)) {
478            diagnostics.push(
479                Diagnostic::warning(
480                    "policy.id_prefix",
481                    format!(
482                        "policy @id {id:?} should start with {:?} followed by '.'",
483                        module.name
484                    ),
485                )
486                .in_module(&module.name)
487                .at_path(relative_path),
488            );
489        }
490        let start = diagnostics.len();
491        validate_policy_ownership(policy, id, module, diagnostics);
492        for diagnostic in &mut diagnostics[start..] {
493            diagnostic.module = Some(module.name.clone());
494            diagnostic.path = Some(relative_path.to_string());
495        }
496    }
497}
498
499fn validate_policy_ownership(
500    policy: &Policy,
501    policy_id: &str,
502    module: &ModuleRecord,
503    diagnostics: &mut Vec<Diagnostic>,
504) {
505    let pst = match policy.to_pst() {
506        Ok(pst) => pst,
507        Err(error) => {
508            diagnostics.push(Diagnostic::error(
509                "policy.structured_representation",
510                format!("policy {policy_id:?} cannot be represented structurally: {error}"),
511            ));
512            return;
513        }
514    };
515    let body = pst.body();
516    if module.role == ModuleRole::Global {
517        return;
518    }
519    match &body.action {
520        PstActionConstraint::Any => diagnostics.push(Diagnostic::error(
521            "policy.action_unconstrained",
522            format!("ordinary policy {policy_id:?} must constrain its actions"),
523        )),
524        PstActionConstraint::Eq(uid) => {
525            check_owned_action(&uid.ty.to_string(), policy_id, module, diagnostics)
526        }
527        PstActionConstraint::In(uids) => {
528            for uid in uids {
529                check_owned_action(&uid.ty.to_string(), policy_id, module, diagnostics);
530            }
531        }
532    }
533
534    let mut references = Vec::new();
535    match &body.principal {
536        PstPrincipalConstraint::Any => {}
537        PstPrincipalConstraint::Eq(value) | PstPrincipalConstraint::In(value) => {
538            collect_entity_or_slot(value, &mut references)
539        }
540        PstPrincipalConstraint::Is(entity_type) => {
541            references.push(entity_type.to_string());
542        }
543        PstPrincipalConstraint::IsIn(entity_type, value) => {
544            references.push(entity_type.to_string());
545            collect_entity_or_slot(value, &mut references);
546        }
547    }
548    match &body.resource {
549        PstResourceConstraint::Any => {}
550        PstResourceConstraint::Eq(value) | PstResourceConstraint::In(value) => {
551            collect_entity_or_slot(value, &mut references)
552        }
553        PstResourceConstraint::Is(entity_type) => references.push(entity_type.to_string()),
554        PstResourceConstraint::IsIn(entity_type, value) => {
555            references.push(entity_type.to_string());
556            collect_entity_or_slot(value, &mut references);
557        }
558    }
559    match &body.action {
560        PstActionConstraint::Any => {}
561        PstActionConstraint::Eq(uid) => references.push(uid.ty.to_string()),
562        PstActionConstraint::In(uids) => {
563            references.extend(uids.iter().map(|uid| uid.ty.to_string()));
564        }
565    }
566    for clause in body.clauses() {
567        let expression = match clause {
568            Clause::When(expression) | Clause::Unless(expression) => expression,
569        };
570        collect_expr_references(expression, &mut references);
571    }
572    for reference in references {
573        if !namespace_owns(&module.namespace, &reference)
574            && !module
575                .imports
576                .iter()
577                .any(|import| namespace_owns(import, &reference))
578        {
579            diagnostics.push(Diagnostic::error(
580                "policy.namespace_violation",
581                format!(
582                    "policy {policy_id:?} references {reference}, outside namespace {} and its imports",
583                    module.namespace
584                ),
585            ));
586        }
587    }
588}
589
590fn check_owned_action(
591    entity_type: &str,
592    policy_id: &str,
593    module: &ModuleRecord,
594    diagnostics: &mut Vec<Diagnostic>,
595) {
596    if !namespace_owns(&module.namespace, entity_type) {
597        diagnostics.push(Diagnostic::error(
598            "policy.action_namespace_violation",
599            format!(
600                "ordinary policy {policy_id:?} constrains action type {entity_type} outside namespace {}",
601                module.namespace
602            ),
603        ));
604    }
605}
606
607fn collect_entity_or_slot(value: &EntityOrSlot, references: &mut Vec<String>) {
608    if let EntityOrSlot::Entity(uid) = value {
609        references.push(uid.ty.to_string());
610    }
611}
612
613fn collect_expr_references(expression: &Expr, references: &mut Vec<String>) {
614    match expression {
615        Expr::Literal(Literal::EntityUID(uid)) => references.push(uid.ty.to_string()),
616        Expr::UnaryOp { expr, .. }
617        | Expr::GetAttr { expr, .. }
618        | Expr::HasAttr { expr, .. }
619        | Expr::Like { expr, .. } => collect_expr_references(expr, references),
620        Expr::BinaryOp { left, right, .. } => {
621            collect_expr_references(left, references);
622            collect_expr_references(right, references);
623        }
624        Expr::Is {
625            expr,
626            entity_type,
627            in_expr,
628        } => {
629            references.push(entity_type.to_string());
630            collect_expr_references(expr, references);
631            if let Some(in_expr) = in_expr {
632                collect_expr_references(in_expr, references);
633            }
634        }
635        Expr::IfThenElse {
636            cond,
637            then_expr,
638            else_expr,
639        } => {
640            collect_expr_references(cond, references);
641            collect_expr_references(then_expr, references);
642            collect_expr_references(else_expr, references);
643        }
644        Expr::Set(expressions) => {
645            for expression in expressions {
646                collect_expr_references(expression, references);
647            }
648        }
649        Expr::Record(expressions) => {
650            for expression in expressions.values() {
651                collect_expr_references(expression, references);
652            }
653        }
654        _ => {}
655    }
656}
657
658fn parse_schema_fragment(
659    source: &str,
660) -> std::result::Result<(SchemaFragment, Vec<Diagnostic>), String> {
661    let trimmed = source.trim_start();
662    if trimmed.starts_with('{') {
663        SchemaFragment::from_json_str(source)
664            .map(|fragment| (fragment, Vec::new()))
665            .map_err(|error| error.to_string())
666    } else {
667        SchemaFragment::from_cedarschema_str(source)
668            .map(|(fragment, warnings)| {
669                (
670                    fragment,
671                    warnings
672                        .map(|warning| Diagnostic::warning("schema.warning", warning.to_string()))
673                        .collect(),
674                )
675            })
676            .map_err(|error| error.to_string())
677    }
678}
679
680fn validate_policy_set(policy_set: &PolicySet, schema: &Schema, diagnostics: &mut Vec<Diagnostic>) {
681    let result = Validator::new(schema.clone()).validate(policy_set, ValidationMode::Strict);
682    diagnostics.extend(
683        result
684            .validation_errors()
685            .map(|error| Diagnostic::error("policy.schema_validation", error.to_string())),
686    );
687    diagnostics.extend(
688        result
689            .validation_warnings()
690            .map(|warning| Diagnostic::warning("policy.schema_warning", warning.to_string())),
691    );
692}
693
694fn validate_schema_ownership(
695    schema_json: &Value,
696    module_name: &str,
697    namespace: &str,
698    relative_path: &str,
699    diagnostics: &mut Vec<Diagnostic>,
700) {
701    let Some(namespaces) = schema_json.as_object() else {
702        return;
703    };
704    for declared_namespace in namespaces.keys() {
705        if !namespace_owns(namespace, declared_namespace) {
706            diagnostics.push(
707                Diagnostic::error(
708                    "schema.namespace_violation",
709                    format!(
710                        "schema namespace {declared_namespace:?} is outside module namespace {namespace:?}"
711                    ),
712                )
713                .in_module(module_name)
714                .at_path(relative_path),
715            );
716        }
717    }
718}
719
720fn validate_aggregate_schema_ownership(
721    schema_json: &Value,
722    modules: &[ModuleRecord],
723    diagnostics: &mut Vec<Diagnostic>,
724) {
725    let Some(namespaces) = schema_json.as_object() else {
726        return;
727    };
728    for namespace in namespaces.keys() {
729        if !modules
730            .iter()
731            .any(|module| namespace_owns(&module.namespace, namespace))
732        {
733            diagnostics.push(Diagnostic::error(
734                "schema.namespace_violation",
735                format!("schema namespace {namespace:?} is not owned by any module"),
736            ));
737        }
738    }
739}
740
741fn merge_schema_fragment(
742    target: &mut Value,
743    fragment: Value,
744    module_name: &str,
745    relative_path: &str,
746    diagnostics: &mut Vec<Diagnostic>,
747) {
748    let Some(target_namespaces) = target.as_object_mut() else {
749        return;
750    };
751    let Some(fragment_namespaces) = fragment.as_object() else {
752        return;
753    };
754    for (namespace, definition) in fragment_namespaces {
755        let target_definition = target_namespaces
756            .entry(namespace.clone())
757            .or_insert_with(|| Value::Object(Map::new()));
758        let Some(target_fields) = target_definition.as_object_mut() else {
759            continue;
760        };
761        let Some(fields) = definition.as_object() else {
762            continue;
763        };
764        for (field, value) in fields {
765            if matches!(field.as_str(), "entityTypes" | "actions" | "commonTypes") {
766                let target_declarations = target_fields
767                    .entry(field.clone())
768                    .or_insert_with(|| Value::Object(Map::new()));
769                let Some(target_declarations) = target_declarations.as_object_mut() else {
770                    continue;
771                };
772                if let Some(declarations) = value.as_object() {
773                    for (name, declaration) in declarations {
774                        if target_declarations
775                            .insert(name.clone(), declaration.clone())
776                            .is_some()
777                        {
778                            diagnostics.push(
779                                Diagnostic::error(
780                                    "schema.duplicate_declaration",
781                                    format!("duplicate {field} declaration {namespace}::{name}"),
782                                )
783                                .in_module(module_name)
784                                .at_path(relative_path),
785                            );
786                        }
787                    }
788                }
789            } else if let Some(existing) = target_fields.get(field) {
790                if existing != value {
791                    diagnostics.push(
792                        Diagnostic::error(
793                            "schema.duplicate_metadata",
794                            format!("conflicting schema namespace field {namespace}.{field}"),
795                        )
796                        .in_module(module_name)
797                        .at_path(relative_path),
798                    );
799                }
800            } else {
801                target_fields.insert(field.clone(), value.clone());
802            }
803        }
804    }
805}
806
807fn read_utf8(path: &Path) -> Result<String> {
808    let bytes = fs::read(path).map_err(|error| BundleError::io(path, error))?;
809    String::from_utf8(bytes).map_err(|error| {
810        BundleError::Validation(vec![
811            Diagnostic::error("input.invalid_utf8", error.to_string())
812                .at_path(path.display().to_string()),
813        ])
814    })
815}
816
817pub(crate) fn normalize_text(source: &str) -> String {
818    let mut normalized = source.replace("\r\n", "\n").replace('\r', "\n");
819    while normalized.ends_with('\n') {
820        normalized.pop();
821    }
822    normalized.push('\n');
823    normalized
824}
825
826fn single_line(value: &str) -> String {
827    value
828        .chars()
829        .map(|character| {
830            if character == '\r' || character == '\n' {
831                ' '
832            } else {
833                character
834            }
835        })
836        .collect()
837}