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