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.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}