Skip to main content

imferno_core/package/
mod.rs

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