1use std::collections::{HashMap, HashSet};
5#[cfg(not(target_family = "wasm"))]
6use std::path::Path;
7use std::sync::OnceLock;
8
9use anyhow::{Context as _, Result};
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 CapabilityProperties, ComponentProperties, LinkProperty, Manifest, Properties, Trait,
15 TraitProperty, DEFAULT_LINK_NAME, LATEST_VERSION,
16};
17
18type KnownInterfaceLookup = HashMap<String, HashMap<String, HashMap<String, ()>>>;
20
21static KNOWN_INTERFACE_LOOKUP: OnceLock<KnownInterfaceLookup> = OnceLock::new();
27
28const SECRET_POLICY_TYPE: &str = "policy.secret.wasmcloud.dev/v1alpha1";
29
30fn get_known_interface_lookup() -> &'static KnownInterfaceLookup {
32 KNOWN_INTERFACE_LOOKUP.get_or_init(|| {
33 HashMap::from([
34 (
35 "wrpc".into(),
36 HashMap::from([
37 (
38 "blobstore".into(),
39 HashMap::from([("blobstore".into(), ())]),
40 ),
41 (
42 "keyvalue".into(),
43 HashMap::from([("atomics".into(), ()), ("store".into(), ())]),
44 ),
45 (
46 "http".into(),
47 HashMap::from([
48 ("incoming-handler".into(), ()),
49 ("outgoing-handler".into(), ()),
50 ]),
51 ),
52 ]),
53 ),
54 (
55 "wasi".into(),
56 HashMap::from([
57 (
58 "blobstore".into(),
59 HashMap::from([("blobstore".into(), ())]),
60 ),
61 ("config".into(), HashMap::from([("runtime".into(), ())])),
62 (
63 "keyvalue".into(),
64 HashMap::from([
65 ("atomics".into(), ()),
66 ("store".into(), ()),
67 ("batch".into(), ()),
68 ("watch".into(), ()),
69 ]),
70 ),
71 (
72 "http".into(),
73 HashMap::from([
74 ("incoming-handler".into(), ()),
75 ("outgoing-handler".into(), ()),
76 ]),
77 ),
78 ("logging".into(), HashMap::from([("logging".into(), ())])),
79 ]),
80 ),
81 (
82 "wasmcloud".into(),
83 HashMap::from([(
84 "messaging".into(),
85 HashMap::from([("consumer".into(), ()), ("handler".into(), ())]),
86 )]),
87 ),
88 ])
89 })
90}
91
92static MANIFEST_NAME_REGEX_STR: &str = r"^[-\w]+$";
93static MANIFEST_NAME_REGEX: OnceLock<Regex> = OnceLock::new();
94
95fn get_manifest_name_regex() -> &'static Regex {
97 MANIFEST_NAME_REGEX.get_or_init(|| {
98 Regex::new(MANIFEST_NAME_REGEX_STR)
99 .context("failed to parse manifest name regex")
100 .unwrap()
101 })
102}
103
104pub fn validate_manifest_name(name: &str) -> impl ValidationOutput {
106 let mut errors = Vec::new();
107 if !get_manifest_name_regex().is_match(name) {
108 errors.push(ValidationFailure::new(
109 ValidationFailureLevel::Error,
110 format!("manifest name [{name}] is not allowed (should match regex [{MANIFEST_NAME_REGEX_STR}])"),
111 ))
112 }
113 errors
114}
115
116pub fn is_valid_manifest_name(name: &str) -> bool {
118 validate_manifest_name(name).valid()
119}
120
121pub fn validate_manifest_version(version: &str) -> impl ValidationOutput {
123 let mut errors = Vec::new();
124 if version == LATEST_VERSION {
125 errors.push(ValidationFailure::new(
126 ValidationFailureLevel::Error,
127 format!("{LATEST_VERSION} is not allowed in wadm"),
128 ))
129 }
130 errors
131}
132
133pub fn is_valid_manifest_version(version: &str) -> bool {
135 validate_manifest_version(version).valid()
136}
137
138fn is_invalid_known_interface(
145 namespace: &str,
146 package: &str,
147 interface: &str,
148) -> Vec<ValidationFailure> {
149 let known_interfaces = get_known_interface_lookup();
150 let Some(pkg_lookup) = known_interfaces.get(namespace) else {
151 return vec![];
153 };
154 let Some(iface_lookup) = pkg_lookup.get(package) else {
155 return vec![ValidationFailure::new(
157 ValidationFailureLevel::Warning,
158 format!("unrecognized interface [{namespace}:{package}/{interface}]"),
159 )];
160 };
161 if !iface_lookup.contains_key(interface) {
163 return vec![ValidationFailure::new(
166 ValidationFailureLevel::Warning,
167 format!("unrecognized interface [{namespace}:{package}/{interface}]"),
168 )];
169 }
170
171 Vec::new()
172}
173
174#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
176#[non_exhaustive]
177pub enum ValidationFailureLevel {
178 #[default]
179 Warning,
180 Error,
181}
182
183impl core::fmt::Display for ValidationFailureLevel {
184 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
185 write!(
186 f,
187 "{}",
188 match self {
189 Self::Warning => "warning",
190 Self::Error => "error",
191 }
192 )
193 }
194}
195
196#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
198#[non_exhaustive]
199pub struct ValidationFailure {
200 pub level: ValidationFailureLevel,
201 pub msg: String,
202}
203
204impl ValidationFailure {
205 fn new(level: ValidationFailureLevel, msg: String) -> Self {
206 ValidationFailure { level, msg }
207 }
208}
209
210impl core::fmt::Display for ValidationFailure {
211 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
212 write!(f, "[{}] {}", self.level, self.msg)
213 }
214}
215
216pub trait ValidationOutput {
218 fn valid(&self) -> bool;
220 fn warnings(&self) -> Vec<&ValidationFailure>;
222 fn errors(&self) -> Vec<&ValidationFailure>;
224}
225
226impl ValidationOutput for [ValidationFailure] {
228 fn valid(&self) -> bool {
229 self.errors().is_empty()
230 }
231 fn warnings(&self) -> Vec<&ValidationFailure> {
232 self.iter()
233 .filter(|m| m.level == ValidationFailureLevel::Warning)
234 .collect()
235 }
236 fn errors(&self) -> Vec<&ValidationFailure> {
237 self.iter()
238 .filter(|m| m.level == ValidationFailureLevel::Error)
239 .collect()
240 }
241}
242
243impl ValidationOutput for Vec<ValidationFailure> {
245 fn valid(&self) -> bool {
246 self.as_slice().valid()
247 }
248 fn warnings(&self) -> Vec<&ValidationFailure> {
249 self.iter()
250 .filter(|m| m.level == ValidationFailureLevel::Warning)
251 .collect()
252 }
253 fn errors(&self) -> Vec<&ValidationFailure> {
254 self.iter()
255 .filter(|m| m.level == ValidationFailureLevel::Error)
256 .collect()
257 }
258}
259
260#[cfg(not(target_family = "wasm"))]
279pub async fn validate_manifest_file(
280 path: impl AsRef<Path>,
281) -> Result<(Manifest, Vec<ValidationFailure>)> {
282 let content = tokio::fs::read_to_string(path.as_ref())
283 .await
284 .with_context(|| format!("failed to read manifest @ [{}]", path.as_ref().display()))?;
285
286 validate_manifest_bytes(&content).await.with_context(|| {
287 format!(
288 "failed to parse YAML manifest [{}]",
289 path.as_ref().display()
290 )
291 })
292}
293
294pub async fn validate_manifest_bytes(
300 content: impl AsRef<[u8]>,
301) -> Result<(Manifest, Vec<ValidationFailure>)> {
302 let raw_yaml_content = content.as_ref();
303 let manifest =
304 serde_yaml::from_slice(content.as_ref()).context("failed to parse manifest content")?;
305 let mut failures = validate_manifest(&manifest).await?;
306 let mut yaml_issues = validate_raw_yaml(raw_yaml_content)?;
307 failures.append(&mut yaml_issues);
308 Ok((manifest, failures))
309}
310
311pub async fn validate_manifest(manifest: &Manifest) -> Result<Vec<ValidationFailure>> {
331 let mut failures = Vec::new();
333 failures.extend(
334 validate_manifest_name(&manifest.metadata.name)
335 .errors()
336 .into_iter()
337 .cloned(),
338 );
339 failures.extend(
340 validate_manifest_version(manifest.version())
341 .errors()
342 .into_iter()
343 .cloned(),
344 );
345 failures.extend(core_validation(manifest));
346 failures.extend(check_misnamed_interfaces(manifest));
347 failures.extend(check_dangling_links(manifest));
348 failures.extend(validate_policies(manifest));
349 failures.extend(ensure_no_custom_traits(manifest));
350 failures.extend(validate_component_properties(manifest));
351 failures.extend(check_duplicate_links(manifest));
352 failures.extend(validate_link_configs(manifest));
353 Ok(failures)
354}
355
356pub fn validate_raw_yaml(content: &[u8]) -> Result<Vec<ValidationFailure>> {
357 let mut failures = Vec::new();
358 let raw_content: serde_yaml::Value =
359 serde_yaml::from_slice(content).context("failed read raw yaml content")?;
360 failures.extend(validate_components_configs(&raw_content));
361 Ok(failures)
362}
363
364fn core_validation(manifest: &Manifest) -> Vec<ValidationFailure> {
365 let mut failures = Vec::new();
366 let mut name_registry: HashSet<String> = HashSet::new();
367 let mut id_registry: HashSet<String> = HashSet::new();
368 let mut required_capability_components: HashSet<String> = HashSet::new();
369
370 for label in manifest.metadata.labels.iter() {
371 if !valid_oam_label(label) {
372 failures.push(ValidationFailure::new(
373 ValidationFailureLevel::Error,
374 format!("Invalid OAM label: {:?}", label),
375 ));
376 }
377 }
378
379 for annotation in manifest.metadata.annotations.iter() {
380 if !valid_oam_label(annotation) {
381 failures.push(ValidationFailure::new(
382 ValidationFailureLevel::Error,
383 format!("Invalid OAM annotation: {:?}", annotation),
384 ));
385 }
386 }
387
388 for component in manifest.spec.components.iter() {
389 if !name_registry.insert(component.name.clone()) {
391 failures.push(ValidationFailure::new(
392 ValidationFailureLevel::Error,
393 format!("Duplicate component name in manifest: {}", component.name),
394 ));
395 }
396 if let Properties::Capability {
401 properties:
402 CapabilityProperties {
403 id: Some(component_id),
404 config: _capability_config,
405 ..
406 },
407 } = &component.properties
408 {
409 if !id_registry.insert(component_id.to_string()) {
410 failures.push(ValidationFailure::new(
411 ValidationFailureLevel::Error,
412 format!(
413 "Duplicate component identifier in manifest: {}",
414 component_id
415 ),
416 ));
417 }
418 }
419
420 if let Properties::Component {
422 properties: ComponentProperties { id: Some(id), .. },
423 } = &component.properties
424 {
425 if !id_registry.insert(id.to_string()) {
426 failures.push(ValidationFailure::new(
427 ValidationFailureLevel::Error,
428 format!("Duplicate component identifier in manifest: {}", id),
429 ));
430 }
431 }
432
433 if let Some(traits_vec) = &component.traits {
435 for trait_item in traits_vec.iter() {
436 if let Trait {
437 properties: TraitProperty::Link(LinkProperty { target, .. }),
439 ..
440 } = &trait_item
441 {
442 required_capability_components.insert(target.name.to_string());
444 }
445 }
446 }
447 }
448
449 let missing_capability_components = required_capability_components
450 .difference(&name_registry)
451 .collect::<Vec<&String>>();
452
453 if !missing_capability_components.is_empty() {
454 failures.push(ValidationFailure::new(
455 ValidationFailureLevel::Error,
456 format!(
457 "The following capability component(s) are missing from the manifest: {:?}",
458 missing_capability_components
459 ),
460 ));
461 };
462 failures
463}
464
465fn check_misnamed_interfaces(manifest: &Manifest) -> Vec<ValidationFailure> {
467 let mut failures = Vec::new();
468 for link_trait in manifest.links() {
469 if let TraitProperty::Link(LinkProperty {
470 namespace,
471 package,
472 interfaces,
473 target: _target,
474 source: _source,
475 ..
476 }) = &link_trait.properties
477 {
478 for interface in interfaces {
479 failures.extend(is_invalid_known_interface(namespace, package, interface))
480 }
481 }
482 }
483
484 failures
485}
486
487fn ensure_no_custom_traits(manifest: &Manifest) -> Vec<ValidationFailure> {
492 let mut failures = Vec::new();
493 for component in manifest.components() {
494 if let Some(traits) = &component.traits {
495 for trait_item in traits {
496 match &trait_item.properties {
497 TraitProperty::Custom(trt) if trait_item.is_link() => failures.push(ValidationFailure::new(
498 ValidationFailureLevel::Error,
499 format!("Link trait deserialized as custom trait, ensure fields are correct: {}", trt),
500 )),
501 TraitProperty::Custom(trt) if trait_item.is_scaler() => failures.push(ValidationFailure::new(
502 ValidationFailureLevel::Error,
503 format!("Scaler trait deserialized as custom trait, ensure fields are correct: {}", trt),
504 )),
505 _ => (),
506 }
507 }
508 }
509 }
510 failures
511}
512
513fn check_dangling_links(manifest: &Manifest) -> Vec<ValidationFailure> {
520 let lookup = manifest.component_lookup();
521 let mut failures = Vec::new();
522 for link_trait in manifest.links() {
523 match &link_trait.properties {
524 TraitProperty::Custom(obj) => {
525 if obj.get("target").is_none() {
526 failures.push(ValidationFailure::new(
527 ValidationFailureLevel::Error,
528 "custom link is missing 'target' property".into(),
529 ));
530 continue;
531 }
532
533 match obj["target"]["name"].as_str() {
535 Some(target) if !lookup.contains_key(&String::from(target)) => {
537 failures.push(ValidationFailure::new(
538 ValidationFailureLevel::Warning,
539 format!("custom link target [{target}] is not a listed component"),
540 ))
541 }
542 Some(_) => {}
544 None => failures.push(ValidationFailure::new(
546 ValidationFailureLevel::Error,
547 "custom link is missing 'target' name property".into(),
548 )),
549 }
550 }
551 TraitProperty::Link(LinkProperty { name, target, .. }) => {
552 let link_identifier = name
553 .as_ref()
554 .map(|n| format!("(name [{n}])"))
555 .unwrap_or_else(|| format!("(target [{}])", target.name));
556 if !lookup.contains_key(&target.name) {
557 failures.push(ValidationFailure::new(
558 ValidationFailureLevel::Warning,
559 format!(
560 "link {link_identifier} target [{}] is not a listed component",
561 target.name
562 ),
563 ))
564 }
565 }
566
567 _ => unreachable!("manifest.links() should only return links"),
568 }
569 }
570
571 failures
572}
573
574fn validate_policies(manifest: &Manifest) -> Vec<ValidationFailure> {
577 let policies = manifest.policy_lookup();
578 let mut failures = Vec::new();
579 for c in manifest.components() {
580 for secret in c.secrets() {
582 match policies.get(&secret.properties.policy) {
583 Some(policy) if policy.policy_type != SECRET_POLICY_TYPE => {
584 failures.push(ValidationFailure::new(
585 ValidationFailureLevel::Error,
586 format!(
587 "secret '{}' is mapped to policy '{}' which is not a secret policy. Expected type '{SECRET_POLICY_TYPE}'",
588 secret.name, secret.properties.policy
589 ),
590 ))
591 }
592 Some(policy) => {
593 if !policy.properties.contains_key("backend") {
594 failures.push(ValidationFailure::new(
595 ValidationFailureLevel::Error,
596 format!(
597 "secret '{}' is mapped to policy '{}' which does not include a 'backend' property",
598 secret.name, secret.properties.policy
599 ),
600 ))
601 }
602 }
603 None => failures.push(ValidationFailure::new(
604 ValidationFailureLevel::Error,
605 format!(
606 "secret '{}' is mapped to unknown policy '{}'",
607 secret.name, secret.properties.policy
608 ),
609 )),
610 }
611 }
612 }
613 failures
614}
615
616pub fn validate_component_properties(application: &Manifest) -> Vec<ValidationFailure> {
620 let mut failures = Vec::new();
621 for component in application.spec.components.iter() {
622 match &component.properties {
623 Properties::Component {
624 properties:
625 ComponentProperties {
626 image,
627 application,
628 config,
629 secrets,
630 ..
631 },
632 }
633 | Properties::Capability {
634 properties:
635 CapabilityProperties {
636 image,
637 application,
638 config,
639 secrets,
640 ..
641 },
642 } => match (image, application) {
643 (Some(_), Some(_)) => {
644 failures.push(ValidationFailure::new(
645 ValidationFailureLevel::Error,
646 "Component cannot have both 'image' and 'application' properties".into(),
647 ));
648 }
649 (None, None) => {
650 failures.push(ValidationFailure::new(
651 ValidationFailureLevel::Error,
652 "Component must have either 'image' or 'application' property".into(),
653 ));
654 }
655 (None, Some(shared_properties)) if !config.is_empty() => {
658 failures.push(ValidationFailure::new(
659 ValidationFailureLevel::Error,
660 format!(
661 "Shared component '{}' cannot specify additional 'config'",
662 shared_properties.name
663 ),
664 ));
665 }
666 (None, Some(shared_properties)) if !secrets.is_empty() => {
667 failures.push(ValidationFailure::new(
668 ValidationFailureLevel::Error,
669 format!(
670 "Shared component '{}' cannot specify additional 'secrets'",
671 shared_properties.name
672 ),
673 ));
674 }
675 (None, Some(shared_properties))
677 if component
678 .traits
679 .as_ref()
680 .is_some_and(|traits| traits.iter().any(|trt| trt.is_scaler())) =>
681 {
682 failures.push(ValidationFailure::new(
683 ValidationFailureLevel::Error,
684 format!(
685 "Shared component '{}' cannot include a scaler trait",
686 shared_properties.name
687 ),
688 ));
689 }
690 _ => {}
691 },
692 }
693 }
694 failures
695}
696
697pub fn validate_link_configs(manifest: &Manifest) -> Vec<ValidationFailure> {
704 let mut failures = Vec::new();
705 let mut link_config_names = HashSet::new();
706 for link_trait in manifest.links() {
707 if let TraitProperty::Link(LinkProperty { target, source, .. }) = &link_trait.properties {
708 for config in &target.config {
709 if config.properties.is_none() {
711 continue;
712 }
713 if !link_config_names.insert(config.name.clone()) {
715 failures.push(ValidationFailure::new(
716 ValidationFailureLevel::Error,
717 format!("Duplicate link config name found: '{}'", config.name),
718 ));
719 }
720 }
721
722 if let Some(source) = source {
723 for config in &source.config {
724 if config.properties.is_none() {
726 continue;
727 }
728 if !link_config_names.insert(config.name.clone()) {
730 failures.push(ValidationFailure::new(
731 ValidationFailureLevel::Error,
732 format!("Duplicate link config name found: '{}'", config.name),
733 ));
734 }
735 }
736 }
737 }
738 }
739 failures
740}
741
742pub fn validate_components_configs(application: &serde_yaml::Value) -> Vec<ValidationFailure> {
746 let mut failures = Vec::new();
747
748 if let Some(specs) = application.get("spec") {
749 if let Some(components) = specs.get("components") {
750 if let Some(components_sequence) = components.as_sequence() {
751 for component in components_sequence.iter() {
752 failures.extend(get_deprecated_configs(component));
753 }
754 }
755 }
756 }
757 failures
758}
759
760fn get_deprecated_configs(component: &serde_yaml::Value) -> Vec<ValidationFailure> {
761 let mut failures = vec![];
762 if let Some(traits) = component.get("traits") {
763 if let Some(traits_sequence) = traits.as_sequence() {
764 for trait_ in traits_sequence.iter() {
765 if let Some(trait_type) = trait_.get("type") {
766 if trait_type.ne("link") {
767 continue;
768 }
769 }
770 if let Some(trait_properties) = trait_.get("properties") {
771 if trait_properties.get("source_config").is_some() {
772 failures.push(ValidationFailure {
773 level: ValidationFailureLevel::Warning,
774 msg: "one of the components' link trait contains a source_config key, please use source:config: rather".to_string(),
775 });
776 }
777 if trait_properties.get("target_config").is_some() {
778 failures.push(ValidationFailure {
779 level: ValidationFailureLevel::Warning,
780 msg: "one of the components' link trait contains a target_config key, please use target:config: rather".to_string(),
781 });
782 }
783 }
784 }
785 }
786 }
787 failures
788}
789
790pub fn valid_oam_label(label: (&String, &String)) -> bool {
796 let (key, _) = label;
797 match key.split_once('/') {
798 Some((prefix, name)) => is_valid_dns_subdomain(prefix) && is_valid_label_name(name),
799 None => is_valid_label_name(key),
800 }
801}
802
803pub fn is_valid_dns_subdomain(s: &str) -> bool {
804 if s.is_empty() || s.len() > 253 {
805 return false;
806 }
807
808 s.split('.').all(|part| {
809 !part.is_empty()
812 && part.len() <= 63
813 && part.starts_with(|c: char| c.is_ascii_alphabetic())
814 && part.ends_with(|c: char| c.is_ascii_alphanumeric())
815 && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
816 })
817}
818
819pub fn is_valid_label_name(name: &str) -> bool {
823 if name.is_empty() || name.len() > 63 {
824 return false;
825 }
826
827 name.starts_with(|c: char| c.is_ascii_alphanumeric())
828 && name.ends_with(|c: char| c.is_ascii_alphanumeric())
829 && name
830 .chars()
831 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
832}
833
834fn check_duplicate_links(manifest: &Manifest) -> Vec<ValidationFailure> {
839 let mut failures = Vec::new();
840 for component in manifest.components() {
841 let mut link_ids = HashSet::new();
842 for link in component.links() {
843 if let TraitProperty::Link(LinkProperty {
844 name,
845 namespace,
846 package,
847 interfaces,
848 ..
849 }) = &link.properties
850 {
851 for interface in interfaces {
852 if !link_ids.insert((
853 name.clone()
854 .unwrap_or_else(|| DEFAULT_LINK_NAME.to_string()),
855 namespace,
856 package,
857 interface,
858 )) {
859 failures.push(ValidationFailure::new(
860 ValidationFailureLevel::Error,
861 format!(
862 "Duplicate link found inside component '{}': {} ({}:{}/{})",
863 component.name,
864 name.clone()
865 .unwrap_or_else(|| DEFAULT_LINK_NAME.to_string()),
866 namespace,
867 package,
868 interface
869 ),
870 ));
871 };
872 }
873 }
874 }
875 }
876 failures
877}
878
879#[cfg(test)]
880mod tests {
881 use super::is_valid_manifest_name;
882
883 const VALID_MANIFEST_NAMES: [&str; 4] = [
884 "mymanifest",
885 "my-manifest",
886 "my_manifest",
887 "mymanifest-v2-v3-final",
888 ];
889
890 const INVALID_MANIFEST_NAMES: [&str; 2] = ["my.manifest", "my manifest"];
891
892 #[test]
894 fn manifest_names_valid() {
895 for valid in VALID_MANIFEST_NAMES {
897 assert!(is_valid_manifest_name(valid));
898 }
899 }
900
901 #[test]
903 fn manifest_names_invalid() {
904 for invalid in INVALID_MANIFEST_NAMES {
905 assert!(!is_valid_manifest_name(invalid))
906 }
907 }
908}