use std::path::Path;
use crate::diagnostics::codes::ValidationCode;
use crate::diagnostics::{Category, Location, Severity, ValidationIssue};
pub mod codes;
use codes::XsdConstraintCode;
const IMF_CPL_2013_XSD: &str = include_str!("../../specs/imf-cpl.xsd");
const IMF_CPL_2016_XSD: &str = include_str!("../../specs/st2067-3a-2016.xsd");
const IMF_OPL_2014_XSD: &str = include_str!("../../specs/st2067-100a-2014.xsd");
const IMF_SCM_2018_XSD: &str = include_str!("../../specs/st2067-9a-2018.xsd");
const DCI_PKL_2007_XSD: &str = include_str!("../../specs/SMPTE-429-8-PKL-2007.xsd");
const IMF_PKL_2016_XSD: &str = include_str!("../../specs/st2067-2b-2016.xsd");
pub fn validate_parsed_cpl(cpl: &crate::cpl::CompositionPlaylist) -> Vec<ValidationIssue> {
let Some(source_xml) = &cpl.source_xml else {
return Vec::new();
};
let primary_xsd = match &cpl.namespace {
crate::cpl::CplNamespace::Smpte2067_3_2013 => IMF_CPL_2013_XSD,
crate::cpl::CplNamespace::Smpte2067_3_2016 => IMF_CPL_2016_XSD,
_ => return Vec::new(),
};
validate_against_schema(source_xml, primary_xsd, Some(cpl.id))
}
pub fn validate_opl_xml(source_xml: &str) -> Vec<ValidationIssue> {
validate_against_schema(source_xml, IMF_OPL_2014_XSD, None)
}
pub fn validate_scm_xml(source_xml: &str) -> Vec<ValidationIssue> {
validate_against_schema(source_xml, IMF_SCM_2018_XSD, None)
}
pub fn validate_pkl_xml(
source_xml: &str,
namespace: &crate::assetmap::PklNamespace,
) -> Vec<ValidationIssue> {
use crate::assetmap::PklNamespace;
match namespace {
PklNamespace::Dci429_8 => validate_against_schema(source_xml, DCI_PKL_2007_XSD, None),
PklNamespace::Smpte2067_2_2016Pkl | PklNamespace::Smpte2067_2_2020 => {
validate_against_schema(source_xml, IMF_PKL_2016_XSD, None)
}
_ => Vec::new(),
}
}
pub fn validate_against_composite_schema_str(
instance_xml: &str,
primary_xsd: &str,
specs_dir: &Path,
cpl_id: Option<crate::assetmap::ImfUuid>,
) -> Vec<ValidationIssue> {
let injected = inject_dcml_schema_location(primary_xsd);
let schema_doc = match uppsala::parse(&injected) {
Ok(d) => d,
Err(e) => return vec![parse_failure_issue("xsd-schema", e, cpl_id)],
};
let virtual_base = specs_dir.join("__primary.xsd");
let validator =
match uppsala::XsdValidator::from_schema_with_base_path(&schema_doc, Some(&virtual_base)) {
Ok(v) => v,
Err(e) => return vec![schema_build_failure_issue(e, cpl_id)],
};
let instance_doc = match uppsala::parse(instance_xml) {
Ok(d) => d,
Err(e) => return vec![parse_failure_issue("xml-instance", e, cpl_id)],
};
validator
.validate(&instance_doc)
.into_iter()
.map(|err| translate(err, cpl_id))
.collect()
}
pub fn validate_against_schema(
instance_xml: &str,
schema_xml: &str,
cpl_id: Option<crate::assetmap::ImfUuid>,
) -> Vec<ValidationIssue> {
let schema_doc = match uppsala::parse(schema_xml) {
Ok(d) => d,
Err(e) => {
return vec![parse_failure_issue("xsd-schema", e, cpl_id)];
}
};
let validator = match uppsala::XsdValidator::from_schema(&schema_doc) {
Ok(v) => v,
Err(e) => {
return vec![schema_build_failure_issue(e, cpl_id)];
}
};
let instance_doc = match uppsala::parse(instance_xml) {
Ok(d) => d,
Err(e) => {
return vec![parse_failure_issue("xml-instance", e, cpl_id)];
}
};
validator
.validate(&instance_doc)
.into_iter()
.map(|err| translate(err, cpl_id))
.collect()
}
pub fn validate_against_composite_schema(
instance_xml: &str,
primary_xsd_path: &Path,
specs_dir: &Path,
cpl_id: Option<crate::assetmap::ImfUuid>,
) -> Vec<ValidationIssue> {
let primary_xsd = match std::fs::read_to_string(primary_xsd_path) {
Ok(s) => s,
Err(e) => {
return vec![parse_failure_issue("primary-xsd", e, cpl_id)];
}
};
let injected = inject_dcml_schema_location(&primary_xsd);
let schema_doc = match uppsala::parse(&injected) {
Ok(d) => d,
Err(e) => return vec![parse_failure_issue("xsd-schema", e, cpl_id)],
};
let virtual_base = specs_dir.join("__primary.xsd");
let validator =
match uppsala::XsdValidator::from_schema_with_base_path(&schema_doc, Some(&virtual_base)) {
Ok(v) => v,
Err(e) => return vec![schema_build_failure_issue(e, cpl_id)],
};
let instance_doc = match uppsala::parse(instance_xml) {
Ok(d) => d,
Err(e) => return vec![parse_failure_issue("xml-instance", e, cpl_id)],
};
validator
.validate(&instance_doc)
.into_iter()
.map(|err| translate(err, cpl_id))
.collect()
}
pub fn validate_cpl_xml(
raw_xml: &str,
primary_xsd_path: &Path,
specs_dir: &Path,
) -> Vec<ValidationIssue> {
let mut issues = validate_against_composite_schema(raw_xml, primary_xsd_path, specs_dir, None);
match crate::cpl::parse_cpl(raw_xml) {
Ok(cpl) => {
issues.extend(crate::validation::validate_cpl(&cpl));
}
Err(e) => {
issues.push(ValidationIssue::new(
Severity::Critical,
Category::Structure,
"IMFERNO:Package/ParseError",
format!("CPL XML failed to parse: {e:?}"),
));
}
}
issues
}
fn inject_dcml_schema_location(xsd_src: &str) -> String {
const DCML_NS: &str = "http://www.smpte-ra.org/schemas/433/2008/dcmlTypes/";
const STUB_PATH: &str = "dcml-types-stub.xsd";
let needle = format!(r#"<xs:import namespace="{DCML_NS}""#);
let Some(start) = xsd_src.find(&needle) else {
return xsd_src.to_string();
};
let tail = &xsd_src[start + needle.len()..];
let Some(end_rel) = tail.find('>') else {
return xsd_src.to_string();
};
let tag_body = &tail[..end_rel]; if tag_body.contains("schemaLocation") {
return xsd_src.to_string(); }
let (attr_body, terminator) = if tag_body.trim_end().ends_with('/') {
let trimmed = tag_body.trim_end();
(&trimmed[..trimmed.len() - 1], "/>")
} else {
(tag_body, ">")
};
let before_tag_end = &xsd_src[..start + needle.len()];
let after_tag = &tail[end_rel + 1..];
format!(r#"{before_tag_end}{attr_body} schemaLocation="{STUB_PATH}"{terminator}{after_tag}"#)
}
pub fn translate(
err: uppsala::ValidationError,
cpl_id: Option<crate::assetmap::ImfUuid>,
) -> ValidationIssue {
let kind = classify(&err.message);
let mut loc = Location::new();
if let Some(id) = cpl_id {
loc = loc.with_cpl(id);
}
let code: String = element_path_code(kind, &err);
let message = match (err.line, err.column) {
(Some(line), Some(col)) => format!("{} (at line {line}, column {col})", err.message),
(Some(line), None) => format!("{} (at line {line})", err.message),
_ => err.message,
};
ValidationIssue::new(kind.default_severity(), kind.category(), code, message).with_location(loc)
}
#[cfg(feature = "uppsala-patched")]
fn element_path_code(kind: XsdConstraintCode, err: &uppsala::ValidationError) -> String {
match &err.element_path {
Some(path) if !path.is_empty() => format!("{}/{}", kind.code(), path),
_ => kind.code().to_string(),
}
}
#[cfg(not(feature = "uppsala-patched"))]
fn element_path_code(kind: XsdConstraintCode, _err: &uppsala::ValidationError) -> String {
kind.code().to_string()
}
fn classify(message: &str) -> XsdConstraintCode {
if message.contains("Expected at least") && message.contains("occurrence") {
XsdConstraintCode::ElementMissing
} else if message.contains("Unexpected element") {
XsdConstraintCode::UnexpectedElement
} else if message.contains("not match pattern") || message.contains("does not match") {
XsdConstraintCode::PatternInvalid
} else if message.contains("is not a valid") {
XsdConstraintCode::TypeInvalid
} else {
XsdConstraintCode::SchemaConstraintFailed
}
}
fn parse_failure_issue(
role: &'static str,
err: impl std::fmt::Debug,
cpl_id: Option<crate::assetmap::ImfUuid>,
) -> ValidationIssue {
let mut loc = Location::new();
if let Some(id) = cpl_id {
loc = loc.with_cpl(id);
}
ValidationIssue::new(
Severity::Critical,
Category::Schema,
XsdConstraintCode::SchemaConstraintFailed.code(),
format!("XSD validation aborted: failed to parse {role}: {err:?}"),
)
.with_location(loc)
}
fn schema_build_failure_issue(
err: impl std::fmt::Debug,
cpl_id: Option<crate::assetmap::ImfUuid>,
) -> ValidationIssue {
let mut loc = Location::new();
if let Some(id) = cpl_id {
loc = loc.with_cpl(id);
}
ValidationIssue::new(
Severity::Critical,
Category::Schema,
XsdConstraintCode::SchemaConstraintFailed.code(),
format!(
"XSD validation aborted: schema parsed but XsdValidator construction failed: {err:?}"
),
)
.with_location(loc)
}
#[cfg(test)]
mod tests {
use super::*;
const MINI_XSD: &str = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="thing">
<xs:complexType>
<xs:sequence>
<xs:element name="name" type="xs:string"/>
<xs:element name="count" type="xs:positiveInteger"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>"#;
#[test]
fn valid_doc_yields_no_issues() {
let xml = "<thing><name>x</name><count>5</count></thing>";
let issues = validate_against_schema(xml, MINI_XSD, None);
assert!(issues.is_empty(), "expected no issues, got: {issues:#?}");
}
#[test]
fn missing_required_classifies_as_element_missing() {
let xml = "<thing><name>x</name></thing>";
let issues = validate_against_schema(xml, MINI_XSD, None);
assert!(!issues.is_empty());
assert!(
issues.iter().any(|i| i.code.contains("ElementMissing")),
"expected XSD/ElementMissing: {issues:#?}"
);
}
#[test]
fn unknown_element_classifies_as_unexpected_element() {
let xml = "<thing><name>x</name><count>5</count><unknown/></thing>";
let issues = validate_against_schema(xml, MINI_XSD, None);
assert!(
issues.iter().any(|i| i.code.contains("UnexpectedElement")),
"expected XSD/UnexpectedElement: {issues:#?}"
);
}
#[test]
fn invalid_type_classifies_as_type_invalid() {
let xml = "<thing><name>x</name><count>not-a-number</count></thing>";
let issues = validate_against_schema(xml, MINI_XSD, None);
assert!(
issues.iter().any(|i| i.code.contains("TypeInvalid")),
"expected XSD/TypeInvalid: {issues:#?}"
);
}
#[test]
fn negative_for_positive_classifies_as_type_invalid() {
let xml = "<thing><name>x</name><count>-1</count></thing>";
let issues = validate_against_schema(xml, MINI_XSD, None);
assert!(
issues.iter().any(|i| i.code.contains("TypeInvalid")),
"expected XSD/TypeInvalid for negative-positive: {issues:#?}"
);
}
#[test]
fn malformed_schema_aborts_with_critical() {
let issues = validate_against_schema("<x/>", "<broken schema", None);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Critical);
}
#[test]
fn malformed_instance_aborts_with_critical() {
let issues = validate_against_schema("<not closed", MINI_XSD, None);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Critical);
}
#[test]
fn classifier_pins_element_missing_shape() {
let m = "Expected at least 1 occurrence of element 'EditRate'";
assert_eq!(classify(m), XsdConstraintCode::ElementMissing);
}
#[test]
fn classifier_pins_unexpected_element_shape() {
let m = "Unexpected element 'BogusTag' encountered";
assert_eq!(classify(m), XsdConstraintCode::UnexpectedElement);
}
#[test]
fn classifier_pins_pattern_invalid_shape_v1() {
let m = "Value 'abc' does not match pattern '[0-9]+'";
assert_eq!(classify(m), XsdConstraintCode::PatternInvalid);
}
#[test]
fn classifier_pins_pattern_invalid_shape_v2() {
let m = "Value does not match the expected facet";
assert_eq!(classify(m), XsdConstraintCode::PatternInvalid);
}
#[test]
fn classifier_pins_type_invalid_shape() {
let m = "Value 'not-a-number' is not a valid xs:positiveInteger";
assert_eq!(classify(m), XsdConstraintCode::TypeInvalid);
}
#[test]
fn classifier_falls_back_to_schema_constraint_failed() {
let m = "Some new message shape we don't know yet";
assert_eq!(classify(m), XsdConstraintCode::SchemaConstraintFailed);
}
#[test]
fn validate_opl_xml_passes_clean_opl() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
<Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
<IssueDate>2016-06-14T19:22:37Z</IssueDate>
<Issuer>Imferno</Issuer>
<Creator>Imferno</Creator>
<CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
<MacroList/>
</OutputProfileList>"#;
let issues = validate_opl_xml(xml);
for i in &issues {
assert!(
i.code.starts_with("XSD/"),
"expected XSD/* codes only, got {i:#?}"
);
}
}
#[test]
fn validate_opl_xml_flags_missing_required_field() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
<Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
<IssueDate>2016-06-14T19:22:37Z</IssueDate>
<Creator>Imferno</Creator>
<CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
<MacroList/>
</OutputProfileList>"#;
let issues = validate_opl_xml(xml);
assert!(
issues.iter().any(|i| i.code.contains("XSD/")),
"expected at least one XSD diagnostic for missing Issuer: {issues:#?}"
);
}
#[test]
fn validate_scm_xml_passes_clean_scm() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
<Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Properties>
<SidecarAssetList>
<SidecarAsset>
<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
<AssociatedCPLList>
<CPLId>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</CPLId>
</AssociatedCPLList>
</SidecarAsset>
</SidecarAssetList>
</Properties>
</SidecarCompositionMap>"#;
let issues = validate_scm_xml(xml);
for i in &issues {
assert!(
i.code.starts_with("XSD/"),
"expected XSD/* codes only, got {i:#?}"
);
}
}
#[test]
fn validate_pkl_xml_skips_namespace_without_pkl_companion() {
use crate::assetmap::PklNamespace;
let issues = validate_pkl_xml("<irrelevant/>", &PklNamespace::Smpte2067_2_2016);
assert!(issues.is_empty(), "expected skip on bare 2016 namespace");
}
#[test]
fn validate_pkl_xml_runs_for_modern_2016_pkl_namespace() {
use crate::assetmap::PklNamespace;
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<PackingList xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/PKL">
<Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>Imferno</Issuer>
<Creator>Imferno</Creator>
<AssetList><Asset>
<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>1024</Size>
<Type>application/mxf</Type>
</Asset></AssetList>
</PackingList>"#;
let issues = validate_pkl_xml(xml, &PklNamespace::Smpte2067_2_2016Pkl);
for i in &issues {
assert!(
i.code.starts_with("XSD/"),
"expected XSD/* codes only, got {i:#?}"
);
}
}
#[test]
fn validate_pkl_xml_runs_for_2020_pkl_namespace() {
use crate::assetmap::PklNamespace;
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<PackingList xmlns="http://www.smpte-ra.org/schemas/2067-2/2016/PKL">
<Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>Imferno</Issuer>
<Creator>Imferno</Creator>
<AssetList><Asset>
<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>1024</Size>
<Type>application/mxf</Type>
</Asset></AssetList>
</PackingList>"#;
let issues = validate_pkl_xml(xml, &PklNamespace::Smpte2067_2_2020);
for i in &issues {
assert!(
i.code.starts_with("XSD/"),
"expected XSD/* codes only, got {i:#?}"
);
}
}
#[test]
fn validate_pkl_xml_runs_for_dci_namespace() {
use crate::assetmap::PklNamespace;
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
<Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<Issuer>Imferno</Issuer>
<Creator>Imferno</Creator>
<AssetList><Asset>
<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
<Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
<Size>1024</Size>
<Type>application/mxf</Type>
</Asset></AssetList>
</PackingList>"#;
let issues = validate_pkl_xml(xml, &PklNamespace::Dci429_8);
for i in &issues {
assert!(
i.code.starts_with("XSD/"),
"expected XSD/* codes only, got {i:#?}"
);
}
}
}