Skip to main content

encypher_c2pa/c2pa-validate/
ingredient_graph.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Bounded recursive ingredient validation (C2PA 2.4 Validation, "Validate the
5//! Ingredients").
6//!
7//! The active manifest's ingredient assertions name other manifests in the same
8//! store, and those manifests may name further ones. This module walks that
9//! graph once, under explicit ceilings, and reports two things:
10//!
11//! - the codes the walk observed for the ACTIVE manifest's own ingredient
12//!   assertions (today only `ingredient.unknownProvenance`, raised where the
13//!   assertion carries no manifest link at all);
14//! - one `ingredientDeltas` entry per ingredient assertion whose manifest the
15//!   walk reached: the difference between the verdict the signer recorded in
16//!   that assertion's `validationResults` and the verdict this validation
17//!   derived, in the shape the spec's `ingredient-delta-validation-result-map`
18//!   prescribes.
19//!
20//! An ingredient manifest's defects are never folded into the active
21//! manifest's verdict. The asset in hand is not inauthentic because a manifest
22//! in its history is: the spec reports an ingredient's outcome per manifest and
23//! as a delta against the recorded verdict, which is exactly what
24//! `validation_results.ingredientDeltas` carries.
25//!
26//! What a reached ingredient manifest is validated against is bounded by what
27//! the store alone can prove: its claim (present, decodable), its claim
28//! signature's cryptography, its claim-to-assertion hashed-URI bindings, and
29//! its own ingredient links. The spec's claim-signature hash validation method
30//! excludes content bindings for ingredients ("except for the hard binding
31//! assertions, which cannot be validated for ingredients") because the
32//! ingredient's own bytes are not present. Signer trust and revocation are also
33//! not re-derived: the caller's trust material describes the asset in hand, and
34//! the ingredient's trust verdict is what its own signer recorded. Nothing here
35//! emits a code claiming more than was checked.
36//!
37//! ## Ceilings
38//!
39//! A manifest store is attacker-supplied. One shared budget bounds depth,
40//! distinct manifests, hashed-URI references, and cumulative manifest bytes.
41//! The walk never re-enters a manifest it has already reached, so a cycle
42//! terminates. Exhausting a ceiling stops the walk and records
43//! `com.encypher.resource.graphLimitExceeded` as informational: running out of
44//! budget says nothing about authenticity and cannot become a validation
45//! failure.
46
47use std::collections::{HashMap, HashSet, VecDeque};
48
49use serde_json::{json, Value as Json};
50
51use super::versions::{self, ClaimGeneration};
52use super::{
53    decode, is_generous, status_array, verify_assertion_bindings, verify_ingredient_references,
54    ClaimAssertionRefs, EngineProfile, IngredientManifestDigestCache, StatusCode, StoreContext,
55    ValidationResults, ALGORITHM_UNSUPPORTED, ASSERTION_INGREDIENT_MALFORMED, CLAIM_CBOR_INVALID,
56    CLAIM_MISSING, CLAIM_SIGNATURE_MISMATCH, CLAIM_SIGNATURE_MISSING, CLAIM_SIGNATURE_VALIDATED,
57};
58use crate::c2pa_cbor::Value;
59use crate::c2pa_core::jumbf::ParsedManifest;
60use crate::c2pa_crypto::{extract_x5chain, verify_claim_with, CryptoError};
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63/// Resource ceilings for one verification graph.
64pub struct GraphLimits {
65    /// Maximum graph edges followed from the active manifest.
66    pub max_depth: usize,
67    /// Maximum distinct manifests visited, including append-history stores.
68    pub max_nodes: usize,
69    /// Maximum references examined across the pass.
70    pub max_references: usize,
71    /// Maximum cumulative JUMBF bytes of visited manifests and predecessor stores.
72    pub max_bytes: usize,
73}
74
75impl GraphLimits {
76    /// Engine ceilings used when the caller supplies no lower limits.
77    pub const DEFAULT: Self = Self {
78        max_depth: 32,
79        max_nodes: 1_024,
80        max_references: 16_384,
81        max_bytes: 64 * 1024 * 1024,
82    };
83
84    /// Construct caller-requested limits, clamped so no engine ceiling rises.
85    pub fn lowered(
86        max_depth: usize,
87        max_nodes: usize,
88        max_references: usize,
89        max_bytes: usize,
90    ) -> Self {
91        Self {
92            max_depth: max_depth.min(Self::DEFAULT.max_depth),
93            max_nodes: max_nodes.min(Self::DEFAULT.max_nodes),
94            max_references: max_references.min(Self::DEFAULT.max_references),
95            max_bytes: max_bytes.min(Self::DEFAULT.max_bytes),
96        }
97    }
98}
99
100impl Default for GraphLimits {
101    fn default() -> Self {
102        Self::DEFAULT
103    }
104}
105
106#[derive(Default, Clone, Copy)]
107/// Caller-supplied resource provider and ceilings for graph validation.
108pub struct GraphInputs<'a> {
109    /// Network-free source of external bytes already held by the caller.
110    pub external: Option<&'a dyn super::refs::ExternalResources>,
111    /// Resource ceilings shared by the complete verification pass.
112    pub limits: GraphLimits,
113}
114
115#[derive(Clone, Copy)]
116enum Ceiling {
117    Depth = 0,
118    Nodes = 1,
119    References = 2,
120    Bytes = 3,
121}
122
123impl Ceiling {
124    const ALL: [Self; 4] = [Self::Depth, Self::Nodes, Self::References, Self::Bytes];
125
126    fn as_str(self) -> &'static str {
127        match self {
128            Self::Depth => "depth",
129            Self::Nodes => "nodes",
130            Self::References => "references",
131            Self::Bytes => "bytes",
132        }
133    }
134}
135
136pub(super) struct Budget {
137    limits: GraphLimits,
138    nodes: usize,
139    references: usize,
140    bytes: usize,
141    exhausted: [Option<usize>; 4],
142    reported: [bool; 4],
143    pub(super) redactions: Option<std::sync::Arc<super::redaction::Index>>,
144}
145
146impl Budget {
147    pub(super) fn new(limits: GraphLimits) -> Self {
148        Self {
149            limits: GraphLimits::lowered(
150                limits.max_depth,
151                limits.max_nodes,
152                limits.max_references,
153                limits.max_bytes,
154            ),
155            nodes: 0,
156            references: 0,
157            bytes: 0,
158            exhausted: [None; 4],
159            reported: [false; 4],
160            redactions: None,
161        }
162    }
163
164    fn note(&mut self, ceiling: Ceiling, observed: usize) {
165        let slot = &mut self.exhausted[ceiling as usize];
166        if slot.is_none() {
167            *slot = Some(observed);
168        }
169    }
170
171    fn node(&mut self) -> bool {
172        if self.nodes >= self.limits.max_nodes {
173            self.note(Ceiling::Nodes, self.nodes.saturating_add(1));
174            return false;
175        }
176        self.nodes += 1;
177        true
178    }
179
180    fn bytes(&mut self, bytes: usize) -> bool {
181        let total = self.bytes.saturating_add(bytes);
182        if total > self.limits.max_bytes {
183            self.note(Ceiling::Bytes, total);
184            return false;
185        }
186        self.bytes = total;
187        true
188    }
189
190    pub(super) fn references_left(&self) -> usize {
191        self.limits.max_references.saturating_sub(self.references)
192    }
193
194    pub(super) fn references(&mut self, count: usize) -> bool {
195        let total = self.references.saturating_add(count);
196        if total > self.limits.max_references {
197            self.note(Ceiling::References, total);
198            self.references = self.limits.max_references;
199            return false;
200        }
201        self.references = total;
202        true
203    }
204
205    pub(super) fn exhaust_references(&mut self) {
206        self.note(
207            Ceiling::References,
208            self.limits.max_references.saturating_add(1),
209        );
210    }
211
212    fn depth(&mut self, depth: usize) -> bool {
213        if depth > self.limits.max_depth {
214            self.note(Ceiling::Depth, depth);
215            return false;
216        }
217        true
218    }
219
220    pub(super) fn resource_edge(&mut self, depth: usize) -> bool {
221        let references = self.references(1);
222        let depth = self.depth(depth);
223        let nodes = self.node();
224        references && depth && nodes
225    }
226
227    pub(super) fn resource_bytes(&mut self, bytes: usize) -> bool {
228        self.bytes(bytes)
229    }
230
231    pub(super) fn report(&mut self, active_label: &str, results: &mut ValidationResults) {
232        for ceiling in Ceiling::ALL {
233            if self.reported[ceiling as usize] {
234                continue;
235            }
236            let Some(observed) = self.exhausted[ceiling as usize] else {
237                continue;
238            };
239            self.reported[ceiling as usize] = true;
240            let limit = match ceiling {
241                Ceiling::Depth => self.limits.max_depth,
242                Ceiling::Nodes => self.limits.max_nodes,
243                Ceiling::References => self.limits.max_references,
244                Ceiling::Bytes => self.limits.max_bytes,
245            };
246            let name = ceiling.as_str();
247            if results.informational.iter().any(|status| {
248                status.code == GRAPH_LIMIT_EXCEEDED
249                    && status
250                        .details
251                        .as_ref()
252                        .and_then(|details| details.get("limit"))
253                        .and_then(serde_json::Value::as_str)
254                        == Some(name)
255            }) {
256                continue;
257            }
258            results.informational.push(StatusCode {
259                code: GRAPH_LIMIT_EXCEEDED.into(),
260                url: format!("self#jumbf=/c2pa/{active_label}"),
261                resource_url: None,
262                explanation: format!(
263                    "verification graph {name} ceiling reached ({limit}); traversal stopped. \
264                     This is a resource outcome and does not affect manifest validity."
265                ),
266                details: Some(json!({ "limit": name, "ceiling": limit, "observed": observed })),
267            });
268        }
269    }
270}
271
272/// Informational: a ceiling stopped the verification graph. Entity-namespaced
273/// because it is a resource outcome, not a C2PA validation outcome.
274pub(super) const GRAPH_LIMIT_EXCEEDED: &str = "com.encypher.resource.graphLimitExceeded";
275
276/// One `validation_results.ingredientDeltas` entry.
277pub(super) struct IngredientDelta {
278    /// JUMBF URI of the ingredient assertion the delta describes.
279    assertion_uri: String,
280    deltas: ValidationResults,
281}
282
283/// One manifest the walk reached, with the claim it was validated from.
284///
285/// CAWG identity validation resolves `referenced_assertions` against these:
286/// an identity may reference an assertion in a recursively reachable
287/// ingredient claim, and only a claim the walk actually reached counts.
288pub(super) struct ReachedManifest {
289    pub(super) label: String,
290    pub(super) claim: Value,
291}
292
293/// The walked ingredient graph.
294pub(super) struct IngredientGraph {
295    /// Codes the walk observed for the active manifest itself. Merged into the
296    /// active verdict by the caller.
297    pub(super) active: ValidationResults,
298    /// Per-ingredient deltas, in walk order.
299    pub(super) deltas: Vec<IngredientDelta>,
300    /// Ingredient manifests the walk reached, excluding the active manifest.
301    pub(super) reached: Vec<ReachedManifest>,
302}
303
304/// One ingredient assertion's edge into the store.
305struct Edge {
306    /// JUMBF URI of the ingredient assertion that carries the link.
307    assertion_uri: String,
308    /// Manifest the `activeManifest` (or v1/v2 `c2pa_manifest`) link names.
309    target: String,
310    /// The verdict the signer recorded for that manifest, if any.
311    recorded: Option<Value>,
312}
313
314/// Discover the complete bounded lineage before validating any target. Sorting
315/// edges makes resource-limited discovery independent of ingredient order.
316pub(super) fn redaction_index(
317    active_label: &str,
318    store: StoreContext<'_>,
319    profile: EngineProfile,
320    limits: GraphLimits,
321    results: &mut ValidationResults,
322) -> std::sync::Arc<super::redaction::Index> {
323    let mut index = super::redaction::Index::default();
324    let mut budget = Budget::new(limits);
325    let mut seen = HashSet::new();
326    let mut queue = VecDeque::from([(active_label.to_string(), 0)]);
327    while let Some((label, depth)) = queue.pop_front() {
328        if !seen.insert(label.clone()) {
329            continue;
330        }
331        let Some(manifest) = store
332            .manifests
333            .iter()
334            .find(|manifest| manifest.label == label)
335        else {
336            continue;
337        };
338        if !budget.depth(depth) || !budget.node() || !budget.bytes(manifest.manifest_jumbf.len()) {
339            break;
340        }
341        let Some(claim) = manifest.claim_cbor.and_then(|bytes| decode(bytes).ok()) else {
342            continue;
343        };
344        let generation = versions::claim_generation(manifest, &claim);
345        let mut refs = ClaimAssertionRefs::build(manifest, &claim, generation);
346        if label != active_label && is_time_stamp_manifest(&refs) {
347            continue;
348        }
349        let declarations = match claim.get("redacted_assertions") {
350            Some(Value::Array(entries)) => {
351                entries.len().min(super::redaction::MAX_REDACTED_ASSERTIONS)
352            }
353            _ => 0,
354        };
355        if !budget.references(declarations) {
356            break;
357        }
358        index.add_claim(&label, &claim, store.manifests);
359        let mut ignored = ValidationResults::default();
360        let mut next = edges(
361            manifest,
362            &claim,
363            &mut refs,
364            generation,
365            profile,
366            &mut ignored,
367        );
368        next.sort_by(|left, right| left.target.cmp(&right.target));
369        if !budget.references(next.len()) {
370            break;
371        }
372        queue.extend(next.into_iter().map(|edge| (edge.target, depth + 1)));
373    }
374    budget.report(active_label, results);
375    std::sync::Arc::new(index)
376}
377
378/// Walk the active manifest's ingredient graph.
379///
380/// `active_label` must name a manifest in `store`; anything else yields an
381/// empty graph rather than a guess.
382pub(super) fn build<'a>(
383    active_label: &str,
384    store: StoreContext<'a>,
385    profile: EngineProfile,
386    budget: &mut Budget,
387    resources: &mut super::refs::ResourceCache<'_>,
388) -> IngredientGraph {
389    let mut graph = IngredientGraph {
390        active: ValidationResults::default(),
391        deltas: Vec::new(),
392        reached: Vec::new(),
393    };
394    let index_of: HashMap<&str, usize> = store
395        .manifests
396        .iter()
397        .enumerate()
398        .map(|(index, manifest)| (manifest.label.as_str(), index))
399        .collect();
400    let Some(active) = index_of.get(active_label).copied() else {
401        return graph;
402    };
403    // Every claim decoded once, in a stable allocation the walk borrows from.
404    let mut claims: Vec<Option<Value>> = store
405        .manifests
406        .iter()
407        .map(|manifest| manifest.claim_cbor.and_then(|cbor| decode(cbor).ok()))
408        .collect();
409
410    let mut results: Vec<ValidationResults> = store
411        .manifests
412        .iter()
413        .map(|_| ValidationResults::default())
414        .collect();
415    let mut reached: Vec<bool> = vec![false; store.manifests.len()];
416    let mut pending: Vec<(usize, Edge)> = Vec::new();
417    let mut cache = IngredientManifestDigestCache::new(store, profile);
418    let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
419
420    queue.push_back((active, 0));
421    reached[active] = true;
422    'walk: while let Some((index, depth)) = queue.pop_front() {
423        let manifest = &store.manifests[index];
424        if !budget.node() || !budget.bytes(manifest.manifest_jumbf.len()) {
425            break;
426        }
427        let is_active = index == active;
428        let Some(claim) = claims[index].as_ref() else {
429            if !is_active {
430                record_missing_claim(manifest, &mut results[index]);
431            }
432            continue;
433        };
434        let generation = versions::claim_generation(manifest, claim);
435        let mut claim_refs = ClaimAssertionRefs::build(manifest, claim, generation);
436        claim_refs.redactions = budget.redactions.clone();
437        // A time-stamp manifest reached through an ingredient "shall be
438        // ignored" (C2PA 2.4, Time-Stamp Manifests in an ingredient).
439        if !is_active && is_time_stamp_manifest(&claim_refs) {
440            continue;
441        }
442        if !is_active {
443            validate_reached_manifest(
444                manifest,
445                claim,
446                generation,
447                &mut claim_refs,
448                &mut cache,
449                profile,
450                &mut results[index],
451            );
452            let walked = super::refs::verify_assertion_references(
453                super::refs::AssertionReferenceContext {
454                    claim,
455                    claim_refs: &claim_refs,
456                    generation,
457                    profile,
458                    manifest_label: &manifest.label,
459                    manifests: store.manifests,
460                    budget: budget.references_left(),
461                },
462                resources,
463                &mut results[index],
464            );
465            if !budget.references(walked.examined) {
466                break;
467            }
468            if walked.truncated {
469                budget.exhaust_references();
470                break;
471            }
472        }
473        let graph_edges = edges(
474            manifest,
475            claim,
476            &mut claim_refs,
477            generation,
478            profile,
479            &mut results[index],
480        );
481        if !budget.references(graph_edges.len()) {
482            break;
483        }
484        for edge in graph_edges {
485            let Some(target) = index_of.get(edge.target.as_str()).copied() else {
486                // The link named a manifest the store does not carry;
487                // `verify_ingredient_references` already reported it.
488                continue;
489            };
490            pending.push((target, edge));
491            if reached[target] {
492                // A diamond (the same ingredient reached twice) or a forged
493                // label loop whose link hash has already failed. Either way the
494                // manifest is validated once and the walk does not re-enter it.
495                continue;
496            }
497            if !budget.depth(depth + 1) {
498                break 'walk;
499            }
500            reached[target] = true;
501            queue.push_back((target, depth + 1));
502        }
503    }
504    budget.report(active_label, &mut graph.active);
505
506    // Deltas last: both sides of every edge are computed by now.
507    for (target, edge) in pending {
508        let deltas = validation_deltas(edge.recorded.as_ref(), &results[target]);
509        if deltas.success.is_empty() && deltas.informational.is_empty() && deltas.failure.is_empty()
510        {
511            continue;
512        }
513        graph.deltas.push(IngredientDelta {
514            assertion_uri: edge.assertion_uri,
515            deltas,
516        });
517    }
518    graph.active.success.append(&mut results[active].success);
519    graph
520        .active
521        .informational
522        .append(&mut results[active].informational);
523    graph.active.failure.append(&mut results[active].failure);
524    for (index, claim) in claims.iter_mut().enumerate() {
525        if index == active || !reached[index] {
526            continue;
527        }
528        if let Some(claim) = claim.take() {
529            graph.reached.push(ReachedManifest {
530                label: store.manifests[index].label.clone(),
531                claim,
532            });
533        }
534    }
535    graph
536}
537
538/// Attach `validation_results.ingredientDeltas` to a reader report.
539///
540/// Additive: the report's existing `validation_results.activeManifest` object,
541/// the flat status fields, and the verdict are untouched, and the block is
542/// absent when the walk produced no delta.
543pub(super) fn attach(report: &mut Json, deltas: &[IngredientDelta]) {
544    if deltas.is_empty() {
545        return;
546    }
547    let Some(results) = report
548        .get_mut("validation_results")
549        .and_then(Json::as_object_mut)
550    else {
551        return;
552    };
553    results.insert("ingredientDeltas".into(), deltas_json(deltas));
554}
555
556/// Serialize the delta list in the spec's
557/// `ingredient-delta-validation-result-map` shape.
558pub(super) fn deltas_json(deltas: &[IngredientDelta]) -> Json {
559    Json::Array(
560        deltas
561            .iter()
562            .map(|delta| {
563                json!({
564                    "ingredientAssertionURI": delta.assertion_uri,
565                    "validationDeltas": {
566                        "success": status_array(&delta.deltas.success),
567                        "informational": status_array(&delta.deltas.informational),
568                        "failure": status_array(&delta.deltas.failure),
569                    },
570                })
571            })
572            .collect(),
573    )
574}
575
576/// A manifest whose claim box is absent or undecodable cannot be validated at
577/// all (C2PA 2.4: "Locate the claim ... If unable to, reject claim with a
578/// `claim.missing` failure code").
579fn record_missing_claim(manifest: &ParsedManifest<'_>, results: &mut ValidationResults) {
580    let url = format!("self#jumbf=/c2pa/{}/c2pa.signature", manifest.label);
581    if manifest.claim_cbor.is_none() {
582        results.push_failure(
583            CLAIM_MISSING,
584            url,
585            "no claim found in ingredient manifest".into(),
586        );
587    } else {
588        results.push_failure(
589            CLAIM_CBOR_INVALID,
590            url,
591            "ingredient manifest claim CBOR could not be decoded".into(),
592        );
593    }
594}
595
596/// A time-stamp manifest carries a time-stamp assertion and ingredient
597/// assertions, and nothing else.
598pub(super) fn is_time_stamp_manifest(claim_refs: &ClaimAssertionRefs<'_>) -> bool {
599    let mut timestamps = false;
600    for label in &claim_refs.declaration_labels {
601        let base = label.split("__").next().unwrap_or(label);
602        match base {
603            "c2pa.time-stamp" => timestamps = true,
604            "c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3" => {}
605            _ => return false,
606        }
607    }
608    timestamps
609}
610
611/// Validate one reached ingredient manifest with what the store alone proves.
612fn validate_reached_manifest<'a>(
613    manifest: &'a ParsedManifest<'a>,
614    claim: &Value,
615    generation: ClaimGeneration,
616    claim_refs: &mut ClaimAssertionRefs<'_>,
617    cache: &mut IngredientManifestDigestCache<'a>,
618    profile: EngineProfile,
619    results: &mut ValidationResults,
620) {
621    let sig_url = format!("self#jumbf=/c2pa/{}/c2pa.signature", manifest.label);
622    super::redaction::verify_claim_redactions(
623        &manifest.label,
624        claim,
625        cache.store.manifests,
626        results,
627    );
628    super::verify_redacted_identity_references(claim_refs, results);
629    verify_assertion_bindings(
630        claim,
631        claim_refs,
632        generation,
633        &manifest.label,
634        profile,
635        results,
636    );
637    verify_ingredient_references(
638        &manifest.label,
639        claim_refs,
640        claim,
641        cache,
642        &sig_url,
643        profile,
644        results,
645    );
646    let (Some(cose), Some(claim_cbor)) = (manifest.signature_cose, manifest.claim_cbor) else {
647        results.push_failure(
648            CLAIM_SIGNATURE_MISSING,
649            sig_url,
650            "ingredient manifest has no claim signature".into(),
651        );
652        return;
653    };
654    let leaf = extract_x5chain(cose)
655        .ok()
656        .and_then(|chain| chain.into_iter().next())
657        .filter(|leaf: &Vec<u8>| !leaf.is_empty());
658    let Some(leaf) = leaf else {
659        results.push_failure(
660            CLAIM_SIGNATURE_MISSING,
661            sig_url,
662            "ingredient manifest claim signature carries no certificate".into(),
663        );
664        return;
665    };
666    match verify_claim_with(
667        cose,
668        claim_cbor,
669        &leaf,
670        super::spec25::claim_algorithms(profile),
671    ) {
672        Ok(()) => results.push_success(
673            CLAIM_SIGNATURE_VALIDATED,
674            sig_url,
675            "ingredient manifest claim signature verified".into(),
676        ),
677        Err(CryptoError::UnsupportedAlg(id)) => results.push_failure(
678            ALGORITHM_UNSUPPORTED,
679            sig_url,
680            format!("ingredient manifest claim signature algorithm {id} is not on the C2PA allowed list"),
681        ),
682        Err(error) => results.push_failure(
683            CLAIM_SIGNATURE_MISMATCH,
684            sig_url,
685            format!("ingredient manifest claim signature invalid: {error}"),
686        ),
687    }
688}
689
690/// An ingredient assertion grants discovery authority only when its claim
691/// declaration resolves to non-zero content with a matching digest. Signature
692/// validity of the declaring manifest is deliberately not a prerequisite.
693fn ingredient_edge_is_eligible(
694    manifest: &ParsedManifest<'_>,
695    claim: &Value,
696    generation: ClaimGeneration,
697    reference: &super::ClaimAssertionReference<'_>,
698    assertion: &mut super::IndexedAssertion<'_>,
699) -> bool {
700    if !matches!(
701        reference.field,
702        "assertions" | "created_assertions" | "gathered_assertions"
703    ) || reference
704        .label
705        .and_then(|label| manifest.assertion_content_is_zeroed(label))
706        != Some(false)
707    {
708        return false;
709    }
710    let Some(expected) = reference.value.get("hash").and_then(Value::as_bytes) else {
711        return false;
712    };
713    let Some(algorithm) = super::resolved_hash_algorithm(&[reference.value, claim]) else {
714        return false;
715    };
716    let Some(index) = super::bmff_algorithm_index(algorithm) else {
717        return false;
718    };
719    if assertion.jumbf_digests[index].is_none() {
720        assertion.jumbf_digests[index] = assertion
721            .jumbf
722            .and_then(|bytes| super::hash_bytes(algorithm, bytes));
723    }
724    assertion.jumbf_digests[index].as_deref() == Some(expected)
725        || (generation == ClaimGeneration::V1
726            && assertion
727                .payload
728                .and_then(|bytes| super::hash_bytes(algorithm, bytes))
729                .as_deref()
730                == Some(expected))
731}
732
733/// The ingredient edges one manifest's claim declares.
734///
735/// Only a digest-checked claim declaration is an edge: an injected or altered
736/// assertion box cannot extend the graph with the claim signature untouched.
737fn edges(
738    manifest: &ParsedManifest<'_>,
739    claim: &Value,
740    claim_refs: &mut ClaimAssertionRefs<'_>,
741    generation: ClaimGeneration,
742    profile: EngineProfile,
743    results: &mut ValidationResults,
744) -> Vec<Edge> {
745    let mut edges = Vec::new();
746    for reference in &claim_refs.references {
747        let Some(label) = reference.label else {
748            continue;
749        };
750        if !matches!(
751            super::refs::base_label(label),
752            "c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3"
753        ) {
754            continue;
755        }
756        if claim_refs.is_redacted(label) {
757            continue;
758        }
759        let Some(assertion) = claim_refs.assertions.get_mut(label) else {
760            continue;
761        };
762        if !ingredient_edge_is_eligible(manifest, claim, generation, reference, assertion) {
763            continue;
764        }
765        let Some(ingredient) = assertion.decoded.as_ref() else {
766            continue;
767        };
768        let assertion_uri = format!(
769            "self#jumbf=/c2pa/{}/c2pa.assertions/{label}",
770            manifest.label
771        );
772        // The ingredient assertion's own binding gates recursion. Preserve its
773        // target even when a manifest/signature link hash is missing or wrong:
774        // the method-selection phase validates those links after discovery.
775        let Some(target) = ingredient
776            .get("activeManifest")
777            .or_else(|| ingredient.get("c2pa_manifest"))
778            .and_then(|link| link.get("url"))
779            .and_then(Value::as_text)
780            .and_then(|url| super::ingredient_manifest_label(url, None))
781        else {
782            continue;
783        };
784        let recorded = ingredient.get("validationResults").cloned();
785        // "If no `validationResults` field is present and the ingredient
786        // assertion is a v3 ingredient assertion with the `activeManifest`
787        // field present, then return the failure code
788        // `assertion.ingredient.malformed`." A recorded verdict the generator
789        // omitted costs the validator nothing it can re-derive, so the rule is
790        // applied where the spec is the bar (strict/conformance) and the
791        // default posture keeps reading the manifest.
792        if recorded.is_none()
793            && !is_generous(profile)
794            && generation == ClaimGeneration::V2
795            && label.starts_with("c2pa.ingredient.v3")
796            && ingredient.get("activeManifest").is_some()
797        {
798            results.push_failure(
799                ASSERTION_INGREDIENT_MALFORMED,
800                assertion_uri.clone(),
801                format!("ingredient '{label}' links a manifest but records no validationResults"),
802            );
803        }
804        edges.push(Edge {
805            assertion_uri,
806            target: target.to_string(),
807            recorded,
808        });
809    }
810    edges
811}
812
813/// The difference between the verdict a signer recorded for an ingredient's
814/// manifest and the verdict this validation derived for it.
815///
816/// C2PA 2.4 defines it in both directions: a recorded entry this validation did
817/// not produce is a delta, and an entry this validation produced that the
818/// recorded verdict does not carry is a delta. Both matter, and they mean
819/// different things. An entry only this validation produced is the
820/// security-relevant one: the ingredient's manifest has changed since it was
821/// ingested, or its signer's verdict was optimistic. An entry only the signer
822/// recorded is usually something offline validation cannot re-derive - the
823/// ingredient's own hard binding, its trust or revocation state - so it is
824/// carried through marked `source: "recorded"` rather than presented as
825/// something this validator checked.
826///
827/// Two entries are equivalent when their `code` and `url` agree; `explanation`
828/// is human text and two validators will word it differently. The recorded side
829/// includes the nested `ingredientDeltas` the signer stored, so an entry it
830/// recorded one level down is not reported here as newly observed.
831fn validation_deltas(recorded: Option<&Value>, observed: &ValidationResults) -> ValidationResults {
832    let mut recorded_entries: Vec<(&'static str, StatusCode)> = Vec::new();
833    if let Some(recorded) = recorded {
834        collect_recorded(recorded.get("activeManifest"), &mut recorded_entries);
835        if let Some(Value::Array(nested)) = recorded.get("ingredientDeltas") {
836            for entry in nested {
837                collect_recorded(entry.get("validationDeltas"), &mut recorded_entries);
838            }
839        }
840    }
841    let recorded_keys: HashSet<(&str, &str)> = recorded_entries
842        .iter()
843        .map(|(_, status)| (status.code.as_str(), status.url.as_str()))
844        .collect();
845    let observed_keys: HashSet<(&str, &str)> = observed
846        .success
847        .iter()
848        .chain(&observed.informational)
849        .chain(&observed.failure)
850        .map(|status| (status.code.as_str(), status.url.as_str()))
851        .collect();
852
853    let mut deltas = ValidationResults::default();
854    let new_codes = |codes: &[StatusCode]| -> Vec<StatusCode> {
855        codes
856            .iter()
857            .filter(|status| !recorded_keys.contains(&(status.code.as_str(), status.url.as_str())))
858            .cloned()
859            .collect()
860    };
861    deltas.success = new_codes(&observed.success);
862    deltas.informational = new_codes(&observed.informational);
863    deltas.failure = new_codes(&observed.failure);
864    for (bucket, status) in recorded_entries {
865        if observed_keys.contains(&(status.code.as_str(), status.url.as_str())) {
866            continue;
867        }
868        let status = StatusCode {
869            details: Some(json!({ "source": "recorded" })),
870            ..status
871        };
872        match bucket {
873            "success" => deltas.success.push(status),
874            "informational" => deltas.informational.push(status),
875            _ => deltas.failure.push(status),
876        }
877    }
878    deltas
879}
880
881/// Read one recorded `status-codes-map` into `(bucket, status)` pairs.
882fn collect_recorded(codes: Option<&Value>, into: &mut Vec<(&'static str, StatusCode)>) {
883    let Some(codes) = codes else {
884        return;
885    };
886    for bucket in ["success", "informational", "failure"] {
887        let Some(Value::Array(entries)) = codes.get(bucket) else {
888            continue;
889        };
890        for entry in entries {
891            let Some(code) = entry.get("code").and_then(Value::as_text) else {
892                continue;
893            };
894            into.push((
895                bucket,
896                StatusCode {
897                    code: code.to_string(),
898                    url: entry
899                        .get("url")
900                        .and_then(Value::as_text)
901                        .unwrap_or_default()
902                        .to_string(),
903                    resource_url: entry
904                        .get("resourceUrl")
905                        .and_then(Value::as_text)
906                        .map(str::to_string),
907                    explanation: entry
908                        .get("explanation")
909                        .and_then(Value::as_text)
910                        .unwrap_or("recorded by the ingredient's claim generator")
911                        .to_string(),
912                    details: None,
913                },
914            ));
915        }
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use std::cell::Cell;
922    use std::collections::HashMap;
923
924    use sha2::{Digest, Sha256};
925    use time::macros::datetime;
926    use time::OffsetDateTime;
927
928    use super::super::signature_conformance_tests::Signer;
929    use super::super::{
930        hash_claim_signature_box, manifest_hashes, verify_manifest, CawgTrustInputs, CrjsonContext,
931        StoreContext, VerifyInput, VerifyOutput, MAX_REPORT_DECODED_VALUE_NODES,
932    };
933    use crate::c2pa_cbor::{encode, Profile, Value};
934    use crate::c2pa_core::jumbf::{
935        assertion_box, build_manifest, build_manifest_store, cbor_box, expand_store,
936        parse_manifest_store, superbox, superbox_content, UUID_ASSERTION_STORE, UUID_CLAIM,
937        UUID_CLAIM_SIGNATURE, UUID_UPDATE_MANIFEST,
938    };
939    use crate::c2pa_core::EngineProfile;
940    use crate::c2pa_formats::AssetFormat;
941    use crate::{ExternalResource, ExternalResources, GraphInputs, GraphLimits, VerifyOptions};
942    use serde_json::Value as Json;
943
944    const INGREDIENT: &str = "urn:c2pa:00000000-0000-4000-8000-000000000001";
945    const ACTIVE: &str = "urn:c2pa:00000000-0000-4000-8000-000000000002";
946    const NOW: OffsetDateTime = datetime!(2026-06-01 0:00 UTC);
947
948    fn enc(value: &Value) -> Vec<u8> {
949        encode(value, Profile::LegacyPipelineBDefinite).expect("encode")
950    }
951
952    fn vmap(pairs: Vec<(&str, Value)>) -> Value {
953        Value::Map(
954            pairs
955                .into_iter()
956                .map(|(key, value)| (Value::Text(key.into()), value))
957                .collect(),
958        )
959    }
960
961    /// An assertion box plus the hashed-URI a claim in the same manifest uses
962    /// to reference it. The claim hash covers the JUMBF superbox content, the
963    /// domain [`super::super::verify_assertion_bindings`] recomputes.
964    #[derive(Clone)]
965    struct Assertion {
966        label: String,
967        jumbf: Vec<u8>,
968    }
969
970    impl Assertion {
971        fn new(label: &str, value: &Value) -> Self {
972            Self {
973                label: label.to_string(),
974                jumbf: assertion_box(label, &enc(value), None),
975            }
976        }
977
978        fn reference(&self) -> Value {
979            let content = superbox_content(&self.jumbf).expect("assertion content");
980            vmap(vec![
981                (
982                    "url",
983                    Value::Text(format!("self#jumbf=c2pa.assertions/{}", self.label)),
984                ),
985                ("alg", Value::Text("sha256".into())),
986                ("hash", Value::Bytes(Sha256::digest(content).to_vec())),
987            ])
988        }
989
990        /// A reference whose hash does not match the stored bytes: an assertion
991        /// that changed after its claim was signed.
992        fn tampered_reference(&self) -> Value {
993            vmap(vec![
994                (
995                    "url",
996                    Value::Text(format!("self#jumbf=c2pa.assertions/{}", self.label)),
997                ),
998                ("alg", Value::Text("sha256".into())),
999                ("hash", Value::Bytes(vec![0x00; 32])),
1000            ])
1001        }
1002    }
1003
1004    fn data_hash() -> Value {
1005        vmap(vec![
1006            ("exclusions", Value::Array(Vec::new())),
1007            ("alg", Value::Text("sha256".into())),
1008            ("hash", Value::Bytes(vec![0x11; 32])),
1009        ])
1010    }
1011
1012    fn created_actions() -> Value {
1013        vmap(vec![(
1014            "actions",
1015            Value::Array(vec![vmap(vec![
1016                ("action", Value::Text("c2pa.created".into())),
1017                (
1018                    "digitalSourceType",
1019                    Value::Text(
1020                        "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture".into(),
1021                    ),
1022                ),
1023            ])]),
1024        )])
1025    }
1026
1027    fn claim(references: Vec<Value>) -> Value {
1028        vmap(vec![
1029            ("instanceID", Value::Text("xmp:iid:fixture".into())),
1030            (
1031                "claim_generator_info",
1032                vmap(vec![("name", Value::Text("Encypher Fixture".into()))]),
1033            ),
1034            ("created_assertions", Value::Array(references)),
1035            ("signature", Value::Text("self#jumbf=c2pa.signature".into())),
1036        ])
1037    }
1038
1039    /// One ingredient assertion: the link to `target`, the claim-signature
1040    /// link, and the verdict the signer recorded at ingest.
1041    fn ingredient(
1042        target_label: &str,
1043        target_manifest: &[u8],
1044        target_cose: &[u8],
1045        relationship: &str,
1046        recorded: Option<Value>,
1047    ) -> Value {
1048        let content = superbox_content(target_manifest).expect("manifest content");
1049        let mut fields = vec![
1050            ("relationship", Value::Text(relationship.into())),
1051            ("dc:title", Value::Text("ingredient.jpg".into())),
1052            (
1053                "activeManifest",
1054                vmap(vec![
1055                    (
1056                        "url",
1057                        Value::Text(format!("self#jumbf=/c2pa/{target_label}")),
1058                    ),
1059                    ("alg", Value::Text("sha256".into())),
1060                    ("hash", Value::Bytes(Sha256::digest(content).to_vec())),
1061                ]),
1062            ),
1063            (
1064                "claimSignature",
1065                vmap(vec![
1066                    (
1067                        "url",
1068                        Value::Text(format!("self#jumbf=/c2pa/{target_label}/c2pa.signature")),
1069                    ),
1070                    ("alg", Value::Text("sha256".into())),
1071                    (
1072                        "hash",
1073                        Value::Bytes(
1074                            hash_claim_signature_box("sha256", target_cose)
1075                                .expect("signature hash"),
1076                        ),
1077                    ),
1078                ]),
1079            ),
1080        ];
1081        if let Some(recorded) = recorded {
1082            fields.push(("validationResults", recorded));
1083        }
1084        vmap(fields)
1085    }
1086
1087    /// An ingredient assertion with no manifest link at all.
1088    fn unlinked_ingredient(relationship: &str) -> Value {
1089        vmap(vec![
1090            ("relationship", Value::Text(relationship.into())),
1091            ("dc:title", Value::Text("no-provenance.jpg".into())),
1092        ])
1093    }
1094
1095    /// The `validationResults` shape a claim generator records for an
1096    /// ingredient it validated at ingest.
1097    fn recorded_results(codes: Vec<(&str, &str, &str)>) -> Value {
1098        let mut success = Vec::new();
1099        let mut informational = Vec::new();
1100        let mut failure = Vec::new();
1101        for (bucket, code, url) in codes {
1102            let entry = vmap(vec![
1103                ("code", Value::Text(code.into())),
1104                ("url", Value::Text(url.into())),
1105            ]);
1106            match bucket {
1107                "success" => success.push(entry),
1108                "informational" => informational.push(entry),
1109                _ => failure.push(entry),
1110            }
1111        }
1112        vmap(vec![(
1113            "activeManifest",
1114            vmap(vec![
1115                ("success", Value::Array(success)),
1116                ("informational", Value::Array(informational)),
1117                ("failure", Value::Array(failure)),
1118            ]),
1119        )])
1120    }
1121
1122    /// Assemble one manifest box from its assertions and a claim signed by
1123    /// `signer`. Returns the box and the COSE signature bytes.
1124    fn manifest_box(
1125        signer: &Signer,
1126        label: &str,
1127        assertions: &[Assertion],
1128        references: Vec<Value>,
1129    ) -> (Vec<u8>, Vec<u8>) {
1130        let claim_cbor = enc(&claim(references));
1131        let cose = signer.sign(&claim_cbor);
1132        let boxes: Vec<Vec<u8>> = assertions
1133            .iter()
1134            .map(|assertion| assertion.jumbf.clone())
1135            .collect();
1136        (
1137            build_manifest(label, &boxes, &claim_cbor, &cose),
1138            cose.clone(),
1139        )
1140    }
1141
1142    /// The same manifest, wrapped as an update manifest (`c2um`).
1143    fn update_manifest_box(
1144        signer: &Signer,
1145        label: &str,
1146        assertions: &[Assertion],
1147        references: Vec<Value>,
1148    ) -> Vec<u8> {
1149        let claim_cbor = enc(&claim(references));
1150        let cose = signer.sign(&claim_cbor);
1151        superbox(
1152            &UUID_UPDATE_MANIFEST,
1153            label,
1154            &[
1155                superbox(
1156                    &UUID_ASSERTION_STORE,
1157                    "c2pa.assertions",
1158                    &assertions
1159                        .iter()
1160                        .map(|assertion| assertion.jumbf.clone())
1161                        .collect::<Vec<_>>(),
1162                    None,
1163                ),
1164                superbox(&UUID_CLAIM, "c2pa.claim.v2", &[cbor_box(&claim_cbor)], None),
1165                superbox(
1166                    &UUID_CLAIM_SIGNATURE,
1167                    "c2pa.signature",
1168                    &[cbor_box(&cose)],
1169                    None,
1170                ),
1171            ],
1172            None,
1173        )
1174    }
1175
1176    /// Verify the last manifest in `boxes` as the active manifest.
1177    fn verify_store(boxes: &[Vec<u8>]) -> VerifyOutput {
1178        verify_store_under(boxes, EngineProfile::GENEROUS)
1179    }
1180
1181    /// The same, under an explicit posture.
1182    fn verify_store_under(boxes: &[Vec<u8>], profile: EngineProfile) -> VerifyOutput {
1183        let store = build_manifest_store(boxes);
1184        let expanded = expand_store(&store).expect("expand store");
1185        let parsed = parse_manifest_store(expanded.bytes()).expect("parse store");
1186        let hashes = manifest_hashes(&expanded, &parsed.manifests).expect("manifest hashes");
1187        let active = parsed.manifests.last().expect("active manifest");
1188        let input = VerifyInput {
1189            data: &[],
1190            mime: "application/c2pa",
1191            claim_signer_trust: None,
1192            tsa_trust: None,
1193            allowed_certs: None,
1194            validation_time: Some(NOW),
1195            profile,
1196            evidence: Default::default(),
1197            cawg_strict_encoding: false,
1198        };
1199        let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
1200        verify_manifest(
1201            active,
1202            StoreContext {
1203                manifests: &parsed.manifests,
1204                manifest_hashes: &hashes,
1205            },
1206            &input,
1207            AssetFormat::C2paStore,
1208            &[],
1209            None,
1210            CawgTrustInputs::default(),
1211            &mut report_decode_nodes,
1212        )
1213    }
1214
1215    fn deltas(out: &VerifyOutput) -> Vec<&Json> {
1216        out.report_json
1217            .pointer("/validation_results/ingredientDeltas")
1218            .and_then(Json::as_array)
1219            .map(|entries| entries.iter().collect())
1220            .unwrap_or_default()
1221    }
1222
1223    fn delta_codes<'a>(delta: &'a Json, bucket: &str) -> Vec<&'a str> {
1224        delta
1225            .pointer(&format!("/validationDeltas/{bucket}"))
1226            .and_then(Json::as_array)
1227            .map(|entries| {
1228                entries
1229                    .iter()
1230                    .filter_map(|entry| entry.get("code").and_then(Json::as_str))
1231                    .collect()
1232            })
1233            .unwrap_or_default()
1234    }
1235
1236    /// A signed store whose ingredient manifest no longer binds its own
1237    /// assertions. The active manifest's link to that ingredient still matches
1238    /// (the link was computed over the store as it stands), so only recursive
1239    /// validation of the ingredient manifest can see the defect.
1240    fn tampered_ingredient_store() -> Vec<Vec<u8>> {
1241        let signer = Signer::conformant();
1242        let ingredient_binding = Assertion::new("c2pa.hash.data", &data_hash());
1243        let ingredient_actions = Assertion::new("c2pa.actions.v2", &created_actions());
1244        let (ingredient_manifest, ingredient_cose) = manifest_box(
1245            &signer,
1246            INGREDIENT,
1247            &[ingredient_binding.clone(), ingredient_actions.clone()],
1248            vec![
1249                ingredient_binding.tampered_reference(),
1250                ingredient_actions.reference(),
1251            ],
1252        );
1253
1254        let active_binding = Assertion::new("c2pa.hash.data", &data_hash());
1255        let active_actions = Assertion::new("c2pa.actions.v2", &created_actions());
1256        let active_ingredient = Assertion::new(
1257            "c2pa.ingredient.v3",
1258            &ingredient(
1259                INGREDIENT,
1260                &ingredient_manifest,
1261                &ingredient_cose,
1262                "componentOf",
1263                Some(recorded_results(vec![(
1264                    "success",
1265                    "claimSignature.validated",
1266                    &format!("self#jumbf=/c2pa/{INGREDIENT}/c2pa.signature"),
1267                )])),
1268            ),
1269        );
1270        let (active_manifest, _) = manifest_box(
1271            &signer,
1272            ACTIVE,
1273            &[
1274                active_binding.clone(),
1275                active_actions.clone(),
1276                active_ingredient.clone(),
1277            ],
1278            vec![
1279                active_binding.reference(),
1280                active_actions.reference(),
1281                active_ingredient.reference(),
1282            ],
1283        );
1284        vec![ingredient_manifest, active_manifest]
1285    }
1286
1287    /// Both manifests are signed after all references have been constructed.
1288    /// Only the nested ingredient fields supplied by the test may be invalid;
1289    /// the claim bindings, signatures and child-manifest link remain intact.
1290    fn related_ingredient_store(
1291        fields: impl FnOnce(&Assertion, &Assertion, &Assertion) -> Vec<(&'static str, Value)>,
1292    ) -> Vec<u8> {
1293        let signer = Signer::conformant();
1294        let binding = Assertion::new("c2pa.hash.data", &data_hash());
1295        let actions = Assertion::new("c2pa.actions.v2", &created_actions());
1296        let related = Assertion::new(
1297            "com.example.ingredient",
1298            &vmap(vec![(
1299                "description",
1300                Value::Text("ingredient metadata".into()),
1301            )]),
1302        );
1303        let child_assertions = vec![binding.clone(), actions.clone(), related.clone()];
1304        let (child, child_cose) = manifest_box(
1305            &signer,
1306            INGREDIENT,
1307            &child_assertions,
1308            child_assertions.iter().map(Assertion::reference).collect(),
1309        );
1310        let mut link = ingredient(
1311            INGREDIENT,
1312            &child,
1313            &child_cose,
1314            "componentOf",
1315            Some(recorded_results(vec![])),
1316        );
1317        let Value::Map(entries) = &mut link else {
1318            unreachable!("ingredient is a map");
1319        };
1320        entries.extend(
1321            fields(&related, &actions, &binding)
1322                .into_iter()
1323                .map(|(key, value)| (Value::Text(key.into()), value)),
1324        );
1325        let link = Assertion::new("c2pa.ingredient.v3", &link);
1326        let assertions = vec![binding, actions, related, link];
1327        let (active, _) = manifest_box(
1328            &signer,
1329            ACTIVE,
1330            &assertions,
1331            assertions.iter().map(Assertion::reference).collect(),
1332        );
1333        build_manifest_store(&[child, active])
1334    }
1335
1336    fn verify_related_ingredient_store(store: &[u8], version: &str) -> crate::StoreOnlyValidation {
1337        crate::verify_manifest_store_only(
1338            store,
1339            &VerifyOptions {
1340                spec_version: Some(version.into()),
1341                no_default_trust: true,
1342                validation_time: Some("2026-06-01T00:00:00Z".into()),
1343                ..VerifyOptions::default()
1344            },
1345        )
1346        .expect("signed ingredient store validation")
1347    }
1348
1349    #[test]
1350    fn final_2_5_accepts_signed_active_manifest_with_related_assertions() {
1351        let store = related_ingredient_store(|related, _, _| {
1352            vec![("relatedAssertions", Value::Array(vec![related.reference()]))]
1353        });
1354        for version in ["2.2", "2.4", "2.5"] {
1355            let validation = verify_related_ingredient_store(&store, version);
1356            let results = &validation.report.validation_results;
1357            assert!(
1358                results.failure.is_empty(),
1359                "{version}: {:?}",
1360                results.failure
1361            );
1362            for code in ["claimSignature.validated", "ingredient.manifest.validated"] {
1363                assert!(
1364                    results.success.iter().any(|status| status.code == code),
1365                    "{version}: missing {code}: {:?}",
1366                    results.success
1367                );
1368            }
1369            let deltas = validation
1370                .report
1371                .manifest_report
1372                .pointer("/validation_results/ingredientDeltas")
1373                .and_then(Json::as_array)
1374                .expect("recursive ingredient validation is reported");
1375            assert_eq!(deltas.len(), 1, "{version}: {deltas:?}");
1376            assert!(
1377                delta_codes(&deltas[0], "failure").is_empty(),
1378                "{version}: {}",
1379                deltas[0]
1380            );
1381            assert!(
1382                delta_codes(&deltas[0], "success").contains(&"claimSignature.validated"),
1383                "{version}: child signature must also validate: {}",
1384                deltas[0]
1385            );
1386            assert_eq!(validation.report.hard_binding, "not_evaluated");
1387            assert_eq!(validation.report.integrity, "unbound");
1388        }
1389    }
1390
1391    #[test]
1392    fn final_2_5_rejects_malformed_related_assertions_in_signed_graphs() {
1393        for case in [
1394            "not-array",
1395            "empty",
1396            "not-reference",
1397            "missing-target",
1398            "wrong-hash",
1399            "wrong-hash-type",
1400            "foreign-manifest",
1401            "ineligible-actions",
1402            "ineligible-label",
1403        ] {
1404            let store = related_ingredient_store(|related, actions, binding| {
1405                let value = match case {
1406                    "not-array" => Value::Text("not an array".into()),
1407                    "empty" => Value::Array(vec![]),
1408                    "not-reference" => Value::Array(vec![Value::Text("not a hashed URI".into())]),
1409                    "ineligible-actions" => Value::Array(vec![actions.reference()]),
1410                    _ => {
1411                        let mut reference = match case {
1412                            "wrong-hash" => related.tampered_reference(),
1413                            // This assertion exists and its digest matches, but its label
1414                            // does not end with .ingredient.
1415                            "ineligible-label" => binding.reference(),
1416                            _ => related.reference(),
1417                        };
1418                        let Value::Map(entries) = &mut reference else {
1419                            unreachable!("reference is a map");
1420                        };
1421                        match case {
1422                            "missing-target" | "foreign-manifest" => {
1423                                let url = if case == "missing-target" {
1424                                    "self#jumbf=c2pa.assertions/com.missing.ingredient".into()
1425                                } else {
1426                                    format!(
1427                                        "self#jumbf=/c2pa/{INGREDIENT}/c2pa.assertions/{}",
1428                                        related.label
1429                                    )
1430                                };
1431                                entries
1432                                    .iter_mut()
1433                                    .find(|(key, _)| key.as_text() == Some("url"))
1434                                    .expect("url")
1435                                    .1 = Value::Text(url);
1436                            }
1437                            "wrong-hash-type" => {
1438                                entries
1439                                    .iter_mut()
1440                                    .find(|(key, _)| key.as_text() == Some("hash"))
1441                                    .expect("hash")
1442                                    .1 = Value::Text("not bytes".into());
1443                            }
1444                            _ => {}
1445                        }
1446                        Value::Array(vec![reference])
1447                    }
1448                };
1449                vec![("relatedAssertions", value)]
1450            });
1451            let validation = verify_related_ingredient_store(&store, "2.5");
1452            let results = &validation.report.validation_results;
1453            assert!(
1454                results.failure.iter().any(|status| {
1455                    status.code == "assertion.ingredient.malformed"
1456                        && status.url
1457                            == format!(
1458                                "self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3"
1459                            )
1460                }),
1461                "{case}: {:?}",
1462                results.failure
1463            );
1464            assert!(
1465                results
1466                    .success
1467                    .iter()
1468                    .any(|status| status.code == "claimSignature.validated"),
1469                "{case}: the negative must retain a valid claim signature"
1470            );
1471            // Earlier policies do not apply the 2.5 relatedAssertions rule.
1472            for version in ["2.2", "2.4"] {
1473                let legacy = verify_related_ingredient_store(&store, version);
1474                assert!(
1475                    legacy.report.validation_results.failure.is_empty(),
1476                    "{case}, {version}: {:?}",
1477                    legacy.report.validation_results.failure
1478                );
1479            }
1480        }
1481    }
1482
1483    #[test]
1484    fn final_2_5_still_rejects_active_manifest_with_digital_source_type() {
1485        let store = related_ingredient_store(|related, _, _| {
1486            vec![
1487                ("relatedAssertions", Value::Array(vec![related.reference()])),
1488                (
1489                    "digitalSourceType",
1490                    Value::Text(
1491                        "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture".into(),
1492                    ),
1493                ),
1494            ]
1495        });
1496        for version in ["2.2", "2.4", "2.5"] {
1497            let validation = verify_related_ingredient_store(&store, version);
1498            assert!(
1499                validation
1500                    .report
1501                    .validation_results
1502                    .failure
1503                    .iter()
1504                    .any(|status| {
1505                        status.code == "assertion.ingredient.malformed"
1506                            && status.url
1507                                == format!(
1508                                    "self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3"
1509                                )
1510                    }),
1511                "{version}: {:?}",
1512                validation.report.validation_results.failure
1513            );
1514        }
1515    }
1516
1517    #[test]
1518    fn ingredient_manifest_defect_is_reported_as_a_delta_and_not_as_an_active_failure() {
1519        let out = verify_store(&tampered_ingredient_store());
1520
1521        let deltas = deltas(&out);
1522        assert_eq!(deltas.len(), 1, "report: {}", out.report_json);
1523        assert_eq!(
1524            deltas[0]
1525                .get("ingredientAssertionURI")
1526                .and_then(Json::as_str),
1527            Some(format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3").as_str())
1528        );
1529        assert!(
1530            delta_codes(deltas[0], "failure").contains(&"assertion.hashedURI.mismatch"),
1531            "delta: {}",
1532            deltas[0]
1533        );
1534        // The ingredient's defect never becomes the active manifest's failure.
1535        assert!(
1536            !out.results
1537                .failure
1538                .iter()
1539                .any(|status| status.url.contains(INGREDIENT)),
1540            "active failures: {:?}",
1541            out.results.failure
1542        );
1543    }
1544
1545    #[test]
1546    fn an_ingredient_without_a_manifest_link_reports_unknown_provenance() {
1547        let signer = Signer::conformant();
1548        let binding = Assertion::new("c2pa.hash.data", &data_hash());
1549        let actions = Assertion::new("c2pa.actions.v2", &created_actions());
1550        let parent = Assertion::new("c2pa.ingredient.v3", &unlinked_ingredient("componentOf"));
1551        let (manifest, _) = manifest_box(
1552            &signer,
1553            ACTIVE,
1554            &[binding.clone(), actions.clone(), parent.clone()],
1555            vec![binding.reference(), actions.reference(), parent.reference()],
1556        );
1557
1558        let out = verify_store(&[manifest]);
1559
1560        let status = out
1561            .results
1562            .informational
1563            .iter()
1564            .find(|status| status.code == "ingredient.unknownProvenance")
1565            .expect("unknownProvenance informational");
1566        assert_eq!(
1567            status.url,
1568            format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3")
1569        );
1570    }
1571
1572    #[test]
1573    fn an_input_only_ingredient_without_provenance_is_silent() {
1574        let signer = Signer::conformant();
1575        let binding = Assertion::new("c2pa.hash.data", &data_hash());
1576        let actions = Assertion::new("c2pa.actions.v2", &created_actions());
1577        let input = Assertion::new("c2pa.ingredient.v3", &unlinked_ingredient("inputTo"));
1578        let (manifest, _) = manifest_box(
1579            &signer,
1580            ACTIVE,
1581            &[binding.clone(), actions.clone(), input.clone()],
1582            vec![binding.reference(), actions.reference(), input.reference()],
1583        );
1584
1585        let out = verify_store(&[manifest]);
1586
1587        assert!(
1588            !out.results
1589                .informational
1590                .iter()
1591                .any(|status| status.code == "ingredient.unknownProvenance"),
1592            "informational: {:?}",
1593            out.results.informational
1594        );
1595    }
1596
1597    /// Two manifests naming each other. The entry link is valid; the return
1598    /// link cannot also commit to the completed cycle. Validate the reached
1599    /// manifest once and report its broken return link without re-entering the active manifest.
1600    #[test]
1601    fn a_label_cycle_terminates_and_validates_each_manifest_once() {
1602        let signer = Signer::conformant();
1603        let link = |label: &str| {
1604            vmap(vec![
1605                ("relationship", Value::Text("componentOf".into())),
1606                (
1607                    "activeManifest",
1608                    vmap(vec![
1609                        ("url", Value::Text(format!("self#jumbf=/c2pa/{label}"))),
1610                        ("alg", Value::Text("sha256".into())),
1611                        ("hash", Value::Bytes(vec![0x00; 32])),
1612                    ]),
1613                ),
1614            ])
1615        };
1616        let first_binding = Assertion::new("c2pa.hash.data", &data_hash());
1617        let first_ingredient = Assertion::new("c2pa.ingredient.v3", &link(ACTIVE));
1618        let (first, first_sig) = manifest_box(
1619            &signer,
1620            INGREDIENT,
1621            &[first_binding.clone(), first_ingredient.clone()],
1622            vec![first_binding.reference(), first_ingredient.reference()],
1623        );
1624        let second_binding = Assertion::new("c2pa.hash.data", &data_hash());
1625        let second_ingredient = Assertion::new(
1626            "c2pa.ingredient.v3",
1627            &ingredient(INGREDIENT, &first, &first_sig, "componentOf", None),
1628        );
1629        let (second, _) = manifest_box(
1630            &signer,
1631            ACTIVE,
1632            &[second_binding.clone(), second_ingredient.clone()],
1633            vec![second_binding.reference(), second_ingredient.reference()],
1634        );
1635
1636        let out = verify_store(&[first, second]);
1637
1638        // Exactly one delta: the walk validated the ingredient manifest once
1639        // and did not re-enter the active manifest its ingredient names back.
1640        // The ingredient's own broken link is reported inside that delta, which
1641        // is only possible if the walk validated the ingredient's own
1642        // ingredient references.
1643        let deltas = deltas(&out);
1644        assert_eq!(deltas.len(), 1, "report: {}", out.report_json);
1645        assert_eq!(
1646            deltas[0]
1647                .get("ingredientAssertionURI")
1648                .and_then(Json::as_str),
1649            Some(format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3").as_str())
1650        );
1651        assert!(
1652            delta_codes(deltas[0], "failure").contains(&"ingredient.manifest.mismatch"),
1653            "delta: {}",
1654            deltas[0]
1655        );
1656    }
1657
1658    /// The Content Credentials document carries the deltas the report produced,
1659    /// on the manifest whose assertion each one describes.
1660    #[test]
1661    fn crjson_carries_the_ingredient_deltas() {
1662        let boxes = tampered_ingredient_store();
1663        let out = verify_store(&boxes);
1664        let store_bytes = build_manifest_store(&boxes);
1665        let expanded = expand_store(&store_bytes).expect("expand store");
1666        let parsed = parse_manifest_store(expanded.bytes()).expect("parse store");
1667
1668        let document = super::super::crjson::to_crjson(
1669            &parsed,
1670            &super::super::CrjsonContext {
1671                report: &out.report_json,
1672                validation_time: NOW,
1673                profile: EngineProfile::GENEROUS,
1674                trust_list_sources: &[],
1675                compressed_manifests: Vec::new(),
1676            },
1677        );
1678
1679        let active = document["manifests"]
1680            .as_array()
1681            .and_then(|manifests| {
1682                manifests
1683                    .iter()
1684                    .find(|manifest| manifest["label"] == Json::String(ACTIVE.into()))
1685            })
1686            .expect("active manifest entry");
1687        assert_eq!(
1688            active["ingredientDeltas"][0]["ingredientAssertionURI"],
1689            Json::String(format!(
1690                "self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3"
1691            ))
1692        );
1693        // The ingredient manifest's own entry carries the verdict the walk
1694        // derived for it, resolved through the `activeManifest` link.
1695        let ingredient = document["manifests"]
1696            .as_array()
1697            .and_then(|manifests| {
1698                manifests
1699                    .iter()
1700                    .find(|manifest| manifest["label"] == Json::String(INGREDIENT.into()))
1701            })
1702            .expect("ingredient manifest entry");
1703        let failures: Vec<&str> = ingredient["validationResults"]["failure"]
1704            .as_array()
1705            .map(|entries| {
1706                entries
1707                    .iter()
1708                    .filter_map(|entry| entry["code"].as_str())
1709                    .collect()
1710            })
1711            .unwrap_or_default();
1712        assert!(
1713            failures.contains(&"assertion.hashedURI.mismatch"),
1714            "ingredient entry: {ingredient}"
1715        );
1716    }
1717
1718    /// An update manifest inherits the hard binding of the first standard
1719    /// manifest in its parentOf chain, which is what a CAWG identity inside it
1720    /// references (C2PA 2.4 Validation, "Validate the Asset's Content").
1721    #[test]
1722    fn an_update_manifest_inherits_its_parents_hard_binding() {
1723        let signer = Signer::conformant();
1724        let binding = Assertion::new("c2pa.hash.data", &data_hash());
1725        let actions = Assertion::new("c2pa.actions.v2", &created_actions());
1726        let (standard, standard_cose) = manifest_box(
1727            &signer,
1728            INGREDIENT,
1729            &[binding.clone(), actions.clone()],
1730            vec![binding.reference(), actions.reference()],
1731        );
1732        let parent = Assertion::new(
1733            "c2pa.ingredient.v3",
1734            &ingredient(
1735                INGREDIENT,
1736                &standard,
1737                &standard_cose,
1738                "parentOf",
1739                Some(recorded_results(Vec::new())),
1740            ),
1741        );
1742        let update =
1743            update_manifest_box(&signer, ACTIVE, &[parent.clone()], vec![parent.reference()]);
1744
1745        let store_bytes = build_manifest_store(&[standard, update]);
1746        let expanded = expand_store(&store_bytes).expect("expand store");
1747        let parsed = parse_manifest_store(expanded.bytes()).expect("parse store");
1748        let hashes = manifest_hashes(&expanded, &parsed.manifests).expect("manifest hashes");
1749        let active = parsed.manifests.last().expect("active manifest");
1750
1751        let inherited = super::super::inherited_hard_binding(
1752            active,
1753            StoreContext {
1754                manifests: &parsed.manifests,
1755                manifest_hashes: &hashes,
1756            },
1757        )
1758        .expect("update manifest inherits a hard binding");
1759
1760        assert_eq!(inherited.0, INGREDIENT);
1761        assert_eq!(
1762            inherited.1.get("url").and_then(Value::as_text),
1763            Some("self#jumbf=c2pa.assertions/c2pa.hash.data")
1764        );
1765    }
1766
1767    /// A v3 ingredient that links a manifest but records no `validationResults`
1768    /// is rejected where the spec is the bar, and read as before by default: a
1769    /// missing recorded verdict costs the validator nothing it cannot re-derive
1770    /// from the store itself.
1771    #[test]
1772    fn a_v3_ingredient_without_recorded_results_is_a_strict_only_failure() {
1773        let signer = Signer::conformant();
1774        let binding = Assertion::new("c2pa.hash.data", &data_hash());
1775        let actions = Assertion::new("c2pa.actions.v2", &created_actions());
1776        let (child, child_cose) = manifest_box(
1777            &signer,
1778            INGREDIENT,
1779            &[binding.clone(), actions.clone()],
1780            vec![binding.reference(), actions.reference()],
1781        );
1782        let link = Assertion::new(
1783            "c2pa.ingredient.v3",
1784            &ingredient(INGREDIENT, &child, &child_cose, "componentOf", None),
1785        );
1786        let active_binding = Assertion::new("c2pa.hash.data", &data_hash());
1787        let (active, _) = manifest_box(
1788            &signer,
1789            ACTIVE,
1790            &[active_binding.clone(), link.clone()],
1791            vec![active_binding.reference(), link.reference()],
1792        );
1793        let boxes = vec![child, active];
1794
1795        let strict = verify_store_under(
1796            &boxes,
1797            EngineProfile::strict(crate::c2pa_core::SpecVersion::V2_4),
1798        );
1799        let generous = verify_store(&boxes);
1800
1801        assert!(
1802            strict.results.failure.iter().any(|status| {
1803                status.code == "assertion.ingredient.malformed"
1804                    && status.url
1805                        == format!("self#jumbf=/c2pa/{ACTIVE}/c2pa.assertions/c2pa.ingredient.v3")
1806            }),
1807            "strict failures: {:?}",
1808            strict.results.failure
1809        );
1810        assert!(
1811            !generous
1812                .results
1813                .failure
1814                .iter()
1815                .any(|status| status.code == "assertion.ingredient.malformed"),
1816            "default failures: {:?}",
1817            generous.results.failure
1818        );
1819    }
1820
1821    /// A store deeper than the walk's ceiling stops at it and says so, without
1822    /// touching the verdict: running out of budget is a resource outcome.
1823    #[test]
1824    fn a_chain_deeper_than_the_ceiling_stops_and_reports_the_limit() {
1825        let signer = Signer::conformant();
1826        let depth = super::GraphLimits::DEFAULT.max_depth + 2;
1827        let mut boxes: Vec<Vec<u8>> = Vec::with_capacity(depth);
1828        let mut previous: Option<(String, Vec<u8>, Vec<u8>)> = None;
1829        for index in 0..depth {
1830            let label = format!("urn:c2pa:00000000-0000-4000-8000-{index:012}");
1831            let binding = Assertion::new("c2pa.hash.data", &data_hash());
1832            let mut assertions = vec![binding.clone()];
1833            let mut references = vec![binding.reference()];
1834            if let Some((parent_label, parent_manifest, parent_cose)) = &previous {
1835                let link = Assertion::new(
1836                    "c2pa.ingredient.v3",
1837                    &ingredient(
1838                        parent_label,
1839                        parent_manifest,
1840                        parent_cose,
1841                        "componentOf",
1842                        None,
1843                    ),
1844                );
1845                references.push(link.reference());
1846                assertions.push(link);
1847            }
1848            let (manifest, cose) = manifest_box(&signer, &label, &assertions, references);
1849            previous = Some((label, manifest.clone(), cose));
1850            boxes.push(manifest);
1851        }
1852        // The last manifest built is the active one, and each step down the
1853        // chain is one more ingredient edge away from it.
1854        let out = verify_store(&boxes);
1855
1856        let limit = out
1857            .results
1858            .informational
1859            .iter()
1860            .find(|status| status.code == "com.encypher.resource.graphLimitExceeded")
1861            .expect("graph ceiling reported");
1862        assert_eq!(
1863            limit
1864                .details
1865                .as_ref()
1866                .and_then(|details| details.get("limit").and_then(serde_json::Value::as_str)),
1867            Some("depth")
1868        );
1869        // The ceiling is informational: it never adds a failure of its own.
1870        assert!(!out
1871            .results
1872            .failure
1873            .iter()
1874            .any(|status| status.code.starts_with("com.encypher.ingredient.")));
1875    }
1876
1877    #[test]
1878    fn store_only_graph_inputs_share_provider_and_budget_without_binding_content() {
1879        const URL: &str = "https://example.test/icon.png";
1880        const ICON: &[u8] = b"caller-held icon bytes";
1881
1882        let external_icon = || {
1883            vmap(vec![
1884                ("url", Value::Text(URL.into())),
1885                ("alg", Value::Text("sha256".into())),
1886                ("hash", Value::Bytes(Sha256::digest(ICON).to_vec())),
1887                ("dc:format", Value::Text("image/png".into())),
1888            ])
1889        };
1890        let actions = Assertion::new(
1891            "c2pa.actions.v2",
1892            &vmap(vec![
1893                (
1894                    "actions",
1895                    Value::Array(vec![vmap(vec![
1896                        ("action", Value::Text("c2pa.created".into())),
1897                        (
1898                            "digitalSourceType",
1899                            Value::Text(
1900                                "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
1901                                    .into(),
1902                            ),
1903                        ),
1904                    ])]),
1905                ),
1906                (
1907                    "softwareAgents",
1908                    Value::Array(vec![
1909                        vmap(vec![
1910                            ("name", Value::Text("agent one".into())),
1911                            ("icon", external_icon()),
1912                        ]),
1913                        vmap(vec![
1914                            ("name", Value::Text("agent two".into())),
1915                            ("icon", external_icon()),
1916                        ]),
1917                    ]),
1918                ),
1919            ]),
1920        );
1921        let binding = Assertion::new("c2pa.hash.data", &data_hash());
1922        let signer = Signer::conformant();
1923        let (manifest, _) = manifest_box(
1924            &signer,
1925            ACTIVE,
1926            &[binding.clone(), actions.clone()],
1927            vec![binding.reference(), actions.reference()],
1928        );
1929        let store = build_manifest_store(&[manifest]);
1930        let options = VerifyOptions {
1931            no_default_trust: true,
1932            validation_time: Some("2026-06-01T00:00:00Z".into()),
1933            ..VerifyOptions::default()
1934        };
1935
1936        struct Provider {
1937            calls: Cell<usize>,
1938        }
1939
1940        impl ExternalResources for Provider {
1941            fn resolve(&self, url: &str) -> ExternalResource<'_> {
1942                assert_eq!(url, URL);
1943                self.calls.set(self.calls.get() + 1);
1944                ExternalResource::Retrieved {
1945                    bytes: ICON,
1946                    media_type: Some("image/png"),
1947                }
1948            }
1949        }
1950
1951        let provider = Provider {
1952            calls: Cell::new(0),
1953        };
1954        let validation = crate::verify_manifest_store_only_with_graph_inputs(
1955            &store,
1956            &options,
1957            GraphInputs {
1958                external: Some(&provider),
1959                limits: GraphLimits::default(),
1960            },
1961        )
1962        .expect("store-only graph validation");
1963        assert_eq!(provider.calls.get(), 1, "one URI is resolved once");
1964        assert_eq!(validation.report.hard_binding, "not_evaluated");
1965        assert_eq!(validation.report.integrity, "unbound");
1966        assert_eq!(
1967            validation
1968                .report
1969                .validation_results
1970                .success
1971                .iter()
1972                .filter(|status| {
1973                    status.code == "assertion.hashedURI.match" && status.url == URL
1974                })
1975                .count(),
1976            2
1977        );
1978
1979        let limited_provider = Provider {
1980            calls: Cell::new(0),
1981        };
1982        let limited = crate::verify_manifest_store_only_with_graph_inputs(
1983            &store,
1984            &options,
1985            GraphInputs {
1986                external: Some(&limited_provider),
1987                limits: GraphLimits::lowered(32, 1_024, 1, 64 * 1024 * 1024),
1988            },
1989        )
1990        .expect("budget-limited store-only graph validation");
1991        assert_eq!(limited.report.hard_binding, "not_evaluated");
1992        assert!(
1993            limited
1994                .report
1995                .validation_results
1996                .informational
1997                .iter()
1998                .any(|status| {
1999                    status.code == "com.encypher.resource.graphLimitExceeded"
2000                        && status
2001                            .details
2002                            .as_ref()
2003                            .and_then(|details| details.get("limit"))
2004                            .and_then(Json::as_str)
2005                            == Some("references")
2006                }),
2007            "{:?}",
2008            limited.report.validation_results.informational
2009        );
2010    }
2011
2012    include!(concat!(
2013        env!("CARGO_MANIFEST_DIR"),
2014        "/tests/redaction_lineage/mod.rs"
2015    ));
2016}