Skip to main content

sigmf/
recording_file.rs

1//! Plain on-disk SigMF recordings: a `.sigmf-meta` file and its `.sigmf-data`
2//! dataset side by side in one directory.
3//!
4//! * [`Recording`] opens an existing recording and reads sample frames by
5//!   absolute SigMF sample index, either all at once with
6//!   [`Recording::read_frames`] or in bounded blocks with
7//!   [`Recording::frame_blocks`] ([`FrameBlocks`]).
8//! * [`RecordingWriter`] streams frames into a new recording and publishes the
9//!   dataset and metadata, including `core:sha512`, without overwriting
10//!   existing files.
11//! * [`Recording::crop_to`] combines both to extract a sample range into a new
12//!   recording.
13//!
14//! A *frame* is one sample for each of the `core:num_channels` channels;
15//! samples are exchanged as flat lists of [`SampleValue`]s in on-disk order
16//! (see [`utils::data`](crate::utils::data)).
17//!
18//! # Sample indices
19//!
20//! Sample indices are absolute, as in the SigMF specification: the first
21//! frame stored in the dataset has index `core:offset` (zero when omitted),
22//! reported by [`Recording::first_sample_index`]. `core:sample_start` values
23//! of captures and annotations use the same index space. Non-conforming
24//! dataset (NCD) capture header bytes (`core:header_bytes`) and
25//! `core:trailing_bytes` are skipped when reading.
26
27use crate::meta::Metadata;
28use crate::utils::data::{self, SampleValue, SampleWriter};
29use crate::{Integer, SigmfError};
30use std::fs::File;
31use std::io::{Read, Seek, SeekFrom, Write};
32use std::path::{Path, PathBuf};
33
34struct DatasetLayout {
35    frame_count: Integer,
36    bytes_per_frame: u64,
37    header_bytes: Vec<(Integer, Integer)>,
38}
39
40/// Writes a new plain recording (dataset and metadata) without replacing
41/// existing files.
42///
43/// Frames passed to [`write_frames`](Self::write_frames) are encoded into a
44/// hidden temporary file next to the final dataset. [`finish`](Self::finish)
45/// computes the dataset's SHA-512 digest, stores it in `core:sha512`, and
46/// publishes the dataset and then the metadata. Each file appears under its
47/// final name atomically and only if that name is unused, but the pair is not
48/// published atomically: if the process is interrupted between the two steps an
49/// orphan dataset can remain, but metadata is never published for a dataset
50/// that was not. Dropping the writer without calling `finish` removes the
51/// temporary data.
52///
53/// # Examples
54///
55/// ```rust
56/// use num_complex::Complex;
57/// use sigmf::datatype::Datatype;
58/// use sigmf::global::Global;
59/// use sigmf::meta::Metadata;
60/// use sigmf::recording_file::{Recording, RecordingWriter};
61/// use sigmf::utils::data::SampleValue;
62/// use std::str::FromStr;
63///
64/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
65/// let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
66/// let dir = std::env::temp_dir().join(format!("sigmf-doc-{}-{unique}", std::process::id()));
67/// std::fs::create_dir_all(&dir)?;
68/// # let result = (|| -> Result<(), Box<dyn std::error::Error>> {
69/// let metadata_path = dir.join("example.sigmf-meta");
70///
71/// let mut global = Global::new("1.2.6", Datatype::from_str("cf32_le")?);
72/// global.sample_rate = Some(1e6);
73/// global.num_channels = Some(2);
74/// let metadata = Metadata::new(global, Vec::new(), Vec::new());
75///
76/// // The dataset is written to `example.sigmf-data` in the same directory.
77/// let mut writer = RecordingWriter::new(&metadata_path, metadata)?;
78/// writer.write_frames(&[
79///     SampleValue::ComplexF32(Complex::new(1.0, 0.0)), // frame 0, channel 0
80///     SampleValue::ComplexF32(Complex::new(0.0, 1.0)), // frame 0, channel 1
81///     SampleValue::ComplexF32(Complex::new(-1.0, 0.0)), // frame 1, channel 0
82///     SampleValue::ComplexF32(Complex::new(0.0, -1.0)), // frame 1, channel 1
83/// ])?;
84/// let digest = writer.finish()?;
85///
86/// let mut recording = Recording::open(&metadata_path)?;
87/// assert_eq!(recording.metadata().global().sha512.as_deref(), Some(digest.as_str()));
88/// assert_eq!(recording.dataset_path(), Some(dir.join("example.sigmf-data").as_path()));
89/// recording.verify_dataset_digest()?;
90/// assert_eq!(recording.frame_count()?, 2);
91/// assert_eq!(
92///     recording.read_frames(1, 1)?,
93///     [
94///         SampleValue::ComplexF32(Complex::new(-1.0, 0.0)),
95///         SampleValue::ComplexF32(Complex::new(0.0, -1.0)),
96///     ]
97/// );
98/// # Ok(())
99/// # })();
100/// std::fs::remove_dir_all(&dir)?;
101/// # result
102/// # }
103/// ```
104pub struct RecordingWriter {
105    metadata: Metadata,
106    metadata_path: PathBuf,
107    dataset_path: PathBuf,
108    temporary_dataset_path: PathBuf,
109    sample_writer: Option<SampleWriter<File>>,
110    dataset_published: bool,
111    metadata_published: bool,
112}
113
114impl RecordingWriter {
115    /// Creates a writer for a new recording whose metadata will be written to
116    /// `metadata_path`.
117    ///
118    /// `metadata_path` must end in `.sigmf-meta` and its parent directory must
119    /// exist. The dataset is written next to it, named by `core:dataset` when
120    /// set, or otherwise by replacing the `.sigmf-meta` suffix with
121    /// `.sigmf-data`. Neither file may exist yet. `core:sha512` is filled in by
122    /// [`finish`](Self::finish); NCD capture headers and trailing bytes are not
123    /// synthesized.
124    ///
125    /// # Errors
126    ///
127    /// * [`SigmfError::InvalidMetadata`] if `metadata_path` does not end in
128    ///   `.sigmf-meta`, the metadata fails
129    ///   [`Interoperability`](crate::validation::ValidationPolicy::Interoperability)
130    ///   validation, `core:dataset` is not a safe filename, either destination
131    ///   file already exists, or `core:num_channels` is zero or does not fit in
132    ///   a `usize`.
133    /// * [`SigmfError::UnsupportedLayout`] if the metadata is
134    ///   `core:metadata_only` or declares `core:header_bytes` or
135    ///   `core:trailing_bytes`.
136    /// * [`SigmfError::Io`] or [`SigmfError::ResourceLimit`] if the temporary
137    ///   dataset file cannot be created.
138    pub fn new(metadata_path: impl AsRef<Path>, metadata: Metadata) -> Result<Self, SigmfError> {
139        let metadata_path = metadata_path.as_ref().to_path_buf();
140        if metadata_path
141            .extension()
142            .and_then(|extension| extension.to_str())
143            != Some("sigmf-meta")
144        {
145            return Err(SigmfError::InvalidMetadata(
146                "recording metadata path must end with .sigmf-meta".into(),
147            ));
148        }
149        if metadata.global().metadata_only == Some(true) {
150            return Err(SigmfError::UnsupportedLayout(
151                "metadata-only recordings cannot be created with sample data".into(),
152            ));
153        }
154        if metadata
155            .captures()
156            .iter()
157            .any(|capture| capture.header_bytes.unwrap_or(0) > 0)
158            || metadata.global().trailing_bytes.unwrap_or(0) > 0
159        {
160            return Err(SigmfError::UnsupportedLayout(
161                "recording writer does not synthesize NCD headers or trailing bytes".into(),
162            ));
163        }
164        if metadata
165            .validate(crate::validation::ValidationPolicy::Interoperability)
166            .status
167            == crate::validation::ValidationStatus::Failed
168        {
169            return Err(SigmfError::InvalidMetadata(
170                "recording metadata failed validation".into(),
171            ));
172        }
173        let dataset_path = dataset_path_for_metadata(&metadata_path, &metadata)?;
174        if dataset_path == metadata_path {
175            return Err(SigmfError::InvalidMetadata(
176                "dataset and metadata paths must be different".into(),
177            ));
178        }
179        if metadata_path.try_exists()? || dataset_path.try_exists()? {
180            return Err(SigmfError::InvalidMetadata(
181                "recording writer does not overwrite existing files".into(),
182            ));
183        }
184        let channel_count =
185            usize::try_from(metadata.global().num_channels.unwrap_or(1)).map_err(|_| {
186                SigmfError::InvalidMetadata("core:num_channels does not fit usize".into())
187            })?;
188        let (temporary_dataset_path, dataset_file) =
189            crate::persistence::create_sibling_temp(&dataset_path)?;
190        let sample_writer = match SampleWriter::new(
191            dataset_file,
192            metadata.global().datatype.clone(),
193            channel_count,
194        ) {
195            Ok(writer) => writer,
196            Err(error) => {
197                let _ = std::fs::remove_file(&temporary_dataset_path);
198                return Err(error);
199            }
200        };
201        Ok(Self {
202            metadata,
203            metadata_path,
204            dataset_path,
205            temporary_dataset_path,
206            sample_writer: Some(sample_writer),
207            dataset_published: false,
208            metadata_published: false,
209        })
210    }
211
212    /// Encodes and appends one block of complete frames.
213    ///
214    /// `samples` holds one [`SampleValue`] per channel per frame, in on-disk
215    /// order, using the variant that matches `core:datatype`.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the samples do not form complete frames or do not
220    /// match the datatype (see [`data::encode`]), or [`SigmfError::Io`] if
221    /// writing the temporary dataset fails.
222    pub fn write_frames(&mut self, samples: &[SampleValue]) -> Result<(), SigmfError> {
223        self.sample_writer
224            .as_mut()
225            .ok_or_else(|| {
226                SigmfError::InvalidMetadata("recording writer is already finalized".into())
227            })?
228            .write_frames(samples)
229    }
230
231    /// Flushes and publishes both files, returning the dataset's SHA-512
232    /// digest.
233    ///
234    /// The temporary dataset and metadata files are synced to disk and then
235    /// published by hard-linking them to their final names, so the destination
236    /// file system must support hard links. If the metadata cannot be
237    /// published, the already published dataset is removed again.
238    ///
239    /// # Errors
240    ///
241    /// * [`SigmfError::InvalidMetadata`] if the metadata fails validation after
242    ///   `core:sha512` is set.
243    /// * [`SigmfError::ResourceLimit`] if the serialized metadata exceeds the
244    ///   1,000,000-byte metadata size limit.
245    /// * [`SigmfError::Io`] if flushing, hashing, or publishing fails,
246    ///   including when a destination file was created by someone else in the
247    ///   meantime.
248    pub fn finish(mut self) -> Result<String, SigmfError> {
249        let file = self
250            .sample_writer
251            .take()
252            .ok_or_else(|| {
253                SigmfError::InvalidMetadata("recording writer is already finalized".into())
254            })?
255            .finish()?;
256        file.sync_all()?;
257        drop(file);
258        let mut file = File::open(&self.temporary_dataset_path)?;
259        let digest = crate::utils::hash::hash_from_reader(&mut file)?;
260        drop(file);
261
262        self.metadata.set_sha512(digest.clone());
263        if self
264            .metadata
265            .validate(crate::validation::ValidationPolicy::Interoperability)
266            .status
267            == crate::validation::ValidationStatus::Failed
268        {
269            return Err(SigmfError::InvalidMetadata(
270                "recording metadata failed validation after digest update".into(),
271            ));
272        }
273        let metadata_bytes = self.metadata.to_vec()?;
274        if metadata_bytes.len() > crate::MAX_META_SIZE {
275            return Err(SigmfError::ResourceLimit(format!(
276                "metadata exceeds the {} byte limit",
277                crate::MAX_META_SIZE
278            )));
279        }
280        let (temporary_metadata_path, mut metadata_file) =
281            crate::persistence::create_sibling_temp(&self.metadata_path)?;
282        let _metadata_guard = TemporaryFilePath(temporary_metadata_path.clone());
283        metadata_file.write_all(&metadata_bytes)?;
284        metadata_file.flush()?;
285        metadata_file.sync_all()?;
286        drop(metadata_file);
287
288        if let Err(error) =
289            crate::persistence::publish_new(&self.temporary_dataset_path, &self.dataset_path)
290        {
291            if crate::utils::hash::hash_from_path(&self.dataset_path)
292                .is_ok_and(|published_digest| published_digest == digest)
293            {
294                self.dataset_published = true;
295            }
296            return Err(error);
297        }
298        self.dataset_published = true;
299        if let Err(error) =
300            crate::persistence::publish_new(&temporary_metadata_path, &self.metadata_path)
301        {
302            if std::fs::read(&self.metadata_path).is_ok_and(|bytes| bytes == metadata_bytes) {
303                self.metadata_published = true;
304                return Err(error);
305            }
306            match std::fs::remove_file(&self.dataset_path) {
307                Ok(()) => self.dataset_published = false,
308                Err(cleanup_error) => {
309                    return Err(SigmfError::InvalidMetadata(format!(
310                        "metadata publication failed ({error}) and dataset cleanup failed ({}): {cleanup_error}",
311                        self.dataset_path.display()
312                    )));
313                }
314            }
315            return Err(error);
316        }
317        self.metadata_published = true;
318        Ok(digest)
319    }
320}
321
322impl Drop for RecordingWriter {
323    fn drop(&mut self) {
324        drop(self.sample_writer.take());
325        if self.dataset_published && !self.metadata_published {
326            let _ = std::fs::remove_file(&self.dataset_path);
327        }
328        let _ = std::fs::remove_file(&self.temporary_dataset_path);
329    }
330}
331
332struct TemporaryFilePath(PathBuf);
333
334impl Drop for TemporaryFilePath {
335    fn drop(&mut self) {
336        let _ = std::fs::remove_file(&self.0);
337    }
338}
339
340fn dataset_path_for_metadata(
341    metadata_path: &Path,
342    metadata: &Metadata,
343) -> Result<PathBuf, SigmfError> {
344    let filename = metadata_path
345        .file_name()
346        .and_then(|filename| filename.to_str())
347        .ok_or_else(|| {
348            SigmfError::InvalidMetadata("metadata filename must be valid UTF-8".into())
349        })?;
350    let stem = filename.strip_suffix(".sigmf-meta").ok_or_else(|| {
351        SigmfError::InvalidMetadata("metadata path must end with .sigmf-meta".into())
352    })?;
353    let dataset_name = metadata
354        .global()
355        .dataset
356        .clone()
357        .unwrap_or_else(|| format!("{stem}.sigmf-data"));
358    crate::validate_dataset_filename(&dataset_name)?;
359    Ok(metadata_path
360        .parent()
361        .filter(|parent| !parent.as_os_str().is_empty())
362        .unwrap_or_else(|| Path::new("."))
363        .join(dataset_name))
364}
365
366/// An open plain SigMF recording: parsed metadata plus a handle to its
367/// dataset file.
368///
369/// See the [module documentation](self) for how sample indices are mapped to
370/// dataset bytes, and [`RecordingWriter`] for an example.
371pub struct Recording {
372    metadata: Metadata,
373    metadata_path: PathBuf,
374    dataset_path: Option<PathBuf>,
375    dataset: Option<File>,
376    dataset_bytes: u64,
377}
378
379impl Recording {
380    /// Opens the recording described by the `.sigmf-meta` file at `path` and
381    /// its dataset.
382    ///
383    /// The dataset is the file named by `core:dataset` in the same directory
384    /// as `path` or, when that is absent, `path` with its extension replaced by
385    /// `sigmf-data`. For `core:metadata_only` recordings the dataset is opened
386    /// only if `core:dataset` names an existing file; otherwise sample access
387    /// returns [`SigmfError::NoDataset`].
388    ///
389    /// # Errors
390    ///
391    /// Returns an error if the metadata cannot be read or parsed,
392    /// [`SigmfError::InvalidMetadata`] if `core:dataset` is not a plain
393    /// filename, or [`SigmfError::Io`] if the dataset cannot be opened.
394    pub fn open(path: impl AsRef<Path>) -> Result<Self, SigmfError> {
395        Self::open_inner(path.as_ref(), true)
396    }
397
398    /// Opens only the metadata at `path`, without opening any dataset.
399    ///
400    /// Methods that access samples return [`SigmfError::NoDataset`].
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the metadata cannot be read or parsed, or
405    /// [`SigmfError::InvalidMetadata`] if `core:dataset` is not a plain
406    /// filename.
407    pub fn open_metadata_only(path: impl AsRef<Path>) -> Result<Self, SigmfError> {
408        Self::open_inner(path.as_ref(), false)
409    }
410
411    fn open_inner(path: &Path, open_dataset: bool) -> Result<Self, SigmfError> {
412        let metadata = Metadata::from_path(path)?;
413        let dataset_name = metadata.global().dataset.clone();
414        let dataset_path = match dataset_name.as_deref() {
415            Some(name) => {
416                if name.is_empty()
417                    || name.contains('/')
418                    || name.contains('\\')
419                    || Path::new(name).components().count() != 1
420                    || !matches!(
421                        Path::new(name).components().next(),
422                        Some(std::path::Component::Normal(_))
423                    )
424                {
425                    return Err(SigmfError::InvalidMetadata(
426                        "core:dataset must be a filename in the metadata directory".into(),
427                    ));
428                }
429                path.parent().unwrap_or_else(|| Path::new("")).join(name)
430            }
431            None => {
432                let mut dataset_path = path.to_path_buf();
433                dataset_path.set_extension("sigmf-data");
434                dataset_path
435            }
436        };
437        let metadata_only = metadata.global().metadata_only == Some(true);
438        let should_open_dataset =
439            open_dataset && (!metadata_only || (dataset_name.is_some() && dataset_path.is_file()));
440        let (dataset, actual_dataset_path, dataset_bytes) = if should_open_dataset {
441            let file = File::open(&dataset_path)?;
442            let dataset_bytes = file.metadata()?.len();
443            (Some(file), Some(dataset_path), dataset_bytes)
444        } else {
445            (None, None, 0)
446        };
447
448        Ok(Self {
449            metadata,
450            metadata_path: path.to_path_buf(),
451            dataset_path: actual_dataset_path,
452            dataset,
453            dataset_bytes,
454        })
455    }
456
457    /// Returns the parsed metadata.
458    pub fn metadata(&self) -> &Metadata {
459        &self.metadata
460    }
461
462    /// Returns the path the metadata was opened from.
463    pub fn metadata_path(&self) -> &Path {
464        &self.metadata_path
465    }
466
467    /// Returns the path of the opened dataset, or `None` if no dataset was
468    /// opened.
469    pub fn dataset_path(&self) -> Option<&Path> {
470        self.dataset_path.as_deref()
471    }
472
473    /// Returns the absolute sample index of the first frame in the dataset:
474    /// `core:offset`, or zero when it is omitted.
475    pub fn first_sample_index(&self) -> Integer {
476        self.metadata.global().offset.unwrap_or(0)
477    }
478
479    /// Returns the number of frames stored in the dataset.
480    ///
481    /// Capture header bytes and trailing bytes are excluded. Valid absolute
482    /// sample indices are `first_sample_index()..first_sample_index() +
483    /// frame_count()`.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`SigmfError::NoDataset`] if no dataset is open,
488    /// [`SigmfError::UnsupportedLayout`] if header or trailing bytes do not fit
489    /// in the dataset, or [`SigmfError::InvalidMetadata`] if the remaining
490    /// bytes are not a whole number of frames.
491    pub fn frame_count(&self) -> Result<Integer, SigmfError> {
492        Ok(self.layout()?.frame_count)
493    }
494
495    /// Computes the SHA-512 digest of the entire dataset file, including any
496    /// header and trailing bytes, as a lowercase hexadecimal string.
497    ///
498    /// # Errors
499    ///
500    /// Returns [`SigmfError::NoDataset`] if no dataset is open, or
501    /// [`SigmfError::Io`] if reading fails.
502    pub fn dataset_digest(&mut self) -> Result<String, SigmfError> {
503        let dataset = self.dataset.as_mut().ok_or(SigmfError::NoDataset)?;
504        dataset.seek(SeekFrom::Start(0))?;
505        crate::utils::hash::hash_from_reader(dataset)
506    }
507
508    /// Checks the dataset against the `core:sha512` digest in the metadata.
509    ///
510    /// The comparison ignores ASCII case.
511    ///
512    /// # Errors
513    ///
514    /// Returns [`SigmfError::InvalidMetadata`] if `core:sha512` is absent,
515    /// [`SigmfError::HashMismatch`] if the digests differ, or any error from
516    /// [`dataset_digest`](Self::dataset_digest).
517    pub fn verify_dataset_digest(&mut self) -> Result<(), SigmfError> {
518        let expected = self
519            .metadata
520            .global()
521            .sha512
522            .clone()
523            .ok_or_else(|| SigmfError::InvalidMetadata("core:sha512 is not present".into()))?;
524        let actual = self.dataset_digest()?;
525        if !actual.eq_ignore_ascii_case(&expected) {
526            return Err(SigmfError::HashMismatch(format!(
527                "dataset digest mismatch: expected {expected}, found {actual}"
528            )));
529        }
530        Ok(())
531    }
532
533    fn layout(&self) -> Result<DatasetLayout, SigmfError> {
534        if self.dataset.is_none() {
535            return Err(SigmfError::NoDataset);
536        }
537        let global = self.metadata.global();
538        let trailer = global.trailing_bytes.unwrap_or(0);
539        if trailer > self.dataset_bytes {
540            return Err(SigmfError::UnsupportedLayout(
541                "core:trailing_bytes exceeds the dataset file size".to_string(),
542            ));
543        }
544        let channels = global.num_channels.unwrap_or(1);
545        let channels = usize::try_from(channels).map_err(|_| {
546            SigmfError::InvalidMetadata("core:num_channels does not fit usize".to_string())
547        })?;
548        let bytes_per_frame = u64::try_from(data::bytes_per_frame(&global.datatype, channels)?)
549            .map_err(|_| {
550                SigmfError::ResourceLimit("frame size does not fit the file address space".into())
551            })?;
552        let first_sample_index = self.first_sample_index();
553        let mut header_bytes: Vec<_> = self
554            .metadata
555            .captures()
556            .iter()
557            .filter_map(|capture| {
558                let bytes = capture.header_bytes.unwrap_or(0);
559                if bytes > 0 && capture.sample_start >= first_sample_index {
560                    Some((capture.sample_start - first_sample_index, bytes))
561                } else {
562                    None
563                }
564            })
565            .collect();
566        header_bytes.sort_by_key(|(boundary, _)| *boundary);
567        let total_header_bytes = header_bytes.iter().try_fold(0u64, |total, (_, bytes)| {
568            total.checked_add(*bytes).ok_or_else(|| {
569                SigmfError::UnsupportedLayout("capture header size overflows".into())
570            })
571        })?;
572        let sample_bytes = self
573            .dataset_bytes
574            .checked_sub(trailer)
575            .and_then(|bytes| bytes.checked_sub(total_header_bytes))
576            .ok_or_else(|| {
577                SigmfError::UnsupportedLayout(
578                    "capture headers and trailing bytes exceed dataset size".into(),
579                )
580            })?;
581        let frame_count = data::frame_count(sample_bytes, &global.datatype, channels)?;
582        if header_bytes
583            .iter()
584            .any(|(boundary, _)| *boundary > frame_count)
585        {
586            return Err(SigmfError::UnsupportedLayout(
587                "capture header starts beyond the dataset sample range".into(),
588            ));
589        }
590        Ok(DatasetLayout {
591            frame_count,
592            bytes_per_frame,
593            header_bytes,
594        })
595    }
596
597    /// Reads `count` frames starting at absolute sample index `start`.
598    ///
599    /// The stored dataset begins at
600    /// [`first_sample_index`](Self::first_sample_index) (`core:offset`, or
601    /// zero when omitted). Capture header bytes are skipped. The result holds
602    /// `count * core:num_channels` samples in on-disk order and is read into
603    /// memory at once; use [`frame_blocks`](Self::frame_blocks) for large
604    /// ranges.
605    ///
606    /// # Errors
607    ///
608    /// Returns [`SigmfError::InvalidRange`] if the range starts before the
609    /// first sample or extends past the last frame,
610    /// [`SigmfError::ResourceLimit`] if the block does not fit in memory, or
611    /// any error from [`frame_count`](Self::frame_count), decoding, or I/O.
612    pub fn read_frames(
613        &mut self,
614        start: Integer,
615        count: Integer,
616    ) -> Result<Vec<SampleValue>, SigmfError> {
617        let layout = self.layout()?;
618        let first_sample_index = self.first_sample_index();
619        let relative_start = start.checked_sub(first_sample_index).ok_or_else(|| {
620            SigmfError::InvalidRange(format!(
621                "requested sample index {start} precedes dataset offset {first_sample_index}"
622            ))
623        })?;
624        let relative_end = relative_start.checked_add(count).ok_or_else(|| {
625            SigmfError::InvalidRange("requested frame range overflows".to_string())
626        })?;
627        if relative_end > layout.frame_count {
628            return Err(SigmfError::InvalidRange(format!(
629                "requested sample range {start}..+{count} exceeds dataset range starting at {first_sample_index} with {} frames",
630                layout.frame_count
631            )));
632        }
633        let global = self.metadata.global();
634        let channels = usize::try_from(global.num_channels.unwrap_or(1)).map_err(|_| {
635            SigmfError::InvalidMetadata("core:num_channels does not fit usize".to_string())
636        })?;
637        let dataset = self.dataset.as_mut().ok_or(SigmfError::NoDataset)?;
638        let sample_count = usize::try_from(count)
639            .ok()
640            .and_then(|count| count.checked_mul(channels))
641            .ok_or_else(|| {
642                SigmfError::ResourceLimit("requested frame block does not fit memory".into())
643            })?;
644        let mut samples = Vec::with_capacity(sample_count);
645        let mut current = relative_start;
646        while current < relative_end {
647            let next_boundary = layout
648                .header_bytes
649                .iter()
650                .find_map(|(boundary, _)| (*boundary > current).then_some(*boundary))
651                .unwrap_or(relative_end)
652                .min(relative_end);
653            let chunk_frames = next_boundary - current;
654            let samples_before = current
655                .checked_mul(layout.bytes_per_frame)
656                .ok_or_else(|| SigmfError::InvalidRange("byte offset overflow".into()))?;
657            let headers_before = layout
658                .header_bytes
659                .iter()
660                .filter(|(boundary, _)| *boundary <= current)
661                .try_fold(0u64, |total, (_, bytes)| total.checked_add(*bytes))
662                .ok_or_else(|| SigmfError::InvalidRange("header offset overflow".into()))?;
663            let byte_offset = samples_before
664                .checked_add(headers_before)
665                .ok_or_else(|| SigmfError::InvalidRange("byte offset overflow".into()))?;
666            let byte_len = chunk_frames
667                .checked_mul(layout.bytes_per_frame)
668                .and_then(|length| usize::try_from(length).ok())
669                .ok_or_else(|| {
670                    SigmfError::ResourceLimit("requested frame block does not fit memory".into())
671                })?;
672            dataset.seek(SeekFrom::Start(byte_offset))?;
673            let mut bytes = vec![0; byte_len];
674            dataset.read_exact(&mut bytes)?;
675            samples.extend(data::decode(&bytes, &global.datatype, channels)?);
676            current = next_boundary;
677        }
678        Ok(samples)
679    }
680
681    /// Returns an iterator that reads `count` frames starting at absolute
682    /// sample index `start`, in blocks of at most `block_frames` frames.
683    ///
684    /// The whole range is validated up front. Each item is the result of
685    /// [`read_frames`](Self::read_frames) for one block; the iterator stops
686    /// after the first error.
687    ///
688    /// # Errors
689    ///
690    /// Returns [`SigmfError::InvalidRange`] if `block_frames` is zero or the
691    /// range lies outside the dataset, or any error from
692    /// [`frame_count`](Self::frame_count).
693    pub fn frame_blocks(
694        &mut self,
695        start: Integer,
696        count: Integer,
697        block_frames: Integer,
698    ) -> Result<FrameBlocks<'_>, SigmfError> {
699        if block_frames == 0 {
700            return Err(SigmfError::InvalidRange(
701                "block size must be greater than zero".to_string(),
702            ));
703        }
704        let frame_count = self.frame_count()?;
705        let first_sample_index = self.first_sample_index();
706        let relative_start = start.checked_sub(first_sample_index).ok_or_else(|| {
707            SigmfError::InvalidRange(format!(
708                "requested sample index {start} precedes dataset offset {first_sample_index}"
709            ))
710        })?;
711        let relative_end = relative_start.checked_add(count).ok_or_else(|| {
712            SigmfError::InvalidRange("requested frame range overflows".to_string())
713        })?;
714        if relative_end > frame_count {
715            return Err(SigmfError::InvalidRange(format!(
716                "requested sample range {start}..+{count} exceeds dataset range starting at {first_sample_index} with {frame_count} frames"
717            )));
718        }
719
720        Ok(FrameBlocks {
721            recording: self,
722            first_sample_index,
723            next_relative: relative_start,
724            remaining: count,
725            block_frames,
726        })
727    }
728
729    /// Writes frames `sample_start..sample_start + sample_count` (absolute
730    /// indices) to a new plain recording at `metadata_path`, returning the new
731    /// dataset's SHA-512 digest.
732    ///
733    /// Absolute sample indices are preserved: the output's `core:offset` is
734    /// `sample_start`, and captures and annotations are clipped to the cropped
735    /// range without being renumbered. `core:dataset` is cleared, so the
736    /// output dataset is named after `metadata_path`, and `core:sha512` is
737    /// recomputed. Input NCD headers and trailing bytes are omitted from the
738    /// output. Samples are copied in blocks of `block_frames` frames, and the
739    /// output is published as described for [`RecordingWriter`].
740    ///
741    /// If extension fields are present, `extension_handler` must define their
742    /// crop semantics; otherwise the operation fails rather than silently
743    /// misaligning application-defined coordinates.
744    ///
745    /// # Errors
746    ///
747    /// Returns [`SigmfError::InvalidRange`] if `block_frames` is zero or the
748    /// range lies outside the dataset, [`SigmfError::InvalidMetadata`] if
749    /// extension fields cannot be cropped, or any error from reading frames,
750    /// [`RecordingWriter::new`], or [`RecordingWriter::finish`].
751    pub fn crop_to(
752        &mut self,
753        metadata_path: impl AsRef<Path>,
754        sample_start: Integer,
755        sample_count: Integer,
756        block_frames: Integer,
757        extension_handler: Option<&mut dyn crate::extensions::CropExtensionHandler>,
758    ) -> Result<String, SigmfError> {
759        if block_frames == 0 {
760            return Err(SigmfError::InvalidRange(
761                "block size must be greater than zero".into(),
762            ));
763        }
764        {
765            let _validated_blocks = self.frame_blocks(sample_start, sample_count, block_frames)?;
766        }
767        let metadata = self
768            .metadata
769            .crop_samples(sample_start, sample_count, extension_handler)?;
770        let blocks = self.frame_blocks(sample_start, sample_count, block_frames)?;
771        let mut writer = RecordingWriter::new(metadata_path, metadata)?;
772        for block in blocks {
773            writer.write_frames(&block?)?;
774        }
775        writer.finish()
776    }
777}
778
779/// Iterator over blocks of frames, created by [`Recording::frame_blocks`].
780///
781/// Each item is a block of at most `block_frames` frames, as a flat list of
782/// [`SampleValue`]s in on-disk order. After an error, iteration ends.
783pub struct FrameBlocks<'a> {
784    recording: &'a mut Recording,
785    first_sample_index: Integer,
786    next_relative: Integer,
787    remaining: Integer,
788    block_frames: Integer,
789}
790
791impl Iterator for FrameBlocks<'_> {
792    type Item = Result<Vec<SampleValue>, SigmfError>;
793
794    fn next(&mut self) -> Option<Self::Item> {
795        if self.remaining == 0 {
796            return None;
797        }
798        let count = self.remaining.min(self.block_frames);
799        let start = match self.first_sample_index.checked_add(self.next_relative) {
800            Some(start) => start,
801            None => {
802                self.remaining = 0;
803                return Some(Err(SigmfError::InvalidRange(
804                    "absolute sample index overflows".to_string(),
805                )));
806            }
807        };
808        match self.recording.read_frames(start, count) {
809            Ok(samples) => {
810                self.next_relative += count;
811                self.remaining -= count;
812                Some(Ok(samples))
813            }
814            Err(error) => {
815                self.remaining = 0;
816                Some(Err(error))
817            }
818        }
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use crate::annotation::Annotation;
826    use crate::capture::Capture;
827    use crate::datatype::Datatype;
828    use crate::global::Global;
829    use crate::meta::Metadata;
830    use std::str::FromStr;
831
832    fn write_recording(root: &Path, datatype: &str, data: &[u8]) -> PathBuf {
833        std::fs::create_dir_all(root).unwrap();
834        let metadata_path = root.join("capture.sigmf-meta");
835        let metadata = Metadata::new(
836            Global::new("1.2.6", Datatype::from_str(datatype).unwrap()),
837            Vec::new(),
838            Vec::new(),
839        );
840        std::fs::write(&metadata_path, metadata.to_vec().unwrap()).unwrap();
841        std::fs::write(root.join("capture.sigmf-data"), data).unwrap();
842        metadata_path
843    }
844
845    #[test]
846    fn crop_writes_bounded_plain_data_and_clips_absolute_metadata_ranges() {
847        let root = std::env::temp_dir().join(format!(
848            "sigmf-recording-crop-test-{}-{}",
849            std::process::id(),
850            std::time::SystemTime::now()
851                .duration_since(std::time::UNIX_EPOCH)
852                .unwrap()
853                .as_nanos()
854        ));
855        std::fs::create_dir_all(&root).unwrap();
856        let source_path = root.join("source.sigmf-meta");
857        let output_path = root.join("cropped.sigmf-meta");
858
859        let mut global = Global::new("1.2.6", Datatype::from_str("ru8").unwrap());
860        global.offset = Some(10);
861        global.trailing_bytes = Some(1);
862        let mut first_capture = Capture::new(10);
863        first_capture.header_bytes = Some(1);
864        let mut second_capture = Capture::new(12);
865        second_capture.header_bytes = Some(1);
866        let metadata = Metadata::new(
867            global,
868            vec![
869                Annotation::new(9, Some(3), None, None, None, None, None, None),
870                Annotation::new(12, Some(4), None, None, None, None, None, None),
871                Annotation::new(16, None, None, None, None, None, None, None),
872            ],
873            vec![first_capture, second_capture],
874        );
875        std::fs::write(&source_path, metadata.to_vec().unwrap()).unwrap();
876        std::fs::write(
877            root.join("source.sigmf-data"),
878            [0xaa, 1, 2, 0xbb, 3, 4, 0xcc],
879        )
880        .unwrap();
881
882        let mut recording = Recording::open(source_path).unwrap();
883        recording.crop_to(&output_path, 11, 2, 1, None).unwrap();
884
885        let mut cropped = Recording::open(&output_path).unwrap();
886        assert_eq!(cropped.first_sample_index(), 11);
887        assert_eq!(cropped.frame_count().unwrap(), 2);
888        assert_eq!(
889            cropped.read_frames(11, 2).unwrap(),
890            vec![
891                SampleValue::RealUnsignedBytes(2),
892                SampleValue::RealUnsignedBytes(3)
893            ]
894        );
895        cropped.verify_dataset_digest().unwrap();
896        assert_eq!(
897            std::fs::read(root.join("cropped.sigmf-data")).unwrap(),
898            [2, 3]
899        );
900        assert_eq!(
901            cropped
902                .metadata()
903                .captures()
904                .iter()
905                .map(|capture| (capture.sample_start, capture.header_bytes))
906                .collect::<Vec<_>>(),
907            vec![(10, None), (12, None)]
908        );
909        assert_eq!(
910            cropped
911                .metadata()
912                .annotations()
913                .iter()
914                .map(|annotation| (annotation.sample_start, annotation.sample_count))
915                .collect::<Vec<_>>(),
916            vec![(11, Some(1)), (12, Some(1))]
917        );
918        assert_eq!(cropped.metadata().global().trailing_bytes, None);
919        std::fs::remove_dir_all(root).unwrap();
920    }
921
922    #[test]
923    fn crop_requires_extension_handler_for_extension_fields() {
924        struct ShiftExtensionCoordinates;
925
926        impl crate::extensions::CropExtensionHandler for ShiftExtensionCoordinates {
927            fn transform_for_crop(
928                &mut self,
929                scope: crate::extensions::ExtensionScope,
930                sample_start: Option<Integer>,
931                _sample_count: Option<Integer>,
932                crop_start: Integer,
933                _crop_count: Integer,
934                fields: &mut serde_json::Map<String, serde_json::Value>,
935            ) -> Result<(), SigmfError> {
936                assert_eq!(scope, crate::extensions::ExtensionScope::Capture);
937                assert_eq!(sample_start, Some(0));
938                fields.insert("lab:sample_start".into(), serde_json::json!(crop_start));
939                Ok(())
940            }
941        }
942
943        let root = std::env::temp_dir().join(format!(
944            "sigmf-recording-crop-extension-test-{}-{}",
945            std::process::id(),
946            std::time::SystemTime::now()
947                .duration_since(std::time::UNIX_EPOCH)
948                .unwrap()
949                .as_nanos()
950        ));
951        std::fs::create_dir_all(&root).unwrap();
952        let source_path = write_recording(&root, "ru8", &[1, 2]);
953        let mut metadata = Metadata::from_path(&source_path).unwrap();
954        let mut capture = Capture::new(0);
955        capture
956            .insert_extra("lab:sample_start", serde_json::json!(0))
957            .unwrap();
958        metadata.add_capture(capture);
959        std::fs::write(&source_path, metadata.to_vec().unwrap()).unwrap();
960
961        let mut recording = Recording::open(&source_path).unwrap();
962        let output_path = root.join("without-handler.sigmf-meta");
963        assert!(recording.crop_to(&output_path, 1, 1, 1, None).is_err());
964        assert!(!output_path.exists());
965
966        let output_path = root.join("with-handler.sigmf-meta");
967        recording
968            .crop_to(&output_path, 1, 1, 1, Some(&mut ShiftExtensionCoordinates))
969            .unwrap();
970        let cropped = Metadata::from_path(output_path).unwrap();
971        assert_eq!(
972            cropped.captures()[0].extra_fields()["lab:sample_start"],
973            serde_json::json!(1)
974        );
975        std::fs::remove_dir_all(root).unwrap();
976    }
977
978    #[test]
979    fn reads_requested_frames_relative_to_metadata_path() {
980        let root = std::env::temp_dir().join(format!(
981            "sigmf-recording-test-{}-{}",
982            std::process::id(),
983            std::time::SystemTime::now()
984                .duration_since(std::time::UNIX_EPOCH)
985                .unwrap()
986                .as_nanos()
987        ));
988        let metadata_path = write_recording(&root, "ri16_be", &[0, 1, 0, 2, 0, 3]);
989        let mut recording = Recording::open(&metadata_path).unwrap();
990        assert_eq!(recording.frame_count().unwrap(), 3);
991        assert_eq!(
992            recording.read_frames(1, 1).unwrap(),
993            vec![SampleValue::RealI16(2)]
994        );
995        std::fs::remove_dir_all(root).unwrap();
996    }
997
998    #[test]
999    fn recording_writer_publishes_dataset_then_matching_metadata() {
1000        let root = std::env::temp_dir().join(format!(
1001            "sigmf-recording-writer-test-{}-{}",
1002            std::process::id(),
1003            std::time::SystemTime::now()
1004                .duration_since(std::time::UNIX_EPOCH)
1005                .unwrap()
1006                .as_nanos()
1007        ));
1008        std::fs::create_dir_all(&root).unwrap();
1009        let metadata_path = root.join("written.sigmf-meta");
1010        let metadata = Metadata::new(
1011            Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1012            Vec::new(),
1013            Vec::new(),
1014        );
1015        let mut writer = RecordingWriter::new(&metadata_path, metadata).unwrap();
1016        writer
1017            .write_frames(&[
1018                SampleValue::RealUnsignedBytes(9),
1019                SampleValue::RealUnsignedBytes(10),
1020            ])
1021            .unwrap();
1022        let digest = writer.finish().unwrap();
1023
1024        let metadata = Metadata::from_path(&metadata_path).unwrap();
1025        assert_eq!(metadata.global().sha512.as_deref(), Some(digest.as_str()));
1026        let mut recording = Recording::open(&metadata_path).unwrap();
1027        recording.verify_dataset_digest().unwrap();
1028        assert_eq!(
1029            recording.read_frames(0, 2).unwrap(),
1030            [
1031                SampleValue::RealUnsignedBytes(9),
1032                SampleValue::RealUnsignedBytes(10)
1033            ]
1034        );
1035        assert!(
1036            RecordingWriter::new(
1037                &metadata_path,
1038                Metadata::new(
1039                    Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1040                    Vec::new(),
1041                    Vec::new(),
1042                )
1043            )
1044            .is_err()
1045        );
1046        std::fs::remove_dir_all(root).unwrap();
1047    }
1048
1049    #[test]
1050    fn recording_writer_rolls_back_dataset_when_metadata_publication_fails() {
1051        let root = std::env::temp_dir().join(format!(
1052            "sigmf-recording-rollback-test-{}-{}",
1053            std::process::id(),
1054            std::time::SystemTime::now()
1055                .duration_since(std::time::UNIX_EPOCH)
1056                .unwrap()
1057                .as_nanos()
1058        ));
1059        std::fs::create_dir_all(&root).unwrap();
1060        let metadata_path = root.join("written.sigmf-meta");
1061        let dataset_path = root.join("written.sigmf-data");
1062        let metadata = Metadata::new(
1063            Global::new("1.2.6", Datatype::from_str("ru8").unwrap()),
1064            Vec::new(),
1065            Vec::new(),
1066        );
1067        let mut writer = RecordingWriter::new(&metadata_path, metadata).unwrap();
1068        writer
1069            .write_frames(&[SampleValue::RealUnsignedBytes(9)])
1070            .unwrap();
1071        std::fs::write(&metadata_path, b"existing metadata").unwrap();
1072
1073        assert!(writer.finish().is_err());
1074        assert_eq!(std::fs::read(&metadata_path).unwrap(), b"existing metadata");
1075        assert!(!dataset_path.exists());
1076        std::fs::remove_dir_all(root).unwrap();
1077    }
1078
1079    #[test]
1080    fn maps_absolute_sample_indices_through_core_offset() {
1081        let root = std::env::temp_dir().join(format!(
1082            "sigmf-offset-test-{}-{}",
1083            std::process::id(),
1084            std::time::SystemTime::now()
1085                .duration_since(std::time::UNIX_EPOCH)
1086                .unwrap()
1087                .as_nanos()
1088        ));
1089        let metadata_path = write_recording(&root, "ru8", &[10, 20, 30]);
1090        let mut metadata = Metadata::from_path(&metadata_path).unwrap();
1091        metadata.set_offset(1000);
1092        std::fs::write(&metadata_path, metadata.to_vec().unwrap()).unwrap();
1093
1094        let mut recording = Recording::open(&metadata_path).unwrap();
1095        assert_eq!(recording.first_sample_index(), 1000);
1096        assert_eq!(recording.frame_count().unwrap(), 3);
1097        assert_eq!(
1098            recording.read_frames(1001, 1).unwrap(),
1099            vec![SampleValue::RealUnsignedBytes(20)]
1100        );
1101        assert!(matches!(
1102            recording.read_frames(999, 1),
1103            Err(SigmfError::InvalidRange(_))
1104        ));
1105        assert!(matches!(
1106            recording.read_frames(1003, 1),
1107            Err(SigmfError::InvalidRange(_))
1108        ));
1109        std::fs::remove_dir_all(root).unwrap();
1110    }
1111
1112    #[test]
1113    fn frame_blocks_are_bounded_and_keep_errors_visible() {
1114        let root = std::env::temp_dir().join(format!(
1115            "sigmf-frame-blocks-test-{}-{}",
1116            std::process::id(),
1117            std::time::SystemTime::now()
1118                .duration_since(std::time::UNIX_EPOCH)
1119                .unwrap()
1120                .as_nanos()
1121        ));
1122        let metadata_path = write_recording(&root, "ru8", &[1, 2, 3, 4, 5]);
1123        let mut recording = Recording::open(&metadata_path).unwrap();
1124        let blocks: Vec<_> = recording.frame_blocks(0, 5, 2).unwrap().collect();
1125        assert_eq!(
1126            blocks
1127                .into_iter()
1128                .map(Result::unwrap)
1129                .map(|block| block.len())
1130                .collect::<Vec<_>>(),
1131            [2, 2, 1]
1132        );
1133        assert!(recording.frame_blocks(0, 1, 0).is_err());
1134        assert!(recording.frame_blocks(4, 2, 1).is_err());
1135        std::fs::remove_dir_all(root).unwrap();
1136    }
1137
1138    #[test]
1139    fn reads_samples_across_capture_headers() {
1140        let root = std::env::temp_dir().join(format!(
1141            "sigmf-capture-headers-test-{}-{}",
1142            std::process::id(),
1143            std::time::SystemTime::now()
1144                .duration_since(std::time::UNIX_EPOCH)
1145                .unwrap()
1146                .as_nanos()
1147        ));
1148        std::fs::create_dir_all(&root).unwrap();
1149        let metadata_path = root.join("header.sigmf-meta");
1150        let mut global = Global::new("1.2.6", Datatype::from_str("ru8").unwrap());
1151        global.set_offset(100);
1152        let mut first_capture = Capture::new(100);
1153        first_capture.set_header_bytes(1);
1154        let mut second_capture = Capture::new(102);
1155        second_capture.set_header_bytes(1);
1156        let metadata = Metadata::new(global, Vec::new(), vec![first_capture, second_capture]);
1157        std::fs::write(&metadata_path, metadata.to_vec().unwrap()).unwrap();
1158        std::fs::write(root.join("header.sigmf-data"), [0xaa, 1, 2, 0xbb, 3]).unwrap();
1159
1160        let mut recording = Recording::open(&metadata_path).unwrap();
1161        assert_eq!(recording.frame_count().unwrap(), 3);
1162        assert_eq!(
1163            recording.read_frames(100, 3).unwrap(),
1164            [
1165                SampleValue::RealUnsignedBytes(1),
1166                SampleValue::RealUnsignedBytes(2),
1167                SampleValue::RealUnsignedBytes(3)
1168            ]
1169        );
1170        std::fs::remove_dir_all(root).unwrap();
1171    }
1172
1173    #[test]
1174    fn trailer_bytes_are_excluded_from_frames_but_included_in_digest() {
1175        let root = std::env::temp_dir().join(format!(
1176            "sigmf-trailer-test-{}-{}",
1177            std::process::id(),
1178            std::time::SystemTime::now()
1179                .duration_since(std::time::UNIX_EPOCH)
1180                .unwrap()
1181                .as_nanos()
1182        ));
1183        let metadata_path = write_recording(&root, "ru8", &[1, 2, 3, 0xaa, 0xbb]);
1184        let mut metadata = Metadata::from_path(&metadata_path).unwrap();
1185        metadata.set_trailing_bytes(2);
1186        std::fs::write(&metadata_path, metadata.to_vec().unwrap()).unwrap();
1187
1188        let mut recording = Recording::open(&metadata_path).unwrap();
1189        assert_eq!(recording.frame_count().unwrap(), 3);
1190        assert_eq!(
1191            recording.read_frames(0, 3).unwrap(),
1192            [
1193                SampleValue::RealUnsignedBytes(1),
1194                SampleValue::RealUnsignedBytes(2),
1195                SampleValue::RealUnsignedBytes(3)
1196            ]
1197        );
1198        assert_eq!(
1199            recording.dataset_digest().unwrap(),
1200            crate::utils::hash::hash_from_slice(&[1, 2, 3, 0xaa, 0xbb]).unwrap()
1201        );
1202        std::fs::remove_dir_all(root).unwrap();
1203    }
1204
1205    #[test]
1206    fn computes_and_verifies_hash_over_exact_dataset_bytes() {
1207        let root = std::env::temp_dir().join(format!(
1208            "sigmf-dataset-hash-test-{}-{}",
1209            std::process::id(),
1210            std::time::SystemTime::now()
1211                .duration_since(std::time::UNIX_EPOCH)
1212                .unwrap()
1213                .as_nanos()
1214        ));
1215        let metadata_path = write_recording(&root, "ru8", &[1, 2, 3]);
1216        let mut metadata = Metadata::from_path(&metadata_path).unwrap();
1217        let digest = crate::utils::hash::hash_from_slice(&[1, 2, 3]).unwrap();
1218        metadata.set_sha512(digest.clone());
1219        std::fs::write(&metadata_path, metadata.to_vec().unwrap()).unwrap();
1220
1221        let mut recording = Recording::open(&metadata_path).unwrap();
1222        assert_eq!(recording.dataset_digest().unwrap(), digest);
1223        recording.verify_dataset_digest().unwrap();
1224        metadata.set_sha512("0".repeat(128));
1225        std::fs::write(&metadata_path, metadata.to_vec().unwrap()).unwrap();
1226        let mut recording = Recording::open(&metadata_path).unwrap();
1227        assert!(matches!(
1228            recording.verify_dataset_digest(),
1229            Err(SigmfError::HashMismatch(_))
1230        ));
1231        std::fs::remove_dir_all(root).unwrap();
1232    }
1233
1234    #[test]
1235    fn metadata_only_recording_reports_unavailable_samples() {
1236        let root = std::env::temp_dir().join(format!(
1237            "sigmf-metadata-only-test-{}-{}",
1238            std::process::id(),
1239            std::time::SystemTime::now()
1240                .duration_since(std::time::UNIX_EPOCH)
1241                .unwrap()
1242                .as_nanos()
1243        ));
1244        std::fs::create_dir_all(&root).unwrap();
1245        let path = root.join("meta.sigmf-meta");
1246        let mut global = Global::new("1.2.6", Datatype::from_str("ru8").unwrap());
1247        global.set_metadata_only(true);
1248        std::fs::write(
1249            &path,
1250            Metadata::new(global, Vec::new(), Vec::new())
1251                .to_vec()
1252                .unwrap(),
1253        )
1254        .unwrap();
1255        let mut recording = Recording::open(&path).unwrap();
1256        assert!(matches!(
1257            recording.frame_count(),
1258            Err(SigmfError::NoDataset)
1259        ));
1260        assert!(matches!(
1261            recording.read_frames(0, 1),
1262            Err(SigmfError::NoDataset)
1263        ));
1264        std::fs::remove_dir_all(root).unwrap();
1265    }
1266}