use std::collections::{HashMap, HashSet, VecDeque};
use serde_json::{json, Value as Json};
use super::versions::{self, ClaimGeneration};
use super::{
decode, is_generous, status_array, verify_assertion_bindings, verify_ingredient_references,
ClaimAssertionRefs, EngineProfile, IngredientManifestDigestCache, StatusCode, StoreContext,
ValidationResults, ALGORITHM_UNSUPPORTED, ASSERTION_INGREDIENT_MALFORMED, CLAIM_CBOR_INVALID,
CLAIM_MISSING, CLAIM_SIGNATURE_MISMATCH, CLAIM_SIGNATURE_MISSING, CLAIM_SIGNATURE_VALIDATED,
};
use crate::c2pa_cbor::Value;
use crate::c2pa_core::jumbf::ParsedManifest;
use crate::c2pa_crypto::{extract_x5chain, verify_claim_with, CryptoError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GraphLimits {
pub max_depth: usize,
pub max_nodes: usize,
pub max_references: usize,
pub max_bytes: usize,
}
impl GraphLimits {
pub const DEFAULT: Self = Self {
max_depth: 32,
max_nodes: 1_024,
max_references: 16_384,
max_bytes: 64 * 1024 * 1024,
};
pub fn lowered(
max_depth: usize,
max_nodes: usize,
max_references: usize,
max_bytes: usize,
) -> Self {
Self {
max_depth: max_depth.min(Self::DEFAULT.max_depth),
max_nodes: max_nodes.min(Self::DEFAULT.max_nodes),
max_references: max_references.min(Self::DEFAULT.max_references),
max_bytes: max_bytes.min(Self::DEFAULT.max_bytes),
}
}
}
impl Default for GraphLimits {
fn default() -> Self {
Self::DEFAULT
}
}
#[derive(Default, Clone, Copy)]
pub struct GraphInputs<'a> {
pub external: Option<&'a dyn super::refs::ExternalResources>,
pub limits: GraphLimits,
}
#[derive(Clone, Copy)]
enum Ceiling {
Depth = 0,
Nodes = 1,
References = 2,
Bytes = 3,
}
impl Ceiling {
const ALL: [Self; 4] = [Self::Depth, Self::Nodes, Self::References, Self::Bytes];
fn as_str(self) -> &'static str {
match self {
Self::Depth => "depth",
Self::Nodes => "nodes",
Self::References => "references",
Self::Bytes => "bytes",
}
}
}
pub(super) struct Budget {
limits: GraphLimits,
nodes: usize,
references: usize,
bytes: usize,
exhausted: [Option<usize>; 4],
reported: [bool; 4],
pub(super) redactions: Option<std::sync::Arc<super::redaction::Index>>,
}
impl Budget {
pub(super) fn new(limits: GraphLimits) -> Self {
Self {
limits: GraphLimits::lowered(
limits.max_depth,
limits.max_nodes,
limits.max_references,
limits.max_bytes,
),
nodes: 0,
references: 0,
bytes: 0,
exhausted: [None; 4],
reported: [false; 4],
redactions: None,
}
}
fn note(&mut self, ceiling: Ceiling, observed: usize) {
let slot = &mut self.exhausted[ceiling as usize];
if slot.is_none() {
*slot = Some(observed);
}
}
fn node(&mut self) -> bool {
if self.nodes >= self.limits.max_nodes {
self.note(Ceiling::Nodes, self.nodes.saturating_add(1));
return false;
}
self.nodes += 1;
true
}
fn bytes(&mut self, bytes: usize) -> bool {
let total = self.bytes.saturating_add(bytes);
if total > self.limits.max_bytes {
self.note(Ceiling::Bytes, total);
return false;
}
self.bytes = total;
true
}
pub(super) fn references_left(&self) -> usize {
self.limits.max_references.saturating_sub(self.references)
}
pub(super) fn references(&mut self, count: usize) -> bool {
let total = self.references.saturating_add(count);
if total > self.limits.max_references {
self.note(Ceiling::References, total);
self.references = self.limits.max_references;
return false;
}
self.references = total;
true
}
pub(super) fn exhaust_references(&mut self) {
self.note(
Ceiling::References,
self.limits.max_references.saturating_add(1),
);
}
fn depth(&mut self, depth: usize) -> bool {
if depth > self.limits.max_depth {
self.note(Ceiling::Depth, depth);
return false;
}
true
}
pub(super) fn resource_edge(&mut self, depth: usize) -> bool {
let references = self.references(1);
let depth = self.depth(depth);
let nodes = self.node();
references && depth && nodes
}
pub(super) fn resource_bytes(&mut self, bytes: usize) -> bool {
self.bytes(bytes)
}
pub(super) fn report(&mut self, active_label: &str, results: &mut ValidationResults) {
for ceiling in Ceiling::ALL {
if self.reported[ceiling as usize] {
continue;
}
let Some(observed) = self.exhausted[ceiling as usize] else {
continue;
};
self.reported[ceiling as usize] = true;
let limit = match ceiling {
Ceiling::Depth => self.limits.max_depth,
Ceiling::Nodes => self.limits.max_nodes,
Ceiling::References => self.limits.max_references,
Ceiling::Bytes => self.limits.max_bytes,
};
let name = ceiling.as_str();
if results.informational.iter().any(|status| {
status.code == GRAPH_LIMIT_EXCEEDED
&& status
.details
.as_ref()
.and_then(|details| details.get("limit"))
.and_then(serde_json::Value::as_str)
== Some(name)
}) {
continue;
}
results.informational.push(StatusCode {
code: GRAPH_LIMIT_EXCEEDED.into(),
url: format!("self#jumbf=/c2pa/{active_label}"),
resource_url: None,
explanation: format!(
"verification graph {name} ceiling reached ({limit}); traversal stopped. \
This is a resource outcome and does not affect manifest validity."
),
details: Some(json!({ "limit": name, "ceiling": limit, "observed": observed })),
});
}
}
}
pub(super) const GRAPH_LIMIT_EXCEEDED: &str = "com.encypher.resource.graphLimitExceeded";
pub(super) struct IngredientDelta {
assertion_uri: String,
deltas: ValidationResults,
}
pub(super) struct ReachedManifest {
pub(super) label: String,
pub(super) claim: Value,
}
pub(super) struct IngredientGraph {
pub(super) active: ValidationResults,
pub(super) deltas: Vec<IngredientDelta>,
pub(super) reached: Vec<ReachedManifest>,
}
struct Edge {
assertion_uri: String,
target: String,
recorded: Option<Value>,
}
pub(super) fn redaction_index(
active_label: &str,
store: StoreContext<'_>,
profile: EngineProfile,
limits: GraphLimits,
results: &mut ValidationResults,
) -> std::sync::Arc<super::redaction::Index> {
let mut index = super::redaction::Index::default();
let mut budget = Budget::new(limits);
let mut seen = HashSet::new();
let mut queue = VecDeque::from([(active_label.to_string(), 0)]);
while let Some((label, depth)) = queue.pop_front() {
if !seen.insert(label.clone()) {
continue;
}
let Some(manifest) = store
.manifests
.iter()
.find(|manifest| manifest.label == label)
else {
continue;
};
if !budget.depth(depth) || !budget.node() || !budget.bytes(manifest.manifest_jumbf.len()) {
break;
}
let Some(claim) = manifest.claim_cbor.and_then(|bytes| decode(bytes).ok()) else {
continue;
};
let generation = versions::claim_generation(manifest, &claim);
let mut refs = ClaimAssertionRefs::build(manifest, &claim, generation);
if label != active_label && is_time_stamp_manifest(&refs) {
continue;
}
let declarations = match claim.get("redacted_assertions") {
Some(Value::Array(entries)) => {
entries.len().min(super::redaction::MAX_REDACTED_ASSERTIONS)
}
_ => 0,
};
if !budget.references(declarations) {
break;
}
index.add_claim(&label, &claim, store.manifests);
let mut ignored = ValidationResults::default();
let mut next = edges(
manifest,
&claim,
&mut refs,
generation,
profile,
&mut ignored,
);
next.sort_by(|left, right| left.target.cmp(&right.target));
if !budget.references(next.len()) {
break;
}
queue.extend(next.into_iter().map(|edge| (edge.target, depth + 1)));
}
budget.report(active_label, results);
std::sync::Arc::new(index)
}
pub(super) fn build<'a>(
active_label: &str,
store: StoreContext<'a>,
profile: EngineProfile,
budget: &mut Budget,
resources: &mut super::refs::ResourceCache<'_>,
) -> IngredientGraph {
let mut graph = IngredientGraph {
active: ValidationResults::default(),
deltas: Vec::new(),
reached: Vec::new(),
};
let index_of: HashMap<&str, usize> = store
.manifests
.iter()
.enumerate()
.map(|(index, manifest)| (manifest.label.as_str(), index))
.collect();
let Some(active) = index_of.get(active_label).copied() else {
return graph;
};
let mut claims: Vec<Option<Value>> = store
.manifests
.iter()
.map(|manifest| manifest.claim_cbor.and_then(|cbor| decode(cbor).ok()))
.collect();
let mut results: Vec<ValidationResults> = store
.manifests
.iter()
.map(|_| ValidationResults::default())
.collect();
let mut reached: Vec<bool> = vec![false; store.manifests.len()];
let mut pending: Vec<(usize, Edge)> = Vec::new();
let mut cache = IngredientManifestDigestCache::new(store, profile);
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
queue.push_back((active, 0));
reached[active] = true;
'walk: while let Some((index, depth)) = queue.pop_front() {
let manifest = &store.manifests[index];
if !budget.node() || !budget.bytes(manifest.manifest_jumbf.len()) {
break;
}
let is_active = index == active;
let Some(claim) = claims[index].as_ref() else {
if !is_active {
record_missing_claim(manifest, &mut results[index]);
}
continue;
};
let generation = versions::claim_generation(manifest, claim);
let mut claim_refs = ClaimAssertionRefs::build(manifest, claim, generation);
claim_refs.redactions = budget.redactions.clone();
if !is_active && is_time_stamp_manifest(&claim_refs) {
continue;
}
if !is_active {
validate_reached_manifest(
manifest,
claim,
generation,
&mut claim_refs,
&mut cache,
profile,
&mut results[index],
);
let walked = super::refs::verify_assertion_references(
super::refs::AssertionReferenceContext {
claim,
claim_refs: &claim_refs,
generation,
profile,
manifest_label: &manifest.label,
manifests: store.manifests,
budget: budget.references_left(),
},
resources,
&mut results[index],
);
if !budget.references(walked.examined) {
break;
}
if walked.truncated {
budget.exhaust_references();
break;
}
}
let graph_edges = edges(
manifest,
claim,
&mut claim_refs,
generation,
profile,
&mut results[index],
);
if !budget.references(graph_edges.len()) {
break;
}
for edge in graph_edges {
let Some(target) = index_of.get(edge.target.as_str()).copied() else {
continue;
};
pending.push((target, edge));
if reached[target] {
continue;
}
if !budget.depth(depth + 1) {
break 'walk;
}
reached[target] = true;
queue.push_back((target, depth + 1));
}
}
budget.report(active_label, &mut graph.active);
for (target, edge) in pending {
let deltas = validation_deltas(edge.recorded.as_ref(), &results[target]);
if deltas.success.is_empty() && deltas.informational.is_empty() && deltas.failure.is_empty()
{
continue;
}
graph.deltas.push(IngredientDelta {
assertion_uri: edge.assertion_uri,
deltas,
});
}
graph.active.success.append(&mut results[active].success);
graph
.active
.informational
.append(&mut results[active].informational);
graph.active.failure.append(&mut results[active].failure);
for (index, claim) in claims.iter_mut().enumerate() {
if index == active || !reached[index] {
continue;
}
if let Some(claim) = claim.take() {
graph.reached.push(ReachedManifest {
label: store.manifests[index].label.clone(),
claim,
});
}
}
graph
}
pub(super) fn attach(report: &mut Json, deltas: &[IngredientDelta]) {
if deltas.is_empty() {
return;
}
let Some(results) = report
.get_mut("validation_results")
.and_then(Json::as_object_mut)
else {
return;
};
results.insert("ingredientDeltas".into(), deltas_json(deltas));
}
pub(super) fn deltas_json(deltas: &[IngredientDelta]) -> Json {
Json::Array(
deltas
.iter()
.map(|delta| {
json!({
"ingredientAssertionURI": delta.assertion_uri,
"validationDeltas": {
"success": status_array(&delta.deltas.success),
"informational": status_array(&delta.deltas.informational),
"failure": status_array(&delta.deltas.failure),
},
})
})
.collect(),
)
}
fn record_missing_claim(manifest: &ParsedManifest<'_>, results: &mut ValidationResults) {
let url = format!("self#jumbf=/c2pa/{}/c2pa.signature", manifest.label);
if manifest.claim_cbor.is_none() {
results.push_failure(
CLAIM_MISSING,
url,
"no claim found in ingredient manifest".into(),
);
} else {
results.push_failure(
CLAIM_CBOR_INVALID,
url,
"ingredient manifest claim CBOR could not be decoded".into(),
);
}
}
pub(super) fn is_time_stamp_manifest(claim_refs: &ClaimAssertionRefs<'_>) -> bool {
let mut timestamps = false;
for label in &claim_refs.declaration_labels {
let base = label.split("__").next().unwrap_or(label);
match base {
"c2pa.time-stamp" => timestamps = true,
"c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3" => {}
_ => return false,
}
}
timestamps
}
fn validate_reached_manifest<'a>(
manifest: &'a ParsedManifest<'a>,
claim: &Value,
generation: ClaimGeneration,
claim_refs: &mut ClaimAssertionRefs<'_>,
cache: &mut IngredientManifestDigestCache<'a>,
profile: EngineProfile,
results: &mut ValidationResults,
) {
let sig_url = format!("self#jumbf=/c2pa/{}/c2pa.signature", manifest.label);
super::redaction::verify_claim_redactions(
&manifest.label,
claim,
cache.store.manifests,
results,
);
super::verify_redacted_identity_references(claim_refs, results);
verify_assertion_bindings(
claim,
claim_refs,
generation,
&manifest.label,
profile,
results,
);
verify_ingredient_references(
&manifest.label,
claim_refs,
claim,
cache,
&sig_url,
profile,
results,
);
let (Some(cose), Some(claim_cbor)) = (manifest.signature_cose, manifest.claim_cbor) else {
results.push_failure(
CLAIM_SIGNATURE_MISSING,
sig_url,
"ingredient manifest has no claim signature".into(),
);
return;
};
let leaf = extract_x5chain(cose)
.ok()
.and_then(|chain| chain.into_iter().next())
.filter(|leaf: &Vec<u8>| !leaf.is_empty());
let Some(leaf) = leaf else {
results.push_failure(
CLAIM_SIGNATURE_MISSING,
sig_url,
"ingredient manifest claim signature carries no certificate".into(),
);
return;
};
match verify_claim_with(
cose,
claim_cbor,
&leaf,
super::spec25::claim_algorithms(profile),
) {
Ok(()) => results.push_success(
CLAIM_SIGNATURE_VALIDATED,
sig_url,
"ingredient manifest claim signature verified".into(),
),
Err(CryptoError::UnsupportedAlg(id)) => results.push_failure(
ALGORITHM_UNSUPPORTED,
sig_url,
format!("ingredient manifest claim signature algorithm {id} is not on the C2PA allowed list"),
),
Err(error) => results.push_failure(
CLAIM_SIGNATURE_MISMATCH,
sig_url,
format!("ingredient manifest claim signature invalid: {error}"),
),
}
}
fn ingredient_edge_is_eligible(
manifest: &ParsedManifest<'_>,
claim: &Value,
generation: ClaimGeneration,
reference: &super::ClaimAssertionReference<'_>,
assertion: &mut super::IndexedAssertion<'_>,
) -> bool {
if !matches!(
reference.field,
"assertions" | "created_assertions" | "gathered_assertions"
) || reference
.label
.and_then(|label| manifest.assertion_content_is_zeroed(label))
!= Some(false)
{
return false;
}
let Some(expected) = reference.value.get("hash").and_then(Value::as_bytes) else {
return false;
};
let Some(algorithm) = super::resolved_hash_algorithm(&[reference.value, claim]) else {
return false;
};
let Some(index) = super::bmff_algorithm_index(algorithm) else {
return false;
};
if assertion.jumbf_digests[index].is_none() {
assertion.jumbf_digests[index] = assertion
.jumbf
.and_then(|bytes| super::hash_bytes(algorithm, bytes));
}
assertion.jumbf_digests[index].as_deref() == Some(expected)
|| (generation == ClaimGeneration::V1
&& assertion
.payload
.and_then(|bytes| super::hash_bytes(algorithm, bytes))
.as_deref()
== Some(expected))
}
fn edges(
manifest: &ParsedManifest<'_>,
claim: &Value,
claim_refs: &mut ClaimAssertionRefs<'_>,
generation: ClaimGeneration,
profile: EngineProfile,
results: &mut ValidationResults,
) -> Vec<Edge> {
let mut edges = Vec::new();
for reference in &claim_refs.references {
let Some(label) = reference.label else {
continue;
};
if !matches!(
super::refs::base_label(label),
"c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3"
) {
continue;
}
if claim_refs.is_redacted(label) {
continue;
}
let Some(assertion) = claim_refs.assertions.get_mut(label) else {
continue;
};
if !ingredient_edge_is_eligible(manifest, claim, generation, reference, assertion) {
continue;
}
let Some(ingredient) = assertion.decoded.as_ref() else {
continue;
};
let assertion_uri = format!(
"self#jumbf=/c2pa/{}/c2pa.assertions/{label}",
manifest.label
);
let Some(target) = ingredient
.get("activeManifest")
.or_else(|| ingredient.get("c2pa_manifest"))
.and_then(|link| link.get("url"))
.and_then(Value::as_text)
.and_then(|url| super::ingredient_manifest_label(url, None))
else {
continue;
};
let recorded = ingredient.get("validationResults").cloned();
if recorded.is_none()
&& !is_generous(profile)
&& generation == ClaimGeneration::V2
&& label.starts_with("c2pa.ingredient.v3")
&& ingredient.get("activeManifest").is_some()
{
results.push_failure(
ASSERTION_INGREDIENT_MALFORMED,
assertion_uri.clone(),
format!("ingredient '{label}' links a manifest but records no validationResults"),
);
}
edges.push(Edge {
assertion_uri,
target: target.to_string(),
recorded,
});
}
edges
}
fn validation_deltas(recorded: Option<&Value>, observed: &ValidationResults) -> ValidationResults {
let mut recorded_entries: Vec<(&'static str, StatusCode)> = Vec::new();
if let Some(recorded) = recorded {
collect_recorded(recorded.get("activeManifest"), &mut recorded_entries);
if let Some(Value::Array(nested)) = recorded.get("ingredientDeltas") {
for entry in nested {
collect_recorded(entry.get("validationDeltas"), &mut recorded_entries);
}
}
}
let recorded_keys: HashSet<(&str, &str)> = recorded_entries
.iter()
.map(|(_, status)| (status.code.as_str(), status.url.as_str()))
.collect();
let observed_keys: HashSet<(&str, &str)> = observed
.success
.iter()
.chain(&observed.informational)
.chain(&observed.failure)
.map(|status| (status.code.as_str(), status.url.as_str()))
.collect();
let mut deltas = ValidationResults::default();
let new_codes = |codes: &[StatusCode]| -> Vec<StatusCode> {
codes
.iter()
.filter(|status| !recorded_keys.contains(&(status.code.as_str(), status.url.as_str())))
.cloned()
.collect()
};
deltas.success = new_codes(&observed.success);
deltas.informational = new_codes(&observed.informational);
deltas.failure = new_codes(&observed.failure);
for (bucket, status) in recorded_entries {
if observed_keys.contains(&(status.code.as_str(), status.url.as_str())) {
continue;
}
let status = StatusCode {
details: Some(json!({ "source": "recorded" })),
..status
};
match bucket {
"success" => deltas.success.push(status),
"informational" => deltas.informational.push(status),
_ => deltas.failure.push(status),
}
}
deltas
}
fn collect_recorded(codes: Option<&Value>, into: &mut Vec<(&'static str, StatusCode)>) {
let Some(codes) = codes else {
return;
};
for bucket in ["success", "informational", "failure"] {
let Some(Value::Array(entries)) = codes.get(bucket) else {
continue;
};
for entry in entries {
let Some(code) = entry.get("code").and_then(Value::as_text) else {
continue;
};
into.push((
bucket,
StatusCode {
code: code.to_string(),
url: entry
.get("url")
.and_then(Value::as_text)
.unwrap_or_default()
.to_string(),
resource_url: entry
.get("resourceUrl")
.and_then(Value::as_text)
.map(str::to_string),
explanation: entry
.get("explanation")
.and_then(Value::as_text)
.unwrap_or("recorded by the ingredient's claim generator")
.to_string(),
details: None,
},
));
}
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::collections::HashMap;
use sha2::{Digest, Sha256};
use time::macros::datetime;
use time::OffsetDateTime;
use super::super::signature_conformance_tests::Signer;
use super::super::{
hash_claim_signature_box, manifest_hashes, verify_manifest, CawgTrustInputs, CrjsonContext,
StoreContext, VerifyInput, VerifyOutput, MAX_REPORT_DECODED_VALUE_NODES,
};
use crate::c2pa_cbor::{encode, Profile, Value};
use crate::c2pa_core::jumbf::{
assertion_box, build_manifest, build_manifest_store, cbor_box, expand_store,
parse_manifest_store, superbox, superbox_content, UUID_ASSERTION_STORE, UUID_CLAIM,
UUID_CLAIM_SIGNATURE, UUID_UPDATE_MANIFEST,
};
use crate::c2pa_core::EngineProfile;
use crate::c2pa_formats::AssetFormat;
use crate::{ExternalResource, ExternalResources, GraphInputs, GraphLimits, VerifyOptions};
use serde_json::Value as Json;
const INGREDIENT: &str = "urn:c2pa:00000000-0000-4000-8000-000000000001";
const ACTIVE: &str = "urn:c2pa:00000000-0000-4000-8000-000000000002";
const NOW: OffsetDateTime = datetime!(2026-06-01 0:00 UTC);
fn enc(value: &Value) -> Vec<u8> {
encode(value, Profile::LegacyPipelineBDefinite).expect("encode")
}
fn vmap(pairs: Vec<(&str, Value)>) -> Value {
Value::Map(
pairs
.into_iter()
.map(|(key, value)| (Value::Text(key.into()), value))
.collect(),
)
}
#[derive(Clone)]
struct Assertion {
label: String,
jumbf: Vec<u8>,
}
impl Assertion {
fn new(label: &str, value: &Value) -> Self {
Self {
label: label.to_string(),
jumbf: assertion_box(label, &enc(value), None),
}
}
fn reference(&self) -> Value {
let content = superbox_content(&self.jumbf).expect("assertion content");
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{}", self.label)),
),
("alg", Value::Text("sha256".into())),
("hash", Value::Bytes(Sha256::digest(content).to_vec())),
])
}
fn tampered_reference(&self) -> Value {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{}", self.label)),
),
("alg", Value::Text("sha256".into())),
("hash", Value::Bytes(vec![0x00; 32])),
])
}
}
fn data_hash() -> Value {
vmap(vec![
("exclusions", Value::Array(Vec::new())),
("alg", Value::Text("sha256".into())),
("hash", Value::Bytes(vec![0x11; 32])),
])
}
fn created_actions() -> Value {
vmap(vec![(
"actions",
Value::Array(vec![vmap(vec![
("action", Value::Text("c2pa.created".into())),
(
"digitalSourceType",
Value::Text(
"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture".into(),
),
),
])]),
)])
}
fn claim(references: Vec<Value>) -> Value {
vmap(vec![
("instanceID", Value::Text("xmp:iid:fixture".into())),
(
"claim_generator_info",
vmap(vec![("name", Value::Text("Encypher Fixture".into()))]),
),
("created_assertions", Value::Array(references)),
("signature", Value::Text("self#jumbf=c2pa.signature".into())),
])
}
fn ingredient(
target_label: &str,
target_manifest: &[u8],
target_cose: &[u8],
relationship: &str,
recorded: Option<Value>,
) -> Value {
let content = superbox_content(target_manifest).expect("manifest content");
let mut fields = vec![
("relationship", Value::Text(relationship.into())),
("dc:title", Value::Text("ingredient.jpg".into())),
(
"activeManifest",
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=/c2pa/{target_label}")),
),
("alg", Value::Text("sha256".into())),
("hash", Value::Bytes(Sha256::digest(content).to_vec())),
]),
),
(
"claimSignature",
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=/c2pa/{target_label}/c2pa.signature")),
),
("alg", Value::Text("sha256".into())),
(
"hash",
Value::Bytes(
hash_claim_signature_box("sha256", target_cose)
.expect("signature hash"),
),
),
]),
),
];
if let Some(recorded) = recorded {
fields.push(("validationResults", recorded));
}
vmap(fields)
}
fn unlinked_ingredient(relationship: &str) -> Value {
vmap(vec![
("relationship", Value::Text(relationship.into())),
("dc:title", Value::Text("no-provenance.jpg".into())),
])
}
fn recorded_results(codes: Vec<(&str, &str, &str)>) -> Value {
let mut success = Vec::new();
let mut informational = Vec::new();
let mut failure = Vec::new();
for (bucket, code, url) in codes {
let entry = vmap(vec![
("code", Value::Text(code.into())),
("url", Value::Text(url.into())),
]);
match bucket {
"success" => success.push(entry),
"informational" => informational.push(entry),
_ => failure.push(entry),
}
}
vmap(vec![(
"activeManifest",
vmap(vec![
("success", Value::Array(success)),
("informational", Value::Array(informational)),
("failure", Value::Array(failure)),
]),
)])
}
fn manifest_box(
signer: &Signer,
label: &str,
assertions: &[Assertion],
references: Vec<Value>,
) -> (Vec<u8>, Vec<u8>) {
let claim_cbor = enc(&claim(references));
let cose = signer.sign(&claim_cbor);
let boxes: Vec<Vec<u8>> = assertions
.iter()
.map(|assertion| assertion.jumbf.clone())
.collect();
(
build_manifest(label, &boxes, &claim_cbor, &cose),
cose.clone(),
)
}
fn update_manifest_box(
signer: &Signer,
label: &str,
assertions: &[Assertion],
references: Vec<Value>,
) -> Vec<u8> {
let claim_cbor = enc(&claim(references));
let cose = signer.sign(&claim_cbor);
superbox(
&UUID_UPDATE_MANIFEST,
label,
&[
superbox(
&UUID_ASSERTION_STORE,
"c2pa.assertions",
&assertions
.iter()
.map(|assertion| assertion.jumbf.clone())
.collect::<Vec<_>>(),
None,
),
superbox(&UUID_CLAIM, "c2pa.claim.v2", &[cbor_box(&claim_cbor)], None),
superbox(
&UUID_CLAIM_SIGNATURE,
"c2pa.signature",
&[cbor_box(&cose)],
None,
),
],
None,
)
}
fn verify_store(boxes: &[Vec<u8>]) -> VerifyOutput {
verify_store_under(boxes, EngineProfile::GENEROUS)
}
fn verify_store_under(boxes: &[Vec<u8>], profile: EngineProfile) -> VerifyOutput {
let store = build_manifest_store(boxes);
let expanded = expand_store(&store).expect("expand store");
let parsed = parse_manifest_store(expanded.bytes()).expect("parse store");
let hashes = manifest_hashes(&expanded, &parsed.manifests).expect("manifest hashes");
let active = parsed.manifests.last().expect("active manifest");
let input = VerifyInput {
data: &[],
mime: "application/c2pa",
claim_signer_trust: None,
tsa_trust: None,
allowed_certs: None,
validation_time: Some(NOW),
profile,
evidence: Default::default(),
cawg_strict_encoding: false,
};
let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
verify_manifest(
active,
StoreContext {
manifests: &parsed.manifests,
manifest_hashes: &hashes,
},
&input,
AssetFormat::C2paStore,
&[],
None,
CawgTrustInputs::default(),
&mut report_decode_nodes,
)
}
fn deltas(out: &VerifyOutput) -> Vec<&Json> {
out.report_json
.pointer("/validation_results/ingredientDeltas")
.and_then(Json::as_array)
.map(|entries| entries.iter().collect())
.unwrap_or_default()
}
fn delta_codes<'a>(delta: &'a Json, bucket: &str) -> Vec<&'a str> {
delta
.pointer(&format!("/validationDeltas/{bucket}"))
.and_then(Json::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.get("code").and_then(Json::as_str))
.collect()
})
.unwrap_or_default()
}
fn tampered_ingredient_store() -> Vec<Vec<u8>> {
let signer = Signer::conformant();
let ingredient_binding = Assertion::new("c2pa.hash.data", &data_hash());
let ingredient_actions = Assertion::new("c2pa.actions.v2", &created_actions());
let (ingredient_manifest, ingredient_cose) = manifest_box(
&signer,
INGREDIENT,
&[ingredient_binding.clone(), ingredient_actions.clone()],
vec![
ingredient_binding.tampered_reference(),
ingredient_actions.reference(),
],
);
let active_binding = Assertion::new("c2pa.hash.data", &data_hash());
let active_actions = Assertion::new("c2pa.actions.v2", &created_actions());
let active_ingredient = Assertion::new(
"c2pa.ingredient.v3",
&ingredient(
INGREDIENT,
&ingredient_manifest,
&ingredient_cose,
"componentOf",
Some(recorded_results(vec![(
"success",
"claimSignature.validated",
&format!("self#jumbf=/c2pa/{INGREDIENT}/c2pa.signature"),
)])),
),
);
let (active_manifest, _) = manifest_box(
&signer,
ACTIVE,
&[
active_binding.clone(),
active_actions.clone(),
active_ingredient.clone(),
],
vec![
active_binding.reference(),
active_actions.reference(),
active_ingredient.reference(),
],
);
vec![ingredient_manifest, active_manifest]
}
fn related_ingredient_store(
fields: impl FnOnce(&Assertion, &Assertion, &Assertion) -> Vec<(&'static str, Value)>,
) -> Vec<u8> {
let signer = Signer::conformant();
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let actions = Assertion::new("c2pa.actions.v2", &created_actions());
let related = Assertion::new(
"com.example.ingredient",
&vmap(vec![(
"description",
Value::Text("ingredient metadata".into()),
)]),
);
let child_assertions = vec![binding.clone(), actions.clone(), related.clone()];
let (child, child_cose) = manifest_box(
&signer,
INGREDIENT,
&child_assertions,
child_assertions.iter().map(Assertion::reference).collect(),
);
let mut link = ingredient(
INGREDIENT,
&child,
&child_cose,
"componentOf",
Some(recorded_results(vec![])),
);
let Value::Map(entries) = &mut link else {
unreachable!("ingredient is a map");
};
entries.extend(
fields(&related, &actions, &binding)
.into_iter()
.map(|(key, value)| (Value::Text(key.into()), value)),
);
let link = Assertion::new("c2pa.ingredient.v3", &link);
let assertions = vec![binding, actions, related, link];
let (active, _) = manifest_box(
&signer,
ACTIVE,
&assertions,
assertions.iter().map(Assertion::reference).collect(),
);
build_manifest_store(&[child, active])
}
fn verify_related_ingredient_store(store: &[u8], version: &str) -> crate::StoreOnlyValidation {
crate::verify_manifest_store_only(
store,
&VerifyOptions {
spec_version: Some(version.into()),
no_default_trust: true,
validation_time: Some("2026-06-01T00:00:00Z".into()),
..VerifyOptions::default()
},
)
.expect("signed ingredient store validation")
}
#[test]
fn final_2_5_accepts_signed_active_manifest_with_related_assertions() {
let store = related_ingredient_store(|related, _, _| {
vec![("relatedAssertions", Value::Array(vec![related.reference()]))]
});
for version in ["2.2", "2.4", "2.5"] {
let validation = verify_related_ingredient_store(&store, version);
let results = &validation.report.validation_results;
assert!(
results.failure.is_empty(),
"{version}: {:?}",
results.failure
);
for code in ["claimSignature.validated", "ingredient.manifest.validated"] {
assert!(
results.success.iter().any(|status| status.code == code),
"{version}: missing {code}: {:?}",
results.success
);
}
let deltas = validation
.report
.manifest_report
.pointer("/validation_results/ingredientDeltas")
.and_then(Json::as_array)
.expect("recursive ingredient validation is reported");
assert_eq!(deltas.len(), 1, "{version}: {deltas:?}");
assert!(
delta_codes(&deltas[0], "failure").is_empty(),
"{version}: {}",
deltas[0]
);
assert!(
delta_codes(&deltas[0], "success").contains(&"claimSignature.validated"),
"{version}: child signature must also validate: {}",
deltas[0]
);
assert_eq!(validation.report.hard_binding, "not_evaluated");
assert_eq!(validation.report.integrity, "unbound");
}
}
#[test]
fn final_2_5_rejects_malformed_related_assertions_in_signed_graphs() {
for case in [
"not-array",
"empty",
"not-reference",
"missing-target",
"wrong-hash",
"wrong-hash-type",
"foreign-manifest",
"ineligible-actions",
"ineligible-label",
] {
let store = related_ingredient_store(|related, actions, binding| {
let value = match case {
"not-array" => Value::Text("not an array".into()),
"empty" => Value::Array(vec![]),
"not-reference" => Value::Array(vec![Value::Text("not a hashed URI".into())]),
"ineligible-actions" => Value::Array(vec![actions.reference()]),
_ => {
let mut reference = match case {
"wrong-hash" => related.tampered_reference(),
"ineligible-label" => binding.reference(),
_ => related.reference(),
};
let Value::Map(entries) = &mut reference else {
unreachable!("reference is a map");
};
match case {
"missing-target" | "foreign-manifest" => {
let url = if case == "missing-target" {
"self#jumbf=c2pa.assertions/com.missing.ingredient".into()
} else {
format!(
"self#jumbf=/c2pa/{INGREDIENT}/c2pa.assertions/{}",
related.label
)
};
entries
.iter_mut()
.find(|(key, _)| key.as_text() == Some("url"))
.expect("url")
.1 = Value::Text(url);
}
"wrong-hash-type" => {
entries
.iter_mut()
.find(|(key, _)| key.as_text() == Some("hash"))
.expect("hash")
.1 = Value::Text("not bytes".into());
}
_ => {}
}
Value::Array(vec![reference])
}
};
vec![("relatedAssertions", value)]
});
let validation = verify_related_ingredient_store(&store, "2.5");
let results = &validation.report.validation_results;
assert!(
results.failure.iter().any(|status| {
status.code == "assertion.ingredient.malformed"
&& status.url
== format!(
"self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3"
)
}),
"{case}: {:?}",
results.failure
);
assert!(
results
.success
.iter()
.any(|status| status.code == "claimSignature.validated"),
"{case}: the negative must retain a valid claim signature"
);
for version in ["2.2", "2.4"] {
let legacy = verify_related_ingredient_store(&store, version);
assert!(
legacy.report.validation_results.failure.is_empty(),
"{case}, {version}: {:?}",
legacy.report.validation_results.failure
);
}
}
}
#[test]
fn final_2_5_still_rejects_active_manifest_with_digital_source_type() {
let store = related_ingredient_store(|related, _, _| {
vec![
("relatedAssertions", Value::Array(vec![related.reference()])),
(
"digitalSourceType",
Value::Text(
"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture".into(),
),
),
]
});
for version in ["2.2", "2.4", "2.5"] {
let validation = verify_related_ingredient_store(&store, version);
assert!(
validation
.report
.validation_results
.failure
.iter()
.any(|status| {
status.code == "assertion.ingredient.malformed"
&& status.url
== format!(
"self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3"
)
}),
"{version}: {:?}",
validation.report.validation_results.failure
);
}
}
#[test]
fn ingredient_manifest_defect_is_reported_as_a_delta_and_not_as_an_active_failure() {
let out = verify_store(&tampered_ingredient_store());
let deltas = deltas(&out);
assert_eq!(deltas.len(), 1, "report: {}", out.report_json);
assert_eq!(
deltas[0]
.get("ingredientAssertionURI")
.and_then(Json::as_str),
Some(format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3").as_str())
);
assert!(
delta_codes(deltas[0], "failure").contains(&"assertion.hashedURI.mismatch"),
"delta: {}",
deltas[0]
);
assert!(
!out.results
.failure
.iter()
.any(|status| status.url.contains(INGREDIENT)),
"active failures: {:?}",
out.results.failure
);
}
#[test]
fn an_ingredient_without_a_manifest_link_reports_unknown_provenance() {
let signer = Signer::conformant();
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let actions = Assertion::new("c2pa.actions.v2", &created_actions());
let parent = Assertion::new("c2pa.ingredient.v3", &unlinked_ingredient("componentOf"));
let (manifest, _) = manifest_box(
&signer,
ACTIVE,
&[binding.clone(), actions.clone(), parent.clone()],
vec![binding.reference(), actions.reference(), parent.reference()],
);
let out = verify_store(&[manifest]);
let status = out
.results
.informational
.iter()
.find(|status| status.code == "ingredient.unknownProvenance")
.expect("unknownProvenance informational");
assert_eq!(
status.url,
format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3")
);
}
#[test]
fn an_input_only_ingredient_without_provenance_is_silent() {
let signer = Signer::conformant();
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let actions = Assertion::new("c2pa.actions.v2", &created_actions());
let input = Assertion::new("c2pa.ingredient.v3", &unlinked_ingredient("inputTo"));
let (manifest, _) = manifest_box(
&signer,
ACTIVE,
&[binding.clone(), actions.clone(), input.clone()],
vec![binding.reference(), actions.reference(), input.reference()],
);
let out = verify_store(&[manifest]);
assert!(
!out.results
.informational
.iter()
.any(|status| status.code == "ingredient.unknownProvenance"),
"informational: {:?}",
out.results.informational
);
}
#[test]
fn a_label_cycle_terminates_and_validates_each_manifest_once() {
let signer = Signer::conformant();
let link = |label: &str| {
vmap(vec![
("relationship", Value::Text("componentOf".into())),
(
"activeManifest",
vmap(vec![
("url", Value::Text(format!("self#jumbf=/c2pa/{label}"))),
("alg", Value::Text("sha256".into())),
("hash", Value::Bytes(vec![0x00; 32])),
]),
),
])
};
let first_binding = Assertion::new("c2pa.hash.data", &data_hash());
let first_ingredient = Assertion::new("c2pa.ingredient.v3", &link(ACTIVE));
let (first, first_sig) = manifest_box(
&signer,
INGREDIENT,
&[first_binding.clone(), first_ingredient.clone()],
vec![first_binding.reference(), first_ingredient.reference()],
);
let second_binding = Assertion::new("c2pa.hash.data", &data_hash());
let second_ingredient = Assertion::new(
"c2pa.ingredient.v3",
&ingredient(INGREDIENT, &first, &first_sig, "componentOf", None),
);
let (second, _) = manifest_box(
&signer,
ACTIVE,
&[second_binding.clone(), second_ingredient.clone()],
vec![second_binding.reference(), second_ingredient.reference()],
);
let out = verify_store(&[first, second]);
let deltas = deltas(&out);
assert_eq!(deltas.len(), 1, "report: {}", out.report_json);
assert_eq!(
deltas[0]
.get("ingredientAssertionURI")
.and_then(Json::as_str),
Some(format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3").as_str())
);
assert!(
delta_codes(deltas[0], "failure").contains(&"ingredient.manifest.mismatch"),
"delta: {}",
deltas[0]
);
}
#[test]
fn crjson_carries_the_ingredient_deltas() {
let boxes = tampered_ingredient_store();
let out = verify_store(&boxes);
let store_bytes = build_manifest_store(&boxes);
let expanded = expand_store(&store_bytes).expect("expand store");
let parsed = parse_manifest_store(expanded.bytes()).expect("parse store");
let document = super::super::crjson::to_crjson(
&parsed,
&super::super::CrjsonContext {
report: &out.report_json,
validation_time: NOW,
profile: EngineProfile::GENEROUS,
trust_list_sources: &[],
compressed_manifests: Vec::new(),
},
);
let active = document["manifests"]
.as_array()
.and_then(|manifests| {
manifests
.iter()
.find(|manifest| manifest["label"] == Json::String(ACTIVE.into()))
})
.expect("active manifest entry");
assert_eq!(
active["ingredientDeltas"][0]["ingredientAssertionURI"],
Json::String(format!(
"self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3"
))
);
let ingredient = document["manifests"]
.as_array()
.and_then(|manifests| {
manifests
.iter()
.find(|manifest| manifest["label"] == Json::String(INGREDIENT.into()))
})
.expect("ingredient manifest entry");
let failures: Vec<&str> = ingredient["validationResults"]["failure"]
.as_array()
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry["code"].as_str())
.collect()
})
.unwrap_or_default();
assert!(
failures.contains(&"assertion.hashedURI.mismatch"),
"ingredient entry: {ingredient}"
);
}
#[test]
fn an_update_manifest_inherits_its_parents_hard_binding() {
let signer = Signer::conformant();
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let actions = Assertion::new("c2pa.actions.v2", &created_actions());
let (standard, standard_cose) = manifest_box(
&signer,
INGREDIENT,
&[binding.clone(), actions.clone()],
vec![binding.reference(), actions.reference()],
);
let parent = Assertion::new(
"c2pa.ingredient.v3",
&ingredient(
INGREDIENT,
&standard,
&standard_cose,
"parentOf",
Some(recorded_results(Vec::new())),
),
);
let update =
update_manifest_box(&signer, ACTIVE, &[parent.clone()], vec![parent.reference()]);
let store_bytes = build_manifest_store(&[standard, update]);
let expanded = expand_store(&store_bytes).expect("expand store");
let parsed = parse_manifest_store(expanded.bytes()).expect("parse store");
let hashes = manifest_hashes(&expanded, &parsed.manifests).expect("manifest hashes");
let active = parsed.manifests.last().expect("active manifest");
let inherited = super::super::inherited_hard_binding(
active,
StoreContext {
manifests: &parsed.manifests,
manifest_hashes: &hashes,
},
)
.expect("update manifest inherits a hard binding");
assert_eq!(inherited.0, INGREDIENT);
assert_eq!(
inherited.1.get("url").and_then(Value::as_text),
Some("self#jumbf=c2pa.assertions/c2pa.hash.data")
);
}
#[test]
fn a_v3_ingredient_without_recorded_results_is_a_strict_only_failure() {
let signer = Signer::conformant();
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let actions = Assertion::new("c2pa.actions.v2", &created_actions());
let (child, child_cose) = manifest_box(
&signer,
INGREDIENT,
&[binding.clone(), actions.clone()],
vec![binding.reference(), actions.reference()],
);
let link = Assertion::new(
"c2pa.ingredient.v3",
&ingredient(INGREDIENT, &child, &child_cose, "componentOf", None),
);
let active_binding = Assertion::new("c2pa.hash.data", &data_hash());
let (active, _) = manifest_box(
&signer,
ACTIVE,
&[active_binding.clone(), link.clone()],
vec![active_binding.reference(), link.reference()],
);
let boxes = vec![child, active];
let strict = verify_store_under(
&boxes,
EngineProfile::strict(crate::c2pa_core::SpecVersion::V2_4),
);
let generous = verify_store(&boxes);
assert!(
strict.results.failure.iter().any(|status| {
status.code == "assertion.ingredient.malformed"
&& status.url
== format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3")
}),
"strict failures: {:?}",
strict.results.failure
);
assert!(
!generous
.results
.failure
.iter()
.any(|status| status.code == "assertion.ingredient.malformed"),
"default failures: {:?}",
generous.results.failure
);
}
#[test]
fn a_chain_deeper_than_the_ceiling_stops_and_reports_the_limit() {
let signer = Signer::conformant();
let depth = super::GraphLimits::DEFAULT.max_depth + 2;
let mut boxes: Vec<Vec<u8>> = Vec::with_capacity(depth);
let mut previous: Option<(String, Vec<u8>, Vec<u8>)> = None;
for index in 0..depth {
let label = format!("urn:c2pa:00000000-0000-4000-8000-{index:012}");
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let mut assertions = vec![binding.clone()];
let mut references = vec![binding.reference()];
if let Some((parent_label, parent_manifest, parent_cose)) = &previous {
let link = Assertion::new(
"c2pa.ingredient.v3",
&ingredient(
parent_label,
parent_manifest,
parent_cose,
"componentOf",
None,
),
);
references.push(link.reference());
assertions.push(link);
}
let (manifest, cose) = manifest_box(&signer, &label, &assertions, references);
previous = Some((label, manifest.clone(), cose));
boxes.push(manifest);
}
let out = verify_store(&boxes);
let limit = out
.results
.informational
.iter()
.find(|status| status.code == "com.encypher.resource.graphLimitExceeded")
.expect("graph ceiling reported");
assert_eq!(
limit
.details
.as_ref()
.and_then(|details| details.get("limit").and_then(serde_json::Value::as_str)),
Some("depth")
);
assert!(!out
.results
.failure
.iter()
.any(|status| status.code.starts_with("com.encypher.ingredient.")));
}
#[test]
fn store_only_graph_inputs_share_provider_and_budget_without_binding_content() {
const URL: &str = "https://example.test/icon.png";
const ICON: &[u8] = b"caller-held icon bytes";
let external_icon = || {
vmap(vec![
("url", Value::Text(URL.into())),
("alg", Value::Text("sha256".into())),
("hash", Value::Bytes(Sha256::digest(ICON).to_vec())),
("dc:format", Value::Text("image/png".into())),
])
};
let actions = Assertion::new(
"c2pa.actions.v2",
&vmap(vec![
(
"actions",
Value::Array(vec![vmap(vec![
("action", Value::Text("c2pa.created".into())),
(
"digitalSourceType",
Value::Text(
"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
.into(),
),
),
])]),
),
(
"softwareAgents",
Value::Array(vec![
vmap(vec![
("name", Value::Text("agent one".into())),
("icon", external_icon()),
]),
vmap(vec![
("name", Value::Text("agent two".into())),
("icon", external_icon()),
]),
]),
),
]),
);
let binding = Assertion::new("c2pa.hash.data", &data_hash());
let signer = Signer::conformant();
let (manifest, _) = manifest_box(
&signer,
ACTIVE,
&[binding.clone(), actions.clone()],
vec![binding.reference(), actions.reference()],
);
let store = build_manifest_store(&[manifest]);
let options = VerifyOptions {
no_default_trust: true,
validation_time: Some("2026-06-01T00:00:00Z".into()),
..VerifyOptions::default()
};
struct Provider {
calls: Cell<usize>,
}
impl ExternalResources for Provider {
fn resolve(&self, url: &str) -> ExternalResource<'_> {
assert_eq!(url, URL);
self.calls.set(self.calls.get() + 1);
ExternalResource::Retrieved {
bytes: ICON,
media_type: Some("image/png"),
}
}
}
let provider = Provider {
calls: Cell::new(0),
};
let validation = crate::verify_manifest_store_only_with_graph_inputs(
&store,
&options,
GraphInputs {
external: Some(&provider),
limits: GraphLimits::default(),
},
)
.expect("store-only graph validation");
assert_eq!(provider.calls.get(), 1, "one URI is resolved once");
assert_eq!(validation.report.hard_binding, "not_evaluated");
assert_eq!(validation.report.integrity, "unbound");
assert_eq!(
validation
.report
.validation_results
.success
.iter()
.filter(|status| {
status.code == "assertion.hashedURI.match" && status.url == URL
})
.count(),
2
);
let limited_provider = Provider {
calls: Cell::new(0),
};
let limited = crate::verify_manifest_store_only_with_graph_inputs(
&store,
&options,
GraphInputs {
external: Some(&limited_provider),
limits: GraphLimits::lowered(32, 1_024, 1, 64 * 1024 * 1024),
},
)
.expect("budget-limited store-only graph validation");
assert_eq!(limited.report.hard_binding, "not_evaluated");
assert!(
limited
.report
.validation_results
.informational
.iter()
.any(|status| {
status.code == "com.encypher.resource.graphLimitExceeded"
&& status
.details
.as_ref()
.and_then(|details| details.get("limit"))
.and_then(Json::as_str)
== Some("references")
}),
"{:?}",
limited.report.validation_results.informational
);
}
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/redaction_lineage/mod.rs"
));
}