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#[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
59pub 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
124pub 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}