1use crate::assetmap::ImfUuid;
12use crate::cpl::EditRate;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17pub mod codes;
18pub mod report;
19
20pub use self::report::{
21 build_report, format_report, format_validation_result, FormatOptions, ImfReport, ReportFormat,
22};
23pub use crate::assetmap::{Asset, AssetMap, PackingList, PklAsset, VolumeIndex};
24pub use crate::cpl::{CompositionPlaylist, Resource as CplResource};
25pub use crate::diagnostics::{
26 Category, Location, Severity, ValidationIssue, ValidationProfile, ValidationReport,
27};
28
29#[derive(Debug, serde::Serialize)]
34pub struct ValidationResult {
35 pub package: Imferno,
37 pub validation: ValidationReport,
39}
40
41pub fn validate(
57 files: std::collections::HashMap<String, String>,
58 options: &ValidationOptions,
59) -> ValidationResult {
60 match Imferno::parse(files) {
61 Ok(package) => {
62 let validation = package.validate(options);
63 ValidationResult {
64 package,
65 validation,
66 }
67 }
68 Err(e) => {
69 let mut validation = ValidationReport::new(ValidationProfile::SMPTE);
70 validation.add(ValidationIssue::new(
71 Severity::Critical,
72 Category::Structure,
73 codes::ImfernoCode::ParseError,
74 format!("Failed to parse IMF package: {e}"),
75 ));
76 let validation = validation.apply_rules(&options.rules);
80 ValidationResult {
82 package: Imferno::empty(),
83 validation,
84 }
85 }
86 }
87}
88
89#[derive(Error, Debug)]
90pub enum ImfError {
91 #[error("IO error: {0}")]
92 Io(#[from] std::io::Error),
93
94 #[error("AssetMap parse error: {0}")]
95 AssetMapParse(#[from] crate::assetmap::AssetMapParseError),
96
97 #[error("CPL parse error: {0}")]
98 CplParse(#[from] crate::cpl::CplParseError),
99
100 #[error("UUID error: {0}")]
101 Uuid(String),
102
103 #[error("Missing required file: {0}")]
104 MissingFile(String),
105
106 #[error("Invalid IMF package structure: {0}")]
107 InvalidStructure(String),
108}
109
110pub type Result<T> = std::result::Result<T, ImfError>;
111
112#[derive(Debug)]
117pub enum FileValidationError {
118 NotInAssetMap {
120 uuid: String,
121 original_file_name: Option<String>,
122 },
123 Missing { uuid: String, path: PathBuf },
125 SizeMismatch {
127 uuid: String,
128 path: PathBuf,
129 expected: u64,
130 actual: u64,
131 },
132 HashMismatch {
134 uuid: String,
135 path: PathBuf,
136 expected: String,
137 actual: String,
138 },
139 Io {
141 uuid: String,
142 path: PathBuf,
143 message: String,
144 },
145 DuplicatePklAssetId { uuid: String, pkl_id: String },
147}
148
149impl FileValidationError {
150 pub fn uuid(&self) -> &str {
151 match self {
152 Self::NotInAssetMap { uuid, .. } => uuid,
153 Self::Missing { uuid, .. } => uuid,
154 Self::SizeMismatch { uuid, .. } => uuid,
155 Self::HashMismatch { uuid, .. } => uuid,
156 Self::Io { uuid, .. } => uuid,
157 Self::DuplicatePklAssetId { uuid, .. } => uuid,
158 }
159 }
160}
161
162impl std::fmt::Display for FileValidationError {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Self::NotInAssetMap {
166 uuid,
167 original_file_name,
168 } => {
169 write!(
170 f,
171 "PKL asset {} ({}) not found in AssetMap",
172 uuid,
173 original_file_name.as_deref().unwrap_or("no filename")
174 )
175 }
176 Self::Missing { uuid, path } => {
177 write!(f, "Missing file for {}: {}", uuid, path.display())
178 }
179 Self::SizeMismatch {
180 uuid,
181 path,
182 expected,
183 actual,
184 } => {
185 write!(
186 f,
187 "Size mismatch for {} ({}): expected {} bytes, found {}",
188 uuid,
189 path.display(),
190 expected,
191 actual
192 )
193 }
194 Self::HashMismatch {
195 uuid,
196 path,
197 expected,
198 actual,
199 } => {
200 write!(
201 f,
202 "Hash mismatch for {} ({}): expected {}, got {}",
203 uuid,
204 path.display(),
205 expected,
206 actual
207 )
208 }
209 Self::Io {
210 uuid,
211 path,
212 message,
213 } => {
214 write!(
215 f,
216 "IO error reading {} ({}): {}",
217 uuid,
218 path.display(),
219 message
220 )
221 }
222 Self::DuplicatePklAssetId { uuid, pkl_id } => {
223 write!(f, "Duplicate asset UUID {} in PKL {}", uuid, pkl_id)
224 }
225 }
226 }
227}
228
229impl From<&FileValidationError> for ValidationIssue {
230 fn from(err: &FileValidationError) -> Self {
231 match err {
232 FileValidationError::NotInAssetMap {
233 uuid,
234 original_file_name,
235 } => ValidationIssue::new(
236 Severity::Error,
237 Category::Reference,
238 codes::St2067_2_2020::UnresolvedUuid,
239 format!(
240 "PKL asset {} ({}) not found in AssetMap",
241 uuid,
242 original_file_name.as_deref().unwrap_or("no filename")
243 ),
244 )
245 .with_context("asset_uuid", uuid.clone()),
246 FileValidationError::Missing { uuid, path } => ValidationIssue::new(
247 Severity::Error,
248 Category::Asset,
249 codes::St2067_2_2020::FileNotFound,
250 format!("Missing file for asset {}: {}", uuid, path.display()),
251 )
252 .with_location(Location::new().with_file(path.clone()))
253 .with_context("asset_uuid", uuid.clone()),
254 FileValidationError::SizeMismatch {
255 uuid,
256 path,
257 expected,
258 actual,
259 } => ValidationIssue::new(
260 Severity::Error,
261 Category::Asset,
262 codes::St2067_2_2020::SizeMismatch,
263 format!(
264 "Size mismatch for asset {} ({}): PKL declares {} bytes, file is {} bytes",
265 uuid,
266 path.display(),
267 expected,
268 actual
269 ),
270 )
271 .with_location(Location::new().with_file(path.clone()))
272 .with_context("asset_uuid", uuid.clone())
273 .with_context("expected_size", expected.to_string())
274 .with_context("actual_size", actual.to_string()),
275 FileValidationError::HashMismatch {
276 uuid,
277 path,
278 expected,
279 actual,
280 } => ValidationIssue::new(
281 Severity::Critical,
282 Category::Asset,
283 codes::St2067_2_2020::ChecksumMismatch,
284 format!(
285 "Hash mismatch for asset {} ({}): expected {}, computed {}",
286 uuid,
287 path.display(),
288 expected,
289 actual
290 ),
291 )
292 .with_location(Location::new().with_file(path.clone()))
293 .with_context("asset_uuid", uuid.clone())
294 .with_suggestion("Re-deliver the asset or re-generate the PKL hash"),
295 FileValidationError::Io {
296 uuid,
297 path,
298 message,
299 } => ValidationIssue::new(
300 Severity::Error,
301 Category::Asset,
302 codes::St2067_2_2020::IoError,
303 format!(
304 "IO error reading asset {} ({}): {}",
305 uuid,
306 path.display(),
307 message
308 ),
309 )
310 .with_location(Location::new().with_file(path.clone()))
311 .with_context("asset_uuid", uuid.clone()),
312 FileValidationError::DuplicatePklAssetId { uuid, pkl_id } => ValidationIssue::new(
313 Severity::Error,
314 Category::Reference,
315 codes::St2067_2_2020::DuplicateUuid,
316 format!("Duplicate asset UUID {} in PKL {}", uuid, pkl_id),
317 )
318 .with_context("asset_uuid", uuid.clone())
319 .with_context("pkl_id", pkl_id.clone()),
320 }
321 }
322}
323
324#[derive(Debug, serde::Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct Imferno {
331 #[serde(serialize_with = "serialize_path")]
333 pub root_path: PathBuf,
334
335 pub volume_index: VolumeIndex,
337
338 #[serde(skip)]
340 pub volindex_issues: Vec<ValidationIssue>,
341
342 #[serde(skip)]
344 pub(crate) parse_issues: Vec<ValidationIssue>,
345
346 pub asset_map: AssetMap,
348
349 pub packing_lists: HashMap<ImfUuid, PackingList>,
351
352 pub composition_playlists: HashMap<ImfUuid, CompositionPlaylist>,
354
355 #[serde(skip)]
357 #[allow(dead_code)]
358 pub(crate) cpl_xml_content: HashMap<ImfUuid, String>,
359
360 pub output_profile_lists: HashMap<ImfUuid, crate::assetmap::OutputProfileList>,
362
363 pub sidecar_composition_maps: HashMap<ImfUuid, crate::scm::SidecarCompositionMap>,
365
366 #[serde(serialize_with = "serialize_path_map")]
368 pub asset_paths: HashMap<ImfUuid, PathBuf>,
369}
370
371fn serialize_path<S: serde::Serializer>(path: &Path, s: S) -> std::result::Result<S::Ok, S::Error> {
372 s.serialize_str(&path.to_string_lossy())
373}
374
375fn serialize_path_map<S: serde::Serializer>(
376 map: &HashMap<ImfUuid, PathBuf>,
377 s: S,
378) -> std::result::Result<S::Ok, S::Error> {
379 use serde::ser::SerializeMap;
380 let mut m = s.serialize_map(Some(map.len()))?;
381 for (k, v) in map {
382 m.serialize_entry(k, &v.to_string_lossy().into_owned())?;
383 }
384 m.end()
385}
386
387fn sanitize_asset_path(root: &Path, chunk_path: &str) -> Option<PathBuf> {
393 let rel = Path::new(chunk_path);
394 if rel.is_absolute() {
396 return None;
397 }
398 for component in rel.components() {
400 if component == std::path::Component::ParentDir {
401 return None;
402 }
403 }
404 let joined = root.join(rel);
405 if let Ok(canonical) = joined.canonicalize() {
407 if canonical.starts_with(root) {
408 return Some(canonical);
409 }
410 return None; }
412 Some(joined)
414}
415
416pub fn read_dir(path: impl AsRef<Path>) -> Result<HashMap<String, String>> {
425 use crate::storage::{fs::FsStorage, StorageUri};
426
427 let path = path
428 .as_ref()
429 .canonicalize()
430 .unwrap_or_else(|_| path.as_ref().to_path_buf());
431 let uri = StorageUri::parse(&path.to_string_lossy())
432 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
433 let storage = FsStorage::new();
434
435 read_xml_files(&uri, &storage).map_err(|e| std::io::Error::other(e.to_string()).into())
436}
437
438pub fn read_xml_files(
469 uri: &crate::storage::StorageUri,
470 storage: &dyn crate::storage::Storage,
471) -> std::result::Result<HashMap<String, String>, crate::storage::StorageError> {
472 let mut files = HashMap::new();
473 for entry in storage.list(uri)? {
474 if !entry.is_file {
475 continue;
476 }
477 if !entry.uri.to_ascii_lowercase().ends_with(".xml") {
478 continue;
479 }
480 let entry_uri = crate::storage::StorageUri::parse(&entry.uri)?;
481 match storage.read_to_string(&entry_uri) {
482 Ok(content) => {
483 files.insert(entry.uri, content);
484 }
485 Err(e) => {
486 eprintln!("Warning: failed to read XML file {}: {}", entry.uri, e);
487 }
488 }
489 }
490 Ok(files)
491}
492
493pub use self::read_xml_files as read;
495
496#[cfg(feature = "aws-s3")]
506pub async fn read_s3(
507 client: &aws_sdk_s3::Client,
508 bucket: &str,
509 prefix: &str,
510) -> Result<HashMap<String, String>> {
511 use crate::storage::{s3::S3Storage, StorageUri};
512
513 let storage =
514 S3Storage::from_client(client.clone()).map_err(|e| std::io::Error::other(e.to_string()))?;
515 let uri_str = format!("s3://{bucket}/{prefix}");
516 let uri = StorageUri::parse(&uri_str)
517 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
518
519 tokio::task::spawn_blocking(move || {
522 crate::package::read_xml_files(&uri, &storage)
523 .map_err(|e| std::io::Error::other(e.to_string()))
524 })
525 .await
526 .map_err(|e| std::io::Error::other(format!("join error: {e}")))?
527 .map_err(Into::into)
528}
529
530impl Imferno {
531 fn empty() -> Self {
533 Self {
534 root_path: PathBuf::new(),
535 volume_index: VolumeIndex { index: 1 },
536 volindex_issues: Vec::new(),
537 parse_issues: Vec::new(),
538 asset_map: crate::assetmap::AssetMap {
539 namespace: Default::default(),
540 id: ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000000")
541 .expect("nil UUID is always valid"),
542 annotation_text: None,
543 creator: None,
544 volume_count: 1,
545 issue_date: "1970-01-01T00:00:00+00:00".into(),
546 issuer: None,
547 asset_list: crate::assetmap::AssetList { assets: Vec::new() },
548 },
549 packing_lists: HashMap::new(),
550 composition_playlists: HashMap::new(),
551 cpl_xml_content: HashMap::new(),
552 output_profile_lists: HashMap::new(),
553 sidecar_composition_maps: HashMap::new(),
554 asset_paths: HashMap::new(),
555 }
556 }
557
558 pub fn parse(files: HashMap<String, String>) -> Result<Self> {
563 Self::from_file_map(&files)
564 }
565
566 pub fn parse_and_validate(
568 files: HashMap<String, String>,
569 options: &ValidationOptions,
570 ) -> ValidationReport {
571 let package = match Self::parse(files) {
572 Ok(pkg) => pkg,
573 Err(e) => {
574 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
575 report.add(ValidationIssue::new(
576 Severity::Critical,
577 Category::Structure,
578 codes::ImfernoCode::ParseError,
579 format!("Failed to parse IMF package: {e}"),
580 ));
581 return report.apply_rules(&options.rules);
582 }
583 };
584
585 package.validate(options)
586 }
587
588 pub fn validate(&self, options: &ValidationOptions) -> ValidationReport {
590 use crate::validation::{
591 validate_cpl_with_registry, ConfigurableValidatorRegistry, ValidatorSelection,
592 };
593
594 let selection = ValidatorSelection {
595 core_spec: options.core_spec,
596 app_specs: options.app_specs.clone(),
597 ..Default::default()
598 };
599 let registry = ConfigurableValidatorRegistry::new(selection);
600 #[cfg(not(target_arch = "wasm32"))]
601 let skip_disk = options.skip_disk_checks;
602 #[cfg(target_arch = "wasm32")]
603 let skip_disk = false;
604 let report = self.validate_package_structure_with_cpl_validator(
605 |cpl| validate_cpl_with_registry(cpl, ®istry),
606 skip_disk,
607 );
608 report.apply_rules(&options.rules)
609 }
610
611 #[cfg(not(target_arch = "wasm32"))]
615 pub fn validate_hashes(&self, options: &ValidationOptions) -> ValidationReport {
616 use crate::validation::{
617 validate_cpl_with_registry, ConfigurableValidatorRegistry, ValidatorSelection,
618 };
619
620 let selection = ValidatorSelection {
621 core_spec: options.core_spec,
622 app_specs: options.app_specs.clone(),
623 ..Default::default()
624 };
625 let registry = ConfigurableValidatorRegistry::new(selection);
626 let report = self.validate_package_with_hashes_with_cpl_validator(|cpl| {
627 validate_cpl_with_registry(cpl, ®istry)
628 });
629 report.apply_rules(&options.rules)
630 }
631
632 fn from_file_map(files: &HashMap<String, String>) -> Result<Self> {
642 let root_path: PathBuf = files
645 .keys()
646 .filter_map(|k| {
647 let p = std::path::Path::new(k.as_str());
648 if p.is_absolute() {
649 p.parent().map(|par| par.to_path_buf())
650 } else {
651 None
652 }
653 })
654 .next()
655 .unwrap_or_default();
656
657 let find = |name: &str| -> Option<&str> {
659 let lower = name.to_lowercase();
660 files
661 .iter()
662 .find(|(k, _)| {
663 let key_basename = std::path::Path::new(k.as_str())
664 .file_name()
665 .and_then(|f| f.to_str())
666 .unwrap_or(k.as_str());
667 key_basename.to_lowercase() == lower
668 })
669 .map(|(_, v)| v.as_str())
670 };
671
672 let mut volindex_issues: Vec<ValidationIssue> = Vec::new();
674 let volume_index = match find("VOLINDEX.xml") {
675 Some(xml) => match crate::assetmap::parse_volindex(xml) {
676 Ok(vi) => vi,
677 Err(e) => {
678 volindex_issues.push(ValidationIssue::new(
679 Severity::Error,
680 Category::Structure,
681 codes::St429_9_2014::MalformedXml,
682 format!("VOLINDEX.xml is not well-formed XML: {e}"),
683 ));
684 VolumeIndex { index: 1 }
685 }
686 },
687 None => {
688 volindex_issues.push(ValidationIssue::new(
689 Severity::Info,
690 Category::Structure,
691 codes::St429_9_2014::VolindexMissing,
692 "VOLINDEX.xml is absent; single-volume package assumed",
693 ));
694 VolumeIndex { index: 1 }
695 }
696 };
697
698 let assetmap_xml = find("ASSETMAP.xml")
700 .ok_or_else(|| ImfError::MissingFile("ASSETMAP.xml".to_string()))?;
701 let asset_map = crate::assetmap::parse_assetmap(assetmap_xml)?;
702
703 let mut asset_paths: HashMap<ImfUuid, PathBuf> = HashMap::new();
707 let mut parse_issues: Vec<ValidationIssue> = Vec::new();
708 for asset in &asset_map.asset_list.assets {
709 for chunk in &asset.chunk_list.chunks {
710 let path = if root_path.as_os_str().is_empty() {
711 Some(PathBuf::from(&chunk.path))
713 } else {
714 sanitize_asset_path(&root_path, &chunk.path)
715 };
716 match path {
717 Some(p) => {
718 asset_paths.insert(asset.id, p);
719 }
720 None => {
721 parse_issues.push(ValidationIssue::new(
722 Severity::Error,
723 Category::Structure,
724 codes::ImfernoCode::PathTraversal,
725 format!(
726 "Asset '{}' chunk path '{}' escapes the package root directory",
727 asset.id, chunk.path,
728 ),
729 ));
730 }
731 }
732 }
733 }
734
735 let mut packing_lists = HashMap::new();
737 for asset in &asset_map.asset_list.assets {
738 if asset.packing_list == Some(true) {
739 for chunk in &asset.chunk_list.chunks {
740 let basename = std::path::Path::new(&chunk.path)
741 .file_name()
742 .and_then(|f| f.to_str())
743 .unwrap_or(&chunk.path);
744 if let Some(pkl_xml) = find(basename) {
745 match crate::assetmap::parse_pkl(pkl_xml) {
746 Ok(pkl) => {
747 packing_lists.insert(asset.id, pkl);
748 }
749 Err(e) => {
750 parse_issues.push(ValidationIssue::new(
751 Severity::Error,
752 Category::Structure,
753 codes::ImfernoCode::PklParseError,
754 format!("PKL '{}' parse error: {}", basename, e),
755 ));
756 }
757 }
758 }
759 }
760 }
761 }
762
763 let mut xml_asset_ids: std::collections::HashSet<ImfUuid> =
765 std::collections::HashSet::new();
766 for pkl in packing_lists.values() {
767 for pkl_asset in &pkl.asset_list.assets {
768 if pkl_asset.mime_type.is_xml() {
769 xml_asset_ids.insert(pkl_asset.id);
770 }
771 }
772 }
773
774 let mut composition_playlists = HashMap::new();
776 let mut cpl_xml_content = HashMap::new();
777 let mut output_profile_lists = HashMap::new();
778 let mut sidecar_composition_maps = HashMap::new();
779 for asset in &asset_map.asset_list.assets {
780 if asset.packing_list == Some(true) {
781 continue;
782 }
783 for chunk in &asset.chunk_list.chunks {
784 if !chunk.path.ends_with(".xml") {
785 continue;
786 }
787 let is_candidate = if !xml_asset_ids.is_empty() {
788 xml_asset_ids.contains(&asset.id)
789 } else {
790 true
791 };
792 if !is_candidate {
793 continue;
794 }
795
796 let basename = std::path::Path::new(&chunk.path)
797 .file_name()
798 .and_then(|f| f.to_str())
799 .unwrap_or(&chunk.path);
800 if let Some(xml) = find(basename) {
801 match crate::cpl::parse_cpl(xml) {
802 Ok(cpl) => {
803 cpl_xml_content.insert(asset.id, xml.to_string());
804 composition_playlists.insert(asset.id, cpl);
805 }
806 Err(cpl_err) => {
807 if let Ok(opl) = crate::assetmap::parse_opl(xml) {
808 output_profile_lists.insert(asset.id, opl);
809 } else if let Ok(scm) = crate::scm::parse_scm(xml) {
810 sidecar_composition_maps.insert(asset.id, scm);
811 } else {
812 parse_issues.push(ValidationIssue::new(
813 Severity::Warning,
814 Category::Structure,
815 codes::ImfernoCode::XmlAssetParseError,
816 format!(
817 "XML asset '{}' ({}) could not be parsed as CPL, OPL, or SCM: {}",
818 basename, asset.id, cpl_err,
819 ),
820 ));
821 }
822 }
823 }
824 }
825 }
826 }
827
828 Ok(Imferno {
829 root_path,
830 volume_index,
831 volindex_issues,
832 parse_issues,
833 asset_map,
834 packing_lists,
835 composition_playlists,
836 cpl_xml_content,
837 output_profile_lists,
838 sidecar_composition_maps,
839 asset_paths,
840 })
841 }
842
843 pub fn get_cpl(&self, uuid: ImfUuid) -> Option<&CompositionPlaylist> {
845 self.composition_playlists.get(&uuid)
846 }
847
848 pub fn get_cpl_str(&self, uuid: &str) -> Option<&CompositionPlaylist> {
850 ImfUuid::parse(uuid)
851 .ok()
852 .and_then(|u| self.composition_playlists.get(&u))
853 }
854
855 pub fn get_asset_path(&self, uuid: ImfUuid) -> Option<&PathBuf> {
857 self.asset_paths.get(&uuid)
858 }
859
860 pub fn get_asset_path_str(&self, uuid: &str) -> Option<&PathBuf> {
862 ImfUuid::parse(uuid)
863 .ok()
864 .and_then(|u| self.asset_paths.get(&u))
865 }
866
867 pub fn list_cpl_uuids(&self) -> Vec<ImfUuid> {
869 self.composition_playlists.keys().copied().collect()
870 }
871
872 pub fn get_main_cpl(&self) -> Option<&CompositionPlaylist> {
874 self.composition_playlists.values().next()
875 }
876
877 pub fn unreferenced_assets(&self) -> Vec<&crate::assetmap::Asset> {
887 use std::collections::HashSet;
888
889 let doc_ids: HashSet<ImfUuid> = self
891 .composition_playlists
892 .keys()
893 .chain(self.packing_lists.keys())
894 .chain(self.sidecar_composition_maps.keys())
895 .chain(self.output_profile_lists.keys())
896 .copied()
897 .collect();
898
899 let track_file_ids: HashSet<ImfUuid> = self
901 .composition_playlists
902 .values()
903 .flat_map(|cpl| cpl.segment_list.segments.iter())
904 .flat_map(|seg| {
905 seg.sequence_list
906 .all_sequences()
907 .into_iter()
908 .flat_map(|seq| {
909 seq.resource_list()
910 .resources
911 .iter()
912 .filter_map(|r| r.track_file_id)
913 })
914 .collect::<Vec<_>>()
915 })
916 .collect();
917
918 let scm_declared: HashSet<ImfUuid> = self
920 .sidecar_composition_maps
921 .values()
922 .flat_map(|scm| scm.sidecar_assets.iter().map(|sa| sa.id))
923 .collect();
924
925 self.asset_map
926 .asset_list
927 .assets
928 .iter()
929 .filter(|a| {
930 a.packing_list != Some(true)
931 && !doc_ids.contains(&a.id)
932 && !track_file_ids.contains(&a.id)
933 && !scm_declared.contains(&a.id)
934 })
935 .collect()
936 }
937
938 fn emit_unreferenced_asset_info(&self, report: &mut ValidationReport) {
941 use crate::diagnostics::codes::ValidationCode as _;
942 for asset in self.unreferenced_assets() {
943 let path = asset
944 .chunk_list
945 .chunks
946 .first()
947 .map(|c| c.path.as_str())
948 .unwrap_or("(unknown)");
949 report.add(ValidationIssue::new(
950 Severity::Info,
951 Category::Structure,
952 codes::ImfernoCode::UnreferencedAsset.code(),
953 format!(
954 "Asset '{}' ({}) is present in the AssetMap but not referenced by any CPL \
955 Virtual Track and has no SCM declaration",
956 path, asset.id,
957 ),
958 ));
959 }
960 }
961
962 #[cfg(not(target_arch = "wasm32"))]
969 fn emit_unlisted_essence(&self, report: &mut ValidationReport) {
970 use crate::diagnostics::codes::ValidationCode as _;
971 if self.root_path.as_os_str().is_empty() {
972 return;
973 }
974
975 let mut known: std::collections::HashSet<String> = self
977 .asset_map
978 .asset_list
979 .assets
980 .iter()
981 .flat_map(|a| a.chunk_list.chunks.iter())
982 .filter_map(|c| {
983 std::path::Path::new(&c.path)
984 .file_name()
985 .map(|n| n.to_string_lossy().into_owned())
986 })
987 .collect();
988
989 known.insert("ASSETMAP.xml".into());
991 known.insert("VOLINDEX.xml".into());
992 known.insert("assetmap.xml".into());
994 known.insert("volindex.xml".into());
995 known.insert("ASSETMAP".into());
996 known.insert("VOLINDEX".into());
997
998 let entries = match std::fs::read_dir(&self.root_path) {
999 Ok(e) => e,
1000 Err(e) => {
1001 report.add(ValidationIssue::new(
1002 Severity::Info,
1003 Category::Structure,
1004 codes::ImfernoCode::ReadDirError,
1005 format!("Could not scan package directory for unlisted files: {}", e,),
1006 ));
1007 return;
1008 }
1009 };
1010
1011 for entry in entries {
1012 let entry = match entry {
1013 Ok(e) => e,
1014 Err(e) => {
1015 report.add(ValidationIssue::new(
1016 Severity::Info,
1017 Category::Structure,
1018 codes::ImfernoCode::DirEntryError,
1019 format!("Could not read directory entry: {}", e),
1020 ));
1021 continue;
1022 }
1023 };
1024 let path = entry.path();
1025 if path.is_dir() {
1027 continue;
1028 }
1029 let filename = match path.file_name() {
1030 Some(n) => n.to_string_lossy().into_owned(),
1031 None => continue,
1032 };
1033 if known.iter().any(|k| k.eq_ignore_ascii_case(&filename)) {
1035 continue;
1036 }
1037 report.add(ValidationIssue::new(
1038 Severity::Warning,
1039 Category::Structure,
1040 codes::ImfernoCode::UnlistedEssence.code(),
1041 format!(
1042 "File '{}' is present in the package directory but not listed in the AssetMap",
1043 filename,
1044 ),
1045 ));
1046 }
1047 }
1048
1049 #[allow(dead_code)]
1053 pub(crate) fn validate_structure(&self) -> Result<()> {
1054 let report = self.validate_package_structure();
1056 if report.has_critical() || report.has_errors() {
1057 let error_messages: Vec<String> = report
1058 .errors
1059 .iter()
1060 .chain(report.critical.iter())
1061 .map(|i| i.message.clone())
1062 .collect();
1063 return Err(ImfError::InvalidStructure(error_messages.join("; ")));
1064 }
1065 Ok(())
1066 }
1067
1068 pub fn validate_file_manifest(&self) -> Vec<FileValidationError> {
1073 let mut errors = Vec::new();
1074
1075 let path_map = self.build_asset_path_map();
1077
1078 for pkl in self.packing_lists.values() {
1079 for asset in &pkl.asset_list.assets {
1080 let uuid_str = asset.id.to_string();
1081 match path_map.get(&asset.id) {
1082 None => {
1083 errors.push(FileValidationError::NotInAssetMap {
1084 uuid: uuid_str,
1085 original_file_name: asset.original_file_name.clone(),
1086 });
1087 }
1088 Some(abs_path) => match std::fs::metadata(abs_path) {
1089 Err(e) => {
1090 if e.kind() == std::io::ErrorKind::NotFound {
1091 errors.push(FileValidationError::Missing {
1092 uuid: uuid_str,
1093 path: abs_path.clone(),
1094 });
1095 } else {
1096 errors.push(FileValidationError::Io {
1097 uuid: uuid_str,
1098 path: abs_path.clone(),
1099 message: format!("Cannot access file: {}", e),
1100 });
1101 }
1102 }
1103 Ok(meta) => {
1104 let actual = meta.len();
1105 if actual != asset.size {
1106 errors.push(FileValidationError::SizeMismatch {
1107 uuid: uuid_str,
1108 path: abs_path.clone(),
1109 expected: asset.size,
1110 actual,
1111 });
1112 }
1113 }
1114 },
1115 }
1116 }
1117 }
1118
1119 errors
1120 }
1121
1122 pub fn validate_file_hashes(&self) -> Vec<FileValidationError> {
1131 self.validate_file_hashes_with_progress(|_, _, _, _, _| {})
1132 }
1133
1134 pub fn validate_file_hashes_with_progress(
1137 &self,
1138 mut on_progress: impl FnMut(usize, usize, &str, u64, u64),
1139 ) -> Vec<FileValidationError> {
1140 let mut errors = self.validate_file_manifest();
1141 let errored_uuids: std::collections::HashSet<String> =
1142 errors.iter().map(|e| e.uuid().to_string()).collect();
1143
1144 let path_map = self.build_asset_path_map();
1145
1146 let total: usize = self
1148 .packing_lists
1149 .values()
1150 .map(|pkl| pkl.asset_list.assets.len())
1151 .sum();
1152 let mut current: usize = 0;
1153
1154 for pkl in self.packing_lists.values() {
1155 for asset in &pkl.asset_list.assets {
1156 current += 1;
1157 let uuid_str = asset.id.to_string();
1158 if errored_uuids.contains(&uuid_str) {
1159 continue;
1160 }
1161 let Some(abs_path) = path_map.get(&asset.id) else {
1162 continue;
1163 };
1164
1165 let filename = abs_path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
1166 let file_size = std::fs::metadata(abs_path).map(|m| m.len()).unwrap_or(0);
1167 on_progress(current, total, filename, 0, file_size);
1168
1169 match std::fs::File::open(abs_path) {
1170 Err(e) => {
1171 errors.push(FileValidationError::Io {
1172 uuid: uuid_str,
1173 path: abs_path.clone(),
1174 message: e.to_string(),
1175 });
1176 }
1177 Ok(file) => {
1178 use std::io::Read;
1179 let mut reader = std::io::BufReader::with_capacity(1024 * 1024, file);
1180 let mut bytes_done: u64 = 0;
1181 let mut had_error = false;
1182 let actual_b64 = match asset.hash.algorithm() {
1183 crate::assetmap::HashAlgorithm::Sha1 => {
1184 use sha1::Digest;
1185 let mut hasher = sha1::Sha1::new();
1186 let mut buf = vec![0u8; 1024 * 1024];
1187 loop {
1188 match reader.read(&mut buf) {
1189 Ok(0) => break,
1190 Ok(n) => {
1191 hasher.update(&buf[..n]);
1192 bytes_done += n as u64;
1193 on_progress(
1194 current, total, filename, bytes_done, file_size,
1195 );
1196 }
1197 Err(e) => {
1198 errors.push(FileValidationError::Io {
1199 uuid: uuid_str.clone(),
1200 path: abs_path.clone(),
1201 message: e.to_string(),
1202 });
1203 had_error = true;
1204 break;
1205 }
1206 }
1207 }
1208 base64::Engine::encode(
1209 &base64::engine::general_purpose::STANDARD,
1210 hasher.finalize(),
1211 )
1212 }
1213 crate::assetmap::HashAlgorithm::Sha256 => {
1214 use sha2::Digest;
1215 let mut hasher = sha2::Sha256::new();
1216 let mut buf = vec![0u8; 1024 * 1024];
1217 loop {
1218 match reader.read(&mut buf) {
1219 Ok(0) => break,
1220 Ok(n) => {
1221 hasher.update(&buf[..n]);
1222 bytes_done += n as u64;
1223 on_progress(
1224 current, total, filename, bytes_done, file_size,
1225 );
1226 }
1227 Err(e) => {
1228 errors.push(FileValidationError::Io {
1229 uuid: uuid_str.clone(),
1230 path: abs_path.clone(),
1231 message: e.to_string(),
1232 });
1233 had_error = true;
1234 break;
1235 }
1236 }
1237 }
1238 base64::Engine::encode(
1239 &base64::engine::general_purpose::STANDARD,
1240 hasher.finalize(),
1241 )
1242 }
1243 };
1244 if !had_error {
1245 let expected_b64 = asset.hash.to_base64();
1246 if actual_b64 != expected_b64 {
1247 errors.push(FileValidationError::HashMismatch {
1248 uuid: uuid_str,
1249 path: abs_path.clone(),
1250 expected: expected_b64,
1251 actual: actual_b64,
1252 });
1253 }
1254 }
1255 }
1256 }
1257 }
1258 }
1259
1260 errors
1261 }
1262
1263 #[cfg(feature = "tokio")]
1268 pub fn hash_verification_size(&self) -> u64 {
1269 let path_map = self.build_asset_path_map();
1270 self.packing_lists
1271 .values()
1272 .flat_map(|pkl| pkl.asset_list.assets.iter())
1273 .filter_map(|asset| {
1274 path_map
1275 .get(&asset.id)
1276 .and_then(|p| std::fs::metadata(p).ok())
1277 .map(|m| m.len())
1278 })
1279 .sum()
1280 }
1281
1282 #[cfg(feature = "tokio")]
1284 pub async fn validate_file_hashes_parallel(
1285 &self,
1286 concurrency: usize,
1287 progress: std::sync::Arc<HashProgressTracker>,
1288 ) -> Vec<FileValidationError> {
1289 use std::sync::Arc;
1290
1291 let path_map = self.build_asset_path_map();
1292 let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
1293 let mut handles = Vec::new();
1294
1295 let manifest_errors = self.validate_file_manifest();
1297 let errored_uuids: std::collections::HashSet<String> = manifest_errors
1298 .iter()
1299 .map(|e| e.uuid().to_string())
1300 .collect();
1301
1302 let mut assets_to_hash: Vec<_> = self
1304 .packing_lists
1305 .values()
1306 .flat_map(|pkl| pkl.asset_list.assets.iter())
1307 .filter(|asset| !errored_uuids.contains(&asset.id.to_string()))
1308 .filter(|asset| path_map.contains_key(&asset.id))
1309 .collect();
1310 assets_to_hash.sort_by_key(|a| a.size);
1311
1312 for asset in assets_to_hash {
1314 let abs_path = path_map.get(&asset.id).unwrap();
1315
1316 let filename = abs_path
1317 .file_name()
1318 .and_then(|n| n.to_str())
1319 .unwrap_or("?")
1320 .to_string();
1321 let file_size = asset.size;
1322 let (bytes_counter, status_flag) = progress.register(filename, file_size);
1323
1324 let uuid_str = asset.id.to_string();
1325 let abs_path = abs_path.clone();
1326 let expected_b64 = asset.hash.to_base64();
1327 let algorithm = asset.hash.algorithm();
1328 let sem = semaphore.clone();
1329
1330 let err_uuid = uuid_str.clone();
1331 let err_path = abs_path.clone();
1332 handles.push(tokio::spawn(async move {
1333 let _permit = sem.acquire().await.unwrap();
1334 status_flag.store(1, std::sync::atomic::Ordering::Relaxed); let result = match tokio::task::spawn_blocking(move || {
1336 hash_single_file(
1337 &uuid_str,
1338 &abs_path,
1339 &expected_b64,
1340 algorithm,
1341 &bytes_counter,
1342 )
1343 })
1344 .await
1345 {
1346 Ok(r) => r,
1347 Err(e) => Some(FileValidationError::Io {
1348 uuid: err_uuid,
1349 path: err_path,
1350 message: format!("hash task failed: {}", e),
1351 }),
1352 };
1353
1354 status_flag.store(
1355 if result.is_some() { 3 } else { 2 }, std::sync::atomic::Ordering::Relaxed,
1357 );
1358 result
1359 }));
1360 }
1361
1362 let mut errors = manifest_errors;
1364 for handle in handles {
1365 if let Ok(Some(err)) = handle.await {
1366 errors.push(err);
1367 }
1368 }
1369
1370 errors
1371 }
1372
1373 pub fn validate_pkl_constraints(&self) -> Vec<FileValidationError> {
1379 let mut errors = Vec::new();
1380
1381 let assetmap_ids: std::collections::HashSet<ImfUuid> = self
1383 .asset_map
1384 .asset_list
1385 .assets
1386 .iter()
1387 .map(|a| a.id)
1388 .collect();
1389
1390 for pkl in self.packing_lists.values() {
1391 let mut seen_ids: std::collections::HashSet<ImfUuid> = std::collections::HashSet::new();
1393 for asset in &pkl.asset_list.assets {
1394 if !seen_ids.insert(asset.id) {
1395 errors.push(FileValidationError::DuplicatePklAssetId {
1396 uuid: asset.id.to_string(),
1397 pkl_id: pkl.id.to_string(),
1398 });
1399 }
1400
1401 if !assetmap_ids.contains(&asset.id) {
1403 errors.push(FileValidationError::NotInAssetMap {
1404 uuid: asset.id.to_string(),
1405 original_file_name: asset.original_file_name.clone(),
1406 });
1407 }
1408 }
1409 }
1410
1411 errors
1412 }
1413
1414 fn build_asset_path_map(&self) -> HashMap<ImfUuid, PathBuf> {
1418 let mut map = HashMap::new();
1419 let has_root = !self.root_path.as_os_str().is_empty();
1420 for asset in &self.asset_map.asset_list.assets {
1421 if let Some(chunk) = asset.chunk_list.chunks.first() {
1422 if has_root {
1423 if let Some(safe_path) = sanitize_asset_path(&self.root_path, &chunk.path) {
1424 map.insert(asset.id, safe_path);
1425 }
1426 } else {
1428 map.insert(asset.id, PathBuf::from(&chunk.path));
1429 }
1430 }
1431 }
1432 map
1433 }
1434
1435 pub fn validate_package_structure(&self) -> ValidationReport {
1450 self.validate_package_structure_with_cpl_validator(|_| Vec::new(), false)
1451 }
1452
1453 pub fn validate_package_structure_with_cpl_validator<F>(
1461 &self,
1462 cpl_validator: F,
1463 skip_disk_checks: bool,
1464 ) -> ValidationReport
1465 where
1466 F: Fn(&CompositionPlaylist) -> Vec<ValidationIssue>,
1467 {
1468 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1469
1470 for issue in &self.volindex_issues {
1472 report.add(issue.clone());
1473 }
1474
1475 for issue in &self.parse_issues {
1477 report.add(issue.clone());
1478 }
1479
1480 for issue in self
1482 .validate_pkl_constraints()
1483 .iter()
1484 .map(ValidationIssue::from)
1485 {
1486 report.add(issue);
1487 }
1488
1489 #[cfg(not(target_arch = "wasm32"))]
1493 if !skip_disk_checks && !self.root_path.as_os_str().is_empty() {
1494 for issue in self
1495 .validate_file_manifest()
1496 .iter()
1497 .map(ValidationIssue::from)
1498 {
1499 report.add(issue);
1500 }
1501 }
1502
1503 for cpl in self.composition_playlists.values() {
1505 self.validate_cpl_asset_references_accumulating(cpl, &mut report);
1506
1507 for issue in cpl_validator(cpl) {
1509 report.add(issue);
1510 }
1511 }
1512
1513 self.validate_scm_references(&mut report);
1515
1516 self.emit_unreferenced_asset_info(&mut report);
1518
1519 self.validate_multi_pkl_consistency(&mut report);
1521
1522 #[cfg(not(target_arch = "wasm32"))]
1525 if !skip_disk_checks && !self.root_path.as_os_str().is_empty() {
1526 self.validate_mxf_headers(&mut report);
1527 self.emit_unlisted_essence(&mut report);
1528 }
1529
1530 report
1531 }
1532
1533 pub fn validate_package_with_hashes(&self) -> ValidationReport {
1538 self.validate_package_with_hashes_with_cpl_validator(|_| Vec::new())
1539 }
1540
1541 pub fn validate_package_with_hashes_with_cpl_validator<F>(
1543 &self,
1544 cpl_validator: F,
1545 ) -> ValidationReport
1546 where
1547 F: Fn(&CompositionPlaylist) -> Vec<ValidationIssue>,
1548 {
1549 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1550
1551 for issue in &self.volindex_issues {
1553 report.add(issue.clone());
1554 }
1555
1556 for issue in &self.parse_issues {
1558 report.add(issue.clone());
1559 }
1560
1561 for issue in self
1563 .validate_pkl_constraints()
1564 .iter()
1565 .map(ValidationIssue::from)
1566 {
1567 report.add(issue);
1568 }
1569
1570 for issue in self
1572 .validate_file_hashes()
1573 .iter()
1574 .map(ValidationIssue::from)
1575 {
1576 report.add(issue);
1577 }
1578
1579 for cpl in self.composition_playlists.values() {
1581 self.validate_cpl_asset_references_accumulating(cpl, &mut report);
1582
1583 for issue in cpl_validator(cpl) {
1585 report.add(issue);
1586 }
1587 }
1588
1589 self.validate_multi_pkl_consistency(&mut report);
1591
1592 self.validate_mxf_headers(&mut report);
1594
1595 report
1596 }
1597
1598 fn validate_scm_references(&self, report: &mut ValidationReport) {
1602 use std::collections::HashSet;
1603
1604 let asset_ids: HashSet<_> = self
1605 .asset_map
1606 .asset_list
1607 .assets
1608 .iter()
1609 .map(|a| a.id)
1610 .collect();
1611
1612 let virtual_track_file_ids: HashSet<ImfUuid> = self
1614 .composition_playlists
1615 .values()
1616 .flat_map(|cpl| cpl.segment_list.segments.iter())
1617 .flat_map(|seg| {
1618 seg.sequence_list
1619 .all_sequences()
1620 .into_iter()
1621 .flat_map(|seq| {
1622 seq.resource_list()
1623 .resources
1624 .iter()
1625 .filter_map(|r| r.track_file_id)
1626 })
1627 .collect::<Vec<_>>()
1628 })
1629 .collect();
1630
1631 for scm in self.sidecar_composition_maps.values() {
1632 if scm.has_signer && !scm.has_signature {
1634 report.add(
1635 ValidationIssue::new(
1636 Severity::Error,
1637 Category::Reference,
1638 codes::St2067_9_2018::SignerWithoutSignature,
1639 format!(
1640 "SCM {}: Signer element present but Signature element is absent",
1641 scm.id
1642 ),
1643 )
1644 .with_context("scm_id", scm.id.to_string()),
1645 );
1646 }
1647
1648 if scm.has_signature && !scm.has_signer {
1650 report.add(
1651 ValidationIssue::new(
1652 Severity::Error,
1653 Category::Reference,
1654 codes::St2067_9_2018::SignatureWithoutSigner,
1655 format!(
1656 "SCM {}: Signature element present but Signer element is absent",
1657 scm.id
1658 ),
1659 )
1660 .with_context("scm_id", scm.id.to_string()),
1661 );
1662 }
1663
1664 let mut seen_asset_ids = HashSet::new();
1665 for sidecar_asset in &scm.sidecar_assets {
1666 if !seen_asset_ids.insert(sidecar_asset.id) {
1668 report.add(
1669 ValidationIssue::new(
1670 Severity::Error,
1671 Category::Reference,
1672 codes::St2067_9_2018::DuplicateAssetId,
1673 format!(
1674 "Duplicate SidecarAsset Id {} in SCM {}",
1675 sidecar_asset.id, scm.id
1676 ),
1677 )
1678 .with_context("scm_id", scm.id.to_string())
1679 .with_context("asset_id", sidecar_asset.id.to_string()),
1680 );
1681 }
1682
1683 if !asset_ids.contains(&sidecar_asset.id) {
1685 report.add(
1686 ValidationIssue::new(
1687 Severity::Error,
1688 Category::Reference,
1689 codes::St2067_9_2018::SidecarAssetNotFound,
1690 format!(
1691 "SCM {} references sidecar asset {} not found in AssetMap",
1692 scm.id, sidecar_asset.id
1693 ),
1694 )
1695 .with_context("scm_id", scm.id.to_string())
1696 .with_context("asset_id", sidecar_asset.id.to_string()),
1697 );
1698 }
1699
1700 if virtual_track_file_ids.contains(&sidecar_asset.id) {
1702 report.add(
1703 ValidationIssue::new(
1704 Severity::Error,
1705 Category::Reference,
1706 codes::St2067_9_2018::SidecarAssetReferencedByVirtualTrack,
1707 format!(
1708 "Sidecar asset {} (SCM {}) is referenced by a Virtual Track in a CPL",
1709 sidecar_asset.id, scm.id
1710 ),
1711 )
1712 .with_context("scm_id", scm.id.to_string())
1713 .with_context("asset_id", sidecar_asset.id.to_string()),
1714 );
1715 }
1716
1717 let mut seen_cpl_ids = HashSet::new();
1719 for cpl_id in &sidecar_asset.cpl_ids {
1720 if !seen_cpl_ids.insert(*cpl_id) {
1722 report.add(ValidationIssue::new(
1723 Severity::Error,
1724 Category::Reference,
1725 codes::St2067_9_2018::DuplicateCplId,
1726 format!(
1727 "Duplicate CPLId {} in AssociatedCPLList of sidecar asset {} (SCM {})",
1728 cpl_id, sidecar_asset.id, scm.id
1729 ),
1730 ).with_context("scm_id", scm.id.to_string())
1731 .with_context("asset_id", sidecar_asset.id.to_string())
1732 .with_context("cpl_id", cpl_id.to_string()));
1733 }
1734
1735 if !self.composition_playlists.contains_key(cpl_id) {
1737 report.add(ValidationIssue::new(
1738 Severity::Error,
1739 Category::Reference,
1740 codes::St2067_9_2018::CplNotFound,
1741 format!(
1742 "SCM {} sidecar asset {} references CPL {} which is not known in this package",
1743 scm.id, sidecar_asset.id, cpl_id
1744 ),
1745 ).with_context("scm_id", scm.id.to_string())
1746 .with_context("asset_id", sidecar_asset.id.to_string())
1747 .with_context("cpl_id", cpl_id.to_string()));
1748 }
1749 }
1750 }
1751 }
1752 }
1753
1754 fn validate_multi_pkl_consistency(&self, report: &mut ValidationReport) {
1760 if self.packing_lists.len() < 2 {
1761 return; }
1763
1764 let mut asset_records: HashMap<ImfUuid, Vec<(ImfUuid, String, u64)>> = HashMap::new();
1766 for (pkl_id, pkl) in &self.packing_lists {
1767 for asset in &pkl.asset_list.assets {
1768 asset_records.entry(asset.id).or_default().push((
1769 *pkl_id,
1770 asset.hash.to_base64(),
1771 asset.size,
1772 ));
1773 }
1774 }
1775
1776 for (asset_id, records) in &asset_records {
1777 if records.len() < 2 {
1778 continue;
1779 }
1780 let (first_pkl, ref first_hash, first_size) = records[0];
1781 for (pkl_id, hash, size) in &records[1..] {
1782 if hash != first_hash {
1783 report.add(
1784 ValidationIssue::new(
1785 Severity::Error,
1786 Category::Asset,
1787 codes::St2067_2_2020::ChecksumMismatch,
1788 format!(
1789 "Asset {} has different hashes in PKL {} ({}) vs PKL {} ({})",
1790 asset_id,
1791 &first_pkl.to_string()[..8],
1792 &first_hash[..8.min(first_hash.len())],
1793 &pkl_id.to_string()[..8],
1794 &hash[..8.min(hash.len())],
1795 ),
1796 )
1797 .with_context("asset_uuid", asset_id.to_string()),
1798 );
1799 }
1800 if *size != first_size {
1801 report.add(
1802 ValidationIssue::new(
1803 Severity::Error,
1804 Category::Asset,
1805 codes::St2067_2_2020::SizeMismatch,
1806 format!(
1807 "Asset {} has different sizes in PKL {} ({} bytes) vs PKL {} ({} bytes)",
1808 asset_id,
1809 &first_pkl.to_string()[..8],
1810 first_size,
1811 &pkl_id.to_string()[..8],
1812 size,
1813 ),
1814 )
1815 .with_context("asset_uuid", asset_id.to_string()),
1816 );
1817 }
1818 }
1819 }
1820 }
1821
1822 fn validate_mxf_headers(&self, report: &mut ValidationReport) {
1829 const OP1A_BYTES_13_14: [u8; 2] = [0x01, 0x01];
1833
1834 for pkl in self.packing_lists.values() {
1836 for asset in &pkl.asset_list.assets {
1837 if !asset.mime_type.is_mxf() {
1838 continue;
1839 }
1840 let path = match self.asset_paths.get(&asset.id) {
1841 Some(p) => p,
1842 None => continue, };
1844 if !path.exists() {
1845 continue; }
1847
1848 match crate::mxf::parse_mxf_header_info(path) {
1849 Ok(info) => {
1850 let op_bytes = parse_ul_bytes(&info.operational_pattern);
1854 if let Some(bytes) = op_bytes {
1855 if bytes[12] != OP1A_BYTES_13_14[0] || bytes[13] != OP1A_BYTES_13_14[1]
1857 {
1858 report.add(
1859 ValidationIssue::new(
1860 Severity::Error,
1861 Category::Encoding,
1862 codes::St377_1_2011::Op1a,
1863 format!(
1864 "MXF track file '{}' has Operational Pattern '{}' \
1865 but IMF requires OP1a (ST 2067-2 §5.1)",
1866 path.file_name()
1867 .map(|n| n.to_string_lossy())
1868 .unwrap_or_default(),
1869 info.operational_pattern,
1870 ),
1871 )
1872 .with_location(Location::new().with_file(path.clone()))
1873 .with_context("asset_uuid", asset.id.to_string()),
1874 );
1875 }
1876 }
1877
1878 if info.essence_containers.is_empty() {
1880 report.add(
1881 ValidationIssue::new(
1882 Severity::Warning,
1883 Category::Encoding,
1884 codes::St377_1_2011::NoEssenceContainers,
1885 format!(
1886 "MXF track file '{}' has no essence containers in its header partition",
1887 path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
1888 ),
1889 )
1890 .with_location(Location::new().with_file(path.clone()))
1891 .with_context("asset_uuid", asset.id.to_string()),
1892 );
1893 }
1894 }
1895 Err(crate::mxf::MxfParseError::NotMxf) => {
1896 report.add(
1897 ValidationIssue::new(
1898 Severity::Warning,
1899 Category::Asset,
1900 codes::St377_1_2011::NotMxf,
1901 format!(
1902 "File '{}' has MXF MIME type but is not a valid MXF file",
1903 path.file_name()
1904 .map(|n| n.to_string_lossy())
1905 .unwrap_or_default(),
1906 ),
1907 )
1908 .with_location(Location::new().with_file(path.clone()))
1909 .with_context("asset_uuid", asset.id.to_string()),
1910 );
1911 }
1912 Err(e) => {
1913 report.add(
1914 ValidationIssue::new(
1915 Severity::Warning,
1916 Category::Asset,
1917 codes::St377_1_2011::ParseError,
1918 format!(
1919 "Could not parse MXF header of '{}': {}",
1920 path.file_name()
1921 .map(|n| n.to_string_lossy())
1922 .unwrap_or_default(),
1923 e,
1924 ),
1925 )
1926 .with_location(Location::new().with_file(path.clone()))
1927 .with_context("asset_uuid", asset.id.to_string()),
1928 );
1929 }
1930 }
1931 }
1932 }
1933 }
1934
1935 #[allow(dead_code)]
1944 fn validate_segment_durations(&self, report: &mut ValidationReport) {
1945 for cpl in self.composition_playlists.values() {
1946 let cpl_id = cpl.id;
1947 let cpl_er = cpl.edit_rate.as_ref();
1948
1949 for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
1950 let mut durations: Vec<(String, f64)> = Vec::new();
1951
1952 for seq in segment.sequence_list.all_sequences() {
1953 let resources = &seq.resource_list().resources;
1954 let mut total_num: u64 = 0;
1955 let mut rate_den: u64 = 1;
1956 for r in resources {
1957 let ep = r.entry_point.unwrap_or(0);
1958 let dur = r
1959 .source_duration
1960 .unwrap_or(r.intrinsic_duration.saturating_sub(ep));
1961 let er = r
1962 .edit_rate
1963 .as_ref()
1964 .or(cpl_er)
1965 .cloned()
1966 .unwrap_or(EditRate::new(1, 1));
1967 total_num =
1968 total_num.saturating_add(dur.saturating_mul(er.denominator as u64));
1969 rate_den = er.numerator as u64;
1970 }
1971 if rate_den > 0 {
1972 durations.push((
1973 seq.track_id().to_string(),
1974 total_num as f64 / rate_den as f64,
1975 ));
1976 }
1977 }
1978
1979 if durations.is_empty() {
1980 continue;
1981 }
1982
1983 let first_dur = durations[0].1;
1984 const TOLERANCE: f64 = 0.000001;
1986 for (track_id, dur) in &durations[1..] {
1987 if (*dur - first_dur).abs() > TOLERANCE {
1988 report.add(
1989 ValidationIssue::new(
1990 Severity::Error,
1991 Category::Timing,
1992 codes::St2067_3_2020::SegmentDuration,
1993 format!(
1994 "Segment {} has mismatched virtual track durations: \
1995 track {} = {:.6}s but track {} = {:.6}s",
1996 seg_idx, durations[0].0, first_dur, track_id, dur,
1997 ),
1998 )
1999 .with_location(Location::new().with_cpl(cpl_id).with_segment(seg_idx)),
2000 );
2001 break; }
2003 }
2004 }
2005 }
2006 }
2007
2008 fn validate_cpl_asset_references_accumulating(
2014 &self,
2015 cpl: &crate::cpl::CompositionPlaylist,
2016 report: &mut ValidationReport,
2017 ) {
2018 if self.asset_map.asset_list.assets.is_empty() {
2019 report.add(
2020 ValidationIssue::new(
2021 Severity::Critical,
2022 Category::Structure,
2023 codes::St2067_2_2020::AssetMap,
2024 "AssetMap contains no assets",
2025 )
2026 .with_location(Location::new().with_cpl(cpl.id)),
2027 );
2028 return;
2029 }
2030
2031 let assetmap_ids: std::collections::HashSet<ImfUuid> = self
2032 .asset_map
2033 .asset_list
2034 .assets
2035 .iter()
2036 .map(|a| a.id)
2037 .collect();
2038
2039 let cpl_id = cpl.id;
2040
2041 for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
2042 for (seq, track_type) in segment.sequence_list.all_sequences_typed() {
2043 for (res_idx, resource) in seq.resource_list().resources.iter().enumerate() {
2044 if let Some(ref track_file_id) = resource.track_file_id {
2045 if !assetmap_ids.contains(track_file_id) {
2046 report.add(
2047 ValidationIssue::new(
2048 Severity::Error,
2049 Category::Reference,
2050 codes::St2067_2_2020::UnresolvedUuid,
2051 format!(
2052 "{} TrackFileId {} not found in AssetMap",
2053 track_type, track_file_id
2054 ),
2055 )
2056 .with_location(
2057 Location::new()
2058 .with_cpl(cpl_id)
2059 .with_segment(seg_idx)
2060 .with_resource(res_idx),
2061 )
2062 .with_context("track_file_id", track_file_id.to_string()),
2063 );
2064 }
2065 }
2066 }
2067 }
2068 }
2069 }
2070}
2071
2072fn parse_ul_bytes(ul: &str) -> Option<[u8; 16]> {
2074 let hex = ul.strip_prefix("urn:smpte:ul:")?;
2075 let hex_clean: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
2076 if hex_clean.len() != 32 {
2077 return None;
2078 }
2079 let mut bytes = [0u8; 16];
2080 for i in 0..16 {
2081 bytes[i] = u8::from_str_radix(&hex_clean[i * 2..i * 2 + 2], 16).ok()?;
2082 }
2083 Some(bytes)
2084}
2085
2086#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2087#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2088pub struct CplDetails {
2089 pub id: String,
2090 pub title: String,
2091 pub kind: String,
2092 pub issue_date: String,
2093 pub annotation: Option<String>,
2094 pub issuer: Option<String>,
2095 pub creator: Option<String>,
2096 pub content_originator: Option<String>,
2097 pub content_versions: Vec<String>,
2098 pub segments: Vec<SegmentInfo>,
2099}
2100
2101#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2103pub struct SegmentInfo {
2104 pub id: String,
2105 pub sequence_count: usize,
2106}
2107
2108#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2109#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2110pub struct TrackAnalysis {
2111 pub cpl_id: String,
2112 pub cpl_title: String,
2113 pub total_tracks: usize,
2114 pub audio_tracks: usize,
2115 pub video_tracks: usize,
2116 pub subtitle_tracks: usize,
2117 pub languages: Vec<String>,
2118 pub codecs: Vec<String>,
2119}
2120
2121impl Imferno {
2122 pub fn get_cpl_details(&self, uuid: &str) -> Option<CplDetails> {
2124 let cpl = self.get_cpl_str(uuid)?;
2125
2126 let content_versions = if let Some(ref version_list) = cpl.content_version_list {
2127 version_list
2128 .content_versions
2129 .iter()
2130 .map(|v| v.id.clone())
2131 .collect()
2132 } else {
2133 Vec::new()
2134 };
2135
2136 let segments = cpl
2137 .segment_list
2138 .segments
2139 .iter()
2140 .map(|seg| {
2141 let seq_list = &seg.sequence_list;
2142 let sequence_count = seq_list.main_image_sequences.len()
2143 + seq_list.main_audio_sequences.len()
2144 + seq_list.subtitles_sequences.len();
2145 SegmentInfo {
2146 id: seg.id.to_string(),
2147 sequence_count,
2148 }
2149 })
2150 .collect();
2151
2152 Some(CplDetails {
2153 id: cpl.id.to_string(),
2154 title: cpl.content_title.text.clone(),
2155 kind: cpl.content_kind.to_string(),
2156 issue_date: cpl.issue_date.clone(),
2157 annotation: cpl.annotation.as_ref().map(|ls| ls.text.clone()),
2158 issuer: cpl.issuer.as_ref().map(|ls| ls.text.clone()),
2159 creator: cpl.creator.as_ref().map(|ls| ls.text.clone()),
2160 content_originator: cpl.content_originator.as_ref().map(|ls| ls.text.clone()),
2161 content_versions,
2162 segments,
2163 })
2164 }
2165
2166 pub fn analyze_tracks(&self) -> Vec<TrackAnalysis> {
2168 let mut analyses = Vec::new();
2169
2170 for (uuid, cpl) in &self.composition_playlists {
2171 let mut total_tracks = 0;
2172 let mut audio_tracks = 0;
2173 let mut video_tracks = 0;
2174 let mut subtitle_tracks = 0;
2175 let mut codecs = std::collections::HashSet::new();
2176
2177 for segment in &cpl.segment_list.segments {
2178 let seq_list = &segment.sequence_list;
2179
2180 if !seq_list.main_image_sequences.is_empty() {
2181 video_tracks += seq_list.main_image_sequences.len();
2182 total_tracks += seq_list.main_image_sequences.len();
2183 codecs.insert("Video".to_string());
2184 }
2185
2186 if !seq_list.main_audio_sequences.is_empty() {
2187 audio_tracks += seq_list.main_audio_sequences.len();
2188 total_tracks += seq_list.main_audio_sequences.len();
2189 codecs.insert("Audio".to_string());
2190 }
2191
2192 if !seq_list.subtitles_sequences.is_empty() {
2193 subtitle_tracks += seq_list.subtitles_sequences.len();
2194 total_tracks += seq_list.subtitles_sequences.len();
2195 codecs.insert("Subtitle".to_string());
2196 }
2197 }
2198
2199 analyses.push(TrackAnalysis {
2200 cpl_id: uuid.to_string(),
2201 cpl_title: cpl.content_title.text.clone(),
2202 total_tracks,
2203 audio_tracks,
2204 video_tracks,
2205 subtitle_tracks,
2206 languages: Vec::new(),
2207 codecs: codecs.into_iter().collect(),
2208 });
2209 }
2210
2211 analyses
2212 }
2213
2214 pub fn analyze_tracks_enhanced(
2216 &self,
2217 feature_data: Option<serde_json::Value>,
2218 ) -> Vec<TrackAnalysis> {
2219 let mut analyses = Vec::new();
2220
2221 for (uuid, cpl) in &self.composition_playlists {
2222 let mut total_tracks = 0;
2223 let mut audio_tracks = 0;
2224 let mut video_tracks = 0;
2225 let mut subtitle_tracks = 0;
2226 let mut codecs = std::collections::HashSet::new();
2227
2228 for segment in &cpl.segment_list.segments {
2229 let seq_list = &segment.sequence_list;
2230
2231 if !seq_list.main_image_sequences.is_empty() {
2232 video_tracks += seq_list.main_image_sequences.len();
2233 total_tracks += seq_list.main_image_sequences.len();
2234 }
2235
2236 if !seq_list.main_audio_sequences.is_empty() {
2237 audio_tracks += seq_list.main_audio_sequences.len();
2238 total_tracks += seq_list.main_audio_sequences.len();
2239 }
2240
2241 if !seq_list.subtitles_sequences.is_empty() {
2242 subtitle_tracks += seq_list.subtitles_sequences.len();
2243 total_tracks += seq_list.subtitles_sequences.len();
2244 }
2245 }
2246
2247 let languages = if let Some(ref data) = feature_data {
2248 if let Some(audio_langs) = data["audio_languages"].as_array() {
2249 audio_langs
2250 .iter()
2251 .filter_map(|v| v.as_str().map(String::from))
2252 .collect()
2253 } else {
2254 Vec::new()
2255 }
2256 } else {
2257 Vec::new()
2258 };
2259
2260 if let Some(ref data) = feature_data {
2261 if let Some(video_codecs) = data["video_codecs"].as_array() {
2262 for codec in video_codecs {
2263 if let Some(codec_str) = codec.as_str() {
2264 codecs.insert(codec_str.to_string());
2265 }
2266 }
2267 }
2268 if let Some(audio_codecs) = data["audio_codecs"].as_array() {
2269 for codec in audio_codecs {
2270 if let Some(codec_str) = codec.as_str() {
2271 codecs.insert(codec_str.to_string());
2272 }
2273 }
2274 }
2275 }
2276
2277 if video_tracks > 0 {
2278 codecs.insert("Video".to_string());
2279 }
2280 if audio_tracks > 0 {
2281 codecs.insert("Audio".to_string());
2282 }
2283 if subtitle_tracks > 0 {
2284 codecs.insert("Subtitle".to_string());
2285 }
2286
2287 analyses.push(TrackAnalysis {
2288 cpl_id: uuid.to_string(),
2289 cpl_title: cpl.content_title.text.clone(),
2290 total_tracks,
2291 audio_tracks,
2292 video_tracks,
2293 subtitle_tracks,
2294 languages,
2295 codecs: codecs.into_iter().collect(),
2296 });
2297 }
2298
2299 analyses
2300 }
2301}
2302
2303pub use crate::diagnostics::{RuleSeverity, RulesConfig};
2306
2307#[derive(Debug, Default, Clone)]
2309pub struct ValidationOptions {
2310 pub rules: RulesConfig,
2313 pub core_spec: Option<crate::validation::CoreSpecTarget>,
2315 pub app_specs: Option<Vec<crate::validation::AppSpecTarget>>,
2317 #[cfg(not(target_arch = "wasm32"))]
2320 pub verify_hashes: Option<PathBuf>,
2321 #[cfg(not(target_arch = "wasm32"))]
2325 pub skip_disk_checks: bool,
2326}
2327
2328#[cfg(feature = "tokio")]
2330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2331pub enum HashFileStatus {
2332 Waiting,
2333 Hashing,
2334 Done,
2335 Failed,
2336}
2337
2338#[cfg(feature = "tokio")]
2340pub struct HashFileInfo {
2341 pub name: String,
2342 pub size: u64,
2343 pub bytes_done: std::sync::Arc<std::sync::atomic::AtomicU64>,
2344 pub status: std::sync::Arc<std::sync::atomic::AtomicU8>,
2345}
2346
2347#[cfg(feature = "tokio")]
2349pub struct HashProgressTracker {
2350 pub files: std::sync::Mutex<Vec<HashFileInfo>>,
2351}
2352
2353#[cfg(feature = "tokio")]
2354impl HashProgressTracker {
2355 pub fn new() -> Self {
2356 Self {
2357 files: std::sync::Mutex::new(Vec::new()),
2358 }
2359 }
2360
2361 pub fn register(
2362 &self,
2363 name: String,
2364 size: u64,
2365 ) -> (
2366 std::sync::Arc<std::sync::atomic::AtomicU64>,
2367 std::sync::Arc<std::sync::atomic::AtomicU8>,
2368 ) {
2369 let bytes_done = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
2370 let status = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0));
2371 let bd = bytes_done.clone();
2372 let st = status.clone();
2373 self.files.lock().unwrap().push(HashFileInfo {
2374 name,
2375 size,
2376 bytes_done,
2377 status,
2378 });
2379 (bd, st)
2380 }
2381
2382 pub fn snapshot(&self) -> Vec<(String, u64, u64, HashFileStatus)> {
2384 use std::sync::atomic::Ordering::Relaxed;
2385 let files = self.files.lock().unwrap();
2386 files
2387 .iter()
2388 .map(|f| {
2389 let status = match f.status.load(Relaxed) {
2390 1 => HashFileStatus::Hashing,
2391 2 => HashFileStatus::Done,
2392 3 => HashFileStatus::Failed,
2393 _ => HashFileStatus::Waiting,
2394 };
2395 (f.name.clone(), f.bytes_done.load(Relaxed), f.size, status)
2396 })
2397 .collect()
2398 }
2399
2400 pub fn total_bytes_done(&self) -> u64 {
2402 use std::sync::atomic::Ordering::Relaxed;
2403 let files = self.files.lock().unwrap();
2404 files.iter().map(|f| f.bytes_done.load(Relaxed)).sum()
2405 }
2406
2407 pub fn total_bytes(&self) -> u64 {
2409 let files = self.files.lock().unwrap();
2410 files.iter().map(|f| f.size).sum()
2411 }
2412}
2413
2414#[cfg(feature = "tokio")]
2415impl Default for HashProgressTracker {
2416 fn default() -> Self {
2417 Self::new()
2418 }
2419}
2420
2421#[cfg(all(not(target_arch = "wasm32"), feature = "tokio"))]
2423fn hash_single_file(
2424 uuid: &str,
2425 path: &std::path::Path,
2426 expected_b64: &str,
2427 algorithm: crate::assetmap::HashAlgorithm,
2428 bytes_done: &std::sync::atomic::AtomicU64,
2429) -> Option<FileValidationError> {
2430 use std::io::Read;
2431 use std::sync::atomic::Ordering;
2432
2433 let file = match std::fs::File::open(path) {
2434 Ok(f) => f,
2435 Err(e) => {
2436 return Some(FileValidationError::Io {
2437 uuid: uuid.to_string(),
2438 path: path.to_path_buf(),
2439 message: e.to_string(),
2440 });
2441 }
2442 };
2443
2444 let mut reader = std::io::BufReader::with_capacity(1024 * 1024, file);
2445 let mut buf = vec![0u8; 1024 * 1024];
2446
2447 let actual_b64 = match algorithm {
2448 crate::assetmap::HashAlgorithm::Sha1 => {
2449 use sha1::Digest;
2450 let mut hasher = sha1::Sha1::new();
2451 loop {
2452 match reader.read(&mut buf) {
2453 Ok(0) => break,
2454 Ok(n) => {
2455 hasher.update(&buf[..n]);
2456 bytes_done.fetch_add(n as u64, Ordering::Relaxed);
2457 }
2458 Err(e) => {
2459 return Some(FileValidationError::Io {
2460 uuid: uuid.to_string(),
2461 path: path.to_path_buf(),
2462 message: e.to_string(),
2463 });
2464 }
2465 }
2466 }
2467 base64::Engine::encode(
2468 &base64::engine::general_purpose::STANDARD,
2469 hasher.finalize(),
2470 )
2471 }
2472 crate::assetmap::HashAlgorithm::Sha256 => {
2473 use sha2::Digest;
2474 let mut hasher = sha2::Sha256::new();
2475 loop {
2476 match reader.read(&mut buf) {
2477 Ok(0) => break,
2478 Ok(n) => {
2479 hasher.update(&buf[..n]);
2480 bytes_done.fetch_add(n as u64, Ordering::Relaxed);
2481 }
2482 Err(e) => {
2483 return Some(FileValidationError::Io {
2484 uuid: uuid.to_string(),
2485 path: path.to_path_buf(),
2486 message: e.to_string(),
2487 });
2488 }
2489 }
2490 }
2491 base64::Engine::encode(
2492 &base64::engine::general_purpose::STANDARD,
2493 hasher.finalize(),
2494 )
2495 }
2496 };
2497
2498 if actual_b64 != expected_b64 {
2499 Some(FileValidationError::HashMismatch {
2500 uuid: uuid.to_string(),
2501 path: path.to_path_buf(),
2502 expected: expected_b64.to_string(),
2503 actual: actual_b64,
2504 })
2505 } else {
2506 None
2507 }
2508}
2509
2510#[cfg(test)]
2511mod tests {
2512 use super::*;
2513 use codes::{St2067_2_2020, St377_1_2011, ValidationCode};
2514
2515 fn test_data(name: &str) -> PathBuf {
2516 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2517 .join("../../test-data")
2518 .join(name)
2519 }
2520
2521 #[test]
2522 fn test_parse_netflix_photon_package() {
2523 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2524
2525 match Imferno::parse(read_dir(test_path).unwrap()) {
2526 Ok(package) => {
2527 assert_eq!(package.volume_index.index, 1);
2528 assert!(!package.asset_map.asset_list.assets.is_empty());
2529 assert!(!package.composition_playlists.is_empty());
2530
2531 let main_cpl = package.get_main_cpl().unwrap();
2532 assert_eq!(main_cpl.content_kind, crate::cpl::ContentKind::Test);
2533 assert_eq!(main_cpl.content_title.text, "MERIDIAN");
2534
2535 package.validate_structure().unwrap();
2536 }
2537 Err(e) => panic!("Failed to parse IMF package: {:?}", e),
2538 }
2539 }
2540
2541 #[test]
2542 fn test_get_cpl_details_api() {
2543 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2544 let package =
2545 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2546
2547 let cpl_uuid = "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85";
2548 let details = package
2549 .get_cpl_details(cpl_uuid)
2550 .expect("Failed to get CPL details");
2551
2552 assert_eq!(details.id, cpl_uuid);
2553 assert_eq!(details.title, "MERIDIAN");
2554 assert_eq!(details.kind, "Test");
2555 assert!(details.annotation.is_some());
2556 assert_eq!(details.segments.len(), 1);
2557
2558 let segment = &details.segments[0];
2559 assert!(!segment.id.is_empty());
2560
2561 assert!(package.get_cpl_details("invalid-uuid").is_none());
2563 }
2564
2565 #[test]
2566 fn test_analyze_tracks_api() {
2567 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2568 let package =
2569 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2570
2571 let track_analyses = package.analyze_tracks();
2572
2573 assert_eq!(track_analyses.len(), 1);
2574 let analysis = &track_analyses[0];
2575
2576 assert_eq!(analysis.cpl_title, "MERIDIAN");
2577 }
2578
2579 #[test]
2580 fn test_list_cpl_uuids_api() {
2581 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2582 let package =
2583 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2584
2585 let uuids = package.list_cpl_uuids();
2586
2587 assert_eq!(uuids.len(), 1);
2588 assert_eq!(uuids[0].to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
2589 }
2590
2591 #[test]
2592 fn test_validation_api() {
2593 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2594 let package =
2595 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2596
2597 let report = package.validate(&ValidationOptions::default());
2598 assert!(
2599 !report.has_errors(),
2600 "Package structure validation should have no errors: {:?}",
2601 report.summary()
2602 );
2603 }
2604
2605 #[test]
2606 fn test_validate_package_structure_with_cpl_validator_injects_issues() {
2607 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2608 let package =
2609 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2610
2611 const INJECTED_CODE: &str = "ST2067-2:2020:6.12/InjectedRuleForTest";
2612
2613 let report = package.validate_package_structure_with_cpl_validator(
2614 |cpl| {
2615 vec![ValidationIssue::new(
2616 Severity::Warning,
2617 Category::Metadata,
2618 INJECTED_CODE,
2619 format!("Injected validator issue for CPL {}", cpl.id),
2620 )]
2621 },
2622 false,
2623 );
2624
2625 let expected_code = INJECTED_CODE;
2626 let injected_present = report
2627 .warnings
2628 .iter()
2629 .any(|issue| issue.code == expected_code)
2630 || report
2631 .errors
2632 .iter()
2633 .any(|issue| issue.code == expected_code)
2634 || report
2635 .critical
2636 .iter()
2637 .any(|issue| issue.code == expected_code)
2638 || report.info.iter().any(|issue| issue.code == expected_code);
2639 assert!(
2640 injected_present,
2641 "Expected injected CPL issue to be present in report"
2642 );
2643 }
2644
2645 #[test]
2646 fn test_validate_package_structure_with_empty_cpl_validator_matches_default_counts() {
2647 use crate::validation::{
2648 validate_cpl_with_registry, ConfigurableValidatorRegistry, ValidatorSelection,
2649 };
2650
2651 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2652 let package =
2653 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2654
2655 let default_report = package.validate(&ValidationOptions::default());
2657
2658 let registry = ConfigurableValidatorRegistry::new(ValidatorSelection::default());
2660 let injected_report = package.validate_package_structure_with_cpl_validator(
2661 |cpl| validate_cpl_with_registry(cpl, ®istry),
2662 false,
2663 );
2664
2665 assert_eq!(
2666 default_report.total_issues(),
2667 injected_report.total_issues()
2668 );
2669 assert_eq!(default_report.errors.len(), injected_report.errors.len());
2670 assert_eq!(
2671 default_report.warnings.len(),
2672 injected_report.warnings.len()
2673 );
2674 assert_eq!(
2675 default_report.critical.len(),
2676 injected_report.critical.len()
2677 );
2678 assert_eq!(default_report.info.len(), injected_report.info.len());
2679 }
2680
2681 #[test]
2682 fn test_package_with_missing_files() {
2683 let test_path = test_data("MissingFilesAndAssetMapEntries");
2684
2685 match Imferno::parse(read_dir(test_path).unwrap()) {
2686 Ok(package) => {
2687 let validation_fails = package.validate_structure().is_err();
2688 let structure_report = package.validate(&ValidationOptions::default());
2689 assert!(validation_fails || structure_report.has_errors());
2690 }
2691 Err(_) => {
2692 }
2694 }
2695 }
2696
2697 #[test]
2698 fn test_package_with_id_mismatch() {
2699 let test_path = test_data("MERIDIAN_Netflix_Photon_161006_ID_MISMATCH");
2700
2701 if let Ok(package) = Imferno::parse(read_dir(test_path).unwrap()) {
2702 assert!(!package.composition_playlists.is_empty());
2703 }
2704 }
2705
2706 #[test]
2707 fn test_lenient_parsing() {
2708 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2709
2710 let package = Imferno::parse(read_dir(&test_path).unwrap_or_default())
2711 .expect("Failed to parse package");
2712
2713 assert_eq!(package.composition_playlists.len(), 1);
2714 }
2715
2716 #[test]
2717 fn test_error_handling_invalid_path() {
2718 let invalid_path = "/nonexistent/path/to/package";
2719
2720 let result = Imferno::parse(read_dir(invalid_path).unwrap_or_default());
2721 assert!(result.is_err());
2723 }
2724
2725 #[test]
2726 fn test_get_asset_path() {
2727 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2728 let package =
2729 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2730
2731 if let Some(first_asset) = package.asset_map.asset_list.assets.first() {
2732 let asset_path = package.get_asset_path(first_asset.id);
2733 assert!(asset_path.is_some());
2734 }
2735
2736 assert!(package.get_asset_path_str("invalid-id").is_none());
2738 }
2739
2740 #[test]
2741 fn test_validation_errors() {
2742 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2743 let package =
2744 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2745
2746 let report = package.validate(&ValidationOptions::default());
2747 assert!(
2748 !report.has_errors(),
2749 "Validation should pass: {:?}",
2750 report.summary()
2751 );
2752 }
2753
2754 #[test]
2755 fn test_get_cpl_with_invalid_uuid() {
2756 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2757 let package =
2758 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2759
2760 assert!(package.get_cpl_str("invalid-uuid").is_none());
2761
2762 let uuid = "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85";
2763 let result = package.get_cpl_str(uuid);
2764 assert!(result.is_some());
2765 }
2766
2767 #[test]
2768 fn test_empty_package_edge_cases() {
2769 let test_path = test_data("MissingFilesAndAssetMapEntries");
2770
2771 if let Ok(package) = Imferno::parse(read_dir(test_path).unwrap()) {
2772 assert!(package.composition_playlists.is_empty());
2773 assert!(package.get_main_cpl().is_none());
2774 assert!(package.analyze_tracks().is_empty());
2775 }
2776 }
2777
2778 #[test]
2779 fn test_bad_xml_package() {
2780 match Imferno::parse(read_dir(test_data("BadXML")).unwrap_or_default()) {
2781 Ok(_) => {}
2782 Err(err) => {
2783 assert!(
2784 err.to_string().contains("parsing")
2785 || err.to_string().contains("XML")
2786 || err.to_string().contains("Invalid")
2787 || err.to_string().contains("Missing")
2788 );
2789 }
2790 }
2791 }
2792
2793 #[test]
2794 fn test_wrong_mime_types_package() {
2795 let test_path = test_data("WrongXmlMimeTypes");
2796
2797 if let Ok(package) = Imferno::parse(read_dir(test_path).unwrap_or_default()) {
2798 assert!(!package.asset_map.asset_list.assets.is_empty());
2799 }
2800 }
2801
2802 #[test]
2803 fn test_cpl_edge_cases() {
2804 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2805 let package =
2806 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2807
2808 assert!(!package.composition_playlists.is_empty());
2809
2810 let first_cpl = package.composition_playlists.values().next().unwrap();
2811 let details = package.get_cpl_details(&first_cpl.id.to_string()).unwrap();
2812 assert_eq!(details.title, first_cpl.content_title.text);
2813
2814 for version in &details.content_versions {
2815 assert!(!version.is_empty());
2816 }
2817 }
2818
2819 #[test]
2820 fn test_directory_structure_validation() {
2821 let current_dir = std::env::current_dir().unwrap();
2822 let result = Imferno::parse(read_dir(¤t_dir).unwrap_or_default());
2823 assert!(result.is_err());
2824
2825 let fake_dir = "/this/path/does/not/exist";
2826 let result = Imferno::parse(read_dir(fake_dir).unwrap_or_default());
2827 assert!(result.is_err());
2828
2829 let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.toml");
2830 let result = Imferno::parse(read_dir(file_path).unwrap_or_default());
2831 assert!(result.is_err());
2832 }
2833
2834 #[test]
2835 fn test_serialization() {
2836 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2837 let package =
2838 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2839
2840 let tracks = package.analyze_tracks();
2841 let json = serde_json::to_string(&tracks).expect("Failed to serialize tracks");
2842 assert!(json.contains("total_tracks") || json == "[]");
2843 }
2844
2845 #[test]
2846 fn test_concurrent_access() {
2847 use std::sync::Arc;
2848 use std::thread;
2849
2850 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2851 let package = Arc::new(
2852 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package"),
2853 );
2854
2855 let mut handles = vec![];
2856
2857 for _ in 0..4 {
2858 let pkg = package.clone();
2859 let handle = thread::spawn(move || {
2860 assert!(!pkg.asset_map.asset_list.assets.is_empty());
2861 assert!(!pkg.composition_playlists.is_empty());
2862 let _ = pkg.analyze_tracks();
2863 });
2864 handles.push(handle);
2865 }
2866
2867 for handle in handles {
2868 handle.join().expect("Thread failed");
2869 }
2870 }
2871
2872 #[test]
2873 fn test_malformed_xml_handling() {
2874 use std::fs;
2875 use tempfile::TempDir;
2876
2877 let temp_dir = TempDir::new().expect("Failed to create temp dir");
2878 let temp_path = temp_dir.path();
2879
2880 let volindex_content = r#"<?xml version="1.0" encoding="UTF-8"?>
2881<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/volindex">
2882 <Index>1</Index>
2883</VolumeIndex>"#;
2884 fs::write(temp_path.join("VOLINDEX.xml"), volindex_content)
2885 .expect("Failed to write VOLINDEX");
2886
2887 let malformed_assetmap = r#"<?xml version="1.0" encoding="UTF-8"?>
2888<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/assetmap">
2889 <Id>urn:uuid:invalid-xml</Id>
2890 <!-- Missing closing tag -->
2891 <AssetList>
2892 <Asset>
2893 <Id>test-asset</Id>
2894"#;
2895 fs::write(temp_path.join("ASSETMAP.xml"), malformed_assetmap)
2896 .expect("Failed to write malformed ASSETMAP");
2897
2898 let result = Imferno::parse(read_dir(temp_path).unwrap());
2899 assert!(result.is_err(), "Should fail with malformed XML");
2900 }
2901
2902 #[test]
2903 fn test_validation_with_complex_structure() {
2904 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2905 let package =
2906 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2907
2908 let report = package.validate(&ValidationOptions::default());
2909 assert!(
2910 !report.has_errors(),
2911 "Package should be valid: {:?}",
2912 report.summary()
2913 );
2914 }
2915
2916 #[test]
2917 fn test_package_with_no_cpls() {
2918 use std::fs;
2919 use tempfile::TempDir;
2920
2921 let temp_dir = TempDir::new().expect("Failed to create temp dir");
2922 let temp_path = temp_dir.path();
2923
2924 let volindex_content = r#"<?xml version="1.0" encoding="UTF-8"?>
2925<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/volindex">
2926 <Index>1</Index>
2927</VolumeIndex>"#;
2928 fs::write(temp_path.join("VOLINDEX.xml"), volindex_content)
2929 .expect("Failed to write VOLINDEX");
2930
2931 let no_cpl_assetmap = r#"<?xml version="1.0" encoding="UTF-8"?>
2932<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/assetmap">
2933 <Id>urn:uuid:12345678-1234-1234-1234-123456789012</Id>
2934 <VolumeCount>1</VolumeCount>
2935 <IssueDate>2023-01-01T00:00:00</IssueDate>
2936 <AssetList>
2937 <Asset>
2938 <Id>urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc</Id>
2939 <ChunkList>
2940 <Chunk>
2941 <Path>video.mxf</Path>
2942 </Chunk>
2943 </ChunkList>
2944 </Asset>
2945 </AssetList>
2946</AssetMap>"#;
2947 fs::write(temp_path.join("ASSETMAP.xml"), no_cpl_assetmap)
2948 .expect("Failed to write ASSETMAP");
2949
2950 let result = Imferno::parse(read_dir(temp_path).unwrap());
2951 assert!(
2952 result.is_ok(),
2953 "Package with no CPLs should parse successfully"
2954 );
2955
2956 let package = result.unwrap();
2957 assert!(package.composition_playlists.is_empty());
2958 assert!(package.get_main_cpl().is_none());
2959 assert!(package.analyze_tracks().is_empty());
2960 }
2961
2962 #[test]
2963 fn test_asset_path_resolution() {
2964 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2965 let package =
2966 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2967
2968 for asset in &package.asset_map.asset_list.assets {
2969 let resolved_path = package.get_asset_path(asset.id);
2970 assert!(
2971 resolved_path.is_some(),
2972 "Should resolve path for asset {}",
2973 asset.id
2974 );
2975
2976 let path = resolved_path.unwrap();
2977 assert!(path.is_absolute(), "Resolved path should be absolute");
2978 assert!(
2979 path.starts_with(&package.root_path),
2980 "Path should be within package directory"
2981 );
2982 }
2983
2984 assert!(package.get_asset_path_str("invalid-id").is_none());
2985 }
2986
2987 #[test]
2988 fn test_boundary_conditions() {
2989 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
2990 let package =
2991 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
2992
2993 assert!(package.get_cpl_details("").is_none());
2994 assert!(package.get_cpl_details(" ").is_none());
2995 assert!(package.get_cpl_details("not-a-uuid").is_none());
2996
2997 assert!(package.get_asset_path_str("").is_none());
2998 assert!(package.get_asset_path_str(" ").is_none());
2999 assert!(package.get_asset_path_str("invalid-asset-id").is_none());
3000 }
3001
3002 #[test]
3003 fn test_large_package_handling() {
3004 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3005 let package =
3006 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
3007
3008 let cpl_count = package.composition_playlists.len();
3009 for _ in 0..10 {
3010 assert!(!package.asset_map.asset_list.assets.is_empty());
3011 assert_eq!(package.analyze_tracks().len(), cpl_count);
3012 }
3013 }
3014
3015 #[test]
3016 fn test_validate_file_manifest_detects_mxf_files() {
3017 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3018 let package =
3019 Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
3020
3021 let errors = package.validate_file_manifest();
3022
3023 for err in &errors {
3024 assert!(
3025 !matches!(err, FileValidationError::Missing { .. }),
3026 "Unexpected missing file: {}",
3027 err
3028 );
3029 }
3030 }
3031
3032 #[test]
3033 fn test_validate_file_manifest_detects_missing_files() {
3034 use tempfile::TempDir;
3035
3036 let dir = TempDir::new().unwrap();
3037 let root = dir.path();
3038
3039 std::fs::write(root.join("VOLINDEX.xml"), r#"<?xml version="1.0"?><VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM"><Index>1</Index></VolumeIndex>"#).unwrap();
3040
3041 let pkl_xml = r#"<?xml version="1.0"?><PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
3042<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3043<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3044<AssetList>
3045 <Asset>
3046 <Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
3047 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
3048 <Size>999</Size>
3049 <Type>application/mxf</Type>
3050 <OriginalFileName>missing_file.mxf</OriginalFileName>
3051 </Asset>
3052</AssetList>
3053</PackingList>"#;
3054 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3055
3056 let assetmap_xml = r#"<?xml version="1.0"?><AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3057<Id>urn:uuid:cccccccc-0000-0000-0000-000000000003</Id>
3058<Creator>test</Creator>
3059<VolumeCount>1</VolumeCount>
3060<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3061<Issuer>test</Issuer>
3062<AssetList>
3063 <Asset>
3064 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3065 <PackingList>true</PackingList>
3066 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3067 </Asset>
3068 <Asset>
3069 <Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
3070 <ChunkList><Chunk><Path>missing_file.mxf</Path></Chunk></ChunkList>
3071 </Asset>
3072</AssetList>
3073</AssetMap>"#;
3074 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3075
3076 let package = Imferno::parse(read_dir(root).unwrap()).expect("Failed to parse package");
3077 let errors = package.validate_file_manifest();
3078
3079 assert!(
3080 errors
3081 .iter()
3082 .any(|e| matches!(e, FileValidationError::Missing { .. })),
3083 "Expected a Missing error, got: {:?}",
3084 errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
3085 );
3086 }
3087
3088 #[test]
3092 fn test_pkl_constraints_detects_missing_assetmap_entries() {
3093 use tempfile::TempDir;
3094
3095 let dir = TempDir::new().unwrap();
3096 let root = dir.path();
3097
3098 std::fs::write(root.join("VOLINDEX.xml"),
3099 r#"<?xml version="1.0"?><VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM"><Index>1</Index></VolumeIndex>"#).unwrap();
3100
3101 let pkl_xml = r#"<?xml version="1.0"?>
3103<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
3104<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3105<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3106<AssetList>
3107 <Asset>
3108 <Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
3109 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
3110 <Size>999</Size>
3111 <Type>application/mxf</Type>
3112 <OriginalFileName>some.mxf</OriginalFileName>
3113 </Asset>
3114 <Asset>
3115 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000099</Id>
3116 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
3117 <Size>100</Size>
3118 <Type>application/mxf</Type>
3119 <OriginalFileName>orphan.mxf</OriginalFileName>
3120 </Asset>
3121</AssetList>
3122</PackingList>"#;
3123 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3124
3125 let assetmap_xml = r#"<?xml version="1.0"?>
3127<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3128<Id>urn:uuid:dddddddd-0000-0000-0000-000000000004</Id>
3129<Creator>test</Creator>
3130<VolumeCount>1</VolumeCount>
3131<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3132<Issuer>test</Issuer>
3133<AssetList>
3134 <Asset>
3135 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3136 <PackingList>true</PackingList>
3137 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3138 </Asset>
3139 <Asset>
3140 <Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
3141 <ChunkList><Chunk><Path>some.mxf</Path></Chunk></ChunkList>
3142 </Asset>
3143</AssetList>
3144</AssetMap>"#;
3145 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3146
3147 let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
3148 let errors = package.validate_pkl_constraints();
3149
3150 assert!(
3151 errors.iter().any(|e| matches!(e, FileValidationError::NotInAssetMap { uuid, .. } if uuid.contains("cccccccc"))),
3152 "Expected NotInAssetMap for cccccccc, got: {:?}",
3153 errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
3154 );
3155 }
3156
3157 #[test]
3159 fn test_cpl_asset_reference_validation_on_meridian() {
3160 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3161 let package = Imferno::parse(read_dir(test_path).unwrap()).expect("parse");
3162
3163 let report = package.validate(&ValidationOptions::default());
3165 assert!(
3166 !report.has_errors(),
3167 "MERIDIAN should be valid: {:?}",
3168 report.summary()
3169 );
3170 }
3171
3172 #[test]
3174 fn test_pkl_constraints_pass_on_meridian() {
3175 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3176 let package = Imferno::parse(read_dir(test_path).unwrap()).expect("parse");
3177
3178 let errors = package.validate_pkl_constraints();
3179 assert!(
3180 errors.is_empty(),
3181 "MERIDIAN PKL constraints should pass, got: {:?}",
3182 errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
3183 );
3184 }
3185
3186 #[test]
3190 fn test_validate_package_structure_meridian() {
3191 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3192 let package = Imferno::parse(read_dir(test_path).unwrap()).expect("parse");
3193
3194 let report = package.validate(&ValidationOptions::default());
3195 assert!(
3196 !report.has_critical(),
3197 "MERIDIAN should have no critical issues: {}",
3198 report.summary()
3199 );
3200 assert!(
3201 !report.has_errors(),
3202 "MERIDIAN should have no errors: {}",
3203 report.summary()
3204 );
3205 }
3206
3207 #[test]
3209 fn test_file_validation_error_to_issue_not_in_assetmap() {
3210 let err = FileValidationError::NotInAssetMap {
3211 uuid: "test-uuid".to_string(),
3212 original_file_name: Some("test.mxf".to_string()),
3213 };
3214 let issue = ValidationIssue::from(&err);
3215 assert_eq!(issue.severity, Severity::Error);
3216 assert_eq!(issue.category, Category::Reference);
3217 assert_eq!(issue.code, codes::St2067_2_2020::UnresolvedUuid.code());
3218 assert!(issue.message.contains("test-uuid"));
3219 }
3220
3221 #[test]
3223 fn test_file_validation_error_to_issue_hash_mismatch() {
3224 let err = FileValidationError::HashMismatch {
3225 uuid: "asset-123".to_string(),
3226 path: PathBuf::from("/tmp/test.mxf"),
3227 expected: "abc123".to_string(),
3228 actual: "def456".to_string(),
3229 };
3230 let issue = ValidationIssue::from(&err);
3231 assert_eq!(issue.severity, Severity::Critical);
3232 assert_eq!(issue.code, codes::St2067_2_2020::ChecksumMismatch.code());
3233 assert!(issue.suggestion.is_some());
3234 }
3235
3236 #[test]
3238 fn test_file_validation_error_to_issue_missing() {
3239 let err = FileValidationError::Missing {
3240 uuid: "missing-uuid".to_string(),
3241 path: PathBuf::from("/tmp/missing.mxf"),
3242 };
3243 let issue = ValidationIssue::from(&err);
3244 assert_eq!(issue.severity, Severity::Error);
3245 assert_eq!(issue.category, Category::Asset);
3246 assert_eq!(issue.code, codes::St2067_2_2020::FileNotFound.code());
3247 }
3248
3249 #[test]
3251 fn test_validate_package_structure_detects_orphan_pkl_assets() {
3252 use tempfile::TempDir;
3253
3254 let dir = TempDir::new().unwrap();
3255 let root = dir.path();
3256
3257 std::fs::write(root.join("VOLINDEX.xml"),
3258 r#"<?xml version="1.0"?><VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM"><Index>1</Index></VolumeIndex>"#).unwrap();
3259
3260 let pkl_xml = r#"<?xml version="1.0"?>
3262<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
3263<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3264<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3265<AssetList>
3266 <Asset>
3267 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000099</Id>
3268 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
3269 <Size>100</Size>
3270 <Type>application/mxf</Type>
3271 <OriginalFileName>orphan.mxf</OriginalFileName>
3272 </Asset>
3273</AssetList>
3274</PackingList>"#;
3275 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3276
3277 let assetmap_xml = r#"<?xml version="1.0"?>
3278<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3279<Id>urn:uuid:dddddddd-0000-0000-0000-000000000004</Id>
3280<Creator>test</Creator>
3281<VolumeCount>1</VolumeCount>
3282<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3283<Issuer>test</Issuer>
3284<AssetList>
3285 <Asset>
3286 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3287 <PackingList>true</PackingList>
3288 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3289 </Asset>
3290</AssetList>
3291</AssetMap>"#;
3292 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3293
3294 let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
3295 let report = package.validate(&ValidationOptions::default());
3296
3297 assert!(
3298 report.has_errors(),
3299 "Should report errors for orphan PKL asset: {}",
3300 report.summary()
3301 );
3302 let all_issues: Vec<_> = report
3304 .errors
3305 .iter()
3306 .filter(|i| i.code == codes::St2067_2_2020::UnresolvedUuid.code())
3307 .collect();
3308 assert!(
3309 !all_issues.is_empty(),
3310 "Should have UnresolvedUuid for orphan PKL asset"
3311 );
3312 }
3313
3314 #[test]
3316 fn test_validate_package_structure_detects_missing_files() {
3317 use tempfile::TempDir;
3318
3319 let dir = TempDir::new().unwrap();
3320 let root = dir.path();
3321
3322 std::fs::write(root.join("VOLINDEX.xml"),
3323 r#"<?xml version="1.0"?><VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM"><Index>1</Index></VolumeIndex>"#).unwrap();
3324
3325 let pkl_xml = r#"<?xml version="1.0"?>
3326<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
3327<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3328<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3329<AssetList>
3330 <Asset>
3331 <Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
3332 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
3333 <Size>999</Size>
3334 <Type>application/mxf</Type>
3335 <OriginalFileName>ghost.mxf</OriginalFileName>
3336 </Asset>
3337</AssetList>
3338</PackingList>"#;
3339 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3340
3341 let assetmap_xml = r#"<?xml version="1.0"?>
3342<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3343<Id>urn:uuid:dddddddd-0000-0000-0000-000000000004</Id>
3344<Creator>test</Creator>
3345<VolumeCount>1</VolumeCount>
3346<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3347<Issuer>test</Issuer>
3348<AssetList>
3349 <Asset>
3350 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3351 <PackingList>true</PackingList>
3352 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3353 </Asset>
3354 <Asset>
3355 <Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
3356 <ChunkList><Chunk><Path>ghost.mxf</Path></Chunk></ChunkList>
3357 </Asset>
3358</AssetList>
3359</AssetMap>"#;
3360 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3361 let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
3364 let report = package.validate(&ValidationOptions::default());
3365
3366 assert!(
3367 report.has_errors(),
3368 "Should report errors for missing file: {}",
3369 report.summary()
3370 );
3371 let missing_issues: Vec<_> = report
3372 .errors
3373 .iter()
3374 .filter(|i| i.code == codes::St2067_2_2020::FileNotFound.code())
3375 .collect();
3376 assert!(
3377 !missing_issues.is_empty(),
3378 "Should have FileNotFound for ghost.mxf"
3379 );
3380 }
3381
3382 #[test]
3385 fn parse_ul_bytes_valid() {
3386 let bytes = parse_ul_bytes("urn:smpte:ul:060e2b34.04010102.0d010201.01010900");
3387 assert!(bytes.is_some());
3388 let b = bytes.unwrap();
3389 assert_eq!(b[0], 0x06);
3390 assert_eq!(b[12], 0x01);
3391 assert_eq!(b[13], 0x01); assert_eq!(b[14], 0x09);
3393 }
3394
3395 #[test]
3396 fn parse_ul_bytes_invalid() {
3397 assert!(parse_ul_bytes("not-a-ul").is_none());
3398 assert!(parse_ul_bytes("urn:smpte:ul:060e2b34").is_none());
3399 }
3400
3401 fn make_mxf_bytes(op_ul: [u8; 16]) -> Vec<u8> {
3405 let mut stream = Vec::new();
3406 stream.extend_from_slice(&[
3408 0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x02,
3409 0x04, 0x00,
3410 ]);
3411 stream.push(88);
3413 stream.extend_from_slice(&[0x00, 0x01, 0x00, 0x03]);
3415 stream.extend_from_slice(&[0x00, 0x00, 0x02, 0x00]);
3417 stream.extend_from_slice(&[0u8; 56]);
3419 stream.extend_from_slice(&op_ul);
3421 stream.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
3423 stream.extend_from_slice(&[0x00, 0x00, 0x00, 0x10]);
3424 stream
3425 }
3426
3427 #[test]
3428 fn mxf_validation_accepts_op1a() {
3429 let root = tempfile::tempdir().unwrap();
3430 let root = root.path();
3431
3432 let op1a: [u8; 16] = [
3434 0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x01,
3435 0x09, 0x00,
3436 ];
3437 std::fs::write(root.join("video.mxf"), make_mxf_bytes(op1a)).unwrap();
3438
3439 let pkl_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3441<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
3442<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3443<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3444<AssetList>
3445 <Asset>
3446 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
3447 <Hash>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</Hash>
3448 <Size>105</Size>
3449 <Type>application/mxf</Type>
3450 <OriginalFileName>video.mxf</OriginalFileName>
3451 </Asset>
3452</AssetList>
3453</PackingList>"#;
3454 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3455
3456 let assetmap_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3457<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3458<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
3459<Creator>test</Creator>
3460<VolumeCount>1</VolumeCount>
3461<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3462<Issuer>test</Issuer>
3463<AssetList>
3464 <Asset>
3465 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3466 <PackingList>true</PackingList>
3467 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3468 </Asset>
3469 <Asset>
3470 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
3471 <ChunkList><Chunk><Path>video.mxf</Path></Chunk></ChunkList>
3472 </Asset>
3473</AssetList>
3474</AssetMap>"#
3475 .to_string();
3476 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3477
3478 let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
3479 let report = package.validate(&ValidationOptions::default());
3480
3481 let op_issues: Vec<_> = report
3482 .critical
3483 .iter()
3484 .chain(report.errors.iter())
3485 .chain(report.warnings.iter())
3486 .chain(report.info.iter())
3487 .filter(|i| i.code == St377_1_2011::Op1a.code())
3488 .collect();
3489 assert!(
3490 op_issues.is_empty(),
3491 "OP1a should not produce OP issues: {:#?}",
3492 op_issues,
3493 );
3494 }
3495
3496 #[test]
3497 fn mxf_validation_flags_non_op1a() {
3498 let root = tempfile::tempdir().unwrap();
3499 let root = root.path();
3500
3501 let op_atom: [u8; 16] = [
3503 0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02, 0x0D, 0x01, 0x02, 0x01, 0x03, 0x01,
3504 0x00, 0x00,
3505 ];
3506 std::fs::write(root.join("video.mxf"), make_mxf_bytes(op_atom)).unwrap();
3507
3508 let pkl_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3509<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
3510<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3511<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3512<AssetList>
3513 <Asset>
3514 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
3515 <Hash>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</Hash>
3516 <Size>105</Size>
3517 <Type>application/mxf</Type>
3518 <OriginalFileName>video.mxf</OriginalFileName>
3519 </Asset>
3520</AssetList>
3521</PackingList>"#;
3522 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3523
3524 let assetmap_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3525<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3526<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
3527<Creator>test</Creator>
3528<VolumeCount>1</VolumeCount>
3529<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3530<Issuer>test</Issuer>
3531<AssetList>
3532 <Asset>
3533 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3534 <PackingList>true</PackingList>
3535 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3536 </Asset>
3537 <Asset>
3538 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
3539 <ChunkList><Chunk><Path>video.mxf</Path></Chunk></ChunkList>
3540 </Asset>
3541</AssetList>
3542</AssetMap>"#;
3543 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3544
3545 let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
3546 let report = package.validate(&ValidationOptions::default());
3547
3548 let op_issues: Vec<_> = report
3549 .critical
3550 .iter()
3551 .chain(report.errors.iter())
3552 .chain(report.warnings.iter())
3553 .chain(report.info.iter())
3554 .filter(|i| i.code == St377_1_2011::Op1a.code())
3555 .collect();
3556 assert_eq!(
3557 op_issues.len(),
3558 1,
3559 "Non-OP1a should produce exactly one OP issue: {:#?}",
3560 op_issues,
3561 );
3562 }
3563
3564 #[test]
3565 fn mxf_validation_warns_invalid_mxf() {
3566 let root = tempfile::tempdir().unwrap();
3567 let root = root.path();
3568
3569 std::fs::write(root.join("bad.mxf"), b"not-an-mxf-file-at-all-garbage").unwrap();
3571
3572 let pkl_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3573<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
3574<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3575<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3576<AssetList>
3577 <Asset>
3578 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
3579 <Hash>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</Hash>
3580 <Size>30</Size>
3581 <Type>application/mxf</Type>
3582 <OriginalFileName>bad.mxf</OriginalFileName>
3583 </Asset>
3584</AssetList>
3585</PackingList>"#;
3586 std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
3587
3588 let assetmap_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3589<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3590<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
3591<Creator>test</Creator>
3592<VolumeCount>1</VolumeCount>
3593<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3594<Issuer>test</Issuer>
3595<AssetList>
3596 <Asset>
3597 <Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
3598 <PackingList>true</PackingList>
3599 <ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
3600 </Asset>
3601 <Asset>
3602 <Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
3603 <ChunkList><Chunk><Path>bad.mxf</Path></Chunk></ChunkList>
3604 </Asset>
3605</AssetList>
3606</AssetMap>"#;
3607 std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
3608
3609 let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
3610 let report = package.validate(&ValidationOptions::default());
3611
3612 let notmxf_issues: Vec<_> = report
3613 .critical
3614 .iter()
3615 .chain(report.errors.iter())
3616 .chain(report.warnings.iter())
3617 .chain(report.info.iter())
3618 .filter(|i| i.code == St377_1_2011::NotMxf.code())
3619 .collect();
3620 assert!(
3621 !notmxf_issues.is_empty(),
3622 "Invalid MXF should produce ST377-1-NotMxf warning: {:#?}",
3623 report.warnings,
3624 );
3625 }
3626
3627 #[test]
3633 fn test_file_validation_error_to_issue_size_mismatch() {
3634 let err = FileValidationError::SizeMismatch {
3635 uuid: "size-uuid".to_string(),
3636 path: PathBuf::from("/tmp/test.mxf"),
3637 expected: 1000,
3638 actual: 2000,
3639 };
3640 let issue = ValidationIssue::from(&err);
3641 assert_eq!(issue.severity, Severity::Error);
3642 assert_eq!(issue.category, Category::Asset);
3643 assert_eq!(issue.code, St2067_2_2020::SizeMismatch.code());
3644 assert!(issue.message.contains("1000"));
3645 assert!(issue.message.contains("2000"));
3646 }
3647
3648 #[test]
3650 fn test_file_validation_error_to_issue_io() {
3651 let err = FileValidationError::Io {
3652 uuid: "io-uuid".to_string(),
3653 path: PathBuf::from("/tmp/broken.mxf"),
3654 message: "permission denied".to_string(),
3655 };
3656 let issue = ValidationIssue::from(&err);
3657 assert_eq!(issue.severity, Severity::Error);
3658 assert_eq!(issue.category, Category::Asset);
3659 assert_eq!(issue.code, "IMF:General/IoError");
3660 assert!(issue.message.contains("permission denied"));
3661 }
3662
3663 #[test]
3665 fn test_file_validation_error_to_issue_duplicate_pkl_asset_id() {
3666 let err = FileValidationError::DuplicatePklAssetId {
3667 uuid: "dup-uuid".to_string(),
3668 pkl_id: "pkl-001".to_string(),
3669 };
3670 let issue = ValidationIssue::from(&err);
3671 assert_eq!(issue.severity, Severity::Error);
3672 assert_eq!(issue.category, Category::Reference);
3673 assert_eq!(issue.code, codes::St2067_2_2020::DuplicateUuid.code());
3674 assert!(issue.message.contains("dup-uuid"));
3675 assert!(issue.message.contains("pkl-001"));
3676 }
3677
3678 #[test]
3684 fn test_multi_pkl_single_pkl_no_cross_pkl_issues() {
3685 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3686 .parent()
3687 .unwrap()
3688 .parent()
3689 .unwrap()
3690 .join("fixture");
3691 if !fixture_path.exists() {
3692 eprintln!("skipping: fixture/ not present");
3693 return;
3694 }
3695 let package = Imferno::parse(read_dir(fixture_path).unwrap()).expect("parse fixture");
3696 let report = package.validate(&ValidationOptions::default());
3697 assert!(
3698 !report
3699 .errors
3700 .iter()
3701 .any(|i| i.code.contains("ChecksumMismatch")
3702 || i.code == St2067_2_2020::SizeMismatch.code()),
3703 "Single-PKL package should have no multi-PKL consistency issues: {:#?}",
3704 report.errors,
3705 );
3706 }
3707
3708 #[test]
3714 fn test_segment_durations_fixture_pass() {
3715 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3716 .parent()
3717 .unwrap()
3718 .parent()
3719 .unwrap()
3720 .join("fixture");
3721 if !fixture_path.exists() {
3722 eprintln!("skipping: fixture/ not present");
3723 return;
3724 }
3725 let package = Imferno::parse(read_dir(fixture_path).unwrap()).expect("parse fixture");
3726 let report = package.validate(&ValidationOptions::default());
3727 let duration_issues: Vec<_> = report
3728 .errors
3729 .iter()
3730 .filter(|i| i.code.contains("SegmentDuration"))
3731 .collect();
3732 assert!(
3733 duration_issues.is_empty(),
3734 "Fixture should have matching segment durations: {:#?}",
3735 duration_issues,
3736 );
3737 }
3738
3739 #[test]
3741 fn test_emitted_codes_do_not_use_general_fallback() {
3742 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3743 .parent()
3744 .unwrap()
3745 .parent()
3746 .unwrap()
3747 .join("fixture");
3748 if !fixture_path.exists() {
3749 eprintln!("skipping: fixture/ not present");
3750 return;
3751 }
3752 let package = Imferno::parse(read_dir(fixture_path).unwrap()).expect("parse fixture");
3753 let report = package.validate(&ValidationOptions::default());
3754
3755 let all_issues: Vec<_> = report
3756 .critical
3757 .iter()
3758 .chain(report.errors.iter())
3759 .chain(report.warnings.iter())
3760 .chain(report.info.iter())
3761 .collect();
3762
3763 assert!(
3764 !all_issues.iter().any(|i| i.code.contains(":General/")),
3765 "Package validator emitted :General fallback codes: {:#?}",
3766 all_issues,
3767 );
3768 }
3769
3770 const MINIMAL_ASSETMAP: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
3775<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3776 <Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
3777 <Creator>test</Creator>
3778 <VolumeCount>1</VolumeCount>
3779 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
3780 <Issuer>test</Issuer>
3781 <AssetList>
3782 <Asset>
3783 <Id>urn:uuid:eeeeeeee-0000-0000-0000-000000000001</Id>
3784 <ChunkList><Chunk><Path>dummy.mxf</Path></Chunk></ChunkList>
3785 </Asset>
3786 </AssetList>
3787</AssetMap>"#;
3788
3789 const VALID_VOLINDEX: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
3790<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3791 <Index>1</Index>
3792</VolumeIndex>"#;
3793
3794 #[test]
3796 fn volindex_missing_emits_info() {
3797 let mut files = HashMap::new();
3798 files.insert("ASSETMAP.xml".to_string(), MINIMAL_ASSETMAP.to_string());
3799
3800 let pkg = Imferno::parse(files).expect("parse");
3801 let report = pkg.validate(&ValidationOptions::default());
3802
3803 let all: Vec<_> = report.info.iter().collect();
3804 assert!(
3805 all.iter().any(|i| i.code.contains("VolindexMissing")),
3806 "expected VolindexMissing info, got: {all:?}",
3807 );
3808 }
3809
3810 #[test]
3812 fn volindex_malformed_emits_error() {
3813 let mut files = HashMap::new();
3814 files.insert("ASSETMAP.xml".to_string(), MINIMAL_ASSETMAP.to_string());
3815 files.insert(
3816 "VOLINDEX.xml".to_string(),
3817 "not xml <<< garbage".to_string(),
3818 );
3819
3820 let pkg = Imferno::parse(files).expect("parse");
3821 let report = pkg.validate(&ValidationOptions::default());
3822
3823 assert!(
3824 report
3825 .errors
3826 .iter()
3827 .any(|i| i.code.contains("MalformedXml")),
3828 "expected MalformedXml error, got: {:?}",
3829 report.errors,
3830 );
3831 }
3832
3833 #[test]
3835 fn volindex_valid_no_issue() {
3836 let mut files = HashMap::new();
3837 files.insert("ASSETMAP.xml".to_string(), MINIMAL_ASSETMAP.to_string());
3838 files.insert("VOLINDEX.xml".to_string(), VALID_VOLINDEX.to_string());
3839
3840 let pkg = Imferno::parse(files).expect("parse");
3841 let report = pkg.validate(&ValidationOptions::default());
3842
3843 let all: Vec<_> = report
3844 .critical
3845 .iter()
3846 .chain(report.errors.iter())
3847 .chain(report.warnings.iter())
3848 .chain(report.info.iter())
3849 .filter(|i| i.code.contains("ST429-9"))
3850 .collect();
3851 assert!(
3852 all.is_empty(),
3853 "expected no ST 429-9 diagnostics for valid VOLINDEX, got: {all:?}",
3854 );
3855 }
3856
3857 #[test]
3860 fn sanitize_simple_relative_path() {
3861 let root = std::env::temp_dir();
3862 assert!(sanitize_asset_path(&root, "video.mxf").is_some());
3863 }
3864
3865 #[test]
3866 fn sanitize_nested_relative_path() {
3867 let root = std::env::temp_dir();
3868 assert!(sanitize_asset_path(&root, "subdir/video.mxf").is_some());
3869 }
3870
3871 #[test]
3872 fn sanitize_rejects_parent_dir_traversal() {
3873 let root = std::env::temp_dir();
3874 assert!(sanitize_asset_path(&root, "../escape.mxf").is_none());
3875 }
3876
3877 #[test]
3878 fn sanitize_rejects_deep_traversal() {
3879 let root = std::env::temp_dir();
3880 assert!(sanitize_asset_path(&root, "sub/../../escape.mxf").is_none());
3881 }
3882
3883 #[test]
3884 fn sanitize_rejects_absolute_path() {
3885 let root = std::env::temp_dir();
3886 assert!(sanitize_asset_path(&root, "/etc/passwd").is_none());
3887 }
3888
3889 #[test]
3890 fn sanitize_rejects_double_dot_prefix() {
3891 let root = std::env::temp_dir();
3892 assert!(sanitize_asset_path(&root, "../../etc/shadow").is_none());
3893 }
3894
3895 fn minimal_assetmap(assets_xml: &str) -> String {
3899 format!(
3900 r#"<?xml version="1.0" encoding="UTF-8"?>
3901 <AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
3902 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3903 <VolumeCount>1</VolumeCount>
3904 <IssueDate>2024-01-01T00:00:00+00:00</IssueDate>
3905 <Issuer>test</Issuer>
3906 <AssetList>{}</AssetList>
3907 </AssetMap>"#,
3908 assets_xml,
3909 )
3910 }
3911
3912 #[test]
3913 fn malformed_pkl_produces_parse_issue() {
3914 let mut files = HashMap::new();
3915 files.insert(
3916 "ASSETMAP.xml".to_string(),
3917 minimal_assetmap(
3918 r#"<Asset>
3919 <Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
3920 <PackingList>true</PackingList>
3921 <ChunkList><Chunk><Path>PKL.xml</Path><VolumeIndex>1</VolumeIndex></Chunk></ChunkList>
3922 </Asset>"#,
3923 ),
3924 );
3925 files.insert("PKL.xml".to_string(), "<not-a-pkl/>".to_string());
3927
3928 let package = Imferno::parse(files).expect("parse should succeed even with bad PKL");
3929 assert!(
3930 package
3931 .parse_issues
3932 .iter()
3933 .any(|i| i.code == codes::ImfernoCode::PklParseError.code()),
3934 "expected PklParseError issue, got: {:?}",
3935 package.parse_issues,
3936 );
3937 }
3938
3939 #[test]
3940 fn unparseable_xml_asset_produces_parse_issue() {
3941 let mut files = HashMap::new();
3942 files.insert(
3943 "ASSETMAP.xml".to_string(),
3944 minimal_assetmap(
3945 r#"<Asset>
3946 <Id>urn:uuid:00000000-0000-0000-0000-000000000003</Id>
3947 <ChunkList><Chunk><Path>MYSTERY.xml</Path><VolumeIndex>1</VolumeIndex></Chunk></ChunkList>
3948 </Asset>"#,
3949 ),
3950 );
3951 files.insert("MYSTERY.xml".to_string(), "<SomethingElse/>".to_string());
3952
3953 let package = Imferno::parse(files).expect("parse should succeed");
3954 assert!(
3955 package
3956 .parse_issues
3957 .iter()
3958 .any(|i| i.code == codes::ImfernoCode::XmlAssetParseError.code()),
3959 "expected XmlAssetParseError issue, got: {:?}",
3960 package.parse_issues,
3961 );
3962 }
3963
3964 #[test]
3965 fn path_traversal_produces_parse_issue() {
3966 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3967 let files = read_dir(test_path).unwrap();
3968 let package = Imferno::parse(files).expect("parse should succeed");
3969
3970 assert!(
3973 !package
3974 .parse_issues
3975 .iter()
3976 .any(|i| i.code == codes::ImfernoCode::PathTraversal.code()),
3977 "valid package should have no path traversal issues",
3978 );
3979 }
3980
3981 #[test]
3982 fn sequence_language_extracted_from_descriptors() {
3983 let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
3984 let files = read_dir(test_path).unwrap();
3985 let package = Imferno::parse(files).unwrap();
3986 let report =
3987 crate::package::report::build_report(&package, &ValidationOptions::default(), None)
3988 .unwrap();
3989 for cpl in &report.cpls {
3990 let audio_seqs: Vec<_> = cpl
3991 .sequences
3992 .iter()
3993 .filter(|s| s.r#type == "MainAudio")
3994 .collect();
3995 assert!(
3996 !audio_seqs.is_empty(),
3997 "should have at least one audio sequence"
3998 );
3999 for seq in &audio_seqs {
4000 eprintln!("Audio seq {} language: {:?}", seq.track_id, seq.language);
4001 assert_eq!(
4002 seq.language.as_deref(),
4003 Some("en"),
4004 "MERIDIAN audio should have language 'en', got {:?}",
4005 seq.language,
4006 );
4007 }
4008 }
4009 }
4010}