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