use crate::c2pa_cbor::Value;
use crate::c2pa_core::jumbf::ParsedManifest;
use crate::c2pa_core::SpecVersion;
use crate::c2pa_formats::AssetFormat;
use serde_json::{json, Value as Json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaimGeneration {
V1,
V2,
}
impl ClaimGeneration {
pub fn as_u8(self) -> u8 {
match self {
ClaimGeneration::V1 => 1,
ClaimGeneration::V2 => 2,
}
}
}
pub fn claim_generation(manifest: &ParsedManifest, claim: &Value) -> ClaimGeneration {
match manifest.claim_box_label.as_deref() {
Some("c2pa.claim.v2") => return ClaimGeneration::V2,
Some("c2pa.claim") => return ClaimGeneration::V1,
_ => {}
}
if claim.get("created_assertions").is_some() || claim.get("gathered_assertions").is_some() {
ClaimGeneration::V2
} else if claim.get("assertions").is_some() || claim.get("claim_generator").is_some() {
ClaimGeneration::V1
} else {
ClaimGeneration::V2
}
}
pub fn declared_spec_version(claim: &Value) -> Option<String> {
declared_spec_version_text(claim).map(str::to_string)
}
fn declared_spec_version_text(claim: &Value) -> Option<&str> {
let info = claim.get("claim_generator_info")?;
let entry = match info {
Value::Array(items) => items.first()?,
other => other,
};
entry.get("specVersion").and_then(Value::as_text)
}
pub fn non_semver_declared_spec_version(claim: &Value) -> Option<&str> {
declared_spec_version_text(claim).filter(|version| !semver_is_valid(version))
}
fn optional_text_member_is_valid(map: &Value, key: &str) -> bool {
map.get(key).is_none() || map.get(key).and_then(Value::as_text).is_some()
}
fn semver_identifier_is_valid(identifier: &str, reject_numeric_leading_zero: bool) -> bool {
!identifier.is_empty()
&& identifier
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& !(reject_numeric_leading_zero
&& identifier.len() > 1
&& identifier.bytes().all(|byte| byte.is_ascii_digit())
&& identifier.starts_with('0'))
}
fn semver_is_valid(version: &str) -> bool {
let mut build_split = version.split('+');
let version_and_prerelease = match build_split.next() {
Some(value) if !value.is_empty() => value,
_ => return false,
};
if let Some(build) = build_split.next() {
if build_split.next().is_some()
|| !build
.split('.')
.all(|identifier| semver_identifier_is_valid(identifier, false))
{
return false;
}
}
let mut prerelease_split = version_and_prerelease.split('-');
let core = match prerelease_split.next() {
Some(value) => value,
None => return false,
};
if let Some(prerelease) = prerelease_split.next() {
if prerelease_split.next().is_some()
|| !prerelease
.split('.')
.all(|identifier| semver_identifier_is_valid(identifier, true))
{
return false;
}
}
let components = core.split('.').collect::<Vec<_>>();
components.len() == 3
&& components.iter().all(|component| {
!component.is_empty()
&& component.bytes().all(|byte| byte.is_ascii_digit())
&& (component == &"0" || !component.starts_with('0'))
})
}
fn declared_spec_revision(version: &str) -> Option<SpecVersion> {
let core = version.split(['-', '+']).next()?;
let mut components = core.split('.');
let major = components.next()?;
let minor = components.next()?;
let patch = components.next()?;
if components.next().is_some() || patch != "0" {
return None;
}
SpecVersion::from_str(&format!("{major}.{minor}"))
}
fn external_url_is_valid(url: &str) -> bool {
let Some(remainder) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
else {
return false;
};
let authority = remainder.split(['/', '?', '#']).next().unwrap_or_default();
let host = authority
.rsplit('@')
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default();
let Some((domain, suffix)) = host.rsplit_once('.') else {
return false;
};
domain.len() >= 2
&& (2..=6).contains(&suffix.len())
&& suffix.bytes().all(|byte| byte.is_ascii_lowercase())
}
fn format_string_is_valid(format: &str) -> bool {
let Some((kind, subtype)) = format.split_once('/') else {
return false;
};
!kind.is_empty()
&& !subtype.is_empty()
&& kind
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
&& subtype
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'+' | b'.'))
}
fn asset_type_map_is_valid(value: &Value) -> bool {
matches!(value, Value::Map(_))
&& value
.get("type")
.and_then(Value::as_text)
.is_some_and(|asset_type| {
asset_type.contains('.')
&& asset_type.split('.').all(|component| {
!component.is_empty()
&& component.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
})
})
})
&& value
.get("version")
.is_none_or(|version| version.as_text().is_some_and(semver_is_valid))
}
fn hashed_uri_map_is_valid(value: &Value) -> bool {
let Some(url) = value.get("url").and_then(Value::as_text) else {
return false;
};
if url.is_empty()
|| value
.get("hash")
.and_then(Value::as_bytes)
.is_none_or(|hash| hash.is_empty())
{
return false;
}
let local = url.starts_with("self#jumbf=");
let external = external_url_is_valid(url);
if !local && !external {
return false;
}
if external
&& value
.get("alg")
.and_then(Value::as_text)
.is_none_or(|algorithm| algorithm.is_empty())
{
return false;
}
optional_text_member_is_valid(value, "alg")
&& value
.get("dc:format")
.is_none_or(|format| format.as_text().is_some_and(format_string_is_valid))
&& value
.get("size")
.is_none_or(|size| matches!(size, Value::Integer(value) if *value > 0))
&& value.get("data_types").is_none_or(|data_types| {
matches!(
data_types,
Value::Array(items)
if !items.is_empty() && items.iter().all(asset_type_map_is_valid)
)
})
}
fn generator_info_map_is_valid(value: &Value) -> bool {
matches!(value, Value::Map(_))
&& value
.get("name")
.and_then(Value::as_text)
.is_some_and(|name| !name.is_empty())
&& optional_text_member_is_valid(value, "version")
&& optional_text_member_is_valid(value, "operating_system")
&& optional_text_member_is_valid(value, "specVersion")
&& value.get("icon").is_none_or(hashed_uri_map_is_valid)
}
pub fn v1_missing_fields(claim: &Value) -> Vec<&'static str> {
let mut missing = Vec::new();
if claim
.get("instanceID")
.and_then(Value::as_text)
.is_none_or(|instance_id| instance_id.is_empty())
{
missing.push("instanceID");
}
if claim
.get("claim_generator")
.and_then(Value::as_text)
.is_none()
{
missing.push("claim_generator");
}
if claim.get("dc:format").and_then(Value::as_text).is_none() {
missing.push("dc:format");
}
if !matches!(claim.get("assertions"), Some(Value::Array(a)) if !a.is_empty()) {
missing.push("assertions");
}
if claim
.get("signature")
.and_then(Value::as_text)
.is_none_or(|signature| signature.is_empty())
{
missing.push("signature");
}
if !matches!(
claim.get("claim_generator_info"),
Some(Value::Array(entries))
if !entries.is_empty() && entries.iter().all(generator_info_map_is_valid)
) {
missing.push("claim_generator_info");
}
missing
}
pub fn v2_missing_fields(claim: &Value) -> Vec<&'static str> {
let mut missing = Vec::new();
if claim
.get("instanceID")
.and_then(Value::as_text)
.is_none_or(|instance_id| instance_id.is_empty())
{
missing.push("instanceID");
}
if !claim
.get("claim_generator_info")
.is_some_and(generator_info_map_is_valid)
{
missing.push("claim_generator_info");
}
if !matches!(claim.get("created_assertions"), Some(Value::Array(a)) if !a.is_empty()) {
missing.push("created_assertions");
}
if claim
.get("signature")
.and_then(Value::as_text)
.is_none_or(|signature| signature.is_empty())
{
missing.push("signature");
}
missing
}
fn assertion_min_version(label: &str) -> Option<SpecVersion> {
if label.starts_with("c2pa.hash.multi-asset") || label.starts_with("c2pa.hash.data.part") {
Some(SpecVersion::V2_2)
} else if label.starts_with("c2pa.ingredient.v3") || label.starts_with("c2pa.hash.collection") {
Some(SpecVersion::V2_1)
} else {
None
}
}
fn format_min_version(format: AssetFormat) -> Option<SpecVersion> {
match format {
AssetFormat::Ogg
| AssetFormat::TextUnstructured
| AssetFormat::TextStructured { .. }
| AssetFormat::TextHtml => Some(SpecVersion::V2_4),
_ => None,
}
}
fn assertion_deprecated_in(label: &str) -> Option<SpecVersion> {
let is = |base: &str| label == base || label.starts_with(&format!("{base}__"));
if is("c2pa.ingredient") || is("c2pa.ingredient.v2") {
Some(SpecVersion::V2_4)
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct VersionEvaluation {
pub version: SpecVersion,
pub structure_conformant: bool,
pub reasons: Vec<String>,
pub deprecations: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct VersionVerdict {
pub claim_generation: ClaimGeneration,
pub declared_spec_version: Option<String>,
pub validated_under: Option<SpecVersion>,
pub evaluations: Vec<VersionEvaluation>,
}
impl VersionVerdict {
pub fn to_json(&self) -> Json {
json!({
"claim_generation": self.claim_generation.as_u8(),
"declared_spec_version": self.declared_spec_version,
"validated_under": self.validated_under.map(SpecVersion::version_str),
"evaluations": self.evaluations.iter().map(|e| {
json!({
"version": e.version.version_str(),
"structure_conformant": e.structure_conformant,
"reasons": e.reasons,
"deprecations": e.deprecations,
})
}).collect::<Vec<_>>(),
})
}
}
pub fn evaluate(manifest: &ParsedManifest, claim: &Value, format: AssetFormat) -> VersionVerdict {
let generation = claim_generation(manifest, claim);
let declared = declared_spec_version(claim);
let field_defects: Vec<String> = match generation {
ClaimGeneration::V1 => v1_missing_fields(claim)
.iter()
.map(|f| format!("claim missing required v1 field: {f}"))
.collect(),
ClaimGeneration::V2 => v2_missing_fields(claim)
.iter()
.map(|f| format!("claim missing required v2 field: {f}"))
.collect(),
};
let mut minimums: Vec<(SpecVersion, String)> = Vec::new();
for (label, _) in &manifest.assertions {
if let Some(min) = assertion_min_version(label) {
minimums.push((
min,
format!("assertion '{label}' requires >= {}", min.version_str()),
));
}
}
if let Some(min) = format_min_version(format) {
minimums.push((
min,
format!(
"text embedding (A.7/A.8/A.9) requires >= {}",
min.version_str()
),
));
}
let mut deprecated: Vec<(SpecVersion, String)> = Vec::new();
for (label, _) in &manifest.assertions {
if let Some(since) = assertion_deprecated_in(label) {
deprecated.push((
since,
format!(
"assertion '{label}' is deprecated as of {}",
since.version_str()
),
));
}
}
let evaluations: Vec<VersionEvaluation> = SpecVersion::ALL
.iter()
.map(|&version| {
let mut reasons: Vec<String> = Vec::new();
match (generation, version) {
(ClaimGeneration::V1, v) if v >= SpecVersion::V2_0 => {
reasons.push("claim is v1 (c2pa.claim); 2.x manifests require claim v2".into());
}
(ClaimGeneration::V2, v) if v < SpecVersion::V2_0 => {
reasons.push("claim is v2 (c2pa.claim.v2); introduced in 2.0".into());
}
_ => {}
}
reasons.extend(field_defects.iter().cloned());
for (min, why) in &minimums {
if version < *min {
reasons.push(why.clone());
}
}
let deprecations: Vec<String> = deprecated
.iter()
.filter(|(since, _)| version >= *since)
.map(|(_, note)| note.clone())
.collect();
VersionEvaluation {
version,
structure_conformant: reasons.is_empty(),
reasons,
deprecations,
}
})
.collect();
let declared_version = declared.as_deref().and_then(declared_spec_revision);
let passes = |v: SpecVersion| {
evaluations
.iter()
.any(|e| e.version == v && e.structure_conformant)
};
let validated_under = match declared_version {
Some(dv) if passes(dv) => Some(dv),
_ => evaluations
.iter()
.rev()
.find(|e| e.structure_conformant)
.map(|e| e.version),
};
VersionVerdict {
claim_generation: generation,
declared_spec_version: declared,
validated_under,
evaluations,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::c2pa_cbor::{map_from_pairs, Profile};
use crate::c2pa_core::claim::{
build_claim_value, AssertionRef, ClaimGeneratorInfo, ClaimOptions, HASH_ALG_SHA256,
};
fn manifest_with_label(label: Option<&str>) -> ParsedManifest<'static> {
ParsedManifest {
label: "urn:c2pa:test".into(),
manifest_jumbf: &[],
assertions: Vec::new(),
assertion_jumbf: Vec::new(),
claim_cbor: None,
signature_cose: None,
claim_count: 1,
claim_box_label: label.map(str::to_string),
}
}
fn v2_claim() -> Value {
map_from_pairs([
("instanceID".to_string(), Value::Text("urn:uuid:x".into())),
(
"signature".to_string(),
Value::Text("self#jumbf=c2pa.signature".into()),
),
(
"created_assertions".to_string(),
Value::Array(vec![map_from_pairs([(
"url".to_string(),
Value::Text("self#jumbf=c2pa.assertions/c2pa.hash.data".into()),
)])]),
),
(
"claim_generator_info".to_string(),
map_from_pairs([
("name".to_string(), Value::Text("t".into())),
("specVersion".to_string(), Value::Text("2.2.0".into())),
]),
),
])
}
fn v1_claim() -> Value {
map_from_pairs([
("instanceID".to_string(), Value::Text("xmp:iid:x".into())),
(
"signature".to_string(),
Value::Text("self#jumbf=c2pa.signature".into()),
),
(
"claim_generator".to_string(),
Value::Text("legacy/1.0".into()),
),
("dc:format".to_string(), Value::Text("image/jpeg".into())),
(
"claim_generator_info".to_string(),
Value::Array(vec![map_from_pairs([(
"name".to_string(),
Value::Text("legacy".into()),
)])]),
),
(
"assertions".to_string(),
Value::Array(vec![map_from_pairs([(
"url".to_string(),
Value::Text("self#jumbf=c2pa.assertions/c2pa.hash.data".into()),
)])]),
),
])
}
fn v2_claim_with_generator_info(info: Option<Value>) -> Value {
let mut fields = vec![
("instanceID".to_string(), Value::Text("urn:uuid:x".into())),
(
"signature".to_string(),
Value::Text("self#jumbf=c2pa.signature".into()),
),
(
"created_assertions".to_string(),
Value::Array(vec![map_from_pairs([(
"url".to_string(),
Value::Text("self#jumbf=c2pa.assertions/c2pa.hash.data".into()),
)])]),
),
];
if let Some(info) = info {
fields.push(("claim_generator_info".to_string(), info));
}
map_from_pairs(fields)
}
#[test]
fn canonical_builder_output_has_valid_v2_generator_info() {
let generator = ClaimGeneratorInfo {
name: "Encypher Engine".into(),
version: "1.0".into(),
spec_version: Some("2.4.0".into()),
c2pa_rs: None,
};
let options = ClaimOptions {
manifest_label: "urn:c2pa:test",
instance_id: "urn:uuid:test",
generator: &generator,
title: None,
alg: HASH_ALG_SHA256,
profile: Profile::LegacyPipelineBDefinite,
};
let claim = build_claim_value(
&options,
&[AssertionRef {
label: "c2pa.hash.data",
jumbf_content: b"assertion",
}],
);
assert!(v2_missing_fields(&claim).is_empty());
}
#[test]
fn v1_generator_info_requires_non_empty_array_of_named_maps() {
let valid = Value::Array(vec![
map_from_pairs([("name".to_string(), Value::Text("first".into()))]),
map_from_pairs([
("name".to_string(), Value::Text("second".into())),
("version".to_string(), Value::Text("1.0".into())),
]),
]);
let malformed = [
("absent", None),
("empty array", Some(Value::Array(Vec::new()))),
("wrong outer type", Some(Value::Map(Vec::new()))),
(
"non-map entry",
Some(Value::Array(vec![Value::Text("generator".into())])),
),
(
"missing name",
Some(Value::Array(vec![map_from_pairs([(
"version".to_string(),
Value::Text("1.0".into()),
)])])),
),
(
"empty name",
Some(Value::Array(vec![map_from_pairs([(
"name".to_string(),
Value::Text(String::new()),
)])])),
),
(
"one malformed entry",
Some(Value::Array(vec![
map_from_pairs([("name".to_string(), Value::Text("valid".into()))]),
map_from_pairs([("name".to_string(), Value::Integer(1.into()))]),
])),
),
];
let mut valid_claim = v1_claim();
if let Value::Map(entries) = &mut valid_claim {
let generator_info = entries
.iter_mut()
.find(|(key, _)| key.as_text() == Some("claim_generator_info"))
.expect("fixture has generator info");
generator_info.1 = valid;
}
assert!(v1_missing_fields(&valid_claim).is_empty());
for (case, info) in malformed {
let mut claim = v1_claim();
if let Value::Map(entries) = &mut claim {
entries.retain(|(key, _)| key.as_text() != Some("claim_generator_info"));
if let Some(info) = info {
entries.push((Value::Text("claim_generator_info".into()), info));
}
}
assert!(
v1_missing_fields(&claim).contains(&"claim_generator_info"),
"{case}"
);
}
}
#[test]
fn generator_info_optional_members_follow_schema() {
let valid_icon = map_from_pairs([
(
"url".to_string(),
Value::Text("self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg".into()),
),
("alg".to_string(), Value::Text("sha256".into())),
("hash".to_string(), Value::Bytes(vec![7; 32])),
]);
let valid = map_from_pairs([
("name".to_string(), Value::Text("generator".into())),
("version".to_string(), Value::Text("1.0".into())),
(
"operating_system".to_string(),
Value::Text("Example OS".into()),
),
("specVersion".to_string(), Value::Text("2.4.0".into())),
("icon".to_string(), valid_icon),
]);
assert!(v2_missing_fields(&v2_claim_with_generator_info(Some(valid))).is_empty());
let malformed_icons = [
("wrong icon type", Value::Text("icon".into())),
(
"missing icon url",
map_from_pairs([("hash".to_string(), Value::Bytes(vec![7; 32]))]),
),
(
"missing icon hash",
map_from_pairs([(
"url".to_string(),
Value::Text("self#jumbf=c2pa.assertions/icon".into()),
)]),
),
];
for (case, icon) in malformed_icons {
let info = map_from_pairs([
("name".to_string(), Value::Text("generator".into())),
("icon".to_string(), icon),
]);
assert_eq!(
v2_missing_fields(&v2_claim_with_generator_info(Some(info))),
vec!["claim_generator_info"],
"{case}"
);
}
for key in ["version", "operating_system", "specVersion"] {
let info = map_from_pairs([
("name".to_string(), Value::Text("generator".into())),
(key.to_string(), Value::Integer(1.into())),
]);
assert_eq!(
v2_missing_fields(&v2_claim_with_generator_info(Some(info))),
vec!["claim_generator_info"],
"{key}"
);
}
}
#[test]
fn malformed_v2_generator_info_is_required_field_failure() {
let malformed = [
("absent", None),
("wrong outer type", Some(Value::Text("generator".into()))),
("empty value", Some(Value::Map(Vec::new()))),
("empty array", Some(Value::Array(Vec::new()))),
(
"legacy array-of-maps shape",
Some(Value::Array(vec![map_from_pairs([(
"name".to_string(),
Value::Text("generator".into()),
)])])),
),
(
"non-map array entry",
Some(Value::Array(vec![Value::Text("generator".into())])),
),
(
"missing name",
Some(map_from_pairs([(
"version".to_string(),
Value::Text("1.0".into()),
)])),
),
(
"non-text name",
Some(map_from_pairs([(
"name".to_string(),
Value::Integer(1.into()),
)])),
),
];
for (case, info) in malformed {
assert_eq!(
v2_missing_fields(&v2_claim_with_generator_info(info)),
vec!["claim_generator_info"],
"{case}"
);
}
}
#[test]
fn detects_generation_from_box_label() {
let m1 = manifest_with_label(Some("c2pa.claim"));
let m2 = manifest_with_label(Some("c2pa.claim.v2"));
assert_eq!(claim_generation(&m1, &v2_claim()), ClaimGeneration::V1);
assert_eq!(claim_generation(&m2, &v1_claim()), ClaimGeneration::V2);
}
#[test]
fn detects_generation_from_field_shape_without_label() {
let m = manifest_with_label(None);
assert_eq!(claim_generation(&m, &v1_claim()), ClaimGeneration::V1);
assert_eq!(claim_generation(&m, &v2_claim()), ClaimGeneration::V2);
}
#[test]
fn v1_claim_validates_under_1x_only() {
let m = manifest_with_label(Some("c2pa.claim"));
let v = evaluate(&m, &v1_claim(), AssetFormat::Jpeg);
assert_eq!(v.claim_generation, ClaimGeneration::V1);
assert_eq!(v.validated_under, Some(SpecVersion::V1_4));
for e in &v.evaluations {
assert_eq!(
e.structure_conformant,
e.version == SpecVersion::V1_4,
"version {}",
e.version.version_str()
);
}
}
#[test]
fn v2_claim_prefers_declared_version() {
let m = manifest_with_label(Some("c2pa.claim.v2"));
let v = evaluate(&m, &v2_claim(), AssetFormat::Jpeg);
assert_eq!(v.declared_spec_version.as_deref(), Some("2.2.0"));
assert_eq!(v.validated_under, Some(SpecVersion::V2_2));
let v14 = v
.evaluations
.iter()
.find(|e| e.version == SpecVersion::V1_4)
.unwrap();
assert!(!v14.structure_conformant);
assert!(
v.evaluations
.iter()
.filter(|e| e.structure_conformant)
.count()
== 5
);
}
#[test]
fn multi_asset_assertion_raises_minimum_to_2_2() {
let mut m = manifest_with_label(Some("c2pa.claim.v2"));
m.assertions
.push(("c2pa.hash.multi-asset".to_string(), &[0xa0][..]));
let mut claim = v2_claim();
if let Value::Map(pairs) = &mut claim {
for (k, val) in pairs.iter_mut() {
if matches!(k, Value::Text(t) if t == "claim_generator_info") {
*val = map_from_pairs([
("name".to_string(), Value::Text("t".into())),
("specVersion".to_string(), Value::Text("2.0.0".into())),
]);
}
}
}
let v = evaluate(&m, &claim, AssetFormat::Jpeg);
for e in &v.evaluations {
assert_eq!(e.structure_conformant, e.version >= SpecVersion::V2_2);
}
assert_eq!(v.validated_under, Some(SpecVersion::V2_4));
}
#[test]
fn text_format_requires_2_4() {
let m = manifest_with_label(Some("c2pa.claim.v2"));
let v = evaluate(&m, &v2_claim(), AssetFormat::TextUnstructured);
for e in &v.evaluations {
assert_eq!(e.structure_conformant, e.version == SpecVersion::V2_4);
}
assert_eq!(v.validated_under, Some(SpecVersion::V2_4));
}
#[test]
fn malformed_v2_claim_fits_nothing() {
let m = manifest_with_label(Some("c2pa.claim.v2"));
let claim = map_from_pairs([("instanceID".to_string(), Value::Text("urn:uuid:x".into()))]);
let v = evaluate(&m, &claim, AssetFormat::Jpeg);
assert_eq!(v.validated_under, None);
assert!(v.evaluations.iter().all(|e| !e.structure_conformant));
}
#[test]
fn deprecated_ingredient_v2_is_flagged_but_conformant() {
let mut m = manifest_with_label(Some("c2pa.claim.v2"));
m.assertions
.push(("c2pa.ingredient.v2".to_string(), &[0xa0][..]));
let v = evaluate(&m, &v2_claim(), AssetFormat::Jpeg);
for e in &v.evaluations {
assert_eq!(e.structure_conformant, e.version >= SpecVersion::V2_0);
assert_eq!(
!e.deprecations.is_empty(),
e.version >= SpecVersion::V2_4,
"deprecation note wrong at {}",
e.version.version_str()
);
}
let mut m3 = manifest_with_label(Some("c2pa.claim.v2"));
m3.assertions
.push(("c2pa.ingredient.v3".to_string(), &[0xa0][..]));
let v3 = evaluate(&m3, &v2_claim(), AssetFormat::Jpeg);
assert!(v3.evaluations.iter().all(|e| e.deprecations.is_empty()));
}
#[test]
fn claim_completeness_rejects_empty_ids_and_missing_signature() {
let mut claim = v2_claim();
if let Value::Map(fields) = &mut claim {
let instance = fields
.iter_mut()
.find(|(key, _)| key.as_text() == Some("instanceID"))
.unwrap();
instance.1 = Value::Text(String::new());
}
assert!(v2_missing_fields(&claim).contains(&"instanceID"));
let mut missing_signature = v2_claim();
if let Value::Map(fields) = &mut missing_signature {
fields.retain(|(key, _)| key.as_text() != Some("signature"));
}
assert!(v2_missing_fields(&missing_signature).contains(&"signature"));
if let Value::Map(fields) = &mut missing_signature {
let info = fields
.iter_mut()
.find(|(key, _)| key.as_text() == Some("claim_generator_info"))
.unwrap();
if let Value::Map(info_fields) = &mut info.1 {
let version = info_fields
.iter_mut()
.find(|(key, _)| key.as_text() == Some("specVersion"))
.unwrap();
version.1 = Value::Text("2.4".into());
}
}
assert!(!v2_missing_fields(&missing_signature).contains(&"claim_generator_info"));
assert_eq!(
non_semver_declared_spec_version(&missing_signature),
Some("2.4")
);
}
#[test]
fn generator_icon_accepts_embedded_or_external_hashed_uri() {
let external = map_from_pairs([
(
"url".to_string(),
Value::Text("https://example.test/icon.svg".into()),
),
("alg".to_string(), Value::Text("sha256".into())),
("hash".to_string(), Value::Bytes(vec![7; 32])),
("dc:format".to_string(), Value::Text("image/svg+xml".into())),
("size".to_string(), Value::Integer(1024.into())),
(
"data_types".to_string(),
Value::Array(vec![map_from_pairs([
("type".to_string(), Value::Text("c2pa.types.icon".into())),
("version".to_string(), Value::Text("1.0.0".into())),
])]),
),
]);
let info = map_from_pairs([
("name".to_string(), Value::Text("generator".into())),
("specVersion".to_string(), Value::Text("2.4.0".into())),
("icon".to_string(), external),
]);
let claim = v2_claim_with_generator_info(Some(info));
assert!(v2_missing_fields(&claim).is_empty());
}
}