1#![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#[cfg(feature = "derive")]
163pub use sigmf_derive::SigmfExtension;
164
165pub use num_complex;
168
169#[cfg(doctest)]
170#[doc = include_str!("../README.md")]
171struct ReadmeDoctests;
172
173pub type Number = f64;
176pub type Integer = u64;
179
180const MAX_META_SIZE: usize = 1_000_000;
181
182#[derive(Debug, Default)]
206pub struct Archive {
207 pub collection: Option<collection::Collection>,
209 pub collection_name: Option<String>,
212 pub recordings: Vec<recording::RecordingRef>,
214 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 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 pub fn load(path: &str) -> Result<Self, SigmfError> {
256 ArchiveWriter::load(path)
257 }
258
259 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 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 pub fn set_recordings(&mut self, recordings: Vec<recording::RecordingRef>) {
494 self.recordings = recordings;
495 }
496
497 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 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 pub fn set_path(&mut self, path: &str) {
530 self.archive_path = Some(path.to_string());
531 }
532
533 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 pub fn add_collection(&mut self, collection: collection::Collection) {
550 self.set_collection(collection);
551 }
552
553 pub fn get_recordings(&self) -> Vec<recording::RecordingRef> {
557 self.recordings.clone()
558 }
559
560 pub fn get_collection(&self) -> Option<collection::Collection> {
562 self.collection.clone()
563 }
564
565 pub fn get_path(&self) -> Option<String> {
567 self.archive_path.clone()
568 }
569
570 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 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 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 pub fn get_meta_from_name(&self, name: &str) -> Result<meta::Metadata, SigmfError> {
653 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 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 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(¤t_dir, name)?;
694 let mut file = File::open(&file_path)?;
695 let mut data = Vec::new();
696 file.read_to_end(&mut data)?; 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
713pub 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 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 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 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 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 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 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 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#[derive(Debug)]
1281#[non_exhaustive]
1282pub enum SigmfError {
1283 Io(std::io::Error),
1285 Serde(serde_json::Error),
1287 InvalidJson(String),
1289 InvalidDataType(String),
1291 InvalidMetadata(String),
1294 UnsupportedLayout(String),
1297 InvalidRange(String),
1299 ResourceLimit(String),
1301 NoDataset,
1303 NotFound(String),
1305 HashMismatch(String),
1307 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}