Skip to main content

sigmf/
validation.rs

1//! Semantic validation of SigMF metadata and collections.
2//!
3//! Validation complements parsing: a document that deserializes successfully
4//! may still violate SigMF rules or use features this crate cannot vouch for.
5//! The entry points are [`Metadata::validate`], [`Collection::validate`], their
6//! `validate_with_registry` variants, and
7//! [`Archive::validate_collection_references`](crate::Archive::validate_collection_references).
8//! Each returns a [`ValidationReport`] listing every [`ValidationIssue`] found
9//! and an overall [`ValidationStatus`].
10//!
11//! # What is checked
12//!
13//! For `.sigmf-meta` documents ([`Metadata`]):
14//!
15//! - `core:version` is the certified version (`1.2.6`);
16//! - `core:sample_rate` is finite, positive, and at most 10<sup>12</sup>;
17//! - indices and counts (`core:num_channels`, `core:offset`,
18//!   `core:sample_start`, `core:sample_count`, ...) fit in 2<sup>63</sup> − 1,
19//!   and `core:num_channels` is non-zero;
20//! - `core:sha512` is 128 hexadecimal characters;
21//! - frequencies are finite, annotation frequency edges are paired and ordered,
22//!   and annotation sample ranges do not overflow;
23//! - `core:datetime` uses the UTC `YYYY-MM-DDTHH:MM:SS[.fraction]Z` form;
24//! - `core:geolocation` is a GeoJSON `Point` with two or three finite coordinates;
25//! - captures and annotations are sorted by `core:sample_start`, and sample
26//!   indices are not before `core:offset`;
27//! - unrecognized `core:` fields, and extension fields whose namespace is not
28//!   declared in `core:extensions`;
29//! - declared extensions are checked against an optional
30//!   [`ExtensionRegistry`](crate::extensions::ExtensionRegistry) for support,
31//!   scope, and field names.
32//!
33//! For `.sigmf-collection` documents ([`Collection`]): version, recording
34//! names (relative, no `..`, no backslashes, no empty segments), duplicate
35//! references, metadata digests, unknown `core:` fields, and extensions.
36//!
37//! # Policies
38//!
39//! A [`ValidationPolicy`] controls how strictly portability concerns are
40//! treated. Under [`ValidationPolicy::Interoperability`] an unsupported version,
41//! unknown `core:` fields, and unregistered required extensions are warnings;
42//! under [`ValidationPolicy::StrictSupportedVersion`] they are errors.
43//!
44//! # Example
45//!
46//! ```rust
47//! use sigmf::datatype::Datatype;
48//! use sigmf::global::Global;
49//! use sigmf::meta::Metadata;
50//! use sigmf::validation::{ValidationPolicy, ValidationStatus};
51//! use std::str::FromStr;
52//!
53//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
54//! let mut metadata = Metadata::new(
55//!     Global::new("1.2.6", Datatype::from_str("cf32_le")?),
56//!     Vec::new(),
57//!     Vec::new(),
58//! );
59//! metadata.set_sample_rate(1e6);
60//! let report = metadata.validate(ValidationPolicy::StrictSupportedVersion);
61//! assert_eq!(report.status, ValidationStatus::Passed);
62//!
63//! metadata.set_sample_rate(-1.0);
64//! let report = metadata.validate(ValidationPolicy::StrictSupportedVersion);
65//! assert_eq!(report.status, ValidationStatus::Failed);
66//! assert!(report
67//!     .issues
68//!     .iter()
69//!     .any(|issue| issue.code == "sample-rate-out-of-range"));
70//! # Ok(())
71//! # }
72//! ```
73
74use crate::collection::Collection;
75use crate::meta::Metadata;
76use geojson::{GeoJson, GeometryValue};
77use std::collections::HashSet;
78
79/// How strictly validation treats content this implementation cannot fully verify.
80///
81/// The policy only affects the severity of portability-related issues
82/// (unsupported `core:version`, unknown `core:` fields, and unregistered
83/// required extensions). Structural and value errors are always
84/// [`Severity::Error`].
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ValidationPolicy {
87    /// Accept documents that may be valid under other SigMF versions or with
88    /// extensions this application does not understand.
89    ///
90    /// Unsupported versions, unknown `core:` fields, and unregistered required
91    /// extensions are reported as [`Severity::Warning`].
92    Interoperability,
93    /// Require a version and feature set this implementation certifies.
94    ///
95    /// Unsupported versions, unknown `core:` fields, and unregistered required
96    /// extensions are reported as [`Severity::Error`].
97    StrictSupportedVersion,
98}
99
100/// Severity of a [`ValidationIssue`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Severity {
103    /// A violation of the specification or of the selected policy; causes
104    /// [`ValidationStatus::Failed`].
105    Error,
106    /// A recommendation violation or a condition that could not be verified.
107    Warning,
108    /// Purely informational; does not affect the outcome.
109    Information,
110}
111
112/// Overall outcome of a validation run.
113///
114/// The status is derived from the report's issues in priority order:
115///
116/// 1. [`Failed`](Self::Failed) if any issue has [`Severity::Error`];
117/// 2. otherwise [`NotChecked`](Self::NotChecked) if any issue indicates content
118///    that could not be verified (issue codes `unsupported-version`,
119///    `unknown-core-field`, `required-extension-unverified`,
120///    `optional-extension-unverified`, `undeclared-extension-field`,
121///    `unrecognized-extension-field`, or `unresolved-recording-reference`);
122/// 3. otherwise [`Passed`](Self::Passed).
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ValidationStatus {
125    /// No errors and nothing left unverified.
126    ///
127    /// Other warnings (for example `sample-index-before-offset`) may still be
128    /// present in [`ValidationReport::issues`].
129    Passed,
130    /// At least one issue has [`Severity::Error`].
131    Failed,
132    /// No errors were found, but some content could not be verified, such as
133    /// an unsupported version, an unknown `core:` field, or an extension
134    /// without registered support.
135    NotChecked,
136}
137
138/// A single problem found during validation.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ValidationIssue {
141    /// Stable, machine-readable identifier such as `"segments-not-sorted"`.
142    pub code: &'static str,
143    /// Severity of the issue under the policy used for validation.
144    pub severity: Severity,
145    /// [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON Pointer to the
146    /// offending location, e.g. `"/captures/0/core:datetime"`.
147    pub json_pointer: String,
148    /// The `core:version` declared by the validated document.
149    pub declared_version: String,
150    /// Human-readable description of the problem.
151    pub message: String,
152    /// Optional suggestion for resolving the problem.
153    pub remediation: Option<String>,
154}
155
156/// The result of validating a SigMF document.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ValidationReport {
159    /// Overall outcome derived from [`issues`](Self::issues); see
160    /// [`ValidationStatus`].
161    pub status: ValidationStatus,
162    /// Every issue found, in the order checks were performed.
163    pub issues: Vec<ValidationIssue>,
164    /// Optional revision identifier for the validated document; validation
165    /// itself leaves this as `None`.
166    pub revision: Option<String>,
167    /// The policy used to produce the report, if recorded.
168    ///
169    /// Set by [`Metadata::validate`] and [`Metadata::validate_with_registry`];
170    /// `None` for collection and archive-reference reports.
171    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    /// Returns `true` only if [`status`](Self::status) is
203    /// [`ValidationStatus::Passed`].
204    ///
205    /// Reports with status [`ValidationStatus::NotChecked`] are not
206    /// considered valid.
207    pub fn is_valid(&self) -> bool {
208        self.status == ValidationStatus::Passed
209    }
210}
211
212impl Metadata {
213    /// Validates this metadata against SigMF rules using `policy`.
214    ///
215    /// Extensions declared in `core:extensions` are reported as unverified
216    /// because no [`ExtensionRegistry`](crate::extensions::ExtensionRegistry)
217    /// is supplied; use [`validate_with_registry`](Self::validate_with_registry)
218    /// to verify them. See the [module documentation](self) for the full list
219    /// of checks.
220    ///
221    /// # Example
222    ///
223    /// ```rust
224    /// use sigmf::datatype::Datatype;
225    /// use sigmf::global::Global;
226    /// use sigmf::meta::Metadata;
227    /// use sigmf::validation::{Severity, ValidationPolicy, ValidationStatus};
228    /// use std::str::FromStr;
229    ///
230    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
231    /// // An older version is tolerated under the interoperability policy...
232    /// let metadata = Metadata::new(
233    ///     Global::new("1.0.0", Datatype::from_str("ri16_le")?),
234    ///     Vec::new(),
235    ///     Vec::new(),
236    /// );
237    /// let report = metadata.validate(ValidationPolicy::Interoperability);
238    /// assert_eq!(report.status, ValidationStatus::NotChecked);
239    /// assert_eq!(report.issues[0].severity, Severity::Warning);
240    ///
241    /// // ...but rejected under the strict policy.
242    /// let report = metadata.validate(ValidationPolicy::StrictSupportedVersion);
243    /// assert_eq!(report.status, ValidationStatus::Failed);
244    /// assert!(!report.is_valid());
245    /// # Ok(())
246    /// # }
247    /// ```
248    pub fn validate(&self, policy: ValidationPolicy) -> ValidationReport {
249        self.validate_with_optional_registry(policy, None)
250    }
251
252    /// Validates this metadata, using `registry` to verify declared extensions.
253    ///
254    /// In addition to the checks performed by [`validate`](Self::validate),
255    /// extensions declared in `core:extensions` whose name and version are
256    /// registered are treated as verified, and their fields in the global,
257    /// capture, and annotation objects are checked for the correct
258    /// [`ExtensionScope`](crate::extensions::ExtensionScope) (an error) and for
259    /// being declared by the extension (a warning).
260    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    /// Validates this collection against SigMF rules using `policy`.
612    ///
613    /// Checks the collection version, that each stream's recording name is a
614    /// safe relative path and is unique, that each metadata digest is 128
615    /// hexadecimal characters, unknown `core:` fields, and extension
616    /// declarations. Recording references are not resolved; see
617    /// [`Archive::validate_collection_references`](crate::Archive::validate_collection_references).
618    ///
619    /// The returned report's [`policy`](ValidationReport::policy) is `None`.
620    pub fn validate(&self, policy: ValidationPolicy) -> ValidationReport {
621        self.validate_with_optional_registry(policy, None)
622    }
623
624    /// Validates this collection, using `registry` to verify declared extensions.
625    ///
626    /// Like [`validate`](Self::validate), but extensions registered in
627    /// `registry` are treated as verified and their fields in the `collection`
628    /// object are checked for scope and field names.
629    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    /// Checks that the archive's collection streams resolve to its recordings.
764    ///
765    /// Each `core:streams` entry is matched by name against the archive's
766    /// [`recordings`](crate::Archive::recordings). A missing recording yields an
767    /// `unresolved-recording-reference` warning (making the status
768    /// [`ValidationStatus::NotChecked`]); a recording whose metadata hash
769    /// differs (case-insensitively) from the reference yields a
770    /// `collection-reference-digest-mismatch` error.
771    ///
772    /// Returns `None` if the archive has no collection.
773    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}