use super::{
hash_bytes, is_allowed_hash_algorithm, is_generous, ClaimAssertionRefs, ClaimGeneration,
EngineProfile, OnlineEvidence, ValidationResults, ALGORITHM_UNSUPPORTED,
ASSERTION_HASHED_URI_MATCH, HASHED_URI_MISMATCH, HASHED_URI_MISSING,
};
use crate::c2pa_cbor::Value;
use crate::c2pa_core::jumbf::ParsedManifest;
use std::collections::HashMap;
pub enum ExternalResource<'a> {
NotAttempted,
Unavailable,
Retrieved {
bytes: &'a [u8],
media_type: Option<&'a str>,
},
}
pub trait ExternalResources {
fn resolve(&self, url: &str) -> ExternalResource<'_>;
}
enum CachedResource<'a> {
NotAttempted,
Unavailable,
Retrieved {
bytes: &'a [u8],
media_type: Option<&'a str>,
},
}
pub(super) enum ResolvedResource<'a> {
NotAttempted,
Unavailable,
Retrieved {
bytes: &'a [u8],
media_type: Option<&'a str>,
},
}
pub(super) struct ResourceCache<'a> {
provider: Option<&'a dyn ExternalResources>,
evidence: OnlineEvidence<'a>,
outcomes: HashMap<String, CachedResource<'a>>,
}
impl<'a> ResourceCache<'a> {
pub(super) fn with_evidence(
provider: Option<&'a dyn ExternalResources>,
evidence: OnlineEvidence<'a>,
) -> Self {
Self {
provider,
evidence,
outcomes: HashMap::new(),
}
}
pub(super) fn resolve(&mut self, url: &str) -> ResolvedResource<'_> {
if !self.outcomes.contains_key(url) {
let supplied = self
.evidence
.external_data
.and_then(|resources| resources.get(url));
let outcome = if let Some(bytes) = supplied {
let media_type = self
.evidence
.external_data_media_types
.and_then(|media_types| media_types.get(url))
.map(String::as_str);
CachedResource::Retrieved {
bytes: bytes.as_slice(),
media_type,
}
} else if self.evidence.external_is_unreachable(url) {
CachedResource::Unavailable
} else {
match self.provider {
None => CachedResource::NotAttempted,
Some(provider) => Self::fetch(provider, url),
}
};
self.outcomes.insert(url.to_string(), outcome);
}
match self.outcomes.get(url).expect("resource outcome inserted") {
CachedResource::NotAttempted => ResolvedResource::NotAttempted,
CachedResource::Unavailable => ResolvedResource::Unavailable,
CachedResource::Retrieved { bytes, media_type } => ResolvedResource::Retrieved {
bytes,
media_type: *media_type,
},
}
}
fn fetch(provider: &'a dyn ExternalResources, url: &str) -> CachedResource<'a> {
match provider.resolve(url) {
ExternalResource::NotAttempted => CachedResource::NotAttempted,
ExternalResource::Unavailable => CachedResource::Unavailable,
ExternalResource::Retrieved { bytes, media_type } => {
CachedResource::Retrieved { bytes, media_type }
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ReferenceWalk {
pub(super) examined: usize,
pub(super) truncated: bool,
}
const MAX_RELATED_DEPTH: usize = 8;
#[derive(Clone, Copy)]
enum Step {
Field(&'static str),
Each,
EachRelated,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Internal,
Ingredient,
Related,
Either,
}
struct RefField {
path: &'static [Step],
kind: Kind,
}
const fn field(path: &'static [Step], kind: Kind) -> RefField {
RefField { path, kind }
}
const INGREDIENT_V1: &[RefField] = &[field(&[Step::Field("thumbnail")], Kind::Internal)];
const INGREDIENT_V2: &[RefField] = &[
field(&[Step::Field("thumbnail")], Kind::Internal),
field(&[Step::Field("data")], Kind::Either),
];
const INGREDIENT_V3: &[RefField] = &[
field(&[Step::Field("thumbnail")], Kind::Internal),
field(&[Step::Field("data")], Kind::Either),
field(
&[Step::Field("relatedAssertions"), Step::Each],
Kind::Related,
),
];
const ACTIONS: &[RefField] = &[
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("parameters"),
Step::Field("ingredient"),
],
Kind::Ingredient,
),
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("parameters"),
Step::Field("ingredients"),
Step::Each,
],
Kind::Ingredient,
),
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("parameters"),
Step::Field("relatedAssertions"),
Step::Each,
],
Kind::Related,
),
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("softwareAgent"),
Step::Field("icon"),
],
Kind::Either,
),
field(
&[
Step::Field("softwareAgents"),
Step::Each,
Step::Field("icon"),
],
Kind::Either,
),
field(
&[Step::Field("templates"), Step::Each, Step::Field("icon")],
Kind::Internal,
),
];
fn ref_fields(label: &str) -> &'static [RefField] {
match base_label(label) {
"c2pa.ingredient" => INGREDIENT_V1,
"c2pa.ingredient.v2" => INGREDIENT_V2,
"c2pa.ingredient.v3" => INGREDIENT_V3,
"c2pa.actions" | "c2pa.actions.v2" => ACTIONS,
_ => &[],
}
}
pub(super) fn base_label(label: &str) -> &str {
match label.split_once("__") {
Some((base, instance))
if !instance.is_empty() && instance.bytes().all(|b| b.is_ascii_digit()) =>
{
base
}
_ => label,
}
}
struct Located<'v> {
kind: Kind,
value: &'v Value,
path: String,
action_kind: Option<&'v str>,
}
fn locate<'v>(
value: &'v Value,
kind: Kind,
steps: &[Step],
depth: usize,
path: &mut String,
out: &mut Vec<Located<'v>>,
limit: usize,
) -> bool {
match steps.split_first() {
None => {
if out.len() >= limit {
return true;
}
out.push(Located {
kind,
value,
path: path.clone(),
action_kind: None,
});
false
}
Some((Step::Field(name), rest)) => {
let Some(child) = value.get(name) else {
return false;
};
let mark = path.len();
if !path.is_empty() {
path.push('.');
}
path.push_str(name);
let capped = locate(child, kind, rest, depth, path, out, limit);
path.truncate(mark);
capped
}
Some((Step::Each, rest)) => {
let Value::Array(items) = value else {
return false;
};
for (index, item) in items.iter().enumerate() {
let mark = path.len();
path.push('[');
path.push_str(&index.to_string());
path.push(']');
let capped = locate(item, kind, rest, depth, path, out, limit);
path.truncate(mark);
if capped {
return true;
}
}
false
}
Some((Step::EachRelated, rest)) => {
let start = out.len();
let capped = locate(value, kind, rest, depth, path, out, limit);
for located in &mut out[start..] {
located.action_kind = value.get("action").and_then(Value::as_text);
}
if capped {
return true;
}
if depth >= MAX_RELATED_DEPTH {
return false;
}
let Some(Value::Array(items)) = value.get("related") else {
return false;
};
for (index, item) in items.iter().enumerate() {
let mark = path.len();
path.push_str(".related[");
path.push_str(&index.to_string());
path.push(']');
let capped = locate(item, kind, steps, depth + 1, path, out, limit);
path.truncate(mark);
if capped {
return true;
}
}
false
}
}
}
pub(super) struct AssertionReferenceContext<'a, 'm> {
pub claim: &'a Value,
pub claim_refs: &'a ClaimAssertionRefs<'m>,
pub generation: ClaimGeneration,
pub profile: EngineProfile,
pub manifest_label: &'a str,
pub manifests: &'a [ParsedManifest<'m>],
pub budget: usize,
}
pub(super) fn verify_assertion_references(
context: AssertionReferenceContext<'_, '_>,
resources: &mut ResourceCache<'_>,
results: &mut ValidationResults,
) -> ReferenceWalk {
let AssertionReferenceContext {
claim,
claim_refs,
generation,
profile,
manifest_label,
manifests,
budget,
} = context;
let legacy = generation == ClaimGeneration::V1 && is_generous(profile);
if legacy
&& claim_refs
.redactions
.as_deref()
.is_none_or(super::redaction::Index::is_empty)
{
return ReferenceWalk {
examined: 0,
truncated: false,
};
}
let claim_alg = claim.get("alg").and_then(Value::as_text);
let mut seen: Vec<&str> = Vec::new();
let mut examined = 0usize;
for reference in &claim_refs.references {
let Some(alabel) = reference.label else {
continue;
};
if seen.contains(&alabel) {
continue;
}
seen.push(alabel);
let fields = ref_fields(alabel);
if fields.is_empty() {
continue;
}
let Some(assertion) = claim_refs
.indexed(alabel)
.and_then(|indexed| indexed.decoded.as_ref())
else {
continue;
};
let assertion_url = format!("self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{alabel}");
let assertion_alg = assertion.get("alg").and_then(Value::as_text);
let mut located = Vec::new();
let mut capped = false;
let capacity = budget.saturating_sub(examined);
for field in fields {
if field.kind == Kind::Related
&& base_label(alabel) == "c2pa.ingredient.v3"
&& !super::spec25::applies(profile)
{
continue;
}
let mut path = String::new();
if locate(
assertion,
field.kind,
field.path,
0,
&mut path,
&mut located,
capacity,
) {
capped = true;
break;
}
}
examined += located.len();
for located in &located {
let redacted = super::spec25::redacted_reference_target(
located.value,
manifest_label,
claim_refs.redactions.as_deref(),
);
if let Some(target) = redacted {
let exempt = super::spec25::applies(profile)
&& match located.kind {
Kind::Related => target.same_manifest,
Kind::Ingredient => {
matches!(
base_label(target.label),
"c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3"
) && match located.action_kind {
Some(
"c2pa.opened" | "c2pa.placed" | "c2pa.transcoded"
| "c2pa.repackaged",
) => target.same_manifest,
Some("c2pa.deleted" | "c2pa.removed") => !target.same_manifest,
_ => false,
}
}
_ => false,
};
if exempt {
results.push_informational(
super::spec25::ASSERTION_REDACTED,
target.url.to_string(),
"reference resolves to a redacted assertion".into(),
);
continue;
}
results.push_failure(
HASHED_URI_MISSING,
target.url.to_string(),
format!(
"{alabel} field '{}' references a redacted assertion",
located.path
),
);
continue;
}
if legacy {
continue;
}
validate_reference(
located,
alabel,
&assertion_url,
generation,
manifest_label,
manifests,
assertion_alg.or(claim_alg),
resources,
results,
);
}
if capped {
return ReferenceWalk {
examined,
truncated: true,
};
}
}
ReferenceWalk {
examined,
truncated: false,
}
}
pub(super) fn locate_external_references(
claim_refs: &ClaimAssertionRefs<'_>,
generation: ClaimGeneration,
profile: EngineProfile,
budget: usize,
) -> (Vec<String>, ReferenceWalk) {
if generation == ClaimGeneration::V1 && is_generous(profile) {
return (
Vec::new(),
ReferenceWalk {
examined: 0,
truncated: false,
},
);
}
let mut urls = Vec::new();
let mut seen = Vec::new();
let mut examined = 0usize;
for reference in &claim_refs.references {
let Some(label) = reference.label else {
continue;
};
if seen.contains(&label) {
continue;
}
seen.push(label);
let fields = ref_fields(label);
if fields.is_empty() {
continue;
}
let Some(assertion) = claim_refs
.indexed(label)
.and_then(|indexed| indexed.decoded.as_ref())
else {
continue;
};
let capacity = budget.saturating_sub(examined);
let mut located = Vec::new();
let mut capped = false;
for field in fields {
if field.kind == Kind::Related
&& base_label(label) == "c2pa.ingredient.v3"
&& !super::spec25::applies(profile)
{
continue;
}
let mut path = String::new();
if locate(
assertion,
field.kind,
field.path,
0,
&mut path,
&mut located,
capacity,
) {
capped = true;
break;
}
}
examined = examined.saturating_add(located.len());
for reference in located {
let Some(url) = reference.value.get("url").and_then(Value::as_text) else {
continue;
};
if reference.kind == Kind::Either
&& (url.starts_with("https://") || url.starts_with("http://"))
{
urls.push(url.to_string());
}
}
if capped {
return (
urls,
ReferenceWalk {
examined,
truncated: true,
},
);
}
}
(
urls,
ReferenceWalk {
examined,
truncated: false,
},
)
}
#[allow(clippy::too_many_arguments)] fn validate_reference(
located: &Located<'_>,
alabel: &str,
assertion_url: &str,
generation: ClaimGeneration,
manifest_label: &str,
manifests: &[ParsedManifest<'_>],
inherited_alg: Option<&str>,
resources: &mut ResourceCache<'_>,
results: &mut ValidationResults,
) {
let path = &located.path;
let Some(url) = located.value.get("url").and_then(Value::as_text) else {
results.push_failure(
HASHED_URI_MISSING,
assertion_url.to_string(),
format!("{alabel} field '{path}' is not a hashed URI: no url"),
);
return;
};
if located.kind == Kind::Either && !url.starts_with("self#jumbf=") {
validate_external_reference(
located,
url,
alabel,
assertion_url,
inherited_alg,
resources,
results,
);
return;
}
let Some(destination) = resolve(url, manifest_label, manifests) else {
results.push_failure(
HASHED_URI_MISSING,
url.to_string(),
format!("{alabel} field '{path}' references '{url}', which cannot be located"),
);
return;
};
let algorithm = located
.value
.get("alg")
.and_then(Value::as_text)
.or(inherited_alg);
let Some(algorithm) = algorithm else {
results.push_failure(
ALGORITHM_UNSUPPORTED,
url.to_string(),
format!("{alabel} field '{path}' has no hash algorithm"),
);
return;
};
if !is_allowed_hash_algorithm(algorithm) {
results.push_failure(
ALGORITHM_UNSUPPORTED,
url.to_string(),
format!("{alabel} field '{path}' uses unsupported hash algorithm '{algorithm}'"),
);
return;
}
let expected = located.value.get("hash").and_then(Value::as_bytes);
let matches_domain = |bytes: Option<&[u8]>| {
matches!(
(bytes.and_then(|bytes| hash_bytes(algorithm, bytes)), expected),
(Some(actual), Some(expected)) if actual.as_slice() == expected
)
};
let matched = matches_domain(Some(destination.jumbf))
|| (generation == ClaimGeneration::V1 && matches_domain(destination.payload));
if !matched {
results.push_failure(
HASHED_URI_MISMATCH,
url.to_string(),
format!("{alabel} field '{path}' hashed uri mismatch: {url}"),
);
}
}
#[allow(clippy::too_many_arguments)]
fn validate_external_reference(
located: &Located<'_>,
url: &str,
alabel: &str,
assertion_url: &str,
inherited_alg: Option<&str>,
resources: &mut ResourceCache<'_>,
results: &mut ValidationResults,
) {
const NOT_RETRIEVED: &str = "com.encypher.reference.externalNotRetrieved";
if !(url.starts_with("https://") || url.starts_with("http://")) {
results.push_failure(
HASHED_URI_MISSING,
assertion_url.to_string(),
format!(
"{alabel} field '{}' has an invalid external URL",
located.path
),
);
return;
}
let algorithm = located
.value
.get("alg")
.and_then(Value::as_text)
.or(inherited_alg);
let Some(algorithm) = algorithm.filter(|value| is_allowed_hash_algorithm(value)) else {
results.push_failure(
ALGORITHM_UNSUPPORTED,
assertion_url.to_string(),
format!(
"{alabel} field '{}' has no supported hash algorithm",
located.path
),
);
return;
};
let Some(expected) = located.value.get("hash").and_then(Value::as_bytes) else {
results.push_failure(
HASHED_URI_MISMATCH,
assertion_url.to_string(),
format!("{alabel} field '{}' has no hash", located.path),
);
return;
};
match resources.resolve(url) {
ResolvedResource::NotAttempted => results.push_informational(
NOT_RETRIEVED,
url.to_string(),
format!(
"{alabel} field '{}' references external data that was not retrieved",
located.path
),
),
ResolvedResource::Unavailable => results.push_failure(
HASHED_URI_MISSING,
url.to_string(),
format!(
"{alabel} field '{}' external data could not be retrieved",
located.path
),
),
ResolvedResource::Retrieved { bytes, media_type } => {
if let Some(declared) = located.value.get("size").and_then(|value| match value {
Value::Integer(value) => usize::try_from(*value).ok(),
_ => None,
}) {
if declared != bytes.len() {
results.push_failure(
HASHED_URI_MISMATCH,
url.to_string(),
format!(
"{alabel} field '{}' declares {declared} bytes; retrieved {}",
located.path,
bytes.len()
),
);
return;
}
}
if let (Some(declared), Some(observed)) = (
located.value.get("dc:format").and_then(Value::as_text),
media_type,
) {
if declared != observed {
results.push_informational(
NOT_RETRIEVED,
url.to_string(),
format!(
"{alabel} field '{}' declares dc:format '{declared}' but was retrieved as '{observed}'",
located.path
),
);
}
}
if hash_bytes(algorithm, bytes).as_deref() == Some(expected) {
results.push_success(
ASSERTION_HASHED_URI_MATCH,
url.to_string(),
format!("{alabel} field '{}' hashed uri matched", located.path),
);
} else {
results.push_failure(
HASHED_URI_MISMATCH,
url.to_string(),
format!("{alabel} field '{}' hashed uri mismatch", located.path),
);
}
}
}
}
struct Destination<'a> {
jumbf: &'a [u8],
payload: Option<&'a [u8]>,
}
fn resolve<'a>(
url: &str,
manifest_label: &str,
manifests: &'a [ParsedManifest<'_>],
) -> Option<Destination<'a>> {
let rest = url.strip_prefix("self#jumbf=")?;
let (target_manifest, alabel) = match rest.strip_prefix("/c2pa/") {
Some(absolute) => absolute.split_once("/c2pa.assertions/")?,
None => (manifest_label, rest.strip_prefix("c2pa.assertions/")?),
};
if alabel.is_empty() || alabel.contains('/') {
return None;
}
let manifest = unique(manifests, |manifest| manifest.label == target_manifest)?;
let (_, jumbf) = unique(&manifest.assertion_jumbf, |(label, _)| label == alabel)?;
Some(Destination {
jumbf,
payload: unique(&manifest.assertions, |(label, _)| label == alabel)
.map(|(_, payload)| *payload),
})
}
fn unique<T>(items: &[T], mut matches: impl FnMut(&T) -> bool) -> Option<&T> {
let mut found = items.iter().filter(|item| matches(item));
let first = found.next()?;
found.next().is_none().then_some(first)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::c2pa_cbor::{encode, Profile};
use crate::c2pa_core::jumbf::{assertion_box, superbox_content};
use sha2::{Digest, Sha256};
fn enc(value: &Value) -> Vec<u8> {
encode(value, Profile::CanonicalForHashedSubstructures).expect("encode")
}
fn vmap(fields: Vec<(&str, Value)>) -> Value {
Value::Map(
fields
.into_iter()
.map(|(key, value)| (Value::Text(key.into()), value))
.collect(),
)
}
fn sha(bytes: &[u8]) -> Vec<u8> {
Sha256::digest(bytes).to_vec()
}
const MANIFEST: &str = "urn:c2pa:active";
struct Fixture {
jumbf: Vec<(String, Vec<u8>)>,
payloads: Vec<(String, Vec<u8>)>,
}
impl Fixture {
fn new(assertions: &[(&str, Value)]) -> Self {
let mut jumbf = Vec::new();
let mut payloads = Vec::new();
for (label, value) in assertions {
let cbor = enc(value);
let boxed = assertion_box(label, &cbor, None);
let content = superbox_content(&boxed).expect("content").to_vec();
jumbf.push(((*label).to_string(), content));
payloads.push(((*label).to_string(), cbor));
}
Self { jumbf, payloads }
}
fn manifest(&self) -> ParsedManifest<'_> {
ParsedManifest {
label: MANIFEST.into(),
manifest_jumbf: &[],
assertions: self
.payloads
.iter()
.map(|(label, bytes)| (label.clone(), bytes.as_slice()))
.collect(),
assertion_jumbf: self
.jumbf
.iter()
.map(|(label, bytes)| (label.clone(), bytes.as_slice()))
.collect(),
claim_cbor: None,
signature_cose: None,
claim_count: 1,
claim_box_label: None,
}
}
fn content(&self, label: &str) -> &[u8] {
self.jumbf
.iter()
.find(|(candidate, _)| candidate == label)
.map(|(_, bytes)| bytes.as_slice())
.expect("assertion")
}
}
fn claim_for(labels: &[&str]) -> Value {
vmap(vec![(
"created_assertions",
Value::Array(
labels
.iter()
.map(|label| {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
),
("hash", Value::Bytes(vec![0; 32])),
])
})
.collect(),
),
)])
}
fn run(fixture: &Fixture, claim: &Value) -> ValidationResults {
run_as(fixture, claim, ClaimGeneration::V2, EngineProfile::GENEROUS)
}
fn run_as(
fixture: &Fixture,
claim: &Value,
generation: ClaimGeneration,
profile: EngineProfile,
) -> ValidationResults {
let manifests = [fixture.manifest()];
let refs = ClaimAssertionRefs::build(&manifests[0], claim, generation);
let mut results = ValidationResults::default();
let mut resources = ResourceCache::with_evidence(None, OnlineEvidence::default());
verify_assertion_references(
AssertionReferenceContext {
claim,
claim_refs: &refs,
generation,
profile,
manifest_label: MANIFEST,
manifests: &manifests,
budget: usize::MAX,
},
&mut resources,
&mut results,
);
results
}
fn hashed_uri(label: &str, hash: Vec<u8>) -> Value {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
),
("hash", Value::Bytes(hash)),
("alg", Value::Text("sha256".into())),
])
}
#[test]
fn ingredient_thumbnail_reference_digest_is_enforced() {
let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
let good = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb.clone()),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.ingredient", digest.clone()),
),
]),
),
]);
let results = run(
&good,
&claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
);
assert!(!results.has_failure(HASHED_URI_MISMATCH));
assert!(!results.has_failure(HASHED_URI_MISSING));
let tampered = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
),
]),
),
]);
let results = run(
&tampered,
&claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
);
assert!(results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn unresolvable_reference_reports_missing_and_active_manifest_is_exempt() {
let fixture = Fixture::new(&[(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.absent", vec![1; 32]),
),
(
"activeManifest",
vmap(vec![
("url", Value::Text("self#jumbf=/c2pa/urn:c2pa:gone".into())),
("hash", Value::Bytes(vec![2; 32])),
("alg", Value::Text("sha256".into())),
]),
),
]),
)]);
let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
assert_eq!(
results
.failure
.iter()
.filter(|status| status.code == HASHED_URI_MISSING)
.count(),
1
);
assert!(results
.failure
.iter()
.all(|status| !status.explanation.contains("activeManifest")));
}
#[test]
fn nested_related_action_icon_is_walked() {
let icon = vmap(vec![("data", Value::Bytes(vec![3; 8]))]);
let related_action = vmap(vec![
("action", Value::Text("c2pa.edited".into())),
(
"softwareAgent",
vmap(vec![
("name", Value::Text("agent".into())),
("icon", hashed_uri("c2pa.icon", vec![0xCD; 32])),
]),
),
]);
let actions = vmap(vec![(
"actions",
Value::Array(vec![vmap(vec![
("action", Value::Text("c2pa.created".into())),
("related", Value::Array(vec![related_action])),
])]),
)]);
let fixture = Fixture::new(&[("c2pa.icon", icon), ("c2pa.actions.v2", actions)]);
let results = run(&fixture, &claim_for(&["c2pa.icon", "c2pa.actions.v2"]));
assert!(results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn metadata_subtree_is_never_validated() {
let fixture = Fixture::new(&[(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"metadata",
vmap(vec![("reference", hashed_uri("c2pa.absent", vec![9; 32]))]),
),
]),
)]);
let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
assert!(results.failure.is_empty(), "{:?}", results.failure);
}
#[test]
fn external_data_reference_is_left_to_external_validation() {
let external = Fixture::new(&[(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("inputTo".into())),
(
"data",
vmap(vec![
("url", Value::Text("https://example.com/data.bin".into())),
("hash", Value::Bytes(vec![4; 32])),
("alg", Value::Text("sha256".into())),
]),
),
]),
)]);
let results = run(&external, &claim_for(&["c2pa.ingredient.v3"]));
assert!(results.failure.is_empty(), "{:?}", results.failure);
let internal = Fixture::new(&[
(
"c2pa.embedded-data",
vmap(vec![("v", Value::Bytes(vec![5]))]),
),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("inputTo".into())),
("data", hashed_uri("c2pa.embedded-data", vec![6; 32])),
]),
),
]);
let results = run(
&internal,
&claim_for(&["c2pa.embedded-data", "c2pa.ingredient.v3"]),
);
assert!(results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn hash_algorithm_is_inherited_then_checked() {
let thumb = vmap(vec![("data", Value::Bytes(vec![8; 4]))]);
let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
let inherited = |alg: Option<&str>, claim_alg: Option<&str>| {
let mut reference = vec![
(
"url",
Value::Text("self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient".into()),
),
("hash", Value::Bytes(digest.clone())),
];
if let Some(alg) = alg {
reference.push(("alg", Value::Text(alg.into())));
}
let fixture = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb.clone()),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
("thumbnail", vmap(reference)),
]),
),
]);
let mut claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
if let (Some(alg), Value::Map(entries)) = (claim_alg, &mut claim) {
entries.push((Value::Text("alg".into()), Value::Text(alg.into())));
}
run(&fixture, &claim)
};
let results = inherited(None, Some("sha256"));
assert!(results.failure.is_empty(), "{:?}", results.failure);
let results = inherited(None, None);
assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
let results = inherited(Some("md5"), Some("sha256"));
assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
assert!(!results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn schema_lookup_handles_instances_and_ignores_vendor_assertions() {
assert!(!ref_fields("c2pa.ingredient.v3__2").is_empty());
assert!(ref_fields("c2pa.ingredient.v3__x").is_empty());
assert!(ref_fields("com.example.thing").is_empty());
let fixture = Fixture::new(&[(
"com.example.thing",
vmap(vec![("thumbnail", hashed_uri("c2pa.absent", vec![1; 32]))]),
)]);
let results = run(&fixture, &claim_for(&["com.example.thing"]));
assert!(results.failure.is_empty(), "{:?}", results.failure);
}
#[test]
fn a_v1_claim_is_held_to_the_reference_rule_only_under_strict_conformance() {
let ingredient = vmap(vec![("relationship", Value::Text("parentOf".into()))]);
let actions = vmap(vec![(
"actions",
Value::Array(vec![vmap(vec![
("action", Value::Text("c2pa.opened".into())),
(
"parameters",
vmap(vec![(
"ingredient",
hashed_uri("c2pa.ingredient", vec![0xEE; 32]),
)]),
),
])]),
)]);
let fixture = Fixture::new(&[("c2pa.ingredient", ingredient), ("c2pa.actions", actions)]);
let claim = vmap(vec![(
"assertions",
Value::Array(
["c2pa.ingredient", "c2pa.actions"]
.iter()
.map(|label| {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
),
("hash", Value::Bytes(vec![0; 32])),
])
})
.collect(),
),
)]);
let generous = run_as(
&fixture,
&claim,
ClaimGeneration::V1,
EngineProfile::GENEROUS,
);
assert!(generous.failure.is_empty(), "{:?}", generous.failure);
let strict = run_as(
&fixture,
&claim,
ClaimGeneration::V1,
EngineProfile::CONFORMANCE_V2_2,
);
assert!(strict.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn a_retargeted_nested_reference_reaches_the_verification_report() {
use super::super::{
verify_manifest, CawgTrustInputs, StoreContext, VerifyInput,
MAX_REPORT_DECODED_VALUE_NODES,
};
use crate::c2pa_core::EngineProfile;
use crate::c2pa_formats::AssetFormat;
let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
let fixture = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
),
]),
),
]);
let claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
let claim_cbor = enc(&claim);
let mut manifest = fixture.manifest();
manifest.claim_cbor = Some(&claim_cbor);
manifest.claim_box_label = Some("c2pa.claim.v2".into());
let manifests = [manifest];
let manifest_hashes = std::collections::HashMap::new();
let input = VerifyInput {
data: &[],
mime: "application/c2pa",
claim_signer_trust: None,
tsa_trust: None,
allowed_certs: None,
validation_time: None,
profile: EngineProfile::GENEROUS,
evidence: Default::default(),
cawg_strict_encoding: false,
};
let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
let out = verify_manifest(
&manifests[0],
StoreContext {
manifests: &manifests,
manifest_hashes: &manifest_hashes,
},
&input,
AssetFormat::C2paStore,
&[],
None,
CawgTrustInputs::default(),
&mut report_decode_nodes,
);
assert!(
out.results.has_failure(HASHED_URI_MISMATCH),
"{:?}",
out.results.failure
);
}
#[test]
fn resource_cache_borrows_and_memoizes_provider_payloads() {
use std::cell::Cell;
struct Provider {
calls: Cell<usize>,
bytes: [u8; 4],
}
impl ExternalResources for Provider {
fn resolve(&self, url: &str) -> ExternalResource<'_> {
assert_eq!(url, "https://example.test/icon");
self.calls.set(self.calls.get() + 1);
ExternalResource::Retrieved {
bytes: &self.bytes,
media_type: Some("image/png"),
}
}
}
let provider = Provider {
calls: Cell::new(0),
bytes: [1, 2, 3, 4],
};
let mut cache = ResourceCache::with_evidence(Some(&provider), OnlineEvidence::default());
for _ in 0..2 {
let ResolvedResource::Retrieved { bytes, media_type } =
cache.resolve("https://example.test/icon")
else {
panic!("provider payload was not returned");
};
assert_eq!(bytes.as_ptr(), provider.bytes.as_ptr());
assert_eq!(media_type, Some("image/png"));
}
assert_eq!(provider.calls.get(), 1);
}
#[test]
fn resource_cache_gives_online_evidence_precedence_over_the_provider() {
use std::cell::Cell;
struct Provider {
calls: Cell<usize>,
}
impl ExternalResources for Provider {
fn resolve(&self, _: &str) -> ExternalResource<'_> {
self.calls.set(self.calls.get() + 1);
ExternalResource::Retrieved {
bytes: b"provider",
media_type: None,
}
}
}
const URL: &str = "https://example.test/evidence";
let provider = Provider {
calls: Cell::new(0),
};
let bodies = HashMap::from([(URL.to_string(), b"evidence".to_vec())]);
let media_types = HashMap::from([(URL.to_string(), "text/plain".to_string())]);
let mut cache = ResourceCache::with_evidence(
Some(&provider),
OnlineEvidence {
external_data: Some(&bodies),
external_data_media_types: Some(&media_types),
..OnlineEvidence::default()
},
);
let ResolvedResource::Retrieved { bytes, media_type } = cache.resolve(URL) else {
panic!("online evidence was not returned");
};
assert_eq!(bytes, b"evidence");
assert_eq!(media_type, Some("text/plain"));
assert_eq!(provider.calls.get(), 0);
let unreachable = [URL.to_string()];
let mut cache = ResourceCache::with_evidence(
Some(&provider),
OnlineEvidence {
external_data_unreachable: Some(&unreachable),
..OnlineEvidence::default()
},
);
assert!(matches!(cache.resolve(URL), ResolvedResource::Unavailable));
assert_eq!(provider.calls.get(), 0);
}
}