Skip to main content

sigmf/
lib.rs

1//! Read, write, and validate [SigMF] (Signal Metadata Format) recordings.
2//!
3//! SigMF describes a recording of digital samples with a JSON metadata file
4//! (`.sigmf-meta`) stored next to a binary dataset (`.sigmf-data`). Recordings
5//! may be grouped by a `.sigmf-collection` file and bundled into a single
6//! `.sigmf` tar archive. This crate implements the SigMF 1.2 specification:
7//!
8//! - **Metadata** — strongly typed [`global`], [`capture`], and [`annotation`]
9//!   objects that round-trip unknown and extension fields losslessly
10//!   ([`meta::Metadata`]).
11//! - **Datasets** — every SigMF [datatype](datatype::Datatype) (`cf32_le`,
12//!   `ri16_be`, `ru8`, ...) with multi-channel, non-conforming (NCD), and
13//!   streaming access ([`recording_file::Recording`],
14//!   [`recording_file::RecordingWriter`], [`utils::data`]).
15//! - **Archives** — create and read `.sigmf` tar archives ([`ArchiveWriter`],
16//!   [`Archive`]) with SHA-512 integrity checks.
17//! - **Collections** — `.sigmf-collection` files ([`collection::Collection`]).
18//! - **Validation** — structural and semantic checks with machine-readable
19//!   issues ([`validation`]).
20//! - **Extensions** — typed, namespaced metadata extensions
21//!   ([`extensions::SigmfExtension`], with an optional derive macro).
22//!
23//! # Quick start
24//!
25//! Write a recording to disk and stream it back:
26//!
27//! ```
28//! use sigmf::datatype::Datatype;
29//! use sigmf::global::Global;
30//! use sigmf::meta::Metadata;
31//! use sigmf::num_complex::Complex;
32//! use sigmf::recording_file::{Recording, RecordingWriter};
33//! use sigmf::utils::data::SampleValue;
34//! use std::str::FromStr;
35//!
36//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
37//! # let dir = std::env::temp_dir().join(format!("sigmf-crate-doc-{}", std::process::id()));
38//! # std::fs::create_dir_all(&dir)?;
39//! let mut metadata = Metadata::new(
40//!     Global::new("1.2.6", Datatype::from_str("cf32_le")?),
41//!     Vec::new(),
42//!     Vec::new(),
43//! );
44//! metadata.set_sample_rate(1e6);
45//! metadata.set_description("A short tone".to_string());
46//!
47//! // Writes `tone.sigmf-data`, then `tone.sigmf-meta` with `core:sha512` filled in.
48//! let mut writer = RecordingWriter::new(dir.join("tone.sigmf-meta"), metadata)?;
49//! let samples: Vec<SampleValue> = (0..1024)
50//!     .map(|n| {
51//!         let phase = n as f32 * 0.1;
52//!         SampleValue::ComplexF32(Complex::new(phase.cos(), phase.sin()))
53//!     })
54//!     .collect();
55//! writer.write_frames(&samples)?;
56//! writer.finish()?;
57//!
58//! let mut recording = Recording::open(dir.join("tone.sigmf-meta"))?;
59//! recording.verify_dataset_digest()?;
60//! assert_eq!(recording.frame_count()?, 1024);
61//! let start = recording.first_sample_index();
62//! for block in recording.frame_blocks(start, 1024, 256)? {
63//!     let block = block?;
64//!     assert_eq!(block.len(), 256);
65//! }
66//! # std::fs::remove_dir_all(&dir)?;
67//! # Ok(())
68//! # }
69//! ```
70//!
71//! Bundle recordings into a `.sigmf` archive and read them back:
72//!
73//! ```
74//! use sigmf::datatype::Datatype;
75//! use sigmf::global::Global;
76//! use sigmf::meta::Metadata;
77//! use sigmf::{Archive, ArchiveWriter};
78//! use std::str::FromStr;
79//!
80//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
81//! # let dir = std::env::temp_dir().join(format!("sigmf-crate-doc-archive-{}", std::process::id()));
82//! # std::fs::create_dir_all(&dir)?;
83//! let path = dir.join("capture.sigmf").to_string_lossy().into_owned();
84//! let metadata = Metadata::new(
85//!     Global::new("1.2.6", Datatype::from_str("ri8")?),
86//!     Vec::new(),
87//!     Vec::new(),
88//! );
89//!
90//! let mut writer = ArchiveWriter::new(path.clone())?;
91//! writer.add("burst", metadata, &[1u8, 2, 3, 4])?;
92//! writer.build()?;
93//!
94//! let archive = Archive::load(&path)?;
95//! assert_eq!(archive.get_names(), ["burst"]);
96//! let samples = archive.get_samples_from_name("burst")?;
97//! assert_eq!(samples.len(), 4); // `ri8` is real 8-bit: one byte per sample
98//! # std::fs::remove_dir_all(&dir)?;
99//! # Ok(())
100//! # }
101//! ```
102//!
103//! # Cargo features
104//!
105//! | Feature  | Description |
106//! |----------|-------------|
107//! | `derive` | Re-exports [`SigmfExtension`](macro@SigmfExtension), a derive macro for [`extensions::SigmfExtension`]. |
108//! | `schema` | JSON Schema generation and validation for extensions ([`extensions::extension_schema`], [`extensions::validate_json_schema`]). |
109//!
110//! No features are enabled by default.
111//!
112//! [SigMF]: https://sigmf.org
113#![cfg_attr(docsrs, feature(doc_cfg))]
114#![warn(missing_docs)]
115
116use core::fmt;
117use rayon::prelude::*;
118use std::collections::{HashMap, HashSet};
119use std::env;
120use std::error::Error;
121use std::fs::File;
122use std::io::{Read, Seek, SeekFrom};
123use tar::Archive as TarArchive;
124use utils::data::SampleValue;
125
126pub mod annotation;
127pub mod capture;
128pub mod collection;
129pub mod datatype;
130pub mod extensions;
131pub mod global;
132mod json;
133pub mod meta;
134mod persistence;
135pub mod recording;
136pub mod recording_file;
137pub mod utils;
138pub mod validation;
139
140/// Derives [`extensions::SigmfExtension`] for a struct of namespaced fields.
141///
142/// Every field needs an explicit `#[serde(rename = "namespace:field")]` that
143/// matches the container's `#[sigmf(namespace = "...")]`. The required
144/// container attributes are `namespace`, `version`, and `scope` (one of
145/// `global`, `capture`, `annotation`, or `collection`). If the crate is
146/// renamed in `Cargo.toml`, pass its path with `crate = "..."`.
147///
148/// ```
149/// use serde::{Deserialize, Serialize};
150/// use sigmf::SigmfExtension;
151///
152/// #[derive(Serialize, Deserialize, SigmfExtension)]
153/// #[sigmf(namespace = "lab", version = "1.0.0", scope = "capture")]
154/// struct LabGain {
155///     #[serde(rename = "lab:gain_db")]
156///     gain_db: f64,
157/// }
158///
159/// use sigmf::extensions::SigmfExtension as _;
160/// assert_eq!(LabGain::FIELDS, ["lab:gain_db"]);
161/// ```
162#[cfg(feature = "derive")]
163pub use sigmf_derive::SigmfExtension;
164
165/// Re-export of [`num_complex`], whose [`Complex`](num_complex::Complex) type
166/// appears in [`utils::data::SampleValue`].
167pub use num_complex;
168
169#[cfg(doctest)]
170#[doc = include_str!("../README.md")]
171struct ReadmeDoctests;
172
173/// JSON number type used for floating-point SigMF fields such as
174/// `core:sample_rate` and `core:frequency`.
175pub type Number = f64;
176/// JSON integer type used for unsigned SigMF fields such as
177/// `core:sample_start` and `core:sample_count`.
178pub type Integer = u64;
179
180const MAX_META_SIZE: usize = 1_000_000;
181
182/// An opened `.sigmf` archive: a tar file of recordings and an optional collection.
183///
184/// Metadata members are indexed and parsed when the archive is loaded; dataset
185/// members are read lazily on request. Each [`recording::RecordingRef`] carries
186/// the SHA-512 digest of its metadata member, which is re-checked whenever the
187/// metadata is read back from the archive.
188///
189/// An archive created with [`Default`] has no [`archive_path`](Self::archive_path);
190/// in that case recordings are resolved as `<name>.sigmf-meta` and
191/// `<name>.sigmf-data` files in the current working directory.
192///
193/// # Examples
194///
195/// ```no_run
196/// # fn main() -> Result<(), sigmf::SigmfError> {
197/// let archive = sigmf::Archive::load("recordings.sigmf")?;
198/// for recording in archive.get_recordings() {
199///     let metadata = archive.get_meta_from_recording(&recording)?;
200///     println!("{}: {}", recording.get_name(), metadata.global().datatype);
201/// }
202/// # Ok(())
203/// # }
204/// ```
205#[derive(Debug, Default)]
206pub struct Archive {
207    /// The archive's `.sigmf-collection` object, if it has one.
208    pub collection: Option<collection::Collection>,
209    /// Name (file stem) of the collection member, e.g. `"collection"` for
210    /// `collection.sigmf-collection`.
211    pub collection_name: Option<String>,
212    /// References to the recordings in the archive, in member order.
213    pub recordings: Vec<recording::RecordingRef>,
214    /// Filesystem path of the `.sigmf` tar file backing this archive.
215    pub archive_path: Option<String>,
216    metadata_by_name: HashMap<String, meta::Metadata>,
217    member_index: HashMap<std::path::PathBuf, ArchiveMemberLocation>,
218}
219
220#[derive(Debug, Clone, Copy)]
221struct ArchiveMemberLocation {
222    offset: u64,
223    size: u64,
224}
225
226impl Archive {
227    // ---------------------------------------- Constructors ---------------------------------------- //
228
229    /// Creates an empty archive handle pointing at `path` without reading it.
230    ///
231    /// Use [`Archive::load`] to open and index an existing archive.
232    pub fn new(path: String) -> Self {
233        Archive {
234            collection: None,
235            collection_name: None,
236            recordings: vec![],
237            archive_path: Some(path),
238            metadata_by_name: HashMap::new(),
239            member_index: HashMap::new(),
240        }
241    }
242
243    /// Opens and indexes a `.sigmf` tar archive.
244    ///
245    /// All `.sigmf-meta` members are parsed and at most one top-level
246    /// `.sigmf-collection` member is accepted. Equivalent to
247    /// [`ArchiveWriter::load`].
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if `path` does not end in `.sigmf`, cannot be read, is
252    /// not a valid tar file, contains unsafe or duplicate member paths,
253    /// non-regular members, invalid metadata, oversized (> 1 MB) metadata, or
254    /// no recordings.
255    pub fn load(path: &str) -> Result<Self, SigmfError> {
256        ArchiveWriter::load(path)
257    }
258
259    // ------------------------------------------- Methods ------------------------------------------ //
260
261    /// Writes a new `.sigmf` archive at `save_path` containing only this
262    /// archive's current [`recordings`](Self::recordings).
263    ///
264    /// Members are copied from the source archive at
265    /// [`archive_path`](Self::archive_path). Metadata and dataset members of
266    /// recordings that were removed (for example by
267    /// [`retain_recordings_with_minimum_frames`](Self::retain_recordings_with_minimum_frames))
268    /// are dropped, other members are preserved, and the collection is replaced
269    /// when [`collection`](Self::collection) is set. The destination must not
270    /// already exist.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if the source path is unset, the destination exists,
275    /// a referenced member is missing, or a metadata digest no longer matches
276    /// its [`recording::RecordingRef`].
277    pub fn save(&self, save_path: &str) -> Result<(), SigmfError> {
278        let archive_path = self
279            .archive_path
280            .as_deref()
281            .ok_or_else(|| SigmfError::NotFound("archive source path is not set".into()))?;
282        let mut metadata_members = HashMap::new();
283        let mut dataset_members = HashSet::new();
284        let mut required_datasets = HashSet::new();
285        for recording in &self.recordings {
286            let name = recording.get_name();
287            validate_recording_name(name)?;
288            let metadata_path = std::path::PathBuf::from(format!("{name}.sigmf-meta"));
289            if metadata_members
290                .insert(metadata_path, recording.get_hash().to_string())
291                .is_some()
292            {
293                return Err(SigmfError::InvalidMetadata(format!(
294                    "duplicate recording name {name:?}"
295                )));
296            }
297            let metadata = self.get_meta_from_recording(recording)?;
298            if let Some(dataset_member) = archive_dataset_member(name, &metadata)? {
299                required_datasets.insert(dataset_member.clone());
300                dataset_members.insert(dataset_member);
301            }
302        }
303
304        let mut writer = ArchiveWriter::new(save_path.to_string())?;
305        let mut source = TarArchive::new(File::open(archive_path)?);
306        let mut seen_members = HashSet::new();
307        let mut seen_metadata = HashSet::new();
308        let mut seen_datasets = HashSet::new();
309        for entry in source.entries()? {
310            let mut entry = entry?;
311            let path = entry.path()?.to_path_buf();
312            validate_archive_member_path(&path)?;
313            if !seen_members.insert(path.clone()) {
314                return Err(SigmfError::InvalidMetadata(format!(
315                    "archive contains a duplicate member path: {}",
316                    path.display()
317                )));
318            }
319            let entry_type = entry.header().entry_type();
320            if entry_type.is_dir() {
321                let header = entry.header().clone();
322                writer.builder_mut()?.append(&header, &mut entry)?;
323                continue;
324            }
325            if !entry_type.is_file() {
326                return Err(SigmfError::UnsupportedLayout(format!(
327                    "archive member {} is not a regular file",
328                    path.display()
329                )));
330            }
331            let header = entry.header().clone();
332            if let Some(expected_hash) = metadata_members.get(&path) {
333                if entry.size() > MAX_META_SIZE as u64 {
334                    return Err(SigmfError::ResourceLimit(format!(
335                        "metadata member {} exceeds the {} byte limit",
336                        path.display(),
337                        MAX_META_SIZE
338                    )));
339                }
340                let mut bytes = Vec::new();
341                entry.read_to_end(&mut bytes)?;
342                let actual_hash = utils::hash::hash_from_slice(&bytes)?;
343                if &actual_hash != expected_hash {
344                    return Err(SigmfError::HashMismatch(format!(
345                        "metadata digest mismatch for {}",
346                        path.display()
347                    )));
348                }
349                writer.builder_mut()?.append(&header, &bytes[..])?;
350                seen_metadata.insert(path);
351            } else if dataset_members.contains(&path) {
352                writer.builder_mut()?.append(&header, &mut entry)?;
353                seen_datasets.insert(path);
354            } else if path.extension().and_then(|extension| extension.to_str())
355                == Some("sigmf-collection")
356            {
357                if self.collection.is_none() {
358                    writer.builder_mut()?.append(&header, &mut entry)?;
359                }
360            } else if matches!(
361                path.extension().and_then(|extension| extension.to_str()),
362                Some("sigmf-meta" | "sigmf-data")
363            ) {
364                continue;
365            } else {
366                writer.builder_mut()?.append(&header, &mut entry)?;
367            }
368        }
369        if seen_metadata.len() != metadata_members.len() {
370            return Err(SigmfError::NotFound(
371                "one or more referenced metadata members are missing".into(),
372            ));
373        }
374        if seen_datasets != required_datasets {
375            return Err(SigmfError::NotFound(
376                "one or more referenced dataset members are missing".into(),
377            ));
378        }
379        if let Some(collection) = &self.collection {
380            let name = self.collection_name.as_deref().unwrap_or("collection");
381            writer.add_collection(name, collection.clone())?;
382        }
383        writer.build()?;
384        Ok(())
385    }
386
387    fn read_archive_member(
388        &self,
389        archive_path: &str,
390        location: &ArchiveMemberLocation,
391    ) -> Result<Vec<u8>, SigmfError> {
392        let length = usize::try_from(location.size).map_err(|_| {
393            SigmfError::ResourceLimit("archive member does not fit in memory".into())
394        })?;
395        let mut bytes = Vec::new();
396        bytes.try_reserve_exact(length).map_err(|error| {
397            SigmfError::ResourceLimit(format!("cannot allocate archive member buffer: {error}"))
398        })?;
399        bytes.resize(length, 0);
400        let mut file = File::open(archive_path)?;
401        file.seek(SeekFrom::Start(location.offset))?;
402        file.read_exact(&mut bytes)?;
403        Ok(bytes)
404    }
405
406    /// Removes recordings whose datasets hold fewer than `minimum_frames` frames.
407    ///
408    /// Frame counts are derived from member sizes, `core:num_channels`,
409    /// `core:datatype`, NCD `core:header_bytes`, and `core:trailing_bytes`
410    /// without reading sample data. Metadata-only recordings count as zero
411    /// frames.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if metadata cannot be read or a dataset layout is
416    /// inconsistent with its size. On error the recordings are left unchanged.
417    pub fn retain_recordings_with_minimum_frames(
418        &mut self,
419        minimum_frames: Integer,
420    ) -> Result<(), SigmfError> {
421        let mut retained = Vec::with_capacity(self.recordings.len());
422        for recording in &self.recordings {
423            let metadata = self.get_meta_from_recording(recording)?;
424            let frame_count = if metadata.global().metadata_only == Some(true) {
425                0
426            } else {
427                let data_size = match &self.archive_path {
428                    Some(path) => {
429                        let member = archive_dataset_member(recording.get_name(), &metadata)?
430                            .ok_or(SigmfError::NoDataset)?;
431                        match self.member_index.get(&member) {
432                            Some(location) => location.size,
433                            None => archive_member_size(path, &member)?,
434                        }
435                    }
436                    None => std::fs::metadata(recording_data_path(recording.get_name())?)?.len(),
437                };
438                let trailer = metadata.global().trailing_bytes.unwrap_or(0);
439                if trailer > data_size {
440                    return Err(SigmfError::UnsupportedLayout(
441                        "core:trailing_bytes exceeds the dataset file size".to_string(),
442                    ));
443                }
444                let offset = metadata.global().offset.unwrap_or(0);
445                let header_bytes = metadata
446                    .captures()
447                    .iter()
448                    .filter(|capture| capture.sample_start >= offset)
449                    .try_fold(0u64, |total, capture| {
450                        total.checked_add(capture.header_bytes.unwrap_or(0))
451                    })
452                    .ok_or_else(|| {
453                        SigmfError::UnsupportedLayout("capture header size overflows".into())
454                    })?;
455                let sample_bytes = data_size
456                    .checked_sub(trailer)
457                    .and_then(|bytes| bytes.checked_sub(header_bytes))
458                    .ok_or_else(|| {
459                        SigmfError::UnsupportedLayout(
460                            "capture headers and trailing bytes exceed dataset size".into(),
461                        )
462                    })?;
463                let channels = usize::try_from(metadata.global().num_channels.unwrap_or(1))
464                    .map_err(|_| {
465                        SigmfError::InvalidMetadata(
466                            "core:num_channels does not fit usize".to_string(),
467                        )
468                    })?;
469                let frames =
470                    utils::data::frame_count(sample_bytes, &metadata.global().datatype, channels)?;
471                if metadata.captures().iter().any(|capture| {
472                    capture.sample_start >= offset
473                        && capture.sample_start - offset > frames
474                        && capture.header_bytes.unwrap_or(0) > 0
475                }) {
476                    return Err(SigmfError::UnsupportedLayout(
477                        "capture header starts beyond the dataset sample range".into(),
478                    ));
479                }
480                frames
481            };
482            if frame_count >= minimum_frames {
483                retained.push(recording.clone());
484            }
485        }
486        self.recordings = retained;
487        Ok(())
488    }
489
490    // ------------------------------------------- Setters ------------------------------------------ //
491
492    /// Replaces the list of recording references.
493    pub fn set_recordings(&mut self, recordings: Vec<recording::RecordingRef>) {
494        self.recordings = recordings;
495    }
496
497    /// Sets the collection, naming it `collection` if no name is set yet.
498    pub fn set_collection(&mut self, collection: collection::Collection) {
499        self.collection = Some(collection);
500        if self.collection_name.is_none() {
501            self.collection_name = Some("collection".into());
502        }
503    }
504
505    /// Sets the collection along with its archive member name (file stem).
506    ///
507    /// # Errors
508    ///
509    /// Returns [`SigmfError::InvalidMetadata`] if `name` is not a safe,
510    /// top-level member name.
511    pub fn set_collection_member(
512        &mut self,
513        name: impl Into<String>,
514        collection: collection::Collection,
515    ) -> Result<(), SigmfError> {
516        let name = name.into();
517        validate_recording_name(&name)?;
518        if name.contains('/') {
519            return Err(SigmfError::InvalidMetadata(
520                "collection metadata must be a top-level archive member".into(),
521            ));
522        }
523        self.collection = Some(collection);
524        self.collection_name = Some(name);
525        Ok(())
526    }
527
528    /// Sets the path of the backing `.sigmf` file.
529    pub fn set_path(&mut self, path: &str) {
530        self.archive_path = Some(path.to_string());
531    }
532
533    /// Appends a recording reference.
534    pub fn add_recording(&mut self, recording: recording::RecordingRef) {
535        self.recordings.push(recording);
536    }
537
538    pub(crate) fn add_recording_with_metadata(
539        &mut self,
540        recording: recording::RecordingRef,
541        metadata: meta::Metadata,
542    ) {
543        self.metadata_by_name
544            .insert(recording.get_name().to_owned(), metadata);
545        self.recordings.push(recording);
546    }
547
548    /// Sets the collection; alias for [`Archive::set_collection`].
549    pub fn add_collection(&mut self, collection: collection::Collection) {
550        self.set_collection(collection);
551    }
552
553    // ------------------------------------------- Getters ------------------------------------------ //
554
555    /// Returns a copy of the recording references.
556    pub fn get_recordings(&self) -> Vec<recording::RecordingRef> {
557        self.recordings.clone()
558    }
559
560    /// Returns a copy of the collection, if any.
561    pub fn get_collection(&self) -> Option<collection::Collection> {
562        self.collection.clone()
563    }
564
565    /// Returns the path of the backing `.sigmf` file, if set.
566    pub fn get_path(&self) -> Option<String> {
567        self.archive_path.clone()
568    }
569
570    /// Returns the name of every recording, in order.
571    ///
572    /// A recording's name is its member path without the `.sigmf-meta`
573    /// extension, e.g. `"dir/burst"` for `dir/burst.sigmf-meta`.
574    pub fn get_names(&self) -> Vec<String> {
575        self.recordings
576            .par_iter()
577            .map(|recording| recording.get_name().to_owned())
578            .collect()
579    }
580
581    /// Finds a recording reference by name.
582    pub fn get_recording_from_name(&self, name: &str) -> Option<recording::RecordingRef> {
583        self.recordings
584            .par_iter()
585            .find_any(|recording| recording.get_name() == name)
586            .cloned()
587    }
588
589    /// Returns the parsed metadata for `recording`.
590    ///
591    /// Metadata parsed at load time is reused when its digest still matches;
592    /// otherwise the member is re-read and its SHA-512 digest is checked
593    /// against [`RecordingRef::get_hash`](recording::RecordingRef::get_hash).
594    ///
595    /// # Errors
596    ///
597    /// Returns [`SigmfError::HashMismatch`] if the digest differs,
598    /// [`SigmfError::NotFound`] if the member is missing, or a parse error.
599    pub fn get_meta_from_recording(
600        &self,
601        recording: &recording::RecordingRef,
602    ) -> Result<meta::Metadata, SigmfError> {
603        if let Some(metadata) = self.metadata_by_name.get(recording.get_name())
604            && metadata.original_digest()?.as_deref() == Some(recording.get_hash())
605        {
606            return Ok(metadata.clone());
607        }
608        match &self.archive_path {
609            Some(path) => {
610                let metadata_path =
611                    std::path::PathBuf::from(format!("{}.sigmf-meta", recording.get_name()));
612                let bytes = match self.member_index.get(&metadata_path) {
613                    Some(location) => {
614                        if location.size > MAX_META_SIZE as u64 {
615                            return Err(SigmfError::ResourceLimit(format!(
616                                "metadata member {} exceeds the {} byte limit",
617                                metadata_path.display(),
618                                MAX_META_SIZE
619                            )));
620                        }
621                        self.read_archive_member(path, location)?
622                    }
623                    None => read_archive_member_by_path(
624                        path,
625                        &metadata_path,
626                        Some(MAX_META_SIZE as u64),
627                    )?,
628                };
629                let digest = utils::hash::hash_from_slice(&bytes)?;
630                if digest != recording.get_hash() {
631                    return Err(SigmfError::HashMismatch(format!(
632                        "metadata digest mismatch for {:?}",
633                        recording.get_name()
634                    )));
635                }
636                meta::Metadata::from_slice(&bytes)
637            }
638            None => {
639                let path = env::current_dir()?;
640                let metadata_path = recording_metadata_path(&path, recording.get_name())?;
641                meta::Metadata::from_path(metadata_path)
642            }
643        }
644    }
645
646    /// Returns the parsed metadata for the recording called `name`.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`SigmfError::NotFound`] if no recording has that name, or any
651    /// error from [`Archive::get_meta_from_recording`].
652    pub fn get_meta_from_name(&self, name: &str) -> Result<meta::Metadata, SigmfError> {
653        // Get the recording by name
654        match self.get_recording_from_name(name) {
655            Some(recording) => self.get_meta_from_recording(&recording),
656            None => Err(SigmfError::NotFound("Recording is None".to_string())),
657        }
658    }
659
660    /// Reads and decodes every sample of `recording` into memory.
661    ///
662    /// See [`Archive::get_samples_from_name`].
663    pub fn get_samples_from_recording(
664        &self,
665        recording: &recording::RecordingRef,
666    ) -> Result<Vec<SampleValue>, SigmfError> {
667        self.get_samples_from_name(recording.get_name())
668    }
669
670    /// Reads and decodes every sample of the recording called `name` into memory.
671    ///
672    /// Samples are returned frame by frame, interleaved across
673    /// `core:num_channels`. The whole dataset member is decoded as plain
674    /// samples, so use [`recording_file::Recording`] for NCD datasets or for
675    /// streaming large recordings.
676    ///
677    /// # Errors
678    ///
679    /// Returns [`SigmfError::NoDataset`] for metadata-only recordings, or an
680    /// error if the dataset cannot be read or does not hold whole frames.
681    pub fn get_samples_from_name(&self, name: &str) -> Result<Vec<SampleValue>, SigmfError> {
682        let meta: meta::Metadata = self.get_meta_from_name(name)?;
683        let data: Vec<u8> = match &self.archive_path {
684            Some(path) => {
685                let member = archive_dataset_member(name, &meta)?.ok_or(SigmfError::NoDataset)?;
686                match self.member_index.get(&member) {
687                    Some(location) => self.read_archive_member(path, location)?,
688                    None => read_archive_member_by_path(path, &member, None)?,
689                }
690            }
691            None => {
692                let current_dir = env::current_dir()?;
693                let file_path = recording_data_path_from(&current_dir, name)?;
694                let mut file = File::open(&file_path)?;
695                let mut data = Vec::new();
696                file.read_to_end(&mut data)?; // Read entire file into buffer
697                data
698            }
699        };
700
701        let data_type = meta.get_datatype();
702        let channel_count = meta
703            .get_num_channels()
704            .unwrap_or(1)
705            .try_into()
706            .map_err(|_| {
707                SigmfError::InvalidMetadata("core:num_channels does not fit usize".to_string())
708            })?;
709        utils::data::decode(&data, &data_type, channel_count)
710    }
711}
712
713/// Builds a new `.sigmf` archive.
714///
715/// Members are streamed into a hidden temporary file next to the destination,
716/// which is only published at the destination path by [`ArchiveWriter::build`].
717/// An existing destination is never overwritten.
718///
719/// # Examples
720///
721/// ```
722/// use sigmf::{datatype::Datatype, global::Global, meta::Metadata, ArchiveWriter};
723/// use std::str::FromStr;
724///
725/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
726/// # let dir = std::env::temp_dir().join(format!("sigmf-archive-writer-doc-{}", std::process::id()));
727/// # std::fs::create_dir_all(&dir)?;
728/// # let path = dir.join("example.sigmf").to_string_lossy().into_owned();
729/// let metadata = Metadata::new(Global::new("1.2.6", Datatype::from_str("ru8")?), vec![], vec![]);
730/// let mut writer = ArchiveWriter::new(path)?;
731/// writer.add("ramp", metadata, &[0, 1, 2, 3])?;
732/// let archive = writer.build()?;
733/// assert_eq!(archive.get_names(), ["ramp"]);
734/// # std::fs::remove_dir_all(&dir)?;
735/// # Ok(())
736/// # }
737/// ```
738pub struct ArchiveWriter {
739    tar_builder: Option<tar::Builder<File>>,
740    path: String,
741    temporary_path: std::path::PathBuf,
742    metadata_hashes: HashMap<String, String>,
743    dataset_names: HashSet<String>,
744    finished: bool,
745}
746
747impl ArchiveWriter {
748    /// Starts a new archive that will be published at `path`.
749    ///
750    /// # Errors
751    ///
752    /// Returns an error if `path` does not end in `.sigmf`, already exists, or
753    /// a temporary file cannot be created in its directory.
754    pub fn new(path: String) -> Result<Self, SigmfError> {
755        if !path.ends_with(".sigmf") {
756            return Err(SigmfError::InvalidMetadata(
757                "archive path must end with .sigmf".to_string(),
758            ));
759        }
760        let destination = std::path::Path::new(&path);
761        if destination.try_exists()? {
762            return Err(SigmfError::Io(std::io::Error::new(
763                std::io::ErrorKind::AlreadyExists,
764                "archive destination already exists",
765            )));
766        }
767        let (temporary_path, file) = persistence::create_sibling_temp(destination)?;
768        Ok(ArchiveWriter {
769            tar_builder: Some(tar::Builder::new(file)),
770            path,
771            temporary_path,
772            metadata_hashes: HashMap::new(),
773            dataset_names: HashSet::new(),
774            finished: false,
775        })
776    }
777
778    fn builder_mut(&mut self) -> Result<&mut tar::Builder<File>, SigmfError> {
779        self.tar_builder.as_mut().ok_or_else(|| {
780            SigmfError::InvalidMetadata("archive writer has already been finalized".to_string())
781        })
782    }
783
784    /// Appends `<filename>.sigmf-meta` without a dataset.
785    ///
786    /// Use this for metadata-only recordings, or pair it with
787    /// [`ArchiveWriter::add_data`]. Prefer [`ArchiveWriter::add`], which also
788    /// fills in `core:sha512`.
789    ///
790    /// # Errors
791    ///
792    /// Returns [`SigmfError::InvalidMetadata`] if `filename` is unsafe or
793    /// already used, or if the metadata fails
794    /// [`ValidationPolicy::Interoperability`](validation::ValidationPolicy::Interoperability).
795    pub fn add_meta(&mut self, filename: &str, meta: meta::Metadata) -> Result<(), SigmfError> {
796        validate_recording_name(filename)?;
797        if self.metadata_hashes.contains_key(filename) {
798            return Err(SigmfError::InvalidMetadata(format!(
799                "duplicate metadata member for recording {filename:?}"
800            )));
801        }
802        let report = meta.validate(validation::ValidationPolicy::Interoperability);
803        if report.status == validation::ValidationStatus::Failed {
804            return Err(SigmfError::InvalidMetadata(
805                "metadata failed validation and was not added to the archive".into(),
806            ));
807        }
808        let meta_bytes = serde_json::to_vec(&meta)?;
809        let meta_header = utils::file::get_meta_header(filename, &meta_bytes)?;
810
811        self.builder_mut()?.append(&meta_header, &meta_bytes[..])?;
812        self.metadata_hashes.insert(
813            filename.to_owned(),
814            utils::hash::hash_from_slice(&meta_bytes)?,
815        );
816        Ok(())
817    }
818
819    /// Appends raw dataset bytes as `<filename>.sigmf-data`.
820    ///
821    /// The bytes are written as-is; no digest is computed or checked.
822    ///
823    /// # Errors
824    ///
825    /// Returns [`SigmfError::InvalidMetadata`] if `filename` is unsafe or the
826    /// dataset member already exists.
827    pub fn add_data(&mut self, filename: &str, data: &[u8]) -> Result<(), SigmfError> {
828        validate_recording_name(filename)?;
829        let member = std::path::PathBuf::from(format!("{filename}.sigmf-data"));
830        self.add_dataset_member(&member, data)
831    }
832
833    fn add_dataset_member(
834        &mut self,
835        member: &std::path::Path,
836        data: &[u8],
837    ) -> Result<(), SigmfError> {
838        validate_archive_member_path(member)?;
839        let member_name = member.to_str().ok_or_else(|| {
840            SigmfError::InvalidMetadata("archive member path must be valid UTF-8".into())
841        })?;
842        if self.dataset_names.contains(member_name) {
843            return Err(SigmfError::InvalidMetadata(format!(
844                "duplicate dataset member {member_name:?}"
845            )));
846        }
847        let data_header = utils::file::get_data_header_for_member(member, data)?;
848        self.builder_mut()?.append(&data_header, data)?;
849        self.dataset_names.insert(member_name.to_owned());
850        Ok(())
851    }
852
853    /// Appends a recording: its metadata and its encoded dataset bytes.
854    ///
855    /// `core:sha512` is set to the SHA-512 digest of `data`. The dataset member
856    /// is `<filename>.sigmf-data`, or the file named by `core:dataset` (in the
857    /// same archive directory as the metadata) when that field is present.
858    ///
859    /// # Errors
860    ///
861    /// Returns [`SigmfError::HashMismatch`] if the metadata already declares a
862    /// different `core:sha512`, [`SigmfError::UnsupportedLayout`] for
863    /// metadata-only recordings, or any error from
864    /// [`ArchiveWriter::add_meta`].
865    pub fn add(
866        &mut self,
867        filename: &str,
868        mut meta: meta::Metadata,
869        data: &[u8],
870    ) -> Result<(), SigmfError> {
871        if meta.global().metadata_only == Some(true) {
872            return Err(SigmfError::UnsupportedLayout(
873                "metadata-only recordings cannot be added with dataset bytes".into(),
874            ));
875        }
876        let dataset_member =
877            archive_dataset_member(filename, &meta)?.ok_or(SigmfError::NoDataset)?;
878        let digest = utils::hash::hash_from_slice(data)?;
879        if meta
880            .global()
881            .sha512
882            .as_deref()
883            .is_some_and(|existing| !existing.eq_ignore_ascii_case(&digest))
884        {
885            return Err(SigmfError::HashMismatch(
886                "provided dataset bytes do not match core:sha512".into(),
887            ));
888        }
889        meta.set_sha512(digest);
890        self.add_meta(filename, meta)?;
891        self.add_dataset_member(&dataset_member, data)
892    }
893
894    /// Appends a top-level `<filename>.sigmf-collection` member.
895    ///
896    /// Stream hashes in `core:streams` are updated to the digests of metadata
897    /// members already added to this writer, so add recordings first.
898    ///
899    /// # Errors
900    ///
901    /// Returns [`SigmfError::InvalidMetadata`] if `filename` is not a safe
902    /// top-level name or the collection fails validation.
903    pub fn add_collection(
904        &mut self,
905        filename: &str,
906        mut collection: collection::Collection,
907    ) -> Result<(), SigmfError> {
908        validate_recording_name(filename)?;
909        if filename.contains('/') {
910            return Err(SigmfError::InvalidMetadata(
911                "collection metadata must be a top-level archive member".into(),
912            ));
913        }
914        if collection
915            .validate(validation::ValidationPolicy::Interoperability)
916            .status
917            == validation::ValidationStatus::Failed
918        {
919            return Err(SigmfError::InvalidMetadata(
920                "collection failed validation and was not added to the archive".into(),
921            ));
922        }
923        for recording in &mut collection.collection.streams {
924            if let Some(hash) = self.metadata_hashes.get(recording.get_name()) {
925                recording.set_hash(hash.clone());
926            }
927        }
928        let collection_bytes = serde_json::to_vec(&collection)?;
929        let collection_header = utils::file::get_collection_header(filename, &collection_bytes)?;
930
931        self.builder_mut()?
932            .append(&collection_header, &collection_bytes[..])?;
933        Ok(())
934    }
935
936    /// Finalizes the tar file, publishes it at the destination path, and
937    /// returns the loaded [`Archive`].
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if the writer was already built, the written archive
942    /// does not load (for example, it has no recordings), or the destination
943    /// was created by someone else in the meantime.
944    pub fn build(&mut self) -> Result<Archive, SigmfError> {
945        if self.finished {
946            return Err(SigmfError::InvalidMetadata(
947                "archive writer has already been finalized".to_string(),
948            ));
949        }
950        let mut builder = self.tar_builder.take().ok_or_else(|| {
951            SigmfError::InvalidMetadata("archive writer has already been finalized".to_string())
952        })?;
953        builder.finish()?;
954        builder.get_ref().sync_all()?;
955        drop(builder);
956        self.finished = true;
957        let mut archive = load_archive_file(&self.temporary_path)?;
958        persistence::publish_new(&self.temporary_path, std::path::Path::new(&self.path))?;
959        archive.set_path(&self.path);
960        Ok(archive)
961    }
962
963    /// Opens and indexes a `.sigmf` tar archive; see [`Archive::load`].
964    pub fn load(path: &str) -> Result<Archive, SigmfError> {
965        if !path.ends_with(".sigmf") {
966            return Err(SigmfError::InvalidMetadata(
967                "archive path must end with .sigmf".to_string(),
968            ));
969        }
970        load_archive_file(std::path::Path::new(path))
971    }
972}
973
974fn load_archive_file(path: &std::path::Path) -> Result<Archive, SigmfError> {
975    let path_string = path.to_string_lossy().into_owned();
976    let mut sigmf = Archive::default();
977    sigmf.set_path(&path_string);
978
979    let file = File::open(path)?;
980    let mut archive = TarArchive::new(file);
981    let mut member_paths = HashSet::new();
982    let mut regular_member_paths = HashSet::new();
983    let mut collection_count = 0usize;
984
985    for file in archive.entries()? {
986        let mut file = file?;
987        let file_path = file.path()?.to_path_buf();
988        validate_archive_member_path(&file_path)?;
989        if !member_paths.insert(file_path.clone()) {
990            return Err(SigmfError::InvalidMetadata(format!(
991                "archive contains a duplicate member path: {}",
992                file_path.display()
993            )));
994        }
995        let entry_type = file.header().entry_type();
996        if entry_type.is_dir() {
997            continue;
998        }
999        if !entry_type.is_file() {
1000            return Err(SigmfError::UnsupportedLayout(format!(
1001                "archive member {} is not a regular file",
1002                file_path.display()
1003            )));
1004        }
1005        sigmf.member_index.insert(
1006            file_path.clone(),
1007            ArchiveMemberLocation {
1008                offset: file.raw_file_position(),
1009                size: file.size(),
1010            },
1011        );
1012        regular_member_paths.insert(file_path.clone());
1013
1014        match file_path
1015            .extension()
1016            .and_then(|extension| extension.to_str())
1017        {
1018            Some("sigmf-meta") => {
1019                let recording_name = archive_member_stem(&file_path)?;
1020                utils::file::process_meta_file(&mut file, &recording_name, &mut sigmf)?;
1021            }
1022            Some("sigmf-collection") => {
1023                if file_path.components().count() != 1 {
1024                    return Err(SigmfError::InvalidMetadata(
1025                        "collection metadata must be a top-level archive member".into(),
1026                    ));
1027                }
1028                collection_count += 1;
1029                if collection_count > 1 {
1030                    return Err(SigmfError::InvalidMetadata(
1031                        "archive contains more than one collection member".into(),
1032                    ));
1033                }
1034                let collection_name = archive_member_stem(&file_path)?;
1035                utils::file::process_collection_file(&mut file, &collection_name, &mut sigmf)?;
1036            }
1037            _ => continue,
1038        }
1039    }
1040
1041    if sigmf.recordings.is_empty() {
1042        return Err(SigmfError::InvalidMetadata(
1043            "archive must contain at least one recording".into(),
1044        ));
1045    }
1046    for recording in &sigmf.recordings {
1047        let metadata = sigmf
1048            .metadata_by_name
1049            .get(recording.get_name())
1050            .ok_or_else(|| {
1051                SigmfError::InvalidMetadata(format!(
1052                    "metadata cache is missing recording {:?}",
1053                    recording.get_name()
1054                ))
1055            })?;
1056        if let Some(dataset_member) = archive_dataset_member(recording.get_name(), metadata)?
1057            && !regular_member_paths.contains(&dataset_member)
1058        {
1059            return Err(SigmfError::NotFound(format!(
1060                "dataset member {} is missing",
1061                dataset_member.display()
1062            )));
1063        }
1064    }
1065    Ok(sigmf)
1066}
1067
1068fn archive_member_size(
1069    archive_path: &str,
1070    member_path: &std::path::Path,
1071) -> Result<u64, SigmfError> {
1072    let mut archive = TarArchive::new(File::open(archive_path)?);
1073    let mut found = None;
1074    for entry in archive.entries()? {
1075        let entry = entry?;
1076        let path = entry.path()?.to_path_buf();
1077        validate_archive_member_path(&path)?;
1078        if path == member_path {
1079            if found.is_some() {
1080                return Err(SigmfError::InvalidMetadata(format!(
1081                    "archive contains a duplicate member path: {}",
1082                    member_path.display()
1083                )));
1084            }
1085            if !entry.header().entry_type().is_file() {
1086                return Err(SigmfError::UnsupportedLayout(format!(
1087                    "archive member {} is not a regular file",
1088                    member_path.display()
1089                )));
1090            }
1091            found = Some(entry.size());
1092        }
1093    }
1094    found.ok_or_else(|| {
1095        SigmfError::NotFound(format!(
1096            "archive member {} is missing",
1097            member_path.display()
1098        ))
1099    })
1100}
1101
1102fn read_archive_member_by_path(
1103    archive_path: &str,
1104    member_path: &std::path::Path,
1105    maximum_size: Option<u64>,
1106) -> Result<Vec<u8>, SigmfError> {
1107    let mut archive = TarArchive::new(File::open(archive_path)?);
1108    let mut found = None;
1109    for entry in archive.entries()? {
1110        let mut entry = entry?;
1111        let path = entry.path()?.to_path_buf();
1112        validate_archive_member_path(&path)?;
1113        if path == member_path {
1114            if found.is_some() {
1115                return Err(SigmfError::InvalidMetadata(format!(
1116                    "archive contains a duplicate member path: {}",
1117                    member_path.display()
1118                )));
1119            }
1120            if !entry.header().entry_type().is_file() {
1121                return Err(SigmfError::UnsupportedLayout(format!(
1122                    "archive member {} is not a regular file",
1123                    member_path.display()
1124                )));
1125            }
1126            if let Some(maximum) = maximum_size
1127                && entry.size() > maximum
1128            {
1129                return Err(SigmfError::ResourceLimit(format!(
1130                    "archive member {} exceeds the {maximum} byte limit",
1131                    member_path.display()
1132                )));
1133            }
1134            let mut bytes = Vec::new();
1135            entry.read_to_end(&mut bytes)?;
1136            found = Some(bytes);
1137        }
1138    }
1139    found.ok_or_else(|| {
1140        SigmfError::NotFound(format!(
1141            "archive member {} is missing",
1142            member_path.display()
1143        ))
1144    })
1145}
1146
1147impl Drop for ArchiveWriter {
1148    fn drop(&mut self) {
1149        drop(self.tar_builder.take());
1150        let _ = std::fs::remove_file(&self.temporary_path);
1151    }
1152}
1153
1154fn archive_member_stem(path: &std::path::Path) -> Result<String, SigmfError> {
1155    let path = path.to_str().ok_or_else(|| {
1156        SigmfError::InvalidMetadata("archive recording name must be valid UTF-8".into())
1157    })?;
1158    let (parent, filename) = path.rsplit_once('/').unwrap_or(("", path));
1159    let stem = filename
1160        .rsplit_once('.')
1161        .filter(|(stem, _)| !stem.is_empty())
1162        .map_or(filename, |(stem, _)| stem);
1163    if stem.is_empty() {
1164        return Err(SigmfError::InvalidMetadata(
1165            "archive recording member has no filename".into(),
1166        ));
1167    }
1168    let name = if parent.is_empty() {
1169        stem.to_owned()
1170    } else {
1171        format!("{parent}/{stem}")
1172    };
1173    validate_recording_name(&name)?;
1174    Ok(name)
1175}
1176
1177fn validate_recording_name(name: &str) -> Result<(), SigmfError> {
1178    let path = std::path::Path::new(name);
1179    if name.is_empty()
1180        || name.contains('\\')
1181        || name
1182            .split('/')
1183            .any(|part| part.is_empty() || part == "." || part == "..")
1184        || path.is_absolute()
1185        || path
1186            .components()
1187            .any(|component| !matches!(component, std::path::Component::Normal(_)))
1188    {
1189        return Err(SigmfError::InvalidMetadata(format!(
1190            "recording name must be a safe relative member path: {name:?}"
1191        )));
1192    }
1193    Ok(())
1194}
1195
1196fn validate_dataset_filename(name: &str) -> Result<(), SigmfError> {
1197    let path = std::path::Path::new(name);
1198    if name.is_empty()
1199        || name.contains('/')
1200        || name.contains('\\')
1201        || name.bytes().any(|byte| {
1202            byte.is_ascii_control()
1203                || matches!(byte, b':' | b'*' | b'?' | b'"' | b'<' | b'>' | b'|')
1204        })
1205        || path.components().count() != 1
1206        || !matches!(
1207            path.components().next(),
1208            Some(std::path::Component::Normal(_))
1209        )
1210    {
1211        return Err(SigmfError::InvalidMetadata(format!(
1212            "dataset name must be a safe filename: {name:?}"
1213        )));
1214    }
1215    Ok(())
1216}
1217
1218fn validate_archive_member_path(path: &std::path::Path) -> Result<(), SigmfError> {
1219    if path.is_absolute()
1220        || path.to_string_lossy().contains('\\')
1221        || path
1222            .components()
1223            .any(|component| !matches!(component, std::path::Component::Normal(_)))
1224    {
1225        return Err(SigmfError::InvalidMetadata(format!(
1226            "archive contains an unsafe member path: {}",
1227            path.display()
1228        )));
1229    }
1230    Ok(())
1231}
1232
1233fn archive_dataset_member(
1234    recording_name: &str,
1235    metadata: &meta::Metadata,
1236) -> Result<Option<std::path::PathBuf>, SigmfError> {
1237    let dataset_name = metadata.global().dataset.as_deref();
1238    if metadata.global().metadata_only == Some(true) && dataset_name.is_none() {
1239        return Ok(None);
1240    }
1241    let member = if let Some(dataset_name) = dataset_name {
1242        validate_dataset_filename(dataset_name)?;
1243        match recording_name.rsplit_once('/') {
1244            Some((parent, _)) => std::path::PathBuf::from(format!("{parent}/{dataset_name}")),
1245            None => std::path::PathBuf::from(dataset_name),
1246        }
1247    } else {
1248        std::path::PathBuf::from(format!("{recording_name}.sigmf-data"))
1249    };
1250    validate_archive_member_path(&member)?;
1251    Ok(Some(member))
1252}
1253
1254fn recording_metadata_path(
1255    root: &std::path::Path,
1256    name: &str,
1257) -> Result<std::path::PathBuf, SigmfError> {
1258    validate_recording_name(name)?;
1259    let mut filename = std::ffi::OsString::from(name);
1260    filename.push(".sigmf-meta");
1261    Ok(root.join(filename))
1262}
1263
1264fn recording_data_path(name: &str) -> Result<std::path::PathBuf, SigmfError> {
1265    let root = env::current_dir()?;
1266    recording_data_path_from(&root, name)
1267}
1268
1269fn recording_data_path_from(
1270    root: &std::path::Path,
1271    name: &str,
1272) -> Result<std::path::PathBuf, SigmfError> {
1273    validate_recording_name(name)?;
1274    let mut filename = std::ffi::OsString::from(name);
1275    filename.push(".sigmf-data");
1276    Ok(root.join(filename))
1277}
1278
1279/// Errors returned by this crate.
1280#[derive(Debug)]
1281#[non_exhaustive]
1282pub enum SigmfError {
1283    /// An underlying filesystem or I/O operation failed.
1284    Io(std::io::Error),
1285    /// JSON serialization or deserialization failed.
1286    Serde(serde_json::Error),
1287    /// The input is not valid JSON for SigMF (for example, duplicate keys).
1288    InvalidJson(String),
1289    /// A `core:datatype` string is malformed or unsupported.
1290    InvalidDataType(String),
1291    /// Metadata, a path, or an argument violates the SigMF specification or
1292    /// this crate's requirements.
1293    InvalidMetadata(String),
1294    /// The dataset layout (NCD headers, trailing bytes, metadata-only, ...)
1295    /// is not supported by the requested operation.
1296    UnsupportedLayout(String),
1297    /// A requested sample range is outside the dataset.
1298    InvalidRange(String),
1299    /// An input exceeds a size limit, or memory could not be reserved.
1300    ResourceLimit(String),
1301    /// The recording has no dataset (it is metadata-only).
1302    NoDataset,
1303    /// A recording, archive member, or file was not found.
1304    NotFound(String),
1305    /// A SHA-512 digest does not match the expected value.
1306    HashMismatch(String),
1307    /// A lock could not be acquired.
1308    LockError(String),
1309}
1310
1311impl From<std::io::Error> for SigmfError {
1312    fn from(error: std::io::Error) -> Self {
1313        SigmfError::Io(error)
1314    }
1315}
1316
1317impl From<serde_json::Error> for SigmfError {
1318    fn from(error: serde_json::Error) -> Self {
1319        SigmfError::Serde(error)
1320    }
1321}
1322
1323impl std::fmt::Display for SigmfError {
1324    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1325        match self {
1326            SigmfError::Io(err) => write!(f, "IO error: {}", err),
1327            SigmfError::Serde(err) => write!(f, "Serde error: {}", err),
1328            SigmfError::InvalidJson(err) => write!(f, "Invalid JSON: {}", err),
1329            SigmfError::InvalidDataType(err) => write!(f, "Invalid data type: {}", err),
1330            SigmfError::InvalidMetadata(err) => write!(f, "Invalid metadata: {}", err),
1331            SigmfError::UnsupportedLayout(err) => write!(f, "Unsupported dataset layout: {}", err),
1332            SigmfError::InvalidRange(err) => write!(f, "Invalid sample range: {}", err),
1333            SigmfError::ResourceLimit(err) => write!(f, "Resource limit exceeded: {}", err),
1334            SigmfError::NoDataset => write!(f, "recording has no available dataset"),
1335            SigmfError::NotFound(err) => write!(f, "Not found: {}", err),
1336            SigmfError::HashMismatch(err) => write!(f, "Hash mismatch: {}", err),
1337            SigmfError::LockError(err) => write!(f, "Lock error: {}", err),
1338        }
1339    }
1340}
1341
1342impl Error for SigmfError {
1343    fn source(&self) -> Option<&(dyn Error + 'static)> {
1344        match self {
1345            SigmfError::Io(err) => Some(err),
1346            SigmfError::Serde(err) => Some(err),
1347            SigmfError::InvalidJson(_err) => None,
1348            SigmfError::InvalidDataType(_err) => None,
1349            SigmfError::InvalidMetadata(_err) => None,
1350            SigmfError::UnsupportedLayout(_err) => None,
1351            SigmfError::InvalidRange(_err) => None,
1352            SigmfError::ResourceLimit(_err) => None,
1353            SigmfError::NoDataset => None,
1354            SigmfError::NotFound(_err) => None,
1355            SigmfError::HashMismatch(_err) => None,
1356            SigmfError::LockError(_err) => None,
1357        }
1358    }
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363    use super::*;
1364    use crate::datatype::Datatype;
1365    use std::str::FromStr;
1366
1367    fn temp_archive_path() -> std::path::PathBuf {
1368        std::env::temp_dir().join(format!(
1369            "sigmf-archive-writer-test-{}-{}.sigmf",
1370            std::process::id(),
1371            std::time::SystemTime::now()
1372                .duration_since(std::time::UNIX_EPOCH)
1373                .unwrap()
1374                .as_nanos()
1375        ))
1376    }
1377
1378    #[test]
1379    fn archive_writer_publishes_only_after_finish_without_overwriting() {
1380        let path = temp_archive_path();
1381        {
1382            let writer = ArchiveWriter::new(path.to_string_lossy().into_owned()).unwrap();
1383            assert!(!path.exists());
1384            drop(writer);
1385        }
1386        assert!(!path.exists());
1387
1388        let datatype = Datatype::from_str("ru8").unwrap();
1389        let metadata = meta::Metadata::new(
1390            global::Global::new("1.2.6", datatype),
1391            Vec::new(),
1392            Vec::new(),
1393        );
1394        let mut writer = ArchiveWriter::new(path.to_string_lossy().into_owned()).unwrap();
1395        writer.add("sample", metadata, &[1, 2, 3]).unwrap();
1396        let archive = writer.build().unwrap();
1397        assert_eq!(archive.recordings.len(), 1);
1398        assert!(ArchiveWriter::new(path.to_string_lossy().into_owned()).is_err());
1399        std::fs::remove_file(path).unwrap();
1400    }
1401
1402    #[test]
1403    fn archive_writer_rejects_missing_dataset_before_publication() {
1404        let path = temp_archive_path();
1405        let metadata = meta::Metadata::new(
1406            global::Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1407            Vec::new(),
1408            Vec::new(),
1409        );
1410        let bytes = metadata.to_vec().unwrap();
1411        let header = utils::file::get_meta_header("orphan", &bytes).unwrap();
1412        let mut writer = ArchiveWriter::new(path.to_string_lossy().into_owned()).unwrap();
1413        let temporary_path = writer.temporary_path.clone();
1414        writer
1415            .builder_mut()
1416            .unwrap()
1417            .append(&header, &bytes[..])
1418            .unwrap();
1419        assert!(writer.build().is_err());
1420        drop(writer);
1421        assert!(!path.exists());
1422        assert!(!temporary_path.exists());
1423    }
1424
1425    #[test]
1426    fn archive_loader_rejects_unsafe_and_duplicate_members() {
1427        for (member_names, expected_error) in [
1428            (vec!["../escape"], "unsafe"),
1429            (vec!["extra.bin", "extra.bin"], "duplicate"),
1430        ] {
1431            let path = temp_archive_path();
1432            let mut builder = tar::Builder::new(File::create(&path).unwrap());
1433            for name in member_names {
1434                let mut header = tar::Header::new_gnu();
1435                header.set_size(0);
1436                header.set_mode(0o644);
1437                if name == "../escape" {
1438                    let path = b"../escape";
1439                    header.as_mut_bytes()[..path.len()].copy_from_slice(path);
1440                    header.as_mut_bytes()[path.len()] = 0;
1441                } else {
1442                    header.set_path(name).unwrap();
1443                }
1444                header.set_cksum();
1445                builder.append(&header, &[][..]).unwrap();
1446            }
1447            builder.finish().unwrap();
1448            let error = Archive::load(path.to_str().unwrap())
1449                .unwrap_err()
1450                .to_string();
1451            assert!(error.contains(expected_error), "{error}");
1452            std::fs::remove_file(path).unwrap();
1453        }
1454    }
1455
1456    #[test]
1457    fn archive_names_are_directory_qualified_and_filtering_uses_frame_counts() {
1458        let path = temp_archive_path();
1459        let metadata = || {
1460            meta::Metadata::new(
1461                global::Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1462                Vec::new(),
1463                Vec::new(),
1464            )
1465        };
1466        let mut writer = ArchiveWriter::new(path.to_string_lossy().into_owned()).unwrap();
1467        writer.add("left/trace", metadata(), &[1]).unwrap();
1468        writer.add("right/trace", metadata(), &[2, 3, 4]).unwrap();
1469        let mut archive = writer.build().unwrap();
1470
1471        assert_eq!(
1472            archive.get_names(),
1473            ["left/trace".to_string(), "right/trace".to_string()]
1474        );
1475        assert_eq!(
1476            archive.get_samples_from_name("left/trace").unwrap(),
1477            [SampleValue::RealUnsignedBytes(1)]
1478        );
1479        assert_eq!(
1480            archive.get_samples_from_name("right/trace").unwrap(),
1481            [
1482                SampleValue::RealUnsignedBytes(2),
1483                SampleValue::RealUnsignedBytes(3),
1484                SampleValue::RealUnsignedBytes(4)
1485            ]
1486        );
1487        archive.retain_recordings_with_minimum_frames(2).unwrap();
1488        assert_eq!(archive.get_names(), ["right/trace".to_string()]);
1489        std::fs::remove_file(path).unwrap();
1490    }
1491
1492    #[test]
1493    fn archive_writer_and_index_honor_custom_ncd_dataset_names() {
1494        let path = temp_archive_path();
1495        let mut global = global::Global::new("1.2.6", Datatype::from_str("ru8").unwrap());
1496        global.set_dataset("samples.raw".into());
1497        let metadata = meta::Metadata::new(global, Vec::new(), Vec::new());
1498        let mut writer = ArchiveWriter::new(path.to_string_lossy().into_owned()).unwrap();
1499        writer.add("folder/trace", metadata, &[7, 8]).unwrap();
1500        let archive = writer.build().unwrap();
1501        assert_eq!(
1502            archive.get_samples_from_name("folder/trace").unwrap(),
1503            [
1504                SampleValue::RealUnsignedBytes(7),
1505                SampleValue::RealUnsignedBytes(8)
1506            ]
1507        );
1508        std::fs::remove_file(path).unwrap();
1509    }
1510
1511    #[test]
1512    fn archive_collection_reference_validation_reports_unresolved_and_stale_hashes() {
1513        let path = temp_archive_path();
1514        let metadata = meta::Metadata::new(
1515            global::Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1516            Vec::new(),
1517            Vec::new(),
1518        );
1519        let collection = collection::Collection::new(collection::CollectionMetadata::new(
1520            "1.2.6",
1521            vec![recording::RecordingRef::new("external", &"a".repeat(128))],
1522        ));
1523        let mut writer = ArchiveWriter::new(path.to_string_lossy().into_owned()).unwrap();
1524        writer.add("local", metadata, &[1]).unwrap();
1525        writer.add_collection("group", collection).unwrap();
1526        let mut archive = writer.build().unwrap();
1527
1528        let report = archive
1529            .validate_collection_references()
1530            .expect("collection is present");
1531        assert_eq!(report.status, validation::ValidationStatus::NotChecked);
1532        archive.collection.as_mut().unwrap().collection.streams[0].set_name("local".into());
1533        let report = archive
1534            .validate_collection_references()
1535            .expect("collection is present");
1536        assert_eq!(report.status, validation::ValidationStatus::Failed);
1537        assert!(
1538            report
1539                .issues
1540                .iter()
1541                .any(|issue| issue.code == "collection-reference-digest-mismatch")
1542        );
1543        std::fs::remove_file(path).unwrap();
1544    }
1545
1546    #[test]
1547    fn archive_save_preserves_collection_name_and_additional_members() {
1548        let directory = std::env::temp_dir().join(format!(
1549            "sigmf-archive-save-test-{}-{}",
1550            std::process::id(),
1551            std::time::SystemTime::now()
1552                .duration_since(std::time::UNIX_EPOCH)
1553                .unwrap()
1554                .as_nanos()
1555        ));
1556        std::fs::create_dir_all(&directory).unwrap();
1557        let source_path = directory.join("source.sigmf");
1558        let output_path = directory.join("output.sigmf");
1559        let metadata = meta::Metadata::new(
1560            global::Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1561            Vec::new(),
1562            Vec::new(),
1563        );
1564        let mut writer = ArchiveWriter::new(source_path.to_string_lossy().into_owned()).unwrap();
1565        writer.add("folder/trace", metadata, &[1, 2]).unwrap();
1566        let collection = collection::Collection::new(collection::CollectionMetadata::new(
1567            "1.2.6",
1568            vec![recording::RecordingRef::new(
1569                "folder/trace",
1570                &"a".repeat(128),
1571            )],
1572        ));
1573        writer.add_collection("release", collection).unwrap();
1574
1575        let mut header = tar::Header::new_gnu();
1576        header.set_size(7);
1577        header.set_mode(0o644);
1578        header.set_path("extra.bin").unwrap();
1579        header.set_cksum();
1580        writer
1581            .builder_mut()
1582            .unwrap()
1583            .append(&header, &b"payload"[..])
1584            .unwrap();
1585        let built = writer.build().unwrap();
1586        assert_eq!(
1587            built.collection.as_ref().unwrap().collection.streams[0].get_hash(),
1588            built.recordings[0].get_hash()
1589        );
1590
1591        let archive = Archive::load(source_path.to_str().unwrap()).unwrap();
1592        archive.save(output_path.to_str().unwrap()).unwrap();
1593        let saved = Archive::load(output_path.to_str().unwrap()).unwrap();
1594        assert_eq!(saved.get_names(), ["folder/trace".to_string()]);
1595        assert_eq!(saved.collection_name.as_deref(), Some("release"));
1596
1597        let mut tar = TarArchive::new(File::open(&output_path).unwrap());
1598        let mut extra = false;
1599        for entry in tar.entries().unwrap() {
1600            let mut entry = entry.unwrap();
1601            if entry.path().unwrap() == std::path::Path::new("extra.bin") {
1602                let mut bytes = Vec::new();
1603                entry.read_to_end(&mut bytes).unwrap();
1604                assert_eq!(bytes, b"payload");
1605                extra = true;
1606            }
1607        }
1608        assert!(extra);
1609        std::fs::remove_dir_all(directory).unwrap();
1610    }
1611}