1use crate::collection::Collection;
75use crate::meta::Metadata;
76use geojson::{GeoJson, GeometryValue};
77use std::collections::HashSet;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ValidationPolicy {
87 Interoperability,
93 StrictSupportedVersion,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Severity {
103 Error,
106 Warning,
108 Information,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ValidationStatus {
125 Passed,
130 Failed,
132 NotChecked,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ValidationIssue {
141 pub code: &'static str,
143 pub severity: Severity,
145 pub json_pointer: String,
148 pub declared_version: String,
150 pub message: String,
152 pub remediation: Option<String>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ValidationReport {
159 pub status: ValidationStatus,
162 pub issues: Vec<ValidationIssue>,
164 pub revision: Option<String>,
167 pub policy: Option<ValidationPolicy>,
172}
173
174impl ValidationReport {
175 pub(crate) fn new(issues: Vec<ValidationIssue>) -> Self {
176 let status = if issues.iter().any(|issue| issue.severity == Severity::Error) {
177 ValidationStatus::Failed
178 } else if issues.iter().any(|issue| {
179 matches!(
180 issue.code,
181 "unsupported-version"
182 | "unknown-core-field"
183 | "required-extension-unverified"
184 | "optional-extension-unverified"
185 | "undeclared-extension-field"
186 | "unrecognized-extension-field"
187 | "unresolved-recording-reference"
188 )
189 }) {
190 ValidationStatus::NotChecked
191 } else {
192 ValidationStatus::Passed
193 };
194 Self {
195 status,
196 issues,
197 revision: None,
198 policy: None,
199 }
200 }
201
202 pub fn is_valid(&self) -> bool {
208 self.status == ValidationStatus::Passed
209 }
210}
211
212impl Metadata {
213 pub fn validate(&self, policy: ValidationPolicy) -> ValidationReport {
249 self.validate_with_optional_registry(policy, None)
250 }
251
252 pub fn validate_with_registry(
261 &self,
262 policy: ValidationPolicy,
263 registry: &crate::extensions::ExtensionRegistry,
264 ) -> ValidationReport {
265 self.validate_with_optional_registry(policy, Some(registry))
266 }
267
268 fn validate_with_optional_registry(
269 &self,
270 policy: ValidationPolicy,
271 registry: Option<&crate::extensions::ExtensionRegistry>,
272 ) -> ValidationReport {
273 let mut issues = Vec::new();
274 let version = self.global().version.as_str();
275 if version != "1.2.6" {
276 let severity = match policy {
277 ValidationPolicy::Interoperability => Severity::Warning,
278 ValidationPolicy::StrictSupportedVersion => Severity::Error,
279 };
280 push_issue(
281 &mut issues,
282 "unsupported-version",
283 severity,
284 "/global/core:version",
285 version,
286 format!("SigMF {version} is not certified by this implementation"),
287 Some(
288 "Use a supported version or validate with an implementation that certifies it."
289 .into(),
290 ),
291 );
292 }
293
294 let global = self.global();
295 if global.sample_rate.is_some_and(|number| !number.is_finite()) {
296 push_issue(
297 &mut issues,
298 "non-finite-number",
299 Severity::Error,
300 "/global/core:sample_rate",
301 version,
302 "metadata numbers must be finite JSON numbers".into(),
303 None,
304 );
305 }
306 if let Some(sample_rate) = global.sample_rate
307 && (sample_rate <= 0.0 || sample_rate > 1_000_000_000_000.0)
308 {
309 push_issue(
310 &mut issues,
311 "sample-rate-out-of-range",
312 Severity::Error,
313 "/global/core:sample_rate",
314 version,
315 "sample rate must be greater than zero and at most 1,000,000,000,000".into(),
316 None,
317 );
318 }
319 check_geolocation(
320 &mut issues,
321 global.geolocation.as_ref(),
322 "/global/core:geolocation",
323 version,
324 );
325 check_index(
326 &mut issues,
327 global.num_channels,
328 "/global/core:num_channels",
329 version,
330 );
331 check_index(&mut issues, global.offset, "/global/core:offset", version);
332 check_index(
333 &mut issues,
334 global.trailing_bytes,
335 "/global/core:trailing_bytes",
336 version,
337 );
338 if global.num_channels == Some(0) {
339 push_issue(
340 &mut issues,
341 "zero-channel-count",
342 Severity::Error,
343 "/global/core:num_channels",
344 version,
345 "channel count must be greater than zero".into(),
346 None,
347 );
348 }
349 if let Some(hash) = &global.sha512
350 && (hash.len() != 128 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
351 {
352 push_issue(
353 &mut issues,
354 "invalid-dataset-digest",
355 Severity::Error,
356 "/global/core:sha512",
357 version,
358 "dataset digest must contain exactly 128 hexadecimal characters".into(),
359 Some("Recompute the digest from the dataset-file bytes.".into()),
360 );
361 }
362
363 check_extra_core_fields(
364 &mut issues,
365 global.extra_fields(),
366 "/global",
367 version,
368 policy,
369 );
370 let declarations = global.extensions.as_deref().unwrap_or(&[]);
371 check_extension_fields(
372 &mut issues,
373 global.extra_fields(),
374 crate::extensions::ExtensionScope::Global,
375 "/global",
376 declarations,
377 registry,
378 version,
379 );
380 check_extra_core_fields(&mut issues, self.extra_fields(), "", version, policy);
381
382 for (index, capture) in self.captures().iter().enumerate() {
383 let pointer = format!("/captures/{index}");
384 check_offset_bound(
385 &mut issues,
386 capture.sample_start,
387 global.offset.unwrap_or(0),
388 &format!("{pointer}/core:sample_start"),
389 version,
390 );
391 check_index(
392 &mut issues,
393 Some(capture.sample_start),
394 &format!("{pointer}/core:sample_start"),
395 version,
396 );
397 check_finite(
398 &mut issues,
399 capture.frequency,
400 &format!("{pointer}/core:frequency"),
401 version,
402 );
403 if let Some(datetime) = &capture.datetime
404 && !is_sigmf_timestamp(datetime)
405 {
406 push_issue(
407 &mut issues,
408 "invalid-utc-timestamp",
409 Severity::Error,
410 &format!("{pointer}/core:datetime"),
411 version,
412 "timestamp must use UTC YYYY-MM-DDTHH:MM:SS[.fraction]Z form".into(),
413 None,
414 );
415 }
416 check_geolocation(
417 &mut issues,
418 capture.geolocation.as_ref(),
419 &format!("{pointer}/core:geolocation"),
420 version,
421 );
422 check_index(
423 &mut issues,
424 capture.global_index,
425 &format!("{pointer}/core:global_index"),
426 version,
427 );
428 check_index(
429 &mut issues,
430 capture.header_bytes,
431 &format!("{pointer}/core:header_bytes"),
432 version,
433 );
434 check_extra_core_fields(
435 &mut issues,
436 capture.extra_fields(),
437 &pointer,
438 version,
439 policy,
440 );
441 check_extension_fields(
442 &mut issues,
443 capture.extra_fields(),
444 crate::extensions::ExtensionScope::Capture,
445 &pointer,
446 declarations,
447 registry,
448 version,
449 );
450 }
451 check_sorted(
452 &mut issues,
453 self.captures().iter().map(|capture| capture.sample_start),
454 "/captures",
455 version,
456 );
457
458 for (index, annotation) in self.annotations().iter().enumerate() {
459 let pointer = format!("/annotations/{index}");
460 check_offset_bound(
461 &mut issues,
462 annotation.sample_start,
463 global.offset.unwrap_or(0),
464 &format!("{pointer}/core:sample_start"),
465 version,
466 );
467 check_index(
468 &mut issues,
469 Some(annotation.sample_start),
470 &format!("{pointer}/core:sample_start"),
471 version,
472 );
473 check_index(
474 &mut issues,
475 annotation.sample_count,
476 &format!("{pointer}/core:sample_count"),
477 version,
478 );
479 if let Some(sample_count) = annotation.sample_count {
480 match annotation.sample_start.checked_add(sample_count) {
481 None => push_issue(
482 &mut issues,
483 "sample-range-overflow",
484 Severity::Error,
485 &pointer,
486 version,
487 "annotation sample range overflows the supported index type".into(),
488 None,
489 ),
490 Some(end) if end > i64::MAX as u64 => push_issue(
491 &mut issues,
492 "sample-range-out-of-range",
493 Severity::Error,
494 &pointer,
495 version,
496 "annotation sample range exceeds the SigMF 1.2.6 index limit".into(),
497 None,
498 ),
499 Some(_) => {}
500 }
501 }
502 check_finite(
503 &mut issues,
504 annotation.freq_lower_edge,
505 &format!("{pointer}/core:freq_lower_edge"),
506 version,
507 );
508 check_finite(
509 &mut issues,
510 annotation.freq_upper_edge,
511 &format!("{pointer}/core:freq_upper_edge"),
512 version,
513 );
514 if annotation.freq_lower_edge.is_some() != annotation.freq_upper_edge.is_some() {
515 push_issue(
516 &mut issues,
517 "unpaired-frequency-edges",
518 Severity::Error,
519 &pointer,
520 version,
521 "frequency lower and upper edges must be supplied together".into(),
522 None,
523 );
524 }
525 if let (Some(lower), Some(upper)) =
526 (annotation.freq_lower_edge, annotation.freq_upper_edge)
527 && lower > upper
528 {
529 push_issue(
530 &mut issues,
531 "reversed-frequency-edges",
532 Severity::Error,
533 &pointer,
534 version,
535 "frequency lower edge exceeds the upper edge".into(),
536 None,
537 );
538 }
539 check_extra_core_fields(
540 &mut issues,
541 annotation.extra_fields(),
542 &pointer,
543 version,
544 policy,
545 );
546 check_extension_fields(
547 &mut issues,
548 annotation.extra_fields(),
549 crate::extensions::ExtensionScope::Annotation,
550 &pointer,
551 declarations,
552 registry,
553 version,
554 );
555 }
556 check_sorted(
557 &mut issues,
558 self.annotations()
559 .iter()
560 .map(|annotation| annotation.sample_start),
561 "/annotations",
562 version,
563 );
564
565 if let Some(extensions) = &global.extensions {
566 for (index, extension) in extensions.iter().enumerate() {
567 if registry
568 .is_some_and(|registry| registry.supports(&extension.name, &extension.version))
569 {
570 continue;
571 }
572 let severity = match (extension.optional, policy) {
573 (true, _) => Severity::Warning,
574 (false, ValidationPolicy::Interoperability) => Severity::Warning,
575 (false, ValidationPolicy::StrictSupportedVersion) => Severity::Error,
576 };
577 let code = if extension.optional {
578 "optional-extension-unverified"
579 } else {
580 "required-extension-unverified"
581 };
582 push_issue(
583 &mut issues,
584 code,
585 severity,
586 &format!("/global/core:extensions/{index}"),
587 version,
588 format!(
589 "{} extension {:?} has no registered semantic support",
590 if extension.optional {
591 "optional"
592 } else {
593 "required"
594 },
595 extension.name
596 ),
597 Some(
598 "Register and validate the extension before relying on its semantics."
599 .into(),
600 ),
601 );
602 }
603 }
604 let mut report = ValidationReport::new(issues);
605 report.policy = Some(policy);
606 report
607 }
608}
609
610impl Collection {
611 pub fn validate(&self, policy: ValidationPolicy) -> ValidationReport {
621 self.validate_with_optional_registry(policy, None)
622 }
623
624 pub fn validate_with_registry(
630 &self,
631 policy: ValidationPolicy,
632 registry: &crate::extensions::ExtensionRegistry,
633 ) -> ValidationReport {
634 self.validate_with_optional_registry(policy, Some(registry))
635 }
636
637 fn validate_with_optional_registry(
638 &self,
639 policy: ValidationPolicy,
640 registry: Option<&crate::extensions::ExtensionRegistry>,
641 ) -> ValidationReport {
642 let version = self.collection.version.as_str();
643 let mut issues = Vec::new();
644 if version != "1.2.6" {
645 push_issue(
646 &mut issues,
647 "unsupported-version",
648 match policy {
649 ValidationPolicy::Interoperability => Severity::Warning,
650 ValidationPolicy::StrictSupportedVersion => Severity::Error,
651 },
652 "/collection/core:version",
653 version,
654 format!("SigMF collection version {version} is not certified"),
655 None,
656 );
657 }
658 let mut names = HashSet::new();
659 for (index, recording) in self.collection.streams.iter().enumerate() {
660 let pointer = format!("/collection/core:streams/{index}");
661 let name = recording.get_name();
662 if name.is_empty()
663 || name.starts_with('/')
664 || name.contains('\\')
665 || name.split('/').any(|part| part == ".." || part.is_empty())
666 {
667 push_issue(
668 &mut issues,
669 "invalid-recording-name",
670 Severity::Error,
671 &format!("{pointer}/name"),
672 version,
673 "recording reference name must be a non-empty relative member path".into(),
674 None,
675 );
676 }
677 if !names.insert(name) {
678 push_issue(
679 &mut issues,
680 "duplicate-recording-reference",
681 Severity::Error,
682 &format!("{pointer}/name"),
683 version,
684 format!("recording {name:?} is referenced more than once"),
685 None,
686 );
687 }
688 let digest = recording.get_hash();
689 if digest.len() != 128 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
690 push_issue(
691 &mut issues,
692 "invalid-metadata-digest",
693 Severity::Error,
694 &format!("{pointer}/hash"),
695 version,
696 "metadata digest must contain exactly 128 hexadecimal characters".into(),
697 None,
698 );
699 }
700 check_extra_core_fields(
701 &mut issues,
702 recording.extra_fields(),
703 &pointer,
704 version,
705 policy,
706 );
707 }
708 check_extra_core_fields(
709 &mut issues,
710 self.collection.extra_fields(),
711 "/collection",
712 version,
713 policy,
714 );
715 let declarations = self.collection.extensions.as_deref().unwrap_or(&[]);
716 check_extension_fields(
717 &mut issues,
718 self.collection.extra_fields(),
719 crate::extensions::ExtensionScope::Collection,
720 "/collection",
721 declarations,
722 registry,
723 version,
724 );
725 check_extra_core_fields(&mut issues, self.extra_fields(), "", version, policy);
726 if let Some(extensions) = &self.collection.extensions {
727 for (index, extension) in extensions.iter().enumerate() {
728 if registry
729 .is_some_and(|registry| registry.supports(&extension.name, &extension.version))
730 {
731 continue;
732 }
733 let severity = if extension.optional || policy == ValidationPolicy::Interoperability
734 {
735 Severity::Warning
736 } else {
737 Severity::Error
738 };
739 let code = if extension.optional {
740 "optional-extension-unverified"
741 } else {
742 "required-extension-unverified"
743 };
744 push_issue(
745 &mut issues,
746 code,
747 severity,
748 &format!("/collection/core:extensions/{index}"),
749 version,
750 format!(
751 "extension {:?} has no registered semantic support",
752 extension.name
753 ),
754 None,
755 );
756 }
757 }
758 ValidationReport::new(issues)
759 }
760}
761
762impl crate::Archive {
763 pub fn validate_collection_references(&self) -> Option<ValidationReport> {
774 let collection = self.collection.as_ref()?;
775 let version = collection.collection.version.as_str();
776 let mut issues = Vec::new();
777 for (index, reference) in collection.collection.streams.iter().enumerate() {
778 let pointer = format!("/collection/core:streams/{index}");
779 let Some(recording) = self
780 .recordings
781 .iter()
782 .find(|recording| recording.get_name() == reference.get_name())
783 else {
784 push_issue(
785 &mut issues,
786 "unresolved-recording-reference",
787 Severity::Warning,
788 &format!("{pointer}/name"),
789 version,
790 format!(
791 "recording {:?} is not present in this archive",
792 reference.get_name()
793 ),
794 Some("Resolve the reference against the complete recording collection.".into()),
795 );
796 continue;
797 };
798 if !recording
799 .get_hash()
800 .eq_ignore_ascii_case(reference.get_hash())
801 {
802 push_issue(
803 &mut issues,
804 "collection-reference-digest-mismatch",
805 Severity::Error,
806 &format!("{pointer}/hash"),
807 version,
808 format!(
809 "recording {:?} does not match the referenced metadata digest",
810 reference.get_name()
811 ),
812 Some("Update the collection reference to the emitted metadata digest.".into()),
813 );
814 }
815 }
816 Some(ValidationReport::new(issues))
817 }
818}
819
820fn check_index(
821 issues: &mut Vec<ValidationIssue>,
822 value: Option<u64>,
823 pointer: &str,
824 version: &str,
825) {
826 if value.is_some_and(|value| value > i64::MAX as u64) {
827 push_issue(
828 issues,
829 "index-out-of-range",
830 Severity::Error,
831 pointer,
832 version,
833 "value exceeds the SigMF 1.2.6 maximum of 2^63 - 1".into(),
834 None,
835 );
836 }
837}
838
839fn check_offset_bound(
840 issues: &mut Vec<ValidationIssue>,
841 sample_index: u64,
842 offset: u64,
843 pointer: &str,
844 version: &str,
845) {
846 if sample_index < offset {
847 push_issue(
848 issues,
849 "sample-index-before-offset",
850 Severity::Warning,
851 pointer,
852 version,
853 format!("sample index {sample_index} is less than core:offset {offset}"),
854 None,
855 );
856 }
857}
858
859fn check_geolocation(
860 issues: &mut Vec<ValidationIssue>,
861 value: Option<&GeoJson>,
862 pointer: &str,
863 version: &str,
864) {
865 let Some(value) = value else {
866 return;
867 };
868 let coordinates = match value {
869 GeoJson::Geometry(geometry) => match &geometry.value {
870 GeometryValue::Point { coordinates } => Some(coordinates),
871 _ => None,
872 },
873 _ => None,
874 };
875 if !coordinates.is_some_and(|coordinates| {
876 (2..=3).contains(&coordinates.len())
877 && coordinates
878 .as_slice()
879 .iter()
880 .all(|coordinate| coordinate.is_finite())
881 }) {
882 push_issue(
883 issues,
884 "invalid-geolocation",
885 Severity::Error,
886 pointer,
887 version,
888 "geolocation must be a GeoJSON Point with two or three finite coordinates".into(),
889 None,
890 );
891 }
892}
893
894fn check_finite(
895 issues: &mut Vec<ValidationIssue>,
896 value: Option<f64>,
897 pointer: &str,
898 version: &str,
899) {
900 if value.is_some_and(|value| !value.is_finite()) {
901 push_issue(
902 issues,
903 "non-finite-number",
904 Severity::Error,
905 pointer,
906 version,
907 "metadata numbers must be finite JSON numbers".into(),
908 None,
909 );
910 }
911}
912
913fn check_sorted(
914 issues: &mut Vec<ValidationIssue>,
915 values: impl Iterator<Item = u64>,
916 pointer: &str,
917 version: &str,
918) {
919 let mut previous = None;
920 let mut unsorted = false;
921 for value in values {
922 if previous.is_some_and(|previous| previous > value) {
923 unsorted = true;
924 break;
925 }
926 previous = Some(value);
927 }
928 if unsorted {
929 push_issue(
930 issues,
931 "segments-not-sorted",
932 Severity::Error,
933 pointer,
934 version,
935 "segments must be ordered by core:sample_start".into(),
936 Some("Use the explicit stable-sort normalization operation.".into()),
937 );
938 }
939}
940
941fn check_extra_core_fields(
942 issues: &mut Vec<ValidationIssue>,
943 extra_fields: &serde_json::Map<String, serde_json::Value>,
944 pointer: &str,
945 version: &str,
946 policy: ValidationPolicy,
947) {
948 for key in extra_fields.keys().filter(|key| key.starts_with("core:")) {
949 let severity = match policy {
950 ValidationPolicy::Interoperability => Severity::Warning,
951 ValidationPolicy::StrictSupportedVersion => Severity::Error,
952 };
953 push_issue(
954 issues,
955 "unknown-core-field",
956 severity,
957 &format!("{pointer}/{}", escape_pointer(key)),
958 version,
959 format!("unrecognized core field {key:?} is preserved but unsupported"),
960 None,
961 );
962 }
963}
964
965fn check_extension_fields(
966 issues: &mut Vec<ValidationIssue>,
967 fields: &serde_json::Map<String, serde_json::Value>,
968 scope: crate::extensions::ExtensionScope,
969 pointer: &str,
970 declarations: &[crate::global::ExtensionDeclaration],
971 registry: Option<&crate::extensions::ExtensionRegistry>,
972 version: &str,
973) {
974 for field in fields.keys() {
975 let Some((namespace, _)) = field.split_once(':') else {
976 continue;
977 };
978 if namespace == "core" {
979 continue;
980 }
981 let field_pointer = format!("{pointer}/{}", escape_pointer(field));
982 let Some(declaration) = declarations
983 .iter()
984 .find(|declaration| declaration.name == namespace)
985 else {
986 push_issue(
987 issues,
988 "undeclared-extension-field",
989 Severity::Warning,
990 &field_pointer,
991 version,
992 format!("extension field {field:?} has no matching core:extensions declaration"),
993 Some("Declare the extension and version that define this field.".into()),
994 );
995 continue;
996 };
997 let Some(registry) = registry else {
998 continue;
999 };
1000 if !registry.supports(&declaration.name, &declaration.version) {
1001 continue;
1002 }
1003 if !registry.supports_scope(&declaration.name, &declaration.version, scope) {
1004 push_issue(
1005 issues,
1006 "extension-scope-mismatch",
1007 Severity::Error,
1008 &field_pointer,
1009 version,
1010 format!("extension field {field:?} is not valid at the {scope:?} scope"),
1011 None,
1012 );
1013 } else if !registry.supports_field(&declaration.name, &declaration.version, scope, field) {
1014 push_issue(
1015 issues,
1016 "unrecognized-extension-field",
1017 Severity::Warning,
1018 &field_pointer,
1019 version,
1020 format!("registered extension does not define field {field:?}"),
1021 None,
1022 );
1023 }
1024 }
1025}
1026
1027fn push_issue(
1028 issues: &mut Vec<ValidationIssue>,
1029 code: &'static str,
1030 severity: Severity,
1031 pointer: &str,
1032 version: &str,
1033 message: String,
1034 remediation: Option<String>,
1035) {
1036 issues.push(ValidationIssue {
1037 code,
1038 severity,
1039 json_pointer: pointer.to_string(),
1040 declared_version: version.to_string(),
1041 message,
1042 remediation,
1043 });
1044}
1045
1046fn escape_pointer(value: &str) -> String {
1047 value.replace('~', "~0").replace('/', "~1")
1048}
1049
1050fn is_sigmf_timestamp(value: &str) -> bool {
1051 let Some(value) = value.strip_suffix('Z') else {
1052 return false;
1053 };
1054 let (date, time) = match value.split_once('T') {
1055 Some(parts) => parts,
1056 None => return false,
1057 };
1058 let mut date_parts = date.split('-');
1059 let (Some(year), Some(month), Some(day), None) = (
1060 date_parts.next(),
1061 date_parts.next(),
1062 date_parts.next(),
1063 date_parts.next(),
1064 ) else {
1065 return false;
1066 };
1067 let (Ok(year), Ok(month), Ok(day)) = (
1068 year.parse::<u32>(),
1069 month.parse::<u32>(),
1070 day.parse::<u32>(),
1071 ) else {
1072 return false;
1073 };
1074 let days_in_month = match month {
1075 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1076 4 | 6 | 9 | 11 => 30,
1077 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
1078 2 => 28,
1079 _ => return false,
1080 };
1081 if day == 0 || day > days_in_month {
1082 return false;
1083 }
1084
1085 let (clock, fraction) = match time.split_once('.') {
1086 Some((clock, fraction))
1087 if !fraction.is_empty() && fraction.bytes().all(|b| b.is_ascii_digit()) =>
1088 {
1089 (clock, Some(fraction))
1090 }
1091 Some(_) => return false,
1092 None => (time, None),
1093 };
1094 let mut clock_parts = clock.split(':');
1095 let (Some(hour), Some(minute), Some(second), None) = (
1096 clock_parts.next(),
1097 clock_parts.next(),
1098 clock_parts.next(),
1099 clock_parts.next(),
1100 ) else {
1101 return false;
1102 };
1103 let (Ok(hour), Ok(minute), Ok(second)) = (
1104 hour.parse::<u32>(),
1105 minute.parse::<u32>(),
1106 second.parse::<u32>(),
1107 ) else {
1108 return false;
1109 };
1110 let _ = fraction;
1111 hour <= 23 && minute <= 59 && second <= 60
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 use crate::{datatype::Datatype, global::Global, meta::Metadata};
1118 use std::str::FromStr;
1119
1120 fn metadata() -> Metadata {
1121 Metadata::new(
1122 Global::new("1.2.6", Datatype::from_str("ri16_le").unwrap()),
1123 Vec::new(),
1124 Vec::new(),
1125 )
1126 }
1127
1128 #[test]
1129 fn distinguishes_errors_from_recommendation_warnings() {
1130 let mut metadata = metadata();
1131 let mut lower_only = crate::annotation::Annotation::new(
1132 0,
1133 Some(0),
1134 Some(10.0),
1135 None,
1136 None,
1137 None,
1138 None,
1139 None,
1140 );
1141 lower_only.set_label("long label".into());
1142 metadata.add_annotation(lower_only);
1143
1144 let report = metadata.validate(ValidationPolicy::StrictSupportedVersion);
1145 assert_eq!(report.status, ValidationStatus::Failed);
1146 assert!(
1147 report
1148 .issues
1149 .iter()
1150 .any(|issue| issue.code == "unpaired-frequency-edges"
1151 && issue.severity == Severity::Error)
1152 );
1153 }
1154
1155 #[test]
1156 fn accepts_zero_length_annotation_and_valid_leap_second_timestamp() {
1157 let mut metadata = metadata();
1158 let mut capture = crate::capture::Capture::new(0);
1159 capture.set_datetime("2016-12-31T23:59:60.123Z".into());
1160 metadata.add_capture(capture);
1161 metadata.add_annotation(crate::annotation::Annotation::new(
1162 0,
1163 Some(0),
1164 None,
1165 None,
1166 None,
1167 None,
1168 None,
1169 None,
1170 ));
1171 let report = metadata.validate(ValidationPolicy::StrictSupportedVersion);
1172 assert_eq!(
1173 report.status,
1174 ValidationStatus::Passed,
1175 "{:?}",
1176 report.issues
1177 );
1178 }
1179
1180 #[test]
1181 fn validates_geolocation_shape_and_preserves_foreign_members() {
1182 let mut metadata = metadata();
1183 let point: GeoJson = serde_json::from_value(serde_json::json!({
1184 "type": "Point",
1185 "coordinates": [-122.0, 37.0],
1186 "vendor:datum": "WGS84"
1187 }))
1188 .unwrap();
1189 metadata.set_geolocation(point);
1190 assert_eq!(
1191 metadata
1192 .validate(ValidationPolicy::StrictSupportedVersion)
1193 .status,
1194 ValidationStatus::Passed
1195 );
1196 let serialized: serde_json::Value =
1197 serde_json::from_slice(&metadata.to_vec().unwrap()).unwrap();
1198 assert_eq!(
1199 serialized["global"]["core:geolocation"]["vendor:datum"],
1200 "WGS84"
1201 );
1202
1203 let invalid: GeoJson = serde_json::from_value(serde_json::json!({
1204 "type": "MultiPoint",
1205 "coordinates": [[-122.0, 37.0]]
1206 }))
1207 .unwrap();
1208 metadata.set_geolocation(invalid);
1209 assert!(
1210 metadata
1211 .validate(ValidationPolicy::StrictSupportedVersion)
1212 .issues
1213 .iter()
1214 .any(|issue| issue.code == "invalid-geolocation")
1215 );
1216 }
1217
1218 #[test]
1219 fn indices_before_core_offset_are_reported_as_recommendation_warnings() {
1220 let mut metadata = metadata();
1221 metadata.set_offset(10);
1222 metadata.add_capture(crate::capture::Capture::new(9));
1223 let report = metadata.validate(ValidationPolicy::StrictSupportedVersion);
1224 assert_eq!(report.status, ValidationStatus::Passed);
1225 assert!(report.issues.iter().any(|issue| {
1226 issue.code == "sample-index-before-offset" && issue.severity == Severity::Warning
1227 }));
1228 }
1229}