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.target().resource_type()) {
242                            diagnostics.push(
243                                Diagnostic::error(
244                                    "labels.namespace_violation",
245                                    format!(
246                                        "label kind {} is outside namespace {}",
247                                        rule.target().resource_type(),
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.target().resource_type()))
418        {
419            diagnostics.push(Diagnostic::error(
420                "labels.namespace_violation",
421                format!(
422                    "label kind {} is not owned by any module",
423                    rule.target().resource_type()
424                ),
425            ));
426        }
427    }
428
429    if diagnostics
430        .iter()
431        .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
432    {
433        Err(BundleError::Validation(diagnostics))
434    } else {
435        Ok(BundleParts {
436            name,
437            modules,
438            policies,
439            schema_json,
440            labels,
441            policy_ids: actual_ids.into_iter().collect(),
442            diagnostics,
443        })
444    }
445}
446
447fn validate_module_policies(
448    policy_set: &PolicySet,
449    module: &ModuleRecord,
450    relative_path: &str,
451    module_policy_ids: &mut Vec<String>,
452    all_policy_ids: &mut BTreeSet<String>,
453    diagnostics: &mut Vec<Diagnostic>,
454) {
455    if policy_set.num_of_templates() != 0 || policy_set.policies().any(|policy| !policy.is_static())
456    {
457        diagnostics.push(
458            Diagnostic::error(
459                "policy.templates_unsupported",
460                "deployable bundles may contain only static policies",
461            )
462            .in_module(&module.name)
463            .at_path(relative_path),
464        );
465    }
466    for policy in policy_set.policies() {
467        let Some(id) = policy.annotation("id").filter(|id| !id.is_empty()) else {
468            diagnostics.push(
469                Diagnostic::error(
470                    "policy.missing_id",
471                    "every bundled policy requires a non-empty @id annotation",
472                )
473                .in_module(&module.name)
474                .at_path(relative_path),
475            );
476            continue;
477        };
478        if !all_policy_ids.insert(id.to_string()) {
479            diagnostics.push(
480                Diagnostic::error(
481                    "policy.duplicate_id",
482                    format!("duplicate policy @id {id:?}"),
483                )
484                .in_module(&module.name)
485                .at_path(relative_path),
486            );
487        }
488        module_policy_ids.push(id.to_string());
489        if !id.starts_with(&format!("{}.", module.name)) {
490            diagnostics.push(
491                Diagnostic::warning(
492                    "policy.id_prefix",
493                    format!(
494                        "policy @id {id:?} should start with {:?} followed by '.'",
495                        module.name
496                    ),
497                )
498                .in_module(&module.name)
499                .at_path(relative_path),
500            );
501        }
502        let start = diagnostics.len();
503        validate_policy_ownership(policy, id, module, diagnostics);
504        for diagnostic in &mut diagnostics[start..] {
505            diagnostic.module = Some(module.name.clone());
506            diagnostic.path = Some(relative_path.to_string());
507        }
508    }
509}
510
511fn validate_policy_ownership(
512    policy: &Policy,
513    policy_id: &str,
514    module: &ModuleRecord,
515    diagnostics: &mut Vec<Diagnostic>,
516) {
517    let pst = match policy.to_pst() {
518        Ok(pst) => pst,
519        Err(error) => {
520            diagnostics.push(Diagnostic::error(
521                "policy.structured_representation",
522                format!("policy {policy_id:?} cannot be represented structurally: {error}"),
523            ));
524            return;
525        }
526    };
527    let body = pst.body();
528    if module.role == ModuleRole::Global {
529        return;
530    }
531    match &body.action {
532        PstActionConstraint::Any => diagnostics.push(Diagnostic::error(
533            "policy.action_unconstrained",
534            format!("ordinary policy {policy_id:?} must constrain its actions"),
535        )),
536        PstActionConstraint::Eq(uid) => {
537            check_owned_action(&uid.ty.to_string(), policy_id, module, diagnostics)
538        }
539        PstActionConstraint::In(uids) => {
540            for uid in uids {
541                check_owned_action(&uid.ty.to_string(), policy_id, module, diagnostics);
542            }
543        }
544    }
545
546    let mut references = Vec::new();
547    match &body.principal {
548        PstPrincipalConstraint::Any => {}
549        PstPrincipalConstraint::Eq(value) | PstPrincipalConstraint::In(value) => {
550            collect_entity_or_slot(value, &mut references)
551        }
552        PstPrincipalConstraint::Is(entity_type) => {
553            references.push(entity_type.to_string());
554        }
555        PstPrincipalConstraint::IsIn(entity_type, value) => {
556            references.push(entity_type.to_string());
557            collect_entity_or_slot(value, &mut references);
558        }
559    }
560    match &body.resource {
561        PstResourceConstraint::Any => {}
562        PstResourceConstraint::Eq(value) | PstResourceConstraint::In(value) => {
563            collect_entity_or_slot(value, &mut references)
564        }
565        PstResourceConstraint::Is(entity_type) => references.push(entity_type.to_string()),
566        PstResourceConstraint::IsIn(entity_type, value) => {
567            references.push(entity_type.to_string());
568            collect_entity_or_slot(value, &mut references);
569        }
570    }
571    match &body.action {
572        PstActionConstraint::Any => {}
573        PstActionConstraint::Eq(uid) => references.push(uid.ty.to_string()),
574        PstActionConstraint::In(uids) => {
575            references.extend(uids.iter().map(|uid| uid.ty.to_string()));
576        }
577    }
578    for clause in body.clauses() {
579        let expression = match clause {
580            Clause::When(expression) | Clause::Unless(expression) => expression,
581        };
582        collect_expr_references(expression, &mut references);
583    }
584    for reference in references {
585        if !namespace_owns(&module.namespace, &reference)
586            && !module
587                .imports
588                .iter()
589                .any(|import| namespace_owns(import, &reference))
590        {
591            diagnostics.push(Diagnostic::error(
592                "policy.namespace_violation",
593                format!(
594                    "policy {policy_id:?} references {reference}, outside namespace {} and its imports",
595                    module.namespace
596                ),
597            ));
598        }
599    }
600}
601
602fn check_owned_action(
603    entity_type: &str,
604    policy_id: &str,
605    module: &ModuleRecord,
606    diagnostics: &mut Vec<Diagnostic>,
607) {
608    if !namespace_owns(&module.namespace, entity_type) {
609        diagnostics.push(Diagnostic::error(
610            "policy.action_namespace_violation",
611            format!(
612                "ordinary policy {policy_id:?} constrains action type {entity_type} outside namespace {}",
613                module.namespace
614            ),
615        ));
616    }
617}
618
619fn collect_entity_or_slot(value: &EntityOrSlot, references: &mut Vec<String>) {
620    if let EntityOrSlot::Entity(uid) = value {
621        references.push(uid.ty.to_string());
622    }
623}
624
625fn collect_expr_references(expression: &Expr, references: &mut Vec<String>) {
626    match expression {
627        Expr::Literal(Literal::EntityUID(uid)) => references.push(uid.ty.to_string()),
628        Expr::UnaryOp { expr, .. }
629        | Expr::GetAttr { expr, .. }
630        | Expr::HasAttr { expr, .. }
631        | Expr::Like { expr, .. } => collect_expr_references(expr, references),
632        Expr::BinaryOp { left, right, .. } => {
633            collect_expr_references(left, references);
634            collect_expr_references(right, references);
635        }
636        Expr::Is {
637            expr,
638            entity_type,
639            in_expr,
640        } => {
641            references.push(entity_type.to_string());
642            collect_expr_references(expr, references);
643            if let Some(in_expr) = in_expr {
644                collect_expr_references(in_expr, references);
645            }
646        }
647        Expr::IfThenElse {
648            cond,
649            then_expr,
650            else_expr,
651        } => {
652            collect_expr_references(cond, references);
653            collect_expr_references(then_expr, references);
654            collect_expr_references(else_expr, references);
655        }
656        Expr::Set(expressions) => {
657            for expression in expressions {
658                collect_expr_references(expression, references);
659            }
660        }
661        Expr::Record(expressions) => {
662            for expression in expressions.values() {
663                collect_expr_references(expression, references);
664            }
665        }
666        _ => {}
667    }
668}
669
670fn parse_schema_fragment(
671    source: &str,
672) -> std::result::Result<(SchemaFragment, Vec<Diagnostic>), String> {
673    let trimmed = source.trim_start();
674    if trimmed.starts_with('{') {
675        SchemaFragment::from_json_str(source)
676            .map(|fragment| (fragment, Vec::new()))
677            .map_err(|error| error.to_string())
678    } else {
679        SchemaFragment::from_cedarschema_str(source)
680            .map(|(fragment, warnings)| {
681                (
682                    fragment,
683                    warnings
684                        .map(|warning| Diagnostic::warning("schema.warning", warning.to_string()))
685                        .collect(),
686                )
687            })
688            .map_err(|error| error.to_string())
689    }
690}
691
692fn validate_policy_set(policy_set: &PolicySet, schema: &Schema, diagnostics: &mut Vec<Diagnostic>) {
693    let result = Validator::new(schema.clone()).validate(policy_set, ValidationMode::Strict);
694    diagnostics.extend(
695        result
696            .validation_errors()
697            .map(|error| Diagnostic::error("policy.schema_validation", error.to_string())),
698    );
699    diagnostics.extend(
700        result
701            .validation_warnings()
702            .map(|warning| Diagnostic::warning("policy.schema_warning", warning.to_string())),
703    );
704}
705
706fn validate_schema_ownership(
707    schema_json: &Value,
708    module_name: &str,
709    namespace: &str,
710    relative_path: &str,
711    diagnostics: &mut Vec<Diagnostic>,
712) {
713    let Some(namespaces) = schema_json.as_object() else {
714        return;
715    };
716    for declared_namespace in namespaces.keys() {
717        if !namespace_owns(namespace, declared_namespace) {
718            diagnostics.push(
719                Diagnostic::error(
720                    "schema.namespace_violation",
721                    format!(
722                        "schema namespace {declared_namespace:?} is outside module namespace {namespace:?}"
723                    ),
724                )
725                .in_module(module_name)
726                .at_path(relative_path),
727            );
728        }
729    }
730}
731
732fn validate_aggregate_schema_ownership(
733    schema_json: &Value,
734    modules: &[ModuleRecord],
735    diagnostics: &mut Vec<Diagnostic>,
736) {
737    let Some(namespaces) = schema_json.as_object() else {
738        return;
739    };
740    for namespace in namespaces.keys() {
741        if !modules
742            .iter()
743            .any(|module| namespace_owns(&module.namespace, namespace))
744        {
745            diagnostics.push(Diagnostic::error(
746                "schema.namespace_violation",
747                format!("schema namespace {namespace:?} is not owned by any module"),
748            ));
749        }
750    }
751}
752
753fn merge_schema_fragment(
754    target: &mut Value,
755    fragment: Value,
756    module_name: &str,
757    relative_path: &str,
758    diagnostics: &mut Vec<Diagnostic>,
759) {
760    let Some(target_namespaces) = target.as_object_mut() else {
761        return;
762    };
763    let Some(fragment_namespaces) = fragment.as_object() else {
764        return;
765    };
766    for (namespace, definition) in fragment_namespaces {
767        let target_definition = target_namespaces
768            .entry(namespace.clone())
769            .or_insert_with(|| Value::Object(Map::new()));
770        let Some(target_fields) = target_definition.as_object_mut() else {
771            continue;
772        };
773        let Some(fields) = definition.as_object() else {
774            continue;
775        };
776        for (field, value) in fields {
777            if matches!(field.as_str(), "entityTypes" | "actions" | "commonTypes") {
778                let target_declarations = target_fields
779                    .entry(field.clone())
780                    .or_insert_with(|| Value::Object(Map::new()));
781                let Some(target_declarations) = target_declarations.as_object_mut() else {
782                    continue;
783                };
784                if let Some(declarations) = value.as_object() {
785                    for (name, declaration) in declarations {
786                        if target_declarations
787                            .insert(name.clone(), declaration.clone())
788                            .is_some()
789                        {
790                            diagnostics.push(
791                                Diagnostic::error(
792                                    "schema.duplicate_declaration",
793                                    format!("duplicate {field} declaration {namespace}::{name}"),
794                                )
795                                .in_module(module_name)
796                                .at_path(relative_path),
797                            );
798                        }
799                    }
800                }
801            } else if let Some(existing) = target_fields.get(field) {
802                if existing != value {
803                    diagnostics.push(
804                        Diagnostic::error(
805                            "schema.duplicate_metadata",
806                            format!("conflicting schema namespace field {namespace}.{field}"),
807                        )
808                        .in_module(module_name)
809                        .at_path(relative_path),
810                    );
811                }
812            } else {
813                target_fields.insert(field.clone(), value.clone());
814            }
815        }
816    }
817}
818
819fn read_utf8(path: &Path) -> Result<String> {
820    let bytes = fs::read(path).map_err(|error| BundleError::io(path, error))?;
821    String::from_utf8(bytes).map_err(|error| {
822        BundleError::Validation(vec![
823            Diagnostic::error("input.invalid_utf8", error.to_string())
824                .at_path(path.display().to_string()),
825        ])
826    })
827}
828
829pub(crate) fn normalize_text(source: &str) -> String {
830    let mut normalized = source.replace("\r\n", "\n").replace('\r', "\n");
831    while normalized.ends_with('\n') {
832        normalized.pop();
833    }
834    normalized.push('\n');
835    normalized
836}
837
838fn single_line(value: &str) -> String {
839    value
840        .chars()
841        .map(|character| {
842            if character == '\r' || character == '\n' {
843                ' '
844            } else {
845                character
846            }
847        })
848        .collect()
849}