use crate::assetmap::ImfUuid;
use crate::cpl::EditRate;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub mod codes;
pub mod report;
pub use self::report::{build_report, format_report, ImfReport};
pub use crate::assetmap::{Asset, AssetMap, PackingList, PklAsset, VolumeIndex};
pub use crate::cpl::{CompositionPlaylist, Resource as CplResource};
pub use crate::diagnostics::{
Category, Location, Severity, ValidationIssue, ValidationProfile, ValidationReport,
};
#[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::{
validate_cpl_with_registry, ConfigurableValidatorRegistry, ValidatorSelection,
};
let selection = ValidatorSelection {
core_spec: options.core_spec,
app_specs: options.app_specs.clone(),
..Default::default()
};
let registry = ConfigurableValidatorRegistry::new(selection);
#[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::{
validate_cpl_with_registry, ConfigurableValidatorRegistry, ValidatorSelection,
};
let selection = ValidatorSelection {
core_spec: options.core_spec,
app_specs: options.app_specs.clone(),
..Default::default()
};
let registry = ConfigurableValidatorRegistry::new(selection);
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 crate::cpl::SequenceAccess;
use std::collections::HashSet;
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 crate::cpl::SequenceAccess;
use std::collections::HashSet;
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 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 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 use crate::diagnostics::{RuleSeverity, RulesConfig};
#[derive(Debug, Default, Clone)]
pub struct ValidationOptions {
pub rules: RulesConfig,
pub core_spec: Option<crate::validation::CoreSpecTarget>,
pub app_specs: Option<Vec<crate::validation::AppSpecTarget>>,
#[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_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::{
validate_cpl_with_registry, ConfigurableValidatorRegistry, ValidatorSelection,
};
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");
if let Ok(package) = Imferno::parse(read_dir(test_path).unwrap()) {
assert!(!package.composition_playlists.is_empty());
}
}
#[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_eq!(package.composition_playlists.len(), 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");
if let Ok(package) = Imferno::parse(read_dir(test_path).unwrap()) {
assert!(package.composition_playlists.is_empty());
assert!(package.get_main_cpl().is_none());
assert!(package.analyze_tracks().is_empty());
}
}
#[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");
if let Ok(package) = Imferno::parse(read_dir(test_path).unwrap_or_default()) {
assert!(!package.asset_map.asset_list.assets.is_empty());
}
}
#[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");
assert!(!package.composition_playlists.is_empty());
let first_cpl = package.composition_playlists.values().next().unwrap();
let details = package.get_cpl_details(&first_cpl.id.to_string()).unwrap();
assert_eq!(details.title, first_cpl.content_title.text);
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_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 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 || {
assert!(!pkg.asset_map.asset_list.assets.is_empty());
assert!(!pkg.composition_playlists.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 std::fs;
use tempfile::TempDir;
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 std::fs;
use tempfile::TempDir;
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!(package.composition_playlists.is_empty());
assert!(package.get_main_cpl().is_none());
assert!(package.analyze_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");
let cpl_count = package.composition_playlists.len();
for _ in 0..10 {
assert!(!package.asset_map.asset_list.assets.is_empty());
assert_eq!(package.analyze_tracks().len(), 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 = 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>"#
.to_string();
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:?}",
);
}
}