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;
#[allow(deprecated)]
pub use self::report::{
build_report, format_report, format_validation_result, FormatOptions, ImfReport, ReportFormat,
};
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(Debug, serde::Serialize)]
pub struct ValidationResult {
pub package: Imferno,
pub validation: ValidationReport,
}
pub fn validate(
files: std::collections::HashMap<String, String>,
options: &ValidationOptions,
) -> ValidationResult {
match Imferno::parse(files) {
Ok(package) => {
let validation = package.validate(options);
ValidationResult {
package,
validation,
}
}
Err(e) => {
let mut validation = ValidationReport::new(ValidationProfile::SMPTE);
validation.add(ValidationIssue::new(
Severity::Critical,
Category::Structure,
codes::ImfernoCode::ParseError,
format!("Failed to parse IMF package: {e}"),
));
let validation = validation.apply_rules(&options.rules);
ValidationResult {
package: Imferno::empty(),
validation,
}
}
}
}
#[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 },
UnknownPklNamespace { pkl_id: String, namespace: String },
AssetMapHasNoPackingList,
PklIdNotInAssetMap { 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,
Self::UnknownPklNamespace { pkl_id, .. } => pkl_id,
Self::PklIdNotInAssetMap { pkl_id } => pkl_id,
Self::AssetMapHasNoPackingList => "—",
}
}
}
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)
}
Self::UnknownPklNamespace { pkl_id, namespace } => {
write!(
f,
"PKL {} carries an unrecognised namespace URI: {}",
pkl_id, namespace
)
}
Self::AssetMapHasNoPackingList => {
write!(
f,
"AssetMap declares no PKL (no asset has <PackingList>true</PackingList>)"
)
}
Self::PklIdNotInAssetMap { pkl_id } => {
write!(
f,
"PKL document {} is not declared as a PackingList asset in the AssetMap",
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()),
FileValidationError::UnknownPklNamespace { pkl_id, namespace } => ValidationIssue::new(
Severity::Error,
Category::Structure,
codes::St2067_2_2020::PklUnknownNamespace,
format!(
"PKL {} carries unrecognised namespace '{}' — not in the published \
SMPTE PKL namespace set",
pkl_id, namespace
),
)
.with_context("pkl_id", pkl_id.clone())
.with_context("namespace", namespace.clone()),
FileValidationError::AssetMapHasNoPackingList => ValidationIssue::new(
Severity::Critical,
Category::Structure,
codes::St2067_2_2020::AssetMapHasNoPackingList,
"AssetMap declares no PKL (no asset has <PackingList>true</PackingList>)"
.to_string(),
),
FileValidationError::PklIdNotInAssetMap { pkl_id } => ValidationIssue::new(
Severity::Error,
Category::Reference,
codes::St2067_2_2020::PklIdNotInAssetMap,
format!(
"PKL document {} is not declared as a PackingList asset in the AssetMap",
pkl_id
),
)
.with_context("pkl_id", pkl_id.clone()),
}
}
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Imferno {
#[serde(serialize_with = "serialize_path")]
pub root_path: PathBuf,
pub volume_index: VolumeIndex,
#[serde(skip)]
pub volindex_issues: Vec<ValidationIssue>,
#[serde(skip)]
pub(crate) parse_issues: Vec<ValidationIssue>,
pub asset_map: AssetMap,
pub packing_lists: HashMap<ImfUuid, PackingList>,
pub composition_playlists: HashMap<ImfUuid, CompositionPlaylist>,
#[serde(skip)]
#[allow(dead_code)]
pub(crate) cpl_xml_content: HashMap<ImfUuid, String>,
pub output_profile_lists: HashMap<ImfUuid, crate::assetmap::OutputProfileList>,
pub sidecar_composition_maps: HashMap<ImfUuid, crate::scm::SidecarCompositionMap>,
#[serde(serialize_with = "serialize_path_map")]
pub asset_paths: HashMap<ImfUuid, PathBuf>,
}
fn serialize_path<S: serde::Serializer>(path: &Path, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&path.to_string_lossy())
}
fn serialize_path_map<S: serde::Serializer>(
map: &HashMap<ImfUuid, PathBuf>,
s: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(map.len()))?;
for (k, v) in map {
m.serialize_entry(k, &v.to_string_lossy().into_owned())?;
}
m.end()
}
fn sanitize_asset_path(root: &Path, chunk_path: &str) -> Option<PathBuf> {
let rel = Path::new(chunk_path);
if rel.is_absolute() {
return None;
}
for component in rel.components() {
if component == std::path::Component::ParentDir {
return None;
}
}
let joined = root.join(rel);
if let Ok(canonical) = joined.canonicalize() {
if canonical.starts_with(root) {
return Some(canonical);
}
return None; }
Some(joined)
}
pub fn read_dir(path: impl AsRef<Path>) -> Result<HashMap<String, String>> {
use crate::storage::{fs::FsStorage, StorageUri};
let path = path
.as_ref()
.canonicalize()
.unwrap_or_else(|_| path.as_ref().to_path_buf());
let uri = StorageUri::parse(&path.to_string_lossy())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
let storage = FsStorage::new();
read_xml_files(&uri, &storage).map_err(|e| std::io::Error::other(e.to_string()).into())
}
pub fn read_xml_files(
uri: &crate::storage::StorageUri,
storage: &dyn crate::storage::Storage,
) -> std::result::Result<HashMap<String, String>, crate::storage::StorageError> {
let mut files = HashMap::new();
for entry in storage.list(uri)? {
if !entry.is_file {
continue;
}
if !entry.uri.to_ascii_lowercase().ends_with(".xml") {
continue;
}
let entry_uri = crate::storage::StorageUri::parse(&entry.uri)?;
match storage.read_to_string(&entry_uri) {
Ok(content) => {
files.insert(entry.uri, content);
}
Err(e) => {
eprintln!("Warning: failed to read XML file {}: {}", entry.uri, e);
}
}
}
Ok(files)
}
pub use self::read_xml_files as read;
#[cfg(feature = "aws-s3")]
pub async fn read_s3(
client: &aws_sdk_s3::Client,
bucket: &str,
prefix: &str,
) -> Result<HashMap<String, String>> {
use crate::storage::{s3::S3Storage, StorageUri};
let storage =
S3Storage::from_client(client.clone()).map_err(|e| std::io::Error::other(e.to_string()))?;
let uri_str = format!("s3://{bucket}/{prefix}");
let uri = StorageUri::parse(&uri_str)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
tokio::task::spawn_blocking(move || {
crate::package::read_xml_files(&uri, &storage)
.map_err(|e| std::io::Error::other(e.to_string()))
})
.await
.map_err(|e| std::io::Error::other(format!("join error: {e}")))?
.map_err(Into::into)
}
impl Imferno {
fn empty() -> Self {
Self {
root_path: PathBuf::new(),
volume_index: VolumeIndex { index: 1 },
volindex_issues: Vec::new(),
parse_issues: Vec::new(),
asset_map: crate::assetmap::AssetMap {
namespace: Default::default(),
id: ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000000")
.expect("nil UUID is always valid"),
annotation_text: None,
creator: None,
volume_count: 1,
issue_date: "1970-01-01T00:00:00+00:00".into(),
issuer: None,
asset_list: crate::assetmap::AssetList { assets: Vec::new() },
},
packing_lists: HashMap::new(),
composition_playlists: HashMap::new(),
cpl_xml_content: HashMap::new(),
output_profile_lists: HashMap::new(),
sidecar_composition_maps: HashMap::new(),
asset_paths: HashMap::new(),
}
}
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,
codes::ImfernoCode::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,
);
let report = self.enrich_cpl_locations(report);
let report = report.apply_rules(&options.rules);
if options.aggregate_repeats {
report.aggregate()
} else {
report
}
}
#[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)
});
let report = self.enrich_cpl_locations(report);
let report = report.apply_rules(&options.rules);
if options.aggregate_repeats {
report.aggregate()
} else {
report
}
}
fn enrich_cpl_locations(&self, mut report: ValidationReport) -> ValidationReport {
let mut cpl_info: std::collections::HashMap<ImfUuid, (Option<String>, String)> =
std::collections::HashMap::new();
for (uuid, cpl) in &self.composition_playlists {
let filename = self
.asset_paths
.get(uuid)
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|s| s.to_string());
cpl_info.insert(*uuid, (filename, cpl.content_title.to_string()));
}
let enrich = |issue: &mut ValidationIssue| {
if let Some(ref cpl_id) = issue.location.cpl_id {
if let Some((filename, title)) = cpl_info.get(cpl_id) {
if issue.location.cpl_filename.is_none() {
issue.location.cpl_filename = filename.clone();
}
if issue.location.cpl_title.is_none() {
issue.location.cpl_title = Some(title.clone());
}
}
}
};
for issue in &mut report.critical {
enrich(issue);
}
for issue in &mut report.errors {
enrich(issue);
}
for issue in &mut report.warnings {
enrich(issue);
}
for issue in &mut report.info {
enrich(issue);
}
report
}
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(e) => {
volindex_issues.push(ValidationIssue::new(
Severity::Error,
Category::Structure,
codes::St429_9_2014::MalformedXml,
format!("VOLINDEX.xml is not well-formed XML: {e}"),
));
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();
let mut parse_issues: Vec<ValidationIssue> = Vec::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() {
Some(PathBuf::from(&chunk.path))
} else {
sanitize_asset_path(&root_path, &chunk.path)
};
match path {
Some(p) => {
asset_paths.insert(asset.id, p);
}
None => {
parse_issues.push(ValidationIssue::new(
Severity::Error,
Category::Structure,
codes::ImfernoCode::PathTraversal,
format!(
"Asset '{}' chunk path '{}' escapes the package root directory",
asset.id, chunk.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) => {
parse_issues.push(ValidationIssue::new(
Severity::Error,
Category::Structure,
codes::ImfernoCode::PklParseError,
format!("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(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);
} else {
parse_issues.push(ValidationIssue::new(
Severity::Warning,
Category::Structure,
codes::ImfernoCode::XmlAssetParseError,
format!(
"XML asset '{}' ({}) could not be parsed as CPL, OPL, or SCM: {}",
basename, asset.id, cpl_err,
),
));
}
}
}
}
}
}
Ok(Imferno {
root_path,
volume_index,
volindex_issues,
parse_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;
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| {
seg.sequence_list
.all_sequences()
.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 mut known: 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();
known.insert("ASSETMAP.xml".into());
known.insert("VOLINDEX.xml".into());
known.insert("assetmap.xml".into());
known.insert("volindex.xml".into());
known.insert("ASSETMAP".into());
known.insert("VOLINDEX".into());
let entries = match std::fs::read_dir(&self.root_path) {
Ok(e) => e,
Err(e) => {
report.add(ValidationIssue::new(
Severity::Info,
Category::Structure,
codes::ImfernoCode::ReadDirError,
format!("Could not scan package directory for unlisted files: {}", e,),
));
return;
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
report.add(ValidationIssue::new(
Severity::Info,
Category::Structure,
codes::ImfernoCode::DirEntryError,
format!("Could not read directory entry: {}", e),
));
continue;
}
};
let path = entry.path();
if path.is_dir() {
continue;
}
let filename = match path.file_name() {
Some(n) => n.to_string_lossy().into_owned(),
None => continue,
};
if known.iter().any(|k| k.eq_ignore_ascii_case(&filename)) {
continue;
}
report.add(ValidationIssue::new(
Severity::Warning,
Category::Structure,
codes::ImfernoCode::UnlistedEssence.code(),
format!(
"File '{}' is present in the package directory but not listed in the AssetMap",
filename,
),
));
}
}
#[allow(dead_code)]
pub(crate) 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(abs_path) => match std::fs::metadata(abs_path) {
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
errors.push(FileValidationError::Missing {
uuid: uuid_str,
path: abs_path.clone(),
});
} else {
errors.push(FileValidationError::Io {
uuid: uuid_str,
path: abs_path.clone(),
message: format!("Cannot access file: {}", e),
});
}
}
Ok(meta) => {
let actual = meta.len();
if actual != asset.size {
errors.push(FileValidationError::SizeMismatch {
uuid: uuid_str,
path: abs_path.clone(),
expected: asset.size,
actual,
});
}
}
},
}
}
}
errors
}
pub fn validate_file_hashes(&self) -> Vec<FileValidationError> {
self.validate_file_hashes_with_progress(|_, _, _, _, _| {})
}
pub fn validate_file_hashes_with_progress(
&self,
mut on_progress: impl FnMut(usize, usize, &str, u64, u64),
) -> Vec<FileValidationError> {
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();
let total: usize = self
.packing_lists
.values()
.map(|pkl| pkl.asset_list.assets.len())
.sum();
let mut current: usize = 0;
for pkl in self.packing_lists.values() {
for asset in &pkl.asset_list.assets {
current += 1;
let uuid_str = asset.id.to_string();
if errored_uuids.contains(&uuid_str) {
continue;
}
let Some(abs_path) = path_map.get(&asset.id) else {
continue;
};
let filename = abs_path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let file_size = std::fs::metadata(abs_path).map(|m| m.len()).unwrap_or(0);
on_progress(current, total, filename, 0, file_size);
match std::fs::File::open(abs_path) {
Err(e) => {
errors.push(FileValidationError::Io {
uuid: uuid_str,
path: abs_path.clone(),
message: e.to_string(),
});
}
Ok(file) => {
use std::io::Read;
let mut reader = std::io::BufReader::with_capacity(1024 * 1024, file);
let mut bytes_done: u64 = 0;
let mut had_error = false;
let actual_b64 = match asset.hash.algorithm() {
crate::assetmap::HashAlgorithm::Sha1 => {
use sha1::Digest;
let mut hasher = sha1::Sha1::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
hasher.update(&buf[..n]);
bytes_done += n as u64;
on_progress(
current, total, filename, bytes_done, file_size,
);
}
Err(e) => {
errors.push(FileValidationError::Io {
uuid: uuid_str.clone(),
path: abs_path.clone(),
message: e.to_string(),
});
had_error = true;
break;
}
}
}
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
hasher.finalize(),
)
}
crate::assetmap::HashAlgorithm::Sha256 => {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
hasher.update(&buf[..n]);
bytes_done += n as u64;
on_progress(
current, total, filename, bytes_done, file_size,
);
}
Err(e) => {
errors.push(FileValidationError::Io {
uuid: uuid_str.clone(),
path: abs_path.clone(),
message: e.to_string(),
});
had_error = true;
break;
}
}
}
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
hasher.finalize(),
)
}
};
if !had_error {
let expected_b64 = asset.hash.to_base64();
if actual_b64 != expected_b64 {
errors.push(FileValidationError::HashMismatch {
uuid: uuid_str,
path: abs_path.clone(),
expected: expected_b64,
actual: actual_b64,
});
}
}
}
}
}
}
errors
}
#[cfg(feature = "tokio")]
pub fn hash_verification_size(&self) -> u64 {
let path_map = self.build_asset_path_map();
self.packing_lists
.values()
.flat_map(|pkl| pkl.asset_list.assets.iter())
.filter_map(|asset| {
path_map
.get(&asset.id)
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
})
.sum()
}
#[cfg(feature = "tokio")]
pub async fn validate_file_hashes_parallel(
&self,
concurrency: usize,
progress: std::sync::Arc<HashProgressTracker>,
) -> Vec<FileValidationError> {
use std::sync::Arc;
let path_map = self.build_asset_path_map();
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
let mut handles = Vec::new();
let manifest_errors = self.validate_file_manifest();
let errored_uuids: std::collections::HashSet<String> = manifest_errors
.iter()
.map(|e| e.uuid().to_string())
.collect();
let mut assets_to_hash: Vec<_> = self
.packing_lists
.values()
.flat_map(|pkl| pkl.asset_list.assets.iter())
.filter(|asset| !errored_uuids.contains(&asset.id.to_string()))
.filter(|asset| path_map.contains_key(&asset.id))
.collect();
assets_to_hash.sort_by_key(|a| a.size);
for asset in assets_to_hash {
let abs_path = path_map.get(&asset.id).unwrap();
let filename = abs_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
let file_size = asset.size;
let (bytes_counter, status_flag) = progress.register(filename, file_size);
let uuid_str = asset.id.to_string();
let abs_path = abs_path.clone();
let expected_b64 = asset.hash.to_base64();
let algorithm = asset.hash.algorithm();
let sem = semaphore.clone();
let err_uuid = uuid_str.clone();
let err_path = abs_path.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
status_flag.store(1, std::sync::atomic::Ordering::Relaxed); let result = match tokio::task::spawn_blocking(move || {
hash_single_file(
&uuid_str,
&abs_path,
&expected_b64,
algorithm,
&bytes_counter,
)
})
.await
{
Ok(r) => r,
Err(e) => Some(FileValidationError::Io {
uuid: err_uuid,
path: err_path,
message: format!("hash task failed: {}", e),
}),
};
status_flag.store(
if result.is_some() { 3 } else { 2 }, std::sync::atomic::Ordering::Relaxed,
);
result
}));
}
let mut errors = manifest_errors;
for handle in handles {
if let Ok(Some(err)) = handle.await {
errors.push(err);
}
}
errors
}
pub fn validate_pkl_constraints(&self) -> Vec<FileValidationError> {
use crate::assetmap::PklNamespace;
let mut errors = Vec::new();
let assetmap_ids: std::collections::HashSet<ImfUuid> = self
.asset_map
.asset_list
.assets
.iter()
.map(|a| a.id)
.collect();
let assetmap_pkl_ids: std::collections::HashSet<ImfUuid> = self
.asset_map
.asset_list
.assets
.iter()
.filter(|a| a.packing_list.unwrap_or(false))
.map(|a| a.id)
.collect();
if assetmap_pkl_ids.is_empty() {
errors.push(FileValidationError::AssetMapHasNoPackingList);
}
for pkl in self.packing_lists.values() {
if let PklNamespace::Unknown(uri) = &pkl.namespace {
errors.push(FileValidationError::UnknownPklNamespace {
pkl_id: pkl.id.to_string(),
namespace: uri.clone(),
});
}
if !assetmap_pkl_ids.contains(&pkl.id) {
errors.push(FileValidationError::PklIdNotInAssetMap {
pkl_id: pkl.id.to_string(),
});
}
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, PathBuf> {
let mut map = HashMap::new();
let has_root = !self.root_path.as_os_str().is_empty();
for asset in &self.asset_map.asset_list.assets {
if let Some(chunk) = asset.chunk_list.chunks.first() {
if has_root {
if let Some(safe_path) = sanitize_asset_path(&self.root_path, &chunk.path) {
map.insert(asset.id, safe_path);
}
} else {
map.insert(asset.id, PathBuf::from(&chunk.path));
}
}
}
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.parse_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.parse_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;
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| {
seg.sequence_list
.all_sequences()
.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; }
#[cfg(not(target_arch = "wasm32"))]
for issue in crate::mxf::essence::validate_mxf_essence(path) {
let issue = issue.with_context("asset_uuid", asset.id.to_string());
report.add(issue);
}
#[cfg(not(target_arch = "wasm32"))]
{
let opts = regxml::MxfFragmentOptions {
partition: regxml::PartitionTarget::Header,
..Default::default()
};
match crate::mxf::metadata::parse_mxf_to_regxml(path, opts) {
Ok(regxml) => {
for issue in crate::mxf::audio_mca::check_audio_mca(®xml, path) {
let issue = issue.with_context("asset_uuid", asset.id.to_string());
report.add(issue);
}
for issue in crate::mxf::timed_text::check_timed_text(®xml, path) {
let issue = issue.with_context("asset_uuid", asset.id.to_string());
report.add(issue);
}
}
Err(e) => {
report.add(
crate::mxf::metadata::regxml_error_issue(path, &e)
.with_context("asset_uuid", asset.id.to_string()),
);
}
}
}
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) {
for cpl in self.composition_playlists.values() {
let cpl_id = cpl.id;
let cpl_er = cpl.edit_rate.as_ref();
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
let mut durations: Vec<(String, f64)> = Vec::new();
for seq in segment.sequence_list.all_sequences() {
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_er)
.cloned()
.unwrap_or(EditRate::new(1, 1));
total_num =
total_num.saturating_add(dur.saturating_mul(er.denominator as u64));
rate_den = er.numerator as u64;
}
if rate_den > 0 {
durations.push((
seq.track_id().to_string(),
total_num as f64 / rate_den as f64,
));
}
}
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_2016::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).with_segment(seg_idx)),
);
break; }
}
}
}
}
fn validate_cpl_asset_references_accumulating(
&self,
cpl: &crate::cpl::CompositionPlaylist,
report: &mut ValidationReport,
) {
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)),
);
return;
}
let assetmap_ids: std::collections::HashSet<ImfUuid> = self
.asset_map
.asset_list
.assets
.iter()
.map(|a| a.id)
.collect();
let cpl_id = cpl.id;
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
for (seq, track_type) in segment.sequence_list.all_sequences_typed() {
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) {
report.add(
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)
.with_segment(seg_idx)
.with_resource(res_idx),
)
.with_context("track_file_id", track_file_id.to_string()),
);
}
}
}
}
}
}
}
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>,
}
pub fn cpl_details_from(cpl: &crate::cpl::CompositionPlaylist) -> CplDetails {
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();
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,
}
}
impl Imferno {
pub fn get_cpl_details(&self, uuid: &str) -> Option<CplDetails> {
self.get_cpl_str(uuid).map(cpl_details_from)
}
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>>,
pub aggregate_repeats: bool,
#[cfg(not(target_arch = "wasm32"))]
pub verify_hashes: Option<PathBuf>,
#[cfg(not(target_arch = "wasm32"))]
pub skip_disk_checks: bool,
}
#[cfg(feature = "tokio")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashFileStatus {
Waiting,
Hashing,
Done,
Failed,
}
#[cfg(feature = "tokio")]
pub struct HashFileInfo {
pub name: String,
pub size: u64,
pub bytes_done: std::sync::Arc<std::sync::atomic::AtomicU64>,
pub status: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
#[cfg(feature = "tokio")]
pub struct HashProgressTracker {
pub files: std::sync::Mutex<Vec<HashFileInfo>>,
}
#[cfg(feature = "tokio")]
impl HashProgressTracker {
pub fn new() -> Self {
Self {
files: std::sync::Mutex::new(Vec::new()),
}
}
pub fn register(
&self,
name: String,
size: u64,
) -> (
std::sync::Arc<std::sync::atomic::AtomicU64>,
std::sync::Arc<std::sync::atomic::AtomicU8>,
) {
let bytes_done = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let status = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0));
let bd = bytes_done.clone();
let st = status.clone();
self.files.lock().unwrap().push(HashFileInfo {
name,
size,
bytes_done,
status,
});
(bd, st)
}
pub fn snapshot(&self) -> Vec<(String, u64, u64, HashFileStatus)> {
use std::sync::atomic::Ordering::Relaxed;
let files = self.files.lock().unwrap();
files
.iter()
.map(|f| {
let status = match f.status.load(Relaxed) {
1 => HashFileStatus::Hashing,
2 => HashFileStatus::Done,
3 => HashFileStatus::Failed,
_ => HashFileStatus::Waiting,
};
(f.name.clone(), f.bytes_done.load(Relaxed), f.size, status)
})
.collect()
}
pub fn total_bytes_done(&self) -> u64 {
use std::sync::atomic::Ordering::Relaxed;
let files = self.files.lock().unwrap();
files.iter().map(|f| f.bytes_done.load(Relaxed)).sum()
}
pub fn total_bytes(&self) -> u64 {
let files = self.files.lock().unwrap();
files.iter().map(|f| f.size).sum()
}
}
#[cfg(feature = "tokio")]
impl Default for HashProgressTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "tokio"))]
fn hash_single_file(
uuid: &str,
path: &std::path::Path,
expected_b64: &str,
algorithm: crate::assetmap::HashAlgorithm,
bytes_done: &std::sync::atomic::AtomicU64,
) -> Option<FileValidationError> {
use std::io::Read;
use std::sync::atomic::Ordering;
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => {
return Some(FileValidationError::Io {
uuid: uuid.to_string(),
path: path.to_path_buf(),
message: e.to_string(),
});
}
};
let mut reader = std::io::BufReader::with_capacity(1024 * 1024, file);
let mut buf = vec![0u8; 1024 * 1024];
let actual_b64 = match algorithm {
crate::assetmap::HashAlgorithm::Sha1 => {
use sha1::Digest;
let mut hasher = sha1::Sha1::new();
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
hasher.update(&buf[..n]);
bytes_done.fetch_add(n as u64, Ordering::Relaxed);
}
Err(e) => {
return Some(FileValidationError::Io {
uuid: uuid.to_string(),
path: path.to_path_buf(),
message: e.to_string(),
});
}
}
}
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
hasher.finalize(),
)
}
crate::assetmap::HashAlgorithm::Sha256 => {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
hasher.update(&buf[..n]);
bytes_done.fetch_add(n as u64, Ordering::Relaxed);
}
Err(e) => {
return Some(FileValidationError::Io {
uuid: uuid.to_string(),
path: path.to_path_buf(),
message: e.to_string(),
});
}
}
}
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
hasher.finalize(),
)
}
};
if actual_b64 != expected_b64 {
Some(FileValidationError::HashMismatch {
uuid: uuid.to_string(),
path: path.to_path_buf(),
expected: expected_b64.to_string(),
actual: actual_b64,
})
} else {
None
}
}
#[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)
}
fn meridian_test_options() -> ValidationOptions {
let mut rules = crate::diagnostics::rules::RulesConfig::default();
rules.set_raw(
"SoundfieldGroupLinkIDMismatch".to_string(),
crate::diagnostics::rules::RuleSeverity::Off,
);
ValidationOptions {
rules,
..ValidationOptions::default()
}
}
#[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");
let report = package.validate(&meridian_test_options());
assert!(
!report.has_errors(),
"MERIDIAN should validate cleanly under meridian_test_options: {:?}",
report.summary()
);
}
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(&meridian_test_options());
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(&meridian_test_options());
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(&meridian_test_options());
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(&meridian_test_options());
assert!(
!report.has_errors(),
"MERIDIAN should be valid: {:?}",
report.summary()
);
}
#[test]
fn test_pkl_constraints_flags_unknown_namespace() {
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="urn:not-a-real-pkl-namespace">
<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>
</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::UnknownPklNamespace { .. })),
"Expected UnknownPklNamespace, got: {:?}",
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn test_pkl_constraints_flags_assetmap_with_no_packinglist() {
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>
</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>
<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::AssetMapHasNoPackingList)),
"Expected AssetMapHasNoPackingList, got: {:?}",
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn test_pkl_constraints_flags_pkl_id_mismatch() {
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:99999999-0000-0000-0000-000000000099</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>
</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::PklIdNotInAssetMap { pkl_id } if pkl_id.contains("99999999"))),
"Expected PklIdNotInAssetMap for 99999999, got: {:?}",
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>()
);
}
#[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(&meridian_test_options());
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:aaaaaaaa-0000-0000-0000-000000000001</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path></Chunk></ChunkList>
</Asset>
<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:?}",
);
}
#[test]
fn sanitize_simple_relative_path() {
let root = std::env::temp_dir();
assert!(sanitize_asset_path(&root, "video.mxf").is_some());
}
#[test]
fn sanitize_nested_relative_path() {
let root = std::env::temp_dir();
assert!(sanitize_asset_path(&root, "subdir/video.mxf").is_some());
}
#[test]
fn sanitize_rejects_parent_dir_traversal() {
let root = std::env::temp_dir();
assert!(sanitize_asset_path(&root, "../escape.mxf").is_none());
}
#[test]
fn sanitize_rejects_deep_traversal() {
let root = std::env::temp_dir();
assert!(sanitize_asset_path(&root, "sub/../../escape.mxf").is_none());
}
#[test]
fn sanitize_rejects_absolute_path() {
let root = std::env::temp_dir();
assert!(sanitize_asset_path(&root, "/etc/passwd").is_none());
}
#[test]
fn sanitize_rejects_double_dot_prefix() {
let root = std::env::temp_dir();
assert!(sanitize_asset_path(&root, "../../etc/shadow").is_none());
}
fn minimal_assetmap(assets_xml: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
<VolumeCount>1</VolumeCount>
<IssueDate>2024-01-01T00:00:00+00:00</IssueDate>
<Issuer>test</Issuer>
<AssetList>{}</AssetList>
</AssetMap>"#,
assets_xml,
)
}
#[test]
fn malformed_pkl_produces_parse_issue() {
let mut files = HashMap::new();
files.insert(
"ASSETMAP.xml".to_string(),
minimal_assetmap(
r#"<Asset>
<Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
<PackingList>true</PackingList>
<ChunkList><Chunk><Path>PKL.xml</Path><VolumeIndex>1</VolumeIndex></Chunk></ChunkList>
</Asset>"#,
),
);
files.insert("PKL.xml".to_string(), "<not-a-pkl/>".to_string());
let package = Imferno::parse(files).expect("parse should succeed even with bad PKL");
assert!(
package
.parse_issues
.iter()
.any(|i| i.code == codes::ImfernoCode::PklParseError.code()),
"expected PklParseError issue, got: {:?}",
package.parse_issues,
);
}
#[test]
fn unparseable_xml_asset_produces_parse_issue() {
let mut files = HashMap::new();
files.insert(
"ASSETMAP.xml".to_string(),
minimal_assetmap(
r#"<Asset>
<Id>urn:uuid:00000000-0000-0000-0000-000000000003</Id>
<ChunkList><Chunk><Path>MYSTERY.xml</Path><VolumeIndex>1</VolumeIndex></Chunk></ChunkList>
</Asset>"#,
),
);
files.insert("MYSTERY.xml".to_string(), "<SomethingElse/>".to_string());
let package = Imferno::parse(files).expect("parse should succeed");
assert!(
package
.parse_issues
.iter()
.any(|i| i.code == codes::ImfernoCode::XmlAssetParseError.code()),
"expected XmlAssetParseError issue, got: {:?}",
package.parse_issues,
);
}
#[test]
fn path_traversal_produces_parse_issue() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let files = read_dir(test_path).unwrap();
let package = Imferno::parse(files).expect("parse should succeed");
assert!(
!package
.parse_issues
.iter()
.any(|i| i.code == codes::ImfernoCode::PathTraversal.code()),
"valid package should have no path traversal issues",
);
}
#[allow(deprecated)]
#[test]
fn sequence_language_extracted_from_descriptors() {
let test_path = test_data("MERIDIAN_Netflix_Photon_161006");
let files = read_dir(test_path).unwrap();
let package = Imferno::parse(files).unwrap();
let report =
crate::package::report::build_report(&package, &ValidationOptions::default(), None)
.unwrap();
for cpl in &report.cpls {
let audio_seqs: Vec<_> = cpl
.sequences
.iter()
.filter(|s| s.r#type == "MainAudio")
.collect();
assert!(
!audio_seqs.is_empty(),
"should have at least one audio sequence"
);
for seq in &audio_seqs {
eprintln!("Audio seq {} language: {:?}", seq.track_id, seq.language);
assert_eq!(
seq.language.as_deref(),
Some("en"),
"MERIDIAN audio should have language 'en', got {:?}",
seq.language,
);
}
}
}
}