use std::path::{Path, PathBuf};
use std::collections::HashMap;
use thiserror::Error;
use crate::assetmap::ImfUuid;
use crate::cpl::EditRate;
pub mod codes;
pub mod source_asset;
pub mod delivery;
pub mod report;
pub use crate::cpl::{CompositionPlaylist, Resource as CplResource};
pub use crate::assetmap::{AssetMap, Asset, VolumeIndex, PackingList, PklAsset};
pub use crate::diagnostics::{ValidationReport, ValidationIssue, ValidationProfile, Severity, Category, Location};
pub use self::source_asset::{SourceAsset, extract_source_asset};
pub use self::delivery::{DeliveryRequest, DeliveryComparison, compare as compare_delivery};
pub use self::report::{ImfReport, build_report};
#[derive(Error, Debug)]
pub enum ImfError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("AssetMap parse error: {0}")]
AssetMapParse(#[from] crate::assetmap::AssetMapParseError),
#[error("CPL parse error: {0}")]
CplParse(#[from] crate::cpl::CplParseError),
#[error("UUID error: {0}")]
Uuid(String),
#[error("Missing required file: {0}")]
MissingFile(String),
#[error("Invalid IMF package structure: {0}")]
InvalidStructure(String),
}
pub type Result<T> = std::result::Result<T, ImfError>;
#[derive(Debug)]
pub enum FileValidationError {
NotInAssetMap {
uuid: String,
original_file_name: Option<String>,
},
Missing {
uuid: String,
path: PathBuf,
},
SizeMismatch {
uuid: String,
path: PathBuf,
expected: u64,
actual: u64,
},
HashMismatch {
uuid: String,
path: PathBuf,
expected: String,
actual: String,
},
Io {
uuid: String,
path: PathBuf,
message: String,
},
DuplicatePklAssetId {
uuid: String,
pkl_id: String,
},
}
impl FileValidationError {
pub fn uuid(&self) -> &str {
match self {
Self::NotInAssetMap { uuid, .. } => uuid,
Self::Missing { uuid, .. } => uuid,
Self::SizeMismatch { uuid, .. } => uuid,
Self::HashMismatch { uuid, .. } => uuid,
Self::Io { uuid, .. } => uuid,
Self::DuplicatePklAssetId { uuid, .. } => uuid,
}
}
}
impl std::fmt::Display for FileValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotInAssetMap { uuid, original_file_name } => {
write!(f, "PKL asset {} ({}) not found in AssetMap",
uuid,
original_file_name.as_deref().unwrap_or("no filename"))
}
Self::Missing { uuid, path } => {
write!(f, "Missing file for {}: {}", uuid, path.display())
}
Self::SizeMismatch { uuid, path, expected, actual } => {
write!(f, "Size mismatch for {} ({}): expected {} bytes, found {}",
uuid, path.display(), expected, actual)
}
Self::HashMismatch { uuid, path, expected, actual } => {
write!(f, "Hash mismatch for {} ({}): expected {}, got {}",
uuid, path.display(), expected, actual)
}
Self::Io { uuid, path, message } => {
write!(f, "IO error reading {} ({}): {}", uuid, path.display(), message)
}
Self::DuplicatePklAssetId { uuid, pkl_id } => {
write!(f, "Duplicate asset UUID {} in PKL {}", uuid, pkl_id)
}
}
}
}
impl From<&FileValidationError> for ValidationIssue {
fn from(err: &FileValidationError) -> Self {
match err {
FileValidationError::NotInAssetMap { uuid, original_file_name } => {
ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_2_2020::UnresolvedUuid,
format!(
"PKL asset {} ({}) not found in AssetMap",
uuid,
original_file_name.as_deref().unwrap_or("no filename")
),
)
.with_context("asset_uuid", uuid.clone())
}
FileValidationError::Missing { uuid, path } => {
ValidationIssue::new(
Severity::Error,
Category::Asset,
codes::St2067_2_2020::FileNotFound,
format!("Missing file for asset {}: {}", uuid, path.display()),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", uuid.clone())
}
FileValidationError::SizeMismatch { uuid, path, expected, actual } => {
ValidationIssue::new(
Severity::Error,
Category::Asset,
codes::St2067_2_2020::SizeMismatch,
format!(
"Size mismatch for asset {} ({}): PKL declares {} bytes, file is {} bytes",
uuid, path.display(), expected, actual
),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", uuid.clone())
.with_context("expected_size", expected.to_string())
.with_context("actual_size", actual.to_string())
}
FileValidationError::HashMismatch { uuid, path, expected, actual } => {
ValidationIssue::new(
Severity::Critical,
Category::Asset,
codes::St2067_2_2020::ChecksumMismatch,
format!(
"Hash mismatch for asset {} ({}): expected {}, computed {}",
uuid, path.display(), expected, actual
),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", uuid.clone())
.with_suggestion("Re-deliver the asset or re-generate the PKL hash")
}
FileValidationError::Io { uuid, path, message } => {
ValidationIssue::new(
Severity::Error,
Category::Asset,
codes::St2067_2_2020::IoError,
format!("IO error reading asset {} ({}): {}", uuid, path.display(), message),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", uuid.clone())
}
FileValidationError::DuplicatePklAssetId { uuid, pkl_id } => {
ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_2_2020::DuplicateUuid,
format!("Duplicate asset UUID {} in PKL {}", uuid, pkl_id),
)
.with_context("asset_uuid", uuid.clone())
.with_context("pkl_id", pkl_id.clone())
}
}
}
}
#[derive(Debug)]
pub struct Imferno {
pub root_path: PathBuf,
pub volume_index: VolumeIndex,
pub volindex_issues: Vec<ValidationIssue>,
pub asset_map: AssetMap,
pub packing_lists: HashMap<ImfUuid, PackingList>,
pub composition_playlists: HashMap<ImfUuid, CompositionPlaylist>,
pub cpl_xml_content: HashMap<ImfUuid, String>,
pub output_profile_lists: HashMap<ImfUuid, crate::assetmap::OutputProfileList>,
pub sidecar_composition_maps: HashMap<ImfUuid, crate::scm::SidecarCompositionMap>,
pub asset_paths: HashMap<ImfUuid, PathBuf>,
}
pub fn read_dir(path: impl AsRef<Path>) -> Result<HashMap<String, String>> {
let path = path.as_ref().canonicalize().unwrap_or_else(|_| path.as_ref().to_path_buf());
let mut files = HashMap::new();
for entry in std::fs::read_dir(&path)? {
let entry = entry?;
let p = entry.path();
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ext != "xml" {
continue;
}
let abs_path = p.to_string_lossy().into_owned();
if let Ok(content) = std::fs::read_to_string(&p) {
files.insert(abs_path, content);
}
}
Ok(files)
}
impl Imferno {
pub fn parse(files: HashMap<String, String>) -> Result<Self> {
Self::from_file_map(&files)
}
pub fn parse_and_validate(files: HashMap<String, String>, options: &ValidationOptions) -> ValidationReport {
let package = match Self::parse(files) {
Ok(pkg) => pkg,
Err(e) => {
let mut report = ValidationReport::new(ValidationProfile::SMPTE);
report.add(
ValidationIssue::new(
Severity::Critical,
Category::Structure,
"IMF/ParseError",
format!("Failed to parse IMF package: {e}"),
)
);
return report.apply_rules(&options.rules);
}
};
package.validate(options)
}
pub fn validate(&self, options: &ValidationOptions) -> ValidationReport {
use crate::validation::{ConfigurableValidatorRegistry, ValidatorSelection, validate_cpl_with_registry};
let registry = ConfigurableValidatorRegistry::new(ValidatorSelection::default());
#[cfg(not(target_arch = "wasm32"))]
let skip_disk = options.skip_disk_checks;
#[cfg(target_arch = "wasm32")]
let skip_disk = false;
let report = self.validate_package_structure_with_cpl_validator(|cpl| {
validate_cpl_with_registry(cpl, ®istry)
}, skip_disk);
report.apply_rules(&options.rules)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn validate_hashes(&self, options: &ValidationOptions) -> ValidationReport {
use crate::validation::{ConfigurableValidatorRegistry, ValidatorSelection, validate_cpl_with_registry};
let registry = ConfigurableValidatorRegistry::new(ValidatorSelection::default());
let report = self.validate_package_with_hashes_with_cpl_validator(|cpl| {
validate_cpl_with_registry(cpl, ®istry)
});
report.apply_rules(&options.rules)
}
fn from_file_map(files: &HashMap<String, String>) -> Result<Self> {
let root_path: PathBuf = files.keys()
.filter_map(|k| {
let p = std::path::Path::new(k.as_str());
if p.is_absolute() { p.parent().map(|par| par.to_path_buf()) } else { None }
})
.next()
.unwrap_or_default();
let find = |name: &str| -> Option<&str> {
let lower = name.to_lowercase();
files.iter().find(|(k, _)| {
let key_basename = std::path::Path::new(k.as_str())
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(k.as_str());
key_basename.to_lowercase() == lower
}).map(|(_, v)| v.as_str())
};
let mut volindex_issues: Vec<ValidationIssue> = Vec::new();
let volume_index = match find("VOLINDEX.xml") {
Some(xml) => match crate::assetmap::parse_volindex(xml) {
Ok(vi) => vi,
Err(_) => {
volindex_issues.push(ValidationIssue::new(
Severity::Error,
Category::Structure,
codes::St429_9_2014::MalformedXml,
"VOLINDEX.xml is not well-formed XML",
));
VolumeIndex { index: 1 }
}
},
None => {
volindex_issues.push(ValidationIssue::new(
Severity::Info,
Category::Structure,
codes::St429_9_2014::VolindexMissing,
"VOLINDEX.xml is absent; single-volume package assumed",
));
VolumeIndex { index: 1 }
}
};
let assetmap_xml = find("ASSETMAP.xml")
.ok_or_else(|| ImfError::MissingFile("ASSETMAP.xml".to_string()))?;
let asset_map = crate::assetmap::parse_assetmap(assetmap_xml)?;
let mut asset_paths: HashMap<ImfUuid, PathBuf> = HashMap::new();
for asset in &asset_map.asset_list.assets {
for chunk in &asset.chunk_list.chunks {
let path = if root_path.as_os_str().is_empty() {
PathBuf::from(&chunk.path)
} else {
root_path.join(&chunk.path)
};
asset_paths.insert(asset.id, path);
}
}
let mut packing_lists = HashMap::new();
for asset in &asset_map.asset_list.assets {
if asset.packing_list == Some(true) {
for chunk in &asset.chunk_list.chunks {
let basename = std::path::Path::new(&chunk.path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(&chunk.path);
if let Some(pkl_xml) = find(basename) {
match crate::assetmap::parse_pkl(pkl_xml) {
Ok(pkl) => { packing_lists.insert(asset.id, pkl); }
Err(e) => eprintln!("from_file_map: PKL {} parse error: {:?}", basename, e),
}
}
}
}
}
let mut xml_asset_ids: std::collections::HashSet<ImfUuid> = std::collections::HashSet::new();
for pkl in packing_lists.values() {
for pkl_asset in &pkl.asset_list.assets {
if pkl_asset.mime_type.is_xml() {
xml_asset_ids.insert(pkl_asset.id);
}
}
}
let mut composition_playlists = HashMap::new();
let mut cpl_xml_content = HashMap::new();
let mut output_profile_lists = HashMap::new();
let mut sidecar_composition_maps = HashMap::new();
for asset in &asset_map.asset_list.assets {
if asset.packing_list == Some(true) { continue; }
for chunk in &asset.chunk_list.chunks {
if !chunk.path.ends_with(".xml") { continue; }
let is_candidate = if !xml_asset_ids.is_empty() {
xml_asset_ids.contains(&asset.id)
} else {
true
};
if !is_candidate { continue; }
let basename = std::path::Path::new(&chunk.path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(&chunk.path);
if let Some(xml) = find(basename) {
match crate::cpl::parse_cpl(xml) {
Ok(cpl) => {
cpl_xml_content.insert(asset.id, xml.to_string());
composition_playlists.insert(asset.id, cpl);
}
Err(_) => {
if let Ok(opl) = crate::assetmap::parse_opl(xml) {
output_profile_lists.insert(asset.id, opl);
} else if let Ok(scm) = crate::scm::parse_scm(xml) {
sidecar_composition_maps.insert(asset.id, scm);
}
}
}
}
}
}
Ok(Imferno {
root_path,
volume_index,
volindex_issues,
asset_map,
packing_lists,
composition_playlists,
cpl_xml_content,
output_profile_lists,
sidecar_composition_maps,
asset_paths,
})
}
pub fn get_cpl(&self, uuid: ImfUuid) -> Option<&CompositionPlaylist> {
self.composition_playlists.get(&uuid)
}
pub fn get_cpl_str(&self, uuid: &str) -> Option<&CompositionPlaylist> {
ImfUuid::parse(uuid).ok().and_then(|u| self.composition_playlists.get(&u))
}
pub fn get_asset_path(&self, uuid: ImfUuid) -> Option<&PathBuf> {
self.asset_paths.get(&uuid)
}
pub fn get_asset_path_str(&self, uuid: &str) -> Option<&PathBuf> {
ImfUuid::parse(uuid).ok().and_then(|u| self.asset_paths.get(&u))
}
pub fn list_cpl_uuids(&self) -> Vec<ImfUuid> {
self.composition_playlists.keys().copied().collect()
}
pub fn get_main_cpl(&self) -> Option<&CompositionPlaylist> {
self.composition_playlists.values().next()
}
pub fn unreferenced_assets(&self) -> Vec<&crate::assetmap::Asset> {
use std::collections::HashSet;
use crate::cpl::SequenceAccess;
let doc_ids: HashSet<ImfUuid> = self.composition_playlists.keys()
.chain(self.packing_lists.keys())
.chain(self.sidecar_composition_maps.keys())
.chain(self.output_profile_lists.keys())
.copied()
.collect();
let track_file_ids: HashSet<ImfUuid> = self.composition_playlists.values()
.flat_map(|cpl| cpl.segment_list.segments.iter())
.flat_map(|seg| {
let sl = &seg.sequence_list;
let mut v: Vec<&dyn SequenceAccess> = Vec::new();
for s in &sl.main_image_sequences { v.push(s); }
for s in &sl.main_audio_sequences { v.push(s); }
for s in &sl.subtitles_sequences { v.push(s); }
for s in &sl.hearing_impaired_captions_sequences { v.push(s); }
for s in &sl.forced_narrative_sequences { v.push(s); }
for s in &sl.iab_sequences { v.push(s); }
for s in &sl.isxd_sequences { v.push(s); }
v.into_iter()
.flat_map(|seq| seq.resource_list().resources.iter()
.filter_map(|r| r.track_file_id))
.collect::<Vec<_>>()
})
.collect();
let scm_declared: HashSet<ImfUuid> = self.sidecar_composition_maps.values()
.flat_map(|scm| scm.sidecar_assets.iter().map(|sa| sa.id))
.collect();
self.asset_map.asset_list.assets.iter()
.filter(|a| {
a.packing_list != Some(true)
&& !doc_ids.contains(&a.id)
&& !track_file_ids.contains(&a.id)
&& !scm_declared.contains(&a.id)
})
.collect()
}
fn emit_unreferenced_asset_info(&self, report: &mut ValidationReport) {
use crate::diagnostics::codes::ValidationCode as _;
for asset in self.unreferenced_assets() {
let path = asset.chunk_list.chunks.first()
.map(|c| c.path.as_str())
.unwrap_or("(unknown)");
report.add(ValidationIssue::new(
Severity::Info,
Category::Structure,
codes::ImfernoCode::UnreferencedAsset.code(),
format!(
"Asset '{}' ({}) is present in the AssetMap but not referenced by any CPL \
Virtual Track and has no SCM declaration",
path, asset.id,
),
));
}
}
#[cfg(not(target_arch = "wasm32"))]
fn emit_unlisted_essence(&self, report: &mut ValidationReport) {
use crate::diagnostics::codes::ValidationCode as _;
if self.root_path.as_os_str().is_empty() {
return;
}
let mapped: std::collections::HashSet<String> = self.asset_map.asset_list.assets.iter()
.flat_map(|a| a.chunk_list.chunks.iter())
.filter_map(|c| {
std::path::Path::new(&c.path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
})
.collect();
let entries = match std::fs::read_dir(&self.root_path) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !ext.eq_ignore_ascii_case("mxf") {
continue;
}
let filename = match path.file_name() {
Some(n) => n.to_string_lossy().into_owned(),
None => continue,
};
if !mapped.contains(&filename) {
report.add(ValidationIssue::new(
Severity::Warning,
Category::Structure,
codes::ImfernoCode::UnlistedEssence.code(),
format!(
"MXF file '{}' is present in the package directory but not listed in the AssetMap",
filename,
),
));
}
}
}
#[allow(dead_code)]
fn validate_structure(&self) -> Result<()> {
let report = self.validate_package_structure();
if report.has_critical() || report.has_errors() {
let error_messages: Vec<String> = report.errors
.iter()
.chain(report.critical.iter())
.map(|i| i.message.clone())
.collect();
return Err(ImfError::InvalidStructure(error_messages.join("; ")));
}
Ok(())
}
pub fn validate_file_manifest(&self) -> Vec<FileValidationError> {
let mut errors = Vec::new();
let path_map = self.build_asset_path_map();
for pkl in self.packing_lists.values() {
for asset in &pkl.asset_list.assets {
let uuid_str = asset.id.to_string();
match path_map.get(&asset.id) {
None => {
errors.push(FileValidationError::NotInAssetMap {
uuid: uuid_str,
original_file_name: asset.original_file_name.clone(),
});
}
Some(rel_path) => {
let abs_path = self.root_path.join(rel_path);
match std::fs::metadata(&abs_path) {
Err(_) => {
errors.push(FileValidationError::Missing {
uuid: uuid_str,
path: abs_path,
});
}
Ok(meta) => {
let actual = meta.len();
if actual != asset.size {
errors.push(FileValidationError::SizeMismatch {
uuid: uuid_str,
path: abs_path,
expected: asset.size,
actual,
});
}
}
}
}
}
}
}
errors
}
pub fn validate_file_hashes(&self) -> Vec<FileValidationError> {
use sha1::Digest as _;
let mut errors = self.validate_file_manifest();
let errored_uuids: std::collections::HashSet<String> = errors
.iter()
.map(|e| e.uuid().to_string())
.collect();
let path_map = self.build_asset_path_map();
for pkl in self.packing_lists.values() {
for asset in &pkl.asset_list.assets {
let uuid_str = asset.id.to_string();
if errored_uuids.contains(&uuid_str) {
continue;
}
let Some(rel_path) = path_map.get(&asset.id) else { continue };
let abs_path = self.root_path.join(rel_path);
match std::fs::read(&abs_path) {
Err(e) => {
errors.push(FileValidationError::Io {
uuid: uuid_str,
path: abs_path,
message: e.to_string(),
});
}
Ok(bytes) => {
let actual_b64 = match asset.hash.algorithm {
crate::assetmap::HashAlgorithm::Sha1 => {
let digest = sha1::Sha1::digest(&bytes);
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
digest,
)
}
crate::assetmap::HashAlgorithm::Sha256 => {
let digest = sha2::Sha256::digest(&bytes);
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
digest,
)
}
};
let expected_b64 = asset.hash.to_base64();
if actual_b64 != expected_b64 {
errors.push(FileValidationError::HashMismatch {
uuid: uuid_str,
path: abs_path,
expected: expected_b64,
actual: actual_b64,
});
}
}
}
}
}
errors
}
pub fn validate_pkl_constraints(&self) -> Vec<FileValidationError> {
let mut errors = Vec::new();
let assetmap_ids: std::collections::HashSet<ImfUuid> = self
.asset_map
.asset_list
.assets
.iter()
.map(|a| a.id)
.collect();
for pkl in self.packing_lists.values() {
let mut seen_ids: std::collections::HashSet<ImfUuid> = std::collections::HashSet::new();
for asset in &pkl.asset_list.assets {
if !seen_ids.insert(asset.id) {
errors.push(FileValidationError::DuplicatePklAssetId {
uuid: asset.id.to_string(),
pkl_id: pkl.id.to_string(),
});
}
if !assetmap_ids.contains(&asset.id) {
errors.push(FileValidationError::NotInAssetMap {
uuid: asset.id.to_string(),
original_file_name: asset.original_file_name.clone(),
});
}
}
}
errors
}
fn build_asset_path_map(&self) -> HashMap<ImfUuid, String> {
let mut map = HashMap::new();
for asset in &self.asset_map.asset_list.assets {
if let Some(chunk) = asset.chunk_list.chunks.first() {
map.insert(asset.id, chunk.path.clone());
}
}
map
}
pub fn validate_package_structure(&self) -> ValidationReport {
self.validate_package_structure_with_cpl_validator(|_| Vec::new(), false)
}
pub fn validate_package_structure_with_cpl_validator<F>(&self, cpl_validator: F, skip_disk_checks: bool) -> ValidationReport
where
F: Fn(&CompositionPlaylist) -> Vec<ValidationIssue>,
{
let mut report = ValidationReport::new(ValidationProfile::SMPTE);
for issue in &self.volindex_issues {
report.add(issue.clone());
}
for issue in self.validate_pkl_constraints().iter().map(ValidationIssue::from) {
report.add(issue);
}
#[cfg(not(target_arch = "wasm32"))]
if !skip_disk_checks && !self.root_path.as_os_str().is_empty() {
for issue in self.validate_file_manifest().iter().map(ValidationIssue::from) {
report.add(issue);
}
}
for cpl in self.composition_playlists.values() {
self.validate_cpl_asset_references_accumulating(cpl, &mut report);
for issue in cpl_validator(cpl) {
report.add(issue);
}
}
self.validate_scm_references(&mut report);
self.emit_unreferenced_asset_info(&mut report);
self.validate_multi_pkl_consistency(&mut report);
#[cfg(not(target_arch = "wasm32"))]
if !skip_disk_checks && !self.root_path.as_os_str().is_empty() {
self.validate_mxf_headers(&mut report);
self.emit_unlisted_essence(&mut report);
}
report
}
pub fn validate_package_with_hashes(&self) -> ValidationReport {
self.validate_package_with_hashes_with_cpl_validator(|_| Vec::new())
}
pub fn validate_package_with_hashes_with_cpl_validator<F>(&self, cpl_validator: F) -> ValidationReport
where
F: Fn(&CompositionPlaylist) -> Vec<ValidationIssue>,
{
let mut report = ValidationReport::new(ValidationProfile::SMPTE);
for issue in &self.volindex_issues {
report.add(issue.clone());
}
for issue in self.validate_pkl_constraints().iter().map(ValidationIssue::from) {
report.add(issue);
}
for issue in self.validate_file_hashes().iter().map(ValidationIssue::from) {
report.add(issue);
}
for cpl in self.composition_playlists.values() {
self.validate_cpl_asset_references_accumulating(cpl, &mut report);
for issue in cpl_validator(cpl) {
report.add(issue);
}
}
self.validate_multi_pkl_consistency(&mut report);
self.validate_mxf_headers(&mut report);
report
}
fn validate_scm_references(&self, report: &mut ValidationReport) {
use std::collections::HashSet;
use crate::cpl::SequenceAccess;
let asset_ids: HashSet<_> = self.asset_map.asset_list.assets.iter()
.map(|a| a.id)
.collect();
let virtual_track_file_ids: HashSet<ImfUuid> = self.composition_playlists.values()
.flat_map(|cpl| cpl.segment_list.segments.iter())
.flat_map(|seg| {
let sl = &seg.sequence_list;
let seqs: Vec<&dyn SequenceAccess> = {
let mut v: Vec<&dyn SequenceAccess> = Vec::new();
for s in &sl.main_image_sequences { v.push(s); }
for s in &sl.main_audio_sequences { v.push(s); }
for s in &sl.subtitles_sequences { v.push(s); }
for s in &sl.hearing_impaired_captions_sequences { v.push(s); }
for s in &sl.forced_narrative_sequences { v.push(s); }
for s in &sl.iab_sequences { v.push(s); }
for s in &sl.isxd_sequences { v.push(s); }
v
};
seqs.into_iter()
.flat_map(|seq| seq.resource_list().resources.iter()
.filter_map(|r| r.track_file_id))
.collect::<Vec<_>>()
})
.collect();
for scm in self.sidecar_composition_maps.values() {
if scm.has_signer && !scm.has_signature {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::SignerWithoutSignature,
format!("SCM {}: Signer element present but Signature element is absent", scm.id),
).with_context("scm_id", scm.id.to_string()));
}
if scm.has_signature && !scm.has_signer {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::SignatureWithoutSigner,
format!("SCM {}: Signature element present but Signer element is absent", scm.id),
).with_context("scm_id", scm.id.to_string()));
}
let mut seen_asset_ids = HashSet::new();
for sidecar_asset in &scm.sidecar_assets {
if !seen_asset_ids.insert(sidecar_asset.id) {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::DuplicateAssetId,
format!(
"Duplicate SidecarAsset Id {} in SCM {}",
sidecar_asset.id, scm.id
),
).with_context("scm_id", scm.id.to_string())
.with_context("asset_id", sidecar_asset.id.to_string()));
}
if !asset_ids.contains(&sidecar_asset.id) {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::SidecarAssetNotFound,
format!(
"SCM {} references sidecar asset {} not found in AssetMap",
scm.id, sidecar_asset.id
),
).with_context("scm_id", scm.id.to_string())
.with_context("asset_id", sidecar_asset.id.to_string()));
}
if virtual_track_file_ids.contains(&sidecar_asset.id) {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::SidecarAssetReferencedByVirtualTrack,
format!(
"Sidecar asset {} (SCM {}) is referenced by a Virtual Track in a CPL",
sidecar_asset.id, scm.id
),
).with_context("scm_id", scm.id.to_string())
.with_context("asset_id", sidecar_asset.id.to_string()));
}
let mut seen_cpl_ids = HashSet::new();
for cpl_id in &sidecar_asset.cpl_ids {
if !seen_cpl_ids.insert(*cpl_id) {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::DuplicateCplId,
format!(
"Duplicate CPLId {} in AssociatedCPLList of sidecar asset {} (SCM {})",
cpl_id, sidecar_asset.id, scm.id
),
).with_context("scm_id", scm.id.to_string())
.with_context("asset_id", sidecar_asset.id.to_string())
.with_context("cpl_id", cpl_id.to_string()));
}
if !self.composition_playlists.contains_key(cpl_id) {
report.add(ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_9_2018::CplNotFound,
format!(
"SCM {} sidecar asset {} references CPL {} which is not known in this package",
scm.id, sidecar_asset.id, cpl_id
),
).with_context("scm_id", scm.id.to_string())
.with_context("asset_id", sidecar_asset.id.to_string())
.with_context("cpl_id", cpl_id.to_string()));
}
}
}
}
}
fn validate_multi_pkl_consistency(&self, report: &mut ValidationReport) {
if self.packing_lists.len() < 2 {
return; }
let mut asset_records: HashMap<ImfUuid, Vec<(ImfUuid, String, u64)>> = HashMap::new();
for (pkl_id, pkl) in &self.packing_lists {
for asset in &pkl.asset_list.assets {
asset_records
.entry(asset.id)
.or_default()
.push((*pkl_id, asset.hash.to_base64(), asset.size));
}
}
for (asset_id, records) in &asset_records {
if records.len() < 2 {
continue;
}
let (first_pkl, ref first_hash, first_size) = records[0];
for (pkl_id, hash, size) in &records[1..] {
if hash != first_hash {
report.add(
ValidationIssue::new(
Severity::Error,
Category::Asset,
codes::St2067_2_2020::ChecksumMismatch,
format!(
"Asset {} has different hashes in PKL {} ({}) vs PKL {} ({})",
asset_id,
&first_pkl.to_string()[..8],
&first_hash[..8.min(first_hash.len())],
&pkl_id.to_string()[..8],
&hash[..8.min(hash.len())],
),
)
.with_context("asset_uuid", asset_id.to_string()),
);
}
if *size != first_size {
report.add(
ValidationIssue::new(
Severity::Error,
Category::Asset,
codes::St2067_2_2020::SizeMismatch,
format!(
"Asset {} has different sizes in PKL {} ({} bytes) vs PKL {} ({} bytes)",
asset_id,
&first_pkl.to_string()[..8],
first_size,
&pkl_id.to_string()[..8],
size,
),
)
.with_context("asset_uuid", asset_id.to_string()),
);
}
}
}
}
fn validate_mxf_headers(&self, report: &mut ValidationReport) {
const OP1A_BYTES_13_14: [u8; 2] = [0x01, 0x01];
for pkl in self.packing_lists.values() {
for asset in &pkl.asset_list.assets {
if !asset.mime_type.is_mxf() {
continue;
}
let path = match self.asset_paths.get(&asset.id) {
Some(p) => p,
None => continue, };
if !path.exists() {
continue; }
match crate::mxf::parse_mxf_header_info(path) {
Ok(info) => {
let op_bytes = parse_ul_bytes(&info.operational_pattern);
if let Some(bytes) = op_bytes {
if bytes[12] != OP1A_BYTES_13_14[0] || bytes[13] != OP1A_BYTES_13_14[1] {
report.add(
ValidationIssue::new(
Severity::Error,
Category::Encoding,
codes::St377_1_2011::Op1a,
format!(
"MXF track file '{}' has Operational Pattern '{}' \
but IMF requires OP1a (ST 2067-2 §5.1)",
path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
info.operational_pattern,
),
)
.with_location(
Location::new().with_file(path.clone()),
)
.with_context("asset_uuid", asset.id.to_string()),
);
}
}
if info.essence_containers.is_empty() {
report.add(
ValidationIssue::new(
Severity::Warning,
Category::Encoding,
codes::St377_1_2011::NoEssenceContainers,
format!(
"MXF track file '{}' has no essence containers in its header partition",
path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", asset.id.to_string()),
);
}
}
Err(crate::mxf::MxfParseError::NotMxf) => {
report.add(
ValidationIssue::new(
Severity::Warning,
Category::Asset,
codes::St377_1_2011::NotMxf,
format!(
"File '{}' has MXF MIME type but is not a valid MXF file",
path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", asset.id.to_string()),
);
}
Err(e) => {
report.add(
ValidationIssue::new(
Severity::Warning,
Category::Asset,
codes::St377_1_2011::ParseError,
format!(
"Could not parse MXF header of '{}': {}",
path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
e,
),
)
.with_location(Location::new().with_file(path.clone()))
.with_context("asset_uuid", asset.id.to_string()),
);
}
}
}
}
}
#[allow(dead_code)]
fn validate_segment_durations(&self, report: &mut ValidationReport) {
use crate::cpl::SequenceAccess;
fn track_durations<S: SequenceAccess>(
sequences: &[S],
cpl_edit_rate: Option<&EditRate>,
) -> Vec<(String, u64, u64)> {
sequences
.iter()
.map(|seq| {
let resources = &seq.resource_list().resources;
let mut total_num: u64 = 0;
let mut rate_den: u64 = 1;
for r in resources {
let ep = r.entry_point.unwrap_or(0);
let dur = r.source_duration.unwrap_or(r.intrinsic_duration.saturating_sub(ep));
let er = r.edit_rate.as_ref()
.or(cpl_edit_rate)
.cloned()
.unwrap_or(EditRate::new(1, 1));
total_num += dur * (er.denominator as u64);
rate_den = er.numerator as u64;
}
(seq.track_id().to_string(), total_num, rate_den)
})
.collect()
}
for cpl in self.composition_playlists.values() {
let cpl_id = cpl.id.to_string();
let cpl_er = cpl.edit_rate.as_ref();
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
let seq_list = &segment.sequence_list;
let mut durations: Vec<(String, f64)> = Vec::new();
fn add_durations<S: SequenceAccess>(
sequences: &[S],
cpl_edit_rate: Option<&EditRate>,
out: &mut Vec<(String, f64)>,
) {
for (tid, num, den) in track_durations(sequences, cpl_edit_rate) {
if den > 0 {
out.push((tid, num as f64 / den as f64));
}
}
}
add_durations(&seq_list.main_image_sequences, cpl_er, &mut durations);
add_durations(&seq_list.main_audio_sequences, cpl_er, &mut durations);
add_durations(&seq_list.subtitles_sequences, cpl_er, &mut durations);
add_durations(&seq_list.hearing_impaired_captions_sequences, cpl_er, &mut durations);
add_durations(&seq_list.forced_narrative_sequences, cpl_er, &mut durations);
if durations.is_empty() {
continue;
}
let first_dur = durations[0].1;
const TOLERANCE: f64 = 0.000001;
for (track_id, dur) in &durations[1..] {
if (*dur - first_dur).abs() > TOLERANCE {
report.add(
ValidationIssue::new(
Severity::Error,
Category::Timing,
codes::St2067_3_2020::SegmentDuration,
format!(
"Segment {} has mismatched virtual track durations: \
track {} = {:.6}s but track {} = {:.6}s",
seg_idx,
durations[0].0,
first_dur,
track_id,
dur,
),
)
.with_location(
Location::new()
.with_cpl(cpl_id.clone())
.with_segment(seg_idx),
),
);
break; }
}
}
}
}
fn validate_cpl_asset_references_accumulating(
&self,
cpl: &crate::cpl::CompositionPlaylist,
report: &mut ValidationReport,
) {
use crate::cpl::SequenceAccess;
if self.asset_map.asset_list.assets.is_empty() {
report.add(
ValidationIssue::new(
Severity::Critical,
Category::Structure,
codes::St2067_2_2020::AssetMap,
"AssetMap contains no assets",
)
.with_location(Location::new().with_cpl(cpl.id.to_string())),
);
return;
}
let assetmap_ids: std::collections::HashSet<ImfUuid> = self
.asset_map
.asset_list
.assets
.iter()
.map(|a| a.id)
.collect();
fn check_sequences<S: SequenceAccess>(
sequences: &[S],
track_type: &str,
cpl_id: &str,
seg_idx: usize,
assetmap_ids: &std::collections::HashSet<ImfUuid>,
issues: &mut Vec<ValidationIssue>,
) {
for seq in sequences {
for (res_idx, resource) in seq.resource_list().resources.iter().enumerate() {
if let Some(ref track_file_id) = resource.track_file_id {
if !assetmap_ids.contains(track_file_id) {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_2_2020::UnresolvedUuid,
format!(
"{} TrackFileId {} not found in AssetMap",
track_type, track_file_id
),
)
.with_location(
Location::new()
.with_cpl(cpl_id.to_string())
.with_segment(seg_idx)
.with_resource(res_idx),
)
.with_context("track_file_id", track_file_id.to_string()),
);
}
}
}
}
}
let cpl_id = cpl.id.to_string();
let mut issues = Vec::new();
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
let seq_list = &segment.sequence_list;
check_sequences(&seq_list.main_image_sequences, "MainImageSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
check_sequences(&seq_list.main_audio_sequences, "MainAudioSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
check_sequences(&seq_list.subtitles_sequences, "SubtitlesSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
check_sequences(&seq_list.hearing_impaired_captions_sequences, "HearingImpairedCaptionsSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
check_sequences(&seq_list.forced_narrative_sequences, "ForcedNarrativeSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
check_sequences(&seq_list.iab_sequences, "IABSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
check_sequences(&seq_list.isxd_sequences, "ISXDSequence", &cpl_id, seg_idx, &assetmap_ids, &mut issues);
}
for issue in issues {
report.add(issue);
}
}
}
fn parse_ul_bytes(ul: &str) -> Option<[u8; 16]> {
let hex = ul.strip_prefix("urn:smpte:ul:")?;
let hex_clean: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if hex_clean.len() != 32 {
return None;
}
let mut bytes = [0u8; 16];
for i in 0..16 {
bytes[i] = u8::from_str_radix(&hex_clean[i * 2..i * 2 + 2], 16).ok()?;
}
Some(bytes)
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PackageInspection {
pub path: PathBuf,
pub volume_index: u32,
pub asset_map_id: String,
pub asset_count: usize,
pub cpl_count: usize,
pub cpl_uuids: Vec<String>,
pub main_cpl: Option<CplSummary>,
pub asset_map_issuer: Option<String>,
pub asset_map_creator: Option<String>,
pub asset_map_issue_date: String,
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CplSummary {
pub id: String,
pub title: String,
pub kind: String,
pub issue_date: String,
pub segments: usize,
pub issuer: Option<String>,
pub creator: Option<String>,
pub annotation: Option<String>,
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CplDetails {
pub id: String,
pub title: String,
pub kind: String,
pub issue_date: String,
pub annotation: Option<String>,
pub issuer: Option<String>,
pub creator: Option<String>,
pub content_originator: Option<String>,
pub content_versions: Vec<String>,
pub segments: Vec<SegmentInfo>,
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SegmentInfo {
pub id: String,
pub sequence_count: usize,
}
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TrackAnalysis {
pub cpl_id: String,
pub cpl_title: String,
pub total_tracks: usize,
pub audio_tracks: usize,
pub video_tracks: usize,
pub subtitle_tracks: usize,
pub languages: Vec<String>,
pub codecs: Vec<String>,
}
impl Imferno {
pub fn inspect(&self) -> PackageInspection {
let main_cpl = self.get_main_cpl().map(|cpl| CplSummary {
id: cpl.id.to_string(),
title: cpl.content_title.text.clone(),
kind: cpl.content_kind.to_string(),
issue_date: cpl.issue_date.clone(),
segments: cpl.segment_list.segments.len(),
issuer: cpl.issuer.as_ref().map(|ls| ls.text.clone()),
creator: cpl.creator.as_ref().map(|ls| ls.text.clone()),
annotation: cpl.annotation.as_ref().map(|ls| ls.text.clone()),
});
PackageInspection {
path: self.root_path.clone(),
volume_index: self.volume_index.index,
asset_map_id: self.asset_map.id.to_string(),
asset_count: self.asset_map.asset_list.assets.len(),
cpl_count: self.composition_playlists.len(),
cpl_uuids: self.composition_playlists.keys().map(|u| u.to_string()).collect(),
main_cpl,
asset_map_issuer: self.asset_map.issuer.clone(),
asset_map_creator: self.asset_map.creator.clone(),
asset_map_issue_date: self.asset_map.issue_date.clone(),
}
}
pub fn get_cpl_details(&self, uuid: &str) -> Option<CplDetails> {
let cpl = self.get_cpl_str(uuid)?;
let content_versions = if let Some(ref version_list) = cpl.content_version_list {
version_list.content_versions.iter().map(|v| v.id.clone()).collect()
} else {
Vec::new()
};
let segments = cpl.segment_list.segments.iter().map(|seg| {
let seq_list = &seg.sequence_list;
let sequence_count = seq_list.main_image_sequences.len() +
seq_list.main_audio_sequences.len() +
seq_list.subtitles_sequences.len();
SegmentInfo {
id: seg.id.to_string(),
sequence_count,
}
}).collect();
Some(CplDetails {
id: cpl.id.to_string(),
title: cpl.content_title.text.clone(),
kind: cpl.content_kind.to_string(),
issue_date: cpl.issue_date.clone(),
annotation: cpl.annotation.as_ref().map(|ls| ls.text.clone()),
issuer: cpl.issuer.as_ref().map(|ls| ls.text.clone()),
creator: cpl.creator.as_ref().map(|ls| ls.text.clone()),
content_originator: cpl.content_originator.as_ref().map(|ls| ls.text.clone()),
content_versions,
segments,
})
}
pub fn analyze_tracks(&self) -> Vec<TrackAnalysis> {
let mut analyses = Vec::new();
for (uuid, cpl) in &self.composition_playlists {
let mut total_tracks = 0;
let mut audio_tracks = 0;
let mut video_tracks = 0;
let mut subtitle_tracks = 0;
let mut codecs = std::collections::HashSet::new();
for segment in &cpl.segment_list.segments {
let seq_list = &segment.sequence_list;
if !seq_list.main_image_sequences.is_empty() {
video_tracks += seq_list.main_image_sequences.len();
total_tracks += seq_list.main_image_sequences.len();
codecs.insert("Video".to_string());
}
if !seq_list.main_audio_sequences.is_empty() {
audio_tracks += seq_list.main_audio_sequences.len();
total_tracks += seq_list.main_audio_sequences.len();
codecs.insert("Audio".to_string());
}
if !seq_list.subtitles_sequences.is_empty() {
subtitle_tracks += seq_list.subtitles_sequences.len();
total_tracks += seq_list.subtitles_sequences.len();
codecs.insert("Subtitle".to_string());
}
}
analyses.push(TrackAnalysis {
cpl_id: uuid.to_string(),
cpl_title: cpl.content_title.text.clone(),
total_tracks,
audio_tracks,
video_tracks,
subtitle_tracks,
languages: Vec::new(),
codecs: codecs.into_iter().collect(),
});
}
analyses
}
pub fn analyze_tracks_enhanced(&self, feature_data: Option<serde_json::Value>) -> Vec<TrackAnalysis> {
let mut analyses = Vec::new();
for (uuid, cpl) in &self.composition_playlists {
let mut total_tracks = 0;
let mut audio_tracks = 0;
let mut video_tracks = 0;
let mut subtitle_tracks = 0;
let mut codecs = std::collections::HashSet::new();
for segment in &cpl.segment_list.segments {
let seq_list = &segment.sequence_list;
if !seq_list.main_image_sequences.is_empty() {
video_tracks += seq_list.main_image_sequences.len();
total_tracks += seq_list.main_image_sequences.len();
}
if !seq_list.main_audio_sequences.is_empty() {
audio_tracks += seq_list.main_audio_sequences.len();
total_tracks += seq_list.main_audio_sequences.len();
}
if !seq_list.subtitles_sequences.is_empty() {
subtitle_tracks += seq_list.subtitles_sequences.len();
total_tracks += seq_list.subtitles_sequences.len();
}
}
let languages = if let Some(ref data) = feature_data {
if let Some(audio_langs) = data["audio_languages"].as_array() {
audio_langs.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
if let Some(ref data) = feature_data {
if let Some(video_codecs) = data["video_codecs"].as_array() {
for codec in video_codecs {
if let Some(codec_str) = codec.as_str() {
codecs.insert(codec_str.to_string());
}
}
}
if let Some(audio_codecs) = data["audio_codecs"].as_array() {
for codec in audio_codecs {
if let Some(codec_str) = codec.as_str() {
codecs.insert(codec_str.to_string());
}
}
}
}
if video_tracks > 0 { codecs.insert("Video".to_string()); }
if audio_tracks > 0 { codecs.insert("Audio".to_string()); }
if subtitle_tracks > 0 { codecs.insert("Subtitle".to_string()); }
analyses.push(TrackAnalysis {
cpl_id: uuid.to_string(),
cpl_title: cpl.content_title.text.clone(),
total_tracks,
audio_tracks,
video_tracks,
subtitle_tracks,
languages,
codecs: codecs.into_iter().collect(),
});
}
analyses
}
pub fn list_cpls(&self) -> Vec<CplSummary> {
self.composition_playlists
.iter()
.map(|(_, cpl)| CplSummary {
id: cpl.id.to_string(),
title: cpl.content_title.text.clone(),
kind: cpl.content_kind.to_string(),
issue_date: cpl.issue_date.clone(),
segments: cpl.segment_list.segments.len(),
issuer: cpl.issuer.as_ref().map(|ls| ls.text.clone()),
creator: cpl.creator.as_ref().map(|ls| ls.text.clone()),
annotation: cpl.annotation.as_ref().map(|ls| ls.text.clone()),
})
.collect()
}
}
pub use crate::diagnostics::{RulesConfig, RuleSeverity};
#[derive(Debug, Default, Clone)]
pub struct ValidationOptions {
pub rules: RulesConfig,
#[cfg(not(target_arch = "wasm32"))]
pub verify_hashes: Option<PathBuf>,
#[cfg(not(target_arch = "wasm32"))]
pub skip_disk_checks: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use codes::{St2067_2_2020, St377_1_2011, ValidationCode};
fn test_data(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test-data").join(name)
}
#[test]
fn test_parse_netflix_photon_package() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
match Imferno::parse(read_dir(test_path).unwrap()) {
Ok(package) => {
assert_eq!(package.volume_index.index, 1);
assert!(!package.asset_map.asset_list.assets.is_empty());
assert!(!package.composition_playlists.is_empty());
let main_cpl = package.get_main_cpl().unwrap();
assert_eq!(main_cpl.content_kind, crate::cpl::ContentKind::Test);
assert_eq!(main_cpl.content_title.text, "MERIDIAN");
package.validate_structure().unwrap();
}
Err(e) => panic!("Failed to parse IMF package: {:?}", e),
}
}
#[test]
fn test_package_inspection_api() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let inspection = package.inspect();
assert_eq!(inspection.volume_index, 1);
assert_eq!(inspection.asset_count, 6);
assert_eq!(inspection.cpl_count, 1);
assert_eq!(inspection.asset_map_id, "75864667-c65e-4aae-a5b2-fa5ea5fe31b7");
assert!(inspection.path.to_string_lossy().contains("MERIDIAN_Netflix_Photon_161006"));
assert!(inspection.main_cpl.is_some());
let main_cpl = inspection.main_cpl.unwrap();
assert_eq!(main_cpl.title, "MERIDIAN");
assert_eq!(main_cpl.kind, "Test");
assert_eq!(main_cpl.id, "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
}
#[test]
fn test_list_cpls_api() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let cpls = package.list_cpls();
assert_eq!(cpls.len(), 1);
let cpl = &cpls[0];
assert_eq!(cpl.id, "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
assert_eq!(cpl.title, "MERIDIAN");
assert_eq!(cpl.kind, "Test");
assert!(cpl.annotation.is_some());
assert_eq!(cpl.annotation.as_ref().unwrap(), "Meridian UHD 5994P");
assert_eq!(cpl.segments, 1);
}
#[test]
fn test_get_cpl_details_api() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let cpl_uuid = "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85";
let details = package.get_cpl_details(cpl_uuid).expect("Failed to get CPL details");
assert_eq!(details.id, cpl_uuid);
assert_eq!(details.title, "MERIDIAN");
assert_eq!(details.kind, "Test");
assert!(details.annotation.is_some());
assert_eq!(details.segments.len(), 1);
let segment = &details.segments[0];
assert!(!segment.id.is_empty());
assert!(package.get_cpl_details("invalid-uuid").is_none());
}
#[test]
fn test_analyze_tracks_api() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let track_analyses = package.analyze_tracks();
assert_eq!(track_analyses.len(), 1);
let analysis = &track_analyses[0];
assert_eq!(analysis.cpl_title, "MERIDIAN");
}
#[test]
fn test_list_cpl_uuids_api() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let uuids = package.list_cpl_uuids();
assert_eq!(uuids.len(), 1);
assert_eq!(uuids[0].to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
}
#[test]
fn test_validation_api() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let report = package.validate(&ValidationOptions::default());
assert!(!report.has_errors(), "Package structure validation should have no errors: {:?}", report.summary());
}
#[test]
fn test_validate_package_structure_with_cpl_validator_injects_issues() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
const INJECTED_CODE: &str = "ST2067-2:2020:6.12/InjectedRuleForTest";
let report = package.validate_package_structure_with_cpl_validator(|cpl| {
vec![ValidationIssue::new(
Severity::Warning,
Category::Metadata,
INJECTED_CODE,
format!("Injected validator issue for CPL {}", cpl.id),
)]
}, false);
let expected_code = INJECTED_CODE;
let injected_present = report.warnings.iter().any(|issue| issue.code == expected_code)
|| report.errors.iter().any(|issue| issue.code == expected_code)
|| report.critical.iter().any(|issue| issue.code == expected_code)
|| report.info.iter().any(|issue| issue.code == expected_code);
assert!(injected_present, "Expected injected CPL issue to be present in report");
}
#[test]
fn test_validate_package_structure_with_empty_cpl_validator_matches_default_counts() {
use crate::validation::{ConfigurableValidatorRegistry, ValidatorSelection, validate_cpl_with_registry};
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let default_report = package.validate(&ValidationOptions::default());
let registry = ConfigurableValidatorRegistry::new(ValidatorSelection::default());
let injected_report = package.validate_package_structure_with_cpl_validator(|cpl| {
validate_cpl_with_registry(cpl, ®istry)
}, false);
assert_eq!(default_report.total_issues(), injected_report.total_issues());
assert_eq!(default_report.errors.len(), injected_report.errors.len());
assert_eq!(default_report.warnings.len(), injected_report.warnings.len());
assert_eq!(default_report.critical.len(), injected_report.critical.len());
assert_eq!(default_report.info.len(), injected_report.info.len());
}
#[test]
fn test_package_with_missing_files() {
let test_path = test_data("MissingFilesAndAssetMapEntries");
match Imferno::parse(read_dir(test_path).unwrap()) {
Ok(package) => {
let validation_fails = package.validate_structure().is_err();
let structure_report = package.validate(&ValidationOptions::default());
assert!(validation_fails || structure_report.has_errors());
}
Err(_) => {
}
}
}
#[test]
fn test_package_with_id_mismatch() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006_ID_MISMATCH");
match Imferno::parse(read_dir(test_path).unwrap()) {
Ok(package) => {
let inspection = package.inspect();
assert!(inspection.cpl_count > 0);
}
Err(_) => {}
}
}
#[test]
fn test_lenient_parsing() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(&test_path).unwrap_or_default())
.expect("Failed to parse package");
assert!(!package.composition_playlists.is_empty());
let inspection = package.inspect();
assert_eq!(inspection.cpl_count, 1);
}
#[test]
fn test_error_handling_invalid_path() {
let invalid_path = "/nonexistent/path/to/package";
let result = Imferno::parse(read_dir(invalid_path).unwrap_or_default());
assert!(result.is_err());
}
#[test]
fn test_get_asset_path() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
if let Some(first_asset) = package.asset_map.asset_list.assets.first() {
let asset_path = package.get_asset_path(first_asset.id);
assert!(asset_path.is_some());
}
assert!(package.get_asset_path_str("invalid-id").is_none());
}
#[test]
fn test_validation_errors() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let report = package.validate(&ValidationOptions::default());
assert!(!report.has_errors(), "Validation should pass: {:?}", report.summary());
}
#[test]
fn test_get_cpl_with_invalid_uuid() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
assert!(package.get_cpl_str("invalid-uuid").is_none());
let uuid = "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85";
let result = package.get_cpl_str(uuid);
assert!(result.is_some());
}
#[test]
fn test_empty_package_edge_cases() {
let test_path = test_data("MissingFilesAndAssetMapEntries");
match Imferno::parse(read_dir(test_path).unwrap()) {
Ok(package) => {
let cpls = package.list_cpls();
assert!(cpls.is_empty());
let cpl_uuids = package.list_cpl_uuids();
assert!(cpl_uuids.is_empty());
let main_cpl = package.get_main_cpl();
assert!(main_cpl.is_none());
let track_analyses = package.analyze_tracks();
assert!(track_analyses.is_empty());
}
Err(_) => {}
}
}
#[test]
fn test_bad_xml_package() {
match Imferno::parse(read_dir(test_data("BadXML")).unwrap_or_default()) {
Ok(_) => {}
Err(err) => {
assert!(err.to_string().contains("parsing") || err.to_string().contains("XML") || err.to_string().contains("Invalid") || err.to_string().contains("Missing"));
}
}
}
#[test]
fn test_wrong_mime_types_package() {
let test_path = test_data("WrongXmlMimeTypes");
match Imferno::parse(read_dir(test_path).unwrap_or_default()) {
Ok(package) => {
let inspection = package.inspect();
assert!(inspection.asset_count > 0);
}
Err(_) => {}
}
}
#[test]
fn test_cpl_edge_cases() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let cpls = package.list_cpls();
assert!(!cpls.is_empty());
let first_cpl = &cpls[0];
if let Some(annotation) = &first_cpl.annotation {
assert!(!annotation.is_empty());
}
let details = package.get_cpl_details(&first_cpl.id).unwrap();
assert_eq!(details.title, first_cpl.title);
assert_eq!(details.kind, first_cpl.kind);
for version in &details.content_versions {
assert!(!version.is_empty());
}
}
#[test]
fn test_directory_structure_validation() {
let current_dir = std::env::current_dir().unwrap();
let result = Imferno::parse(read_dir(¤t_dir).unwrap_or_default());
assert!(result.is_err());
let fake_dir = "/this/path/does/not/exist";
let result = Imferno::parse(read_dir(fake_dir).unwrap_or_default());
assert!(result.is_err());
let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.toml");
let result = Imferno::parse(read_dir(file_path).unwrap_or_default());
assert!(result.is_err());
}
#[test]
fn test_inspect_all_fields() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let inspection = package.inspect();
assert!(inspection.path.exists());
assert!(inspection.volume_index > 0);
assert!(!inspection.asset_map_id.is_empty());
assert!(!inspection.asset_map_issue_date.is_empty());
if let Some(issuer) = &inspection.asset_map_issuer {
assert!(!issuer.is_empty());
}
if let Some(creator) = &inspection.asset_map_creator {
assert!(!creator.is_empty());
}
assert!(inspection.asset_count > 0);
assert!(inspection.cpl_count > 0);
}
#[test]
fn test_serialization() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let inspection = package.inspect();
let json = serde_json::to_string(&inspection).expect("Failed to serialize inspection");
assert!(json.contains("volume_index"));
assert!(json.contains("asset_count"));
let _deserialized: crate::package::PackageInspection = serde_json::from_str(&json).expect("Failed to deserialize inspection");
let cpls = package.list_cpls();
let json = serde_json::to_string(&cpls).expect("Failed to serialize CPLs");
assert!(json.contains("title"));
let tracks = package.analyze_tracks();
let json = serde_json::to_string(&tracks).expect("Failed to serialize tracks");
assert!(json.contains("total_tracks") || json == "[]");
}
#[test]
fn test_concurrent_access() {
use std::sync::Arc;
use std::thread;
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Arc::new(Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package"));
let mut handles = vec![];
for _ in 0..4 {
let pkg = package.clone();
let handle = thread::spawn(move || {
let inspection = pkg.inspect();
assert!(inspection.asset_count > 0);
let cpls = pkg.list_cpls();
assert!(!cpls.is_empty());
let _ = pkg.analyze_tracks();
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("Thread failed");
}
}
#[test]
fn test_malformed_xml_handling() {
use tempfile::TempDir;
use std::fs;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let temp_path = temp_dir.path();
let volindex_content = r#"<?xml version="1.0" encoding="UTF-8"?>
<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/volindex">
<Index>1</Index>
</VolumeIndex>"#;
fs::write(temp_path.join("VOLINDEX.xml"), volindex_content).expect("Failed to write VOLINDEX");
let malformed_assetmap = r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/assetmap">
<Id>urn:uuid:invalid-xml</Id>
<!-- Missing closing tag -->
<AssetList>
<Asset>
<Id>test-asset</Id>
"#;
fs::write(temp_path.join("ASSETMAP.xml"), malformed_assetmap).expect("Failed to write malformed ASSETMAP");
let result = Imferno::parse(read_dir(temp_path).unwrap());
assert!(result.is_err(), "Should fail with malformed XML");
}
#[test]
fn test_validation_with_complex_structure() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let report = package.validate(&ValidationOptions::default());
assert!(!report.has_errors(), "Package should be valid: {:?}", report.summary());
}
#[test]
fn test_package_with_no_cpls() {
use tempfile::TempDir;
use std::fs;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let temp_path = temp_dir.path();
let volindex_content = r#"<?xml version="1.0" encoding="UTF-8"?>
<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/volindex">
<Index>1</Index>
</VolumeIndex>"#;
fs::write(temp_path.join("VOLINDEX.xml"), volindex_content).expect("Failed to write VOLINDEX");
let no_cpl_assetmap = r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/assetmap">
<Id>urn:uuid:12345678-1234-1234-1234-123456789012</Id>
<VolumeCount>1</VolumeCount>
<IssueDate>2023-01-01T00:00:00</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc</Id>
<ChunkList>
<Chunk>
<Path>video.mxf</Path>
</Chunk>
</ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
fs::write(temp_path.join("ASSETMAP.xml"), no_cpl_assetmap).expect("Failed to write ASSETMAP");
let result = Imferno::parse(read_dir(temp_path).unwrap());
assert!(result.is_ok(), "Package with no CPLs should parse successfully");
let package = result.unwrap();
assert_eq!(package.composition_playlists.len(), 0);
let cpls = package.list_cpls();
assert!(cpls.is_empty());
let inspection = package.inspect();
assert_eq!(inspection.cpl_count, 0);
assert!(inspection.main_cpl.is_none());
let tracks = package.analyze_tracks();
assert!(tracks.is_empty());
}
#[test]
fn test_asset_path_resolution() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
for asset in &package.asset_map.asset_list.assets {
let resolved_path = package.get_asset_path(asset.id);
assert!(resolved_path.is_some(), "Should resolve path for asset {}", asset.id);
let path = resolved_path.unwrap();
assert!(path.is_absolute(), "Resolved path should be absolute");
assert!(path.starts_with(&package.root_path), "Path should be within package directory");
}
assert!(package.get_asset_path_str("invalid-id").is_none());
}
#[test]
fn test_boundary_conditions() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
assert!(package.get_cpl_details("").is_none());
assert!(package.get_cpl_details(" ").is_none());
assert!(package.get_cpl_details("not-a-uuid").is_none());
assert!(package.get_asset_path_str("").is_none());
assert!(package.get_asset_path_str(" ").is_none());
assert!(package.get_asset_path_str("invalid-asset-id").is_none());
}
#[test]
fn test_large_package_handling() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
for _ in 0..10 {
let inspection = package.inspect();
assert!(inspection.asset_count > 0);
let cpls = package.list_cpls();
assert_eq!(cpls.len(), inspection.cpl_count);
let tracks = package.analyze_tracks();
assert_eq!(tracks.len(), inspection.cpl_count);
}
}
#[test]
fn test_validate_file_manifest_detects_mxf_files() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("Failed to parse package");
let errors = package.validate_file_manifest();
for err in &errors {
assert!(
!matches!(err, FileValidationError::Missing { .. }),
"Unexpected missing file: {}", err
);
}
}
#[test]
fn test_validate_file_manifest_detects_missing_files() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
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();
let pkl_xml = r#"<?xml version="1.0"?><PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>999</Size>
<Type>application/mxf</Type>
<OriginalFileName>missing_file.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = r#"<?xml version="1.0"?><AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000003</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<Asset>
<Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
<ChunkList><Chunk><Path>missing_file.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
std::fs::write(root.join("ASSETMAP.xml"), &assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("Failed to parse package");
let errors = package.validate_file_manifest();
assert!(
errors.iter().any(|e| matches!(e, FileValidationError::Missing { .. })),
"Expected a Missing error, got: {:?}", errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn test_pkl_constraints_detects_missing_assetmap_entries() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
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();
let pkl_xml = r#"<?xml version="1.0"?>
<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>999</Size>
<Type>application/mxf</Type>
<OriginalFileName>some.mxf</OriginalFileName>
</Asset>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000099</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>100</Size>
<Type>application/mxf</Type>
<OriginalFileName>orphan.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = r#"<?xml version="1.0"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000004</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<Asset>
<Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
<ChunkList><Chunk><Path>some.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
let errors = package.validate_pkl_constraints();
assert!(
errors.iter().any(|e| matches!(e, FileValidationError::NotInAssetMap { uuid, .. } if uuid.contains("cccccccc"))),
"Expected NotInAssetMap for cccccccc, got: {:?}",
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn test_cpl_asset_reference_validation_on_meridian() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
assert!(!report.has_errors(), "MERIDIAN should be valid: {:?}", report.summary());
}
#[test]
fn test_pkl_constraints_pass_on_meridian() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("parse");
let errors = package.validate_pkl_constraints();
assert!(
errors.is_empty(),
"MERIDIAN PKL constraints should pass, got: {:?}",
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn test_validate_package_structure_meridian() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let package = Imferno::parse(read_dir(test_path).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
assert!(
!report.has_critical(),
"MERIDIAN should have no critical issues: {}",
report.summary()
);
assert!(
!report.has_errors(),
"MERIDIAN should have no errors: {}",
report.summary()
);
}
#[test]
fn test_file_validation_error_to_issue_not_in_assetmap() {
let err = FileValidationError::NotInAssetMap {
uuid: "test-uuid".to_string(),
original_file_name: Some("test.mxf".to_string()),
};
let issue = ValidationIssue::from(&err);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.category, Category::Reference);
assert_eq!(issue.code, codes::St2067_2_2020::UnresolvedUuid.code());
assert!(issue.message.contains("test-uuid"));
}
#[test]
fn test_file_validation_error_to_issue_hash_mismatch() {
let err = FileValidationError::HashMismatch {
uuid: "asset-123".to_string(),
path: PathBuf::from("/tmp/test.mxf"),
expected: "abc123".to_string(),
actual: "def456".to_string(),
};
let issue = ValidationIssue::from(&err);
assert_eq!(issue.severity, Severity::Critical);
assert_eq!(issue.code, codes::St2067_2_2020::ChecksumMismatch.code());
assert!(issue.suggestion.is_some());
}
#[test]
fn test_file_validation_error_to_issue_missing() {
let err = FileValidationError::Missing {
uuid: "missing-uuid".to_string(),
path: PathBuf::from("/tmp/missing.mxf"),
};
let issue = ValidationIssue::from(&err);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.category, Category::Asset);
assert_eq!(issue.code, codes::St2067_2_2020::FileNotFound.code());
}
#[test]
fn test_validate_package_structure_detects_orphan_pkl_assets() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
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();
let pkl_xml = r#"<?xml version="1.0"?>
<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000099</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>100</Size>
<Type>application/mxf</Type>
<OriginalFileName>orphan.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = r#"<?xml version="1.0"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000004</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
assert!(
report.has_errors(),
"Should report errors for orphan PKL asset: {}",
report.summary()
);
let all_issues: Vec<_> = report.errors.iter()
.filter(|i| i.code == codes::St2067_2_2020::UnresolvedUuid.code())
.collect();
assert!(
!all_issues.is_empty(),
"Should have UnresolvedUuid for orphan PKL asset"
);
}
#[test]
fn test_validate_package_structure_detects_missing_files() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
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();
let pkl_xml = r#"<?xml version="1.0"?>
<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>999</Size>
<Type>application/mxf</Type>
<OriginalFileName>ghost.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = r#"<?xml version="1.0"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000004</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<Asset>
<Id>urn:uuid:bbbbbbbb-0000-0000-0000-000000000002</Id>
<ChunkList><Chunk><Path>ghost.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
assert!(
report.has_errors(),
"Should report errors for missing file: {}",
report.summary()
);
let missing_issues: Vec<_> = report.errors.iter()
.filter(|i| i.code == codes::St2067_2_2020::FileNotFound.code())
.collect();
assert!(
!missing_issues.is_empty(),
"Should have FileNotFound for ghost.mxf"
);
}
#[test]
fn parse_ul_bytes_valid() {
let bytes = parse_ul_bytes("urn:smpte:ul:060e2b34.04010102.0d010201.01010900");
assert!(bytes.is_some());
let b = bytes.unwrap();
assert_eq!(b[0], 0x06);
assert_eq!(b[12], 0x01);
assert_eq!(b[13], 0x01); assert_eq!(b[14], 0x09);
}
#[test]
fn parse_ul_bytes_invalid() {
assert!(parse_ul_bytes("not-a-ul").is_none());
assert!(parse_ul_bytes("urn:smpte:ul:060e2b34").is_none());
}
fn make_mxf_bytes(op_ul: [u8; 16]) -> Vec<u8> {
let mut stream = Vec::new();
stream.extend_from_slice(&[
0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01,
0x0D, 0x01, 0x02, 0x01, 0x01, 0x02, 0x04, 0x00,
]);
stream.push(88);
stream.extend_from_slice(&[0x00, 0x01, 0x00, 0x03]);
stream.extend_from_slice(&[0x00, 0x00, 0x02, 0x00]);
stream.extend_from_slice(&[0u8; 56]);
stream.extend_from_slice(&op_ul);
stream.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
stream.extend_from_slice(&[0x00, 0x00, 0x00, 0x10]);
stream
}
#[test]
fn mxf_validation_accepts_op1a() {
let root = tempfile::tempdir().unwrap();
let root = root.path();
let op1a: [u8; 16] = [
0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02,
0x0D, 0x01, 0x02, 0x01, 0x01, 0x01, 0x09, 0x00,
];
std::fs::write(root.join("video.mxf"), make_mxf_bytes(op1a)).unwrap();
let pkl_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
<Hash>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</Hash>
<Size>105</Size>
<Type>application/mxf</Type>
<OriginalFileName>video.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
<ChunkList><Chunk><Path>video.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#);
std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
let op_issues: Vec<_> = report.critical.iter()
.chain(report.errors.iter())
.chain(report.warnings.iter())
.chain(report.info.iter())
.filter(|i| i.code == St377_1_2011::Op1a.code())
.collect();
assert!(
op_issues.is_empty(),
"OP1a should not produce OP issues: {:#?}", op_issues,
);
}
#[test]
fn mxf_validation_flags_non_op1a() {
let root = tempfile::tempdir().unwrap();
let root = root.path();
let op_atom: [u8; 16] = [
0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02,
0x0D, 0x01, 0x02, 0x01, 0x03, 0x01, 0x00, 0x00,
];
std::fs::write(root.join("video.mxf"), make_mxf_bytes(op_atom)).unwrap();
let pkl_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
<Hash>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</Hash>
<Size>105</Size>
<Type>application/mxf</Type>
<OriginalFileName>video.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
<ChunkList><Chunk><Path>video.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
let op_issues: Vec<_> = report.critical.iter()
.chain(report.errors.iter())
.chain(report.warnings.iter())
.chain(report.info.iter())
.filter(|i| i.code == St377_1_2011::Op1a.code())
.collect();
assert_eq!(
op_issues.len(), 1,
"Non-OP1a should produce exactly one OP issue: {:#?}", op_issues,
);
}
#[test]
fn mxf_validation_warns_invalid_mxf() {
let root = tempfile::tempdir().unwrap();
let root = root.path();
std::fs::write(root.join("bad.mxf"), b"not-an-mxf-file-at-all-garbage").unwrap();
let pkl_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<AssetList>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
<Hash>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</Hash>
<Size>30</Size>
<Type>application/mxf</Type>
<OriginalFileName>bad.mxf</OriginalFileName>
</Asset>
</AssetList>
</PackingList>"#;
std::fs::write(root.join("PKL.xml"), pkl_xml).unwrap();
let assetmap_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<Asset>
<Id>urn:uuid:cccccccc-0000-0000-0000-000000000001</Id>
<ChunkList><Chunk><Path>bad.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
std::fs::write(root.join("ASSETMAP.xml"), assetmap_xml).unwrap();
let package = Imferno::parse(read_dir(root).unwrap()).expect("parse");
let report = package.validate(&ValidationOptions::default());
let notmxf_issues: Vec<_> = report.critical.iter()
.chain(report.errors.iter())
.chain(report.warnings.iter())
.chain(report.info.iter())
.filter(|i| i.code == St377_1_2011::NotMxf.code())
.collect();
assert!(
!notmxf_issues.is_empty(),
"Invalid MXF should produce ST377-1-NotMxf warning: {:#?}",
report.warnings,
);
}
#[test]
fn test_file_validation_error_to_issue_size_mismatch() {
let err = FileValidationError::SizeMismatch {
uuid: "size-uuid".to_string(),
path: PathBuf::from("/tmp/test.mxf"),
expected: 1000,
actual: 2000,
};
let issue = ValidationIssue::from(&err);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.category, Category::Asset);
assert_eq!(issue.code, St2067_2_2020::SizeMismatch.code());
assert!(issue.message.contains("1000"));
assert!(issue.message.contains("2000"));
}
#[test]
fn test_file_validation_error_to_issue_io() {
let err = FileValidationError::Io {
uuid: "io-uuid".to_string(),
path: PathBuf::from("/tmp/broken.mxf"),
message: "permission denied".to_string(),
};
let issue = ValidationIssue::from(&err);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.category, Category::Asset);
assert_eq!(issue.code, "IMF:General/IoError");
assert!(issue.message.contains("permission denied"));
}
#[test]
fn test_file_validation_error_to_issue_duplicate_pkl_asset_id() {
let err = FileValidationError::DuplicatePklAssetId {
uuid: "dup-uuid".to_string(),
pkl_id: "pkl-001".to_string(),
};
let issue = ValidationIssue::from(&err);
assert_eq!(issue.severity, Severity::Error);
assert_eq!(issue.category, Category::Reference);
assert_eq!(issue.code, codes::St2067_2_2020::DuplicateUuid.code());
assert!(issue.message.contains("dup-uuid"));
assert!(issue.message.contains("pkl-001"));
}
#[test]
fn test_multi_pkl_single_pkl_no_cross_pkl_issues() {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap().join("fixture");
if !fixture_path.exists() { eprintln!("skipping: fixture/ not present"); return; }
let package = Imferno::parse(read_dir(fixture_path).unwrap()).expect("parse fixture");
let report = package.validate(&ValidationOptions::default());
assert!(
!report.errors.iter().any(|i| i.code.contains("ChecksumMismatch") || i.code == St2067_2_2020::SizeMismatch.code()),
"Single-PKL package should have no multi-PKL consistency issues: {:#?}", report.errors,
);
}
#[test]
fn test_segment_durations_fixture_pass() {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap().join("fixture");
if !fixture_path.exists() { eprintln!("skipping: fixture/ not present"); return; }
let package = Imferno::parse(read_dir(fixture_path).unwrap()).expect("parse fixture");
let report = package.validate(&ValidationOptions::default());
let duration_issues: Vec<_> = report.errors.iter()
.filter(|i| i.code.contains("SegmentDuration"))
.collect();
assert!(
duration_issues.is_empty(),
"Fixture should have matching segment durations: {:#?}", duration_issues,
);
}
#[test]
fn test_emitted_codes_do_not_use_general_fallback() {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap().join("fixture");
if !fixture_path.exists() { eprintln!("skipping: fixture/ not present"); return; }
let package = Imferno::parse(read_dir(fixture_path).unwrap()).expect("parse fixture");
let report = package.validate(&ValidationOptions::default());
let all_issues: Vec<_> = report
.critical
.iter()
.chain(report.errors.iter())
.chain(report.warnings.iter())
.chain(report.info.iter())
.collect();
assert!(
!all_issues.iter().any(|i| i.code.contains(":General/")),
"Package validator emitted :General fallback codes: {:#?}",
all_issues,
);
}
const MINIMAL_ASSETMAP: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:dddddddd-0000-0000-0000-000000000001</Id>
<Creator>test</Creator>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>test</Issuer>
<AssetList>
<Asset>
<Id>urn:uuid:eeeeeeee-0000-0000-0000-000000000001</Id>
<ChunkList><Chunk><Path>dummy.mxf</Path></Chunk></ChunkList>
</Asset>
</AssetList>
</AssetMap>"#;
const VALID_VOLINDEX: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Index>1</Index>
</VolumeIndex>"#;
#[test]
fn volindex_missing_emits_info() {
let mut files = HashMap::new();
files.insert("ASSETMAP.xml".to_string(), MINIMAL_ASSETMAP.to_string());
let pkg = Imferno::parse(files).expect("parse");
let report = pkg.validate(&ValidationOptions::default());
let all: Vec<_> = report.info.iter().collect();
assert!(
all.iter().any(|i| i.code.contains("VolindexMissing")),
"expected VolindexMissing info, got: {all:?}",
);
}
#[test]
fn volindex_malformed_emits_error() {
let mut files = HashMap::new();
files.insert("ASSETMAP.xml".to_string(), MINIMAL_ASSETMAP.to_string());
files.insert("VOLINDEX.xml".to_string(), "not xml <<< garbage".to_string());
let pkg = Imferno::parse(files).expect("parse");
let report = pkg.validate(&ValidationOptions::default());
assert!(
report.errors.iter().any(|i| i.code.contains("MalformedXml")),
"expected MalformedXml error, got: {:?}", report.errors,
);
}
#[test]
fn volindex_valid_no_issue() {
let mut files = HashMap::new();
files.insert("ASSETMAP.xml".to_string(), MINIMAL_ASSETMAP.to_string());
files.insert("VOLINDEX.xml".to_string(), VALID_VOLINDEX.to_string());
let pkg = Imferno::parse(files).expect("parse");
let report = pkg.validate(&ValidationOptions::default());
let all: Vec<_> = report.critical.iter()
.chain(report.errors.iter())
.chain(report.warnings.iter())
.chain(report.info.iter())
.filter(|i| i.code.contains("ST429-9"))
.collect();
assert!(
all.is_empty(),
"expected no ST 429-9 diagnostics for valid VOLINDEX, got: {all:?}",
);
}
}