Skip to main content

encypher_c2pa/c2pa-validate/
refs.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Generic hashed-URI validation for the reference FIELDS of standard
5//! assertions (C2PA 2.4 Validation, "Validation of References").
6//!
7//! The claim's own `created_assertions` / `gathered_assertions` walk lives in
8//! the crate root ([`super::verify_assertion_bindings`]). This module covers
9//! the other direction the spec requires: "If the value of any field of a
10//! standard assertion is a `hashed_uri` or `hashed_ext_uri`, the validator
11//! shall perform the steps described in Validation of References, except for
12//! the `activeManifest` field in `c2pa.ingredient.v3`."
13//!
14//! Four properties are load-bearing.
15//!
16//! 1. **Schema-driven, not shape-guessing.** [`ref_fields`] is derived from the
17//!    2.4 CDDL: every property whose declared type is `$hashed-uri-map` (or the
18//!    `$hashed-uri-map / $hashed-ext-uri-map` union). A map is walked because
19//!    the spec says that field is a reference, never because some map happened
20//!    to carry `{url, hash}`. An unknown or vendor assertion contributes no
21//!    reference and is never touched.
22//! 2. **The `metadata` subtree is skipped.** `$assertion-metadata-map` carries
23//!    its own `reference` hashed URI, but C2PA 2.4 Validation, Assertion
24//!    Validation, General is explicit: "For those assertions that support
25//!    assertion metadata (via a `metadata` field), if the `metadata` field is
26//!    present, the validator shall not perform any validation on the contents
27//!    of that structure." So no path in this module descends into `metadata`.
28//! 3. **No network.** External `http` or `https` references are resolved only
29//!    through an injected [`ExternalResources`] provider over bytes the caller
30//!    already holds. Without a provider, retrieval remains optional and is
31//!    reported as informational.
32//! 4. **Store-scoped, exact-label resolution.** The procedure is scoped to the
33//!    C2PA Manifest Store: an ingredient `thumbnail` commonly names the
34//!    thumbnail already sitting in the INGREDIENT's manifest rather than
35//!    copying those bytes forward. Resolution is by exact manifest label and
36//!    exact assertion label, never by suffix or best effort, so a signed URI
37//!    can never silently re-target another manifest's bytes. Reaching another
38//!    manifest is not a trust escalation: the reference carries the hash the
39//!    referring claim committed to, so those bytes are the referrer's own
40//!    statement about itself.
41//!
42//! Fields that already have their own dedicated procedure and status codes are
43//! deliberately absent from the tables, so no defect is reported twice under
44//! two codes:
45//!
46//! - `c2pa.ingredient.v3.activeManifest` and `claimSignature`, and the v1/v2
47//!   `c2pa_manifest` field: graph edges carrying `ingredient.manifest.*` and
48//!   `ingredient.claimSignature.*` (Validation, Validate the Ingredients).
49//!   `activeManifest` is carved out of this procedure by name.
50//! - `c2pa.hash.multi-asset`'s `parts[].hashAssertion`: resolved and digested
51//!   by [`super::verify_multi_asset`] under `assertion.multiAssetHash.*`.
52//! - `c2pa.alternative-content-representation`'s
53//!   `embeddedOriginalPreservationImage`: resolved and digested by
54//!   [`super::verify_alternative_content`] under
55//!   `assertion.alternativeContentRepresentation.*`.
56//!
57//! An action's `parameters.ingredient(s)` and `parameters.relatedAssertions`
58//! ARE walked here. Those are checked by the `c2pa.actions` rules too, but only
59//! for the action kinds those rules name, and only as a precondition of an
60//! `assertion.action.*` verdict. The blanket reference rule applies to every
61//! action kind, so walking them here closes the gap for the rest; where both
62//! apply, the two codes describe two different true facts about the same
63//! defect.
64
65use super::{
66    hash_bytes, is_allowed_hash_algorithm, is_generous, ClaimAssertionRefs, ClaimGeneration,
67    EngineProfile, OnlineEvidence, ValidationResults, ALGORITHM_UNSUPPORTED,
68    ASSERTION_HASHED_URI_MATCH, HASHED_URI_MISMATCH, HASHED_URI_MISSING,
69};
70use crate::c2pa_cbor::Value;
71use crate::c2pa_core::jumbf::ParsedManifest;
72use std::collections::HashMap;
73
74/// What a caller-side retrieval attempt for one external hashed URI produced.
75pub enum ExternalResource<'a> {
76    /// No retrieval was attempted.
77    NotAttempted,
78    /// Retrieval was attempted but no usable body was obtained.
79    Unavailable,
80    /// Retrieved bytes and the observed response media type, when available.
81    Retrieved {
82        /// Bytes to verify against the reference digest.
83        bytes: &'a [u8],
84        /// Media type reported by the transport.
85        media_type: Option<&'a str>,
86    },
87}
88
89/// Injected, network-free source of external hashed-URI resources.
90pub trait ExternalResources {
91    /// Resolve one external URL without allowing the verifier to open a socket.
92    fn resolve(&self, url: &str) -> ExternalResource<'_>;
93}
94
95enum CachedResource<'a> {
96    NotAttempted,
97    Unavailable,
98    Retrieved {
99        bytes: &'a [u8],
100        media_type: Option<&'a str>,
101    },
102}
103
104pub(super) enum ResolvedResource<'a> {
105    NotAttempted,
106    Unavailable,
107    Retrieved {
108        bytes: &'a [u8],
109        media_type: Option<&'a str>,
110    },
111}
112
113pub(super) struct ResourceCache<'a> {
114    provider: Option<&'a dyn ExternalResources>,
115    evidence: OnlineEvidence<'a>,
116    outcomes: HashMap<String, CachedResource<'a>>,
117}
118
119impl<'a> ResourceCache<'a> {
120    pub(super) fn with_evidence(
121        provider: Option<&'a dyn ExternalResources>,
122        evidence: OnlineEvidence<'a>,
123    ) -> Self {
124        Self {
125            provider,
126            evidence,
127            outcomes: HashMap::new(),
128        }
129    }
130
131    pub(super) fn resolve(&mut self, url: &str) -> ResolvedResource<'_> {
132        if !self.outcomes.contains_key(url) {
133            let supplied = self
134                .evidence
135                .external_data
136                .and_then(|resources| resources.get(url));
137            let outcome = if let Some(bytes) = supplied {
138                let media_type = self
139                    .evidence
140                    .external_data_media_types
141                    .and_then(|media_types| media_types.get(url))
142                    .map(String::as_str);
143                CachedResource::Retrieved {
144                    bytes: bytes.as_slice(),
145                    media_type,
146                }
147            } else if self.evidence.external_is_unreachable(url) {
148                CachedResource::Unavailable
149            } else {
150                match self.provider {
151                    None => CachedResource::NotAttempted,
152                    Some(provider) => Self::fetch(provider, url),
153                }
154            };
155            self.outcomes.insert(url.to_string(), outcome);
156        }
157        match self.outcomes.get(url).expect("resource outcome inserted") {
158            CachedResource::NotAttempted => ResolvedResource::NotAttempted,
159            CachedResource::Unavailable => ResolvedResource::Unavailable,
160            CachedResource::Retrieved { bytes, media_type } => ResolvedResource::Retrieved {
161                bytes,
162                media_type: *media_type,
163            },
164        }
165    }
166
167    /// Borrow the provider's stable response directly. `ExternalResources`
168    /// ties the response lifetime to its own borrow, so memoization needs no
169    /// payload or media-type copy.
170    fn fetch(provider: &'a dyn ExternalResources, url: &str) -> CachedResource<'a> {
171        match provider.resolve(url) {
172            ExternalResource::NotAttempted => CachedResource::NotAttempted,
173            ExternalResource::Unavailable => CachedResource::Unavailable,
174            ExternalResource::Retrieved { bytes, media_type } => {
175                CachedResource::Retrieved { bytes, media_type }
176            }
177        }
178    }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(super) struct ReferenceWalk {
183    pub(super) examined: usize,
184    pub(super) truncated: bool,
185}
186
187/// Ceiling on `related` action nesting. `action-item-map-v2.related` is a list
188/// of action items, so it recurses; the bound keeps a hostile assertion from
189/// turning that recursion into unbounded work.
190const MAX_RELATED_DEPTH: usize = 8;
191
192/// One step of a path from an assertion's root to a reference-bearing field.
193#[derive(Clone, Copy)]
194enum Step {
195    /// Descend into a map field.
196    Field(&'static str),
197    /// Iterate every element of an array.
198    Each,
199    /// Apply the remaining steps at this action item and at every action item
200    /// reachable through nested `related` arrays.
201    EachRelated,
202}
203
204/// Which reference flavor the 2.4 CDDL declares for a field.
205#[derive(Clone, Copy, PartialEq, Eq)]
206enum Kind {
207    /// `$hashed-uri-map`: a `self#jumbf=` URI.
208    Internal,
209    /// Action ingredient references have a versioned redaction exception.
210    Ingredient,
211    /// Action and ingredient relatedAssertions have a 2.5 exception.
212    Related,
213    /// `$hashed-uri-map / $hashed-ext-uri-map`: the URL's own scheme decides.
214    /// An external URL is left to the external-data procedure.
215    Either,
216}
217
218/// A reference-bearing field of a standard assertion.
219struct RefField {
220    path: &'static [Step],
221    kind: Kind,
222}
223
224const fn field(path: &'static [Step], kind: Kind) -> RefField {
225    RefField { path, kind }
226}
227
228/// `c2pa.ingredient` (v1, deprecated): `ingredient-map`.
229const INGREDIENT_V1: &[RefField] = &[field(&[Step::Field("thumbnail")], Kind::Internal)];
230
231/// `c2pa.ingredient.v2` (deprecated): `ingredient-map-v2`.
232const INGREDIENT_V2: &[RefField] = &[
233    field(&[Step::Field("thumbnail")], Kind::Internal),
234    field(&[Step::Field("data")], Kind::Either),
235];
236
237/// `c2pa.ingredient.v3`: `ingredient-map-v3`.
238const INGREDIENT_V3: &[RefField] = &[
239    field(&[Step::Field("thumbnail")], Kind::Internal),
240    field(&[Step::Field("data")], Kind::Either),
241    field(
242        &[Step::Field("relatedAssertions"), Step::Each],
243        Kind::Related,
244    ),
245];
246
247/// `c2pa.actions` / `c2pa.actions.v2`: `actions-map`, `actions-map-v2`,
248/// `action-common-map-v2`, `action-template-map-v2`, `parameters-map`, and
249/// `parameters-map-v2`.
250const ACTIONS: &[RefField] = &[
251    field(
252        &[
253            Step::Field("actions"),
254            Step::Each,
255            Step::EachRelated,
256            Step::Field("parameters"),
257            Step::Field("ingredient"),
258        ],
259        Kind::Ingredient,
260    ),
261    field(
262        &[
263            Step::Field("actions"),
264            Step::Each,
265            Step::EachRelated,
266            Step::Field("parameters"),
267            Step::Field("ingredients"),
268            Step::Each,
269        ],
270        Kind::Ingredient,
271    ),
272    field(
273        &[
274            Step::Field("actions"),
275            Step::Each,
276            Step::EachRelated,
277            Step::Field("parameters"),
278            Step::Field("relatedAssertions"),
279            Step::Each,
280        ],
281        Kind::Related,
282    ),
283    field(
284        &[
285            Step::Field("actions"),
286            Step::Each,
287            Step::EachRelated,
288            Step::Field("softwareAgent"),
289            Step::Field("icon"),
290        ],
291        Kind::Either,
292    ),
293    field(
294        &[
295            Step::Field("softwareAgents"),
296            Step::Each,
297            Step::Field("icon"),
298        ],
299        Kind::Either,
300    ),
301    field(
302        &[Step::Field("templates"), Step::Each, Step::Field("icon")],
303        Kind::Internal,
304    ),
305];
306
307/// The reference-bearing fields the 2.4 CDDL declares for `label`.
308///
309/// Multi-instance labels (`c2pa.ingredient.v3__2`) resolve to their base. A
310/// label with no entry contributes nothing: an unknown assertion is never
311/// walked speculatively.
312fn ref_fields(label: &str) -> &'static [RefField] {
313    match base_label(label) {
314        "c2pa.ingredient" => INGREDIENT_V1,
315        "c2pa.ingredient.v2" => INGREDIENT_V2,
316        "c2pa.ingredient.v3" => INGREDIENT_V3,
317        "c2pa.actions" | "c2pa.actions.v2" => ACTIONS,
318        _ => &[],
319    }
320}
321
322/// Strip the `__N` multi-instance suffix a store uses for repeated assertions.
323pub(super) fn base_label(label: &str) -> &str {
324    match label.split_once("__") {
325        Some((base, instance))
326            if !instance.is_empty() && instance.bytes().all(|b| b.is_ascii_digit()) =>
327        {
328            base
329        }
330        _ => label,
331    }
332}
333
334/// One located reference: its schema flavor, its value, and a readable path.
335struct Located<'v> {
336    kind: Kind,
337    value: &'v Value,
338    path: String,
339    action_kind: Option<&'v str>,
340}
341
342/// Walk `steps` through `value`, collecting references until `limit` is hit.
343///
344/// Returns true when the cap was reached, which the caller treats as a
345/// resource ceiling rather than first materializing an attacker-sized array.
346fn locate<'v>(
347    value: &'v Value,
348    kind: Kind,
349    steps: &[Step],
350    depth: usize,
351    path: &mut String,
352    out: &mut Vec<Located<'v>>,
353    limit: usize,
354) -> bool {
355    match steps.split_first() {
356        None => {
357            if out.len() >= limit {
358                return true;
359            }
360            out.push(Located {
361                kind,
362                value,
363                path: path.clone(),
364                action_kind: None,
365            });
366            false
367        }
368        Some((Step::Field(name), rest)) => {
369            let Some(child) = value.get(name) else {
370                return false;
371            };
372            let mark = path.len();
373            if !path.is_empty() {
374                path.push('.');
375            }
376            path.push_str(name);
377            let capped = locate(child, kind, rest, depth, path, out, limit);
378            path.truncate(mark);
379            capped
380        }
381        Some((Step::Each, rest)) => {
382            let Value::Array(items) = value else {
383                return false;
384            };
385            for (index, item) in items.iter().enumerate() {
386                let mark = path.len();
387                path.push('[');
388                path.push_str(&index.to_string());
389                path.push(']');
390                let capped = locate(item, kind, rest, depth, path, out, limit);
391                path.truncate(mark);
392                if capped {
393                    return true;
394                }
395            }
396            false
397        }
398        Some((Step::EachRelated, rest)) => {
399            let start = out.len();
400            let capped = locate(value, kind, rest, depth, path, out, limit);
401            for located in &mut out[start..] {
402                located.action_kind = value.get("action").and_then(Value::as_text);
403            }
404            if capped {
405                return true;
406            }
407            if depth >= MAX_RELATED_DEPTH {
408                return false;
409            }
410            let Some(Value::Array(items)) = value.get("related") else {
411                return false;
412            };
413            for (index, item) in items.iter().enumerate() {
414                let mark = path.len();
415                path.push_str(".related[");
416                path.push_str(&index.to_string());
417                path.push(']');
418                let capped = locate(item, kind, steps, depth + 1, path, out, limit);
419                path.truncate(mark);
420                if capped {
421                    return true;
422                }
423            }
424            false
425        }
426    }
427}
428
429/// Immutable inputs for one assertion-reference walk.
430pub(super) struct AssertionReferenceContext<'a, 'm> {
431    pub claim: &'a Value,
432    pub claim_refs: &'a ClaimAssertionRefs<'m>,
433    pub generation: ClaimGeneration,
434    pub profile: EngineProfile,
435    pub manifest_label: &'a str,
436    pub manifests: &'a [ParsedManifest<'m>],
437    pub budget: usize,
438}
439
440/// Validate every schema-declared `hashed_uri` field of every standard
441/// assertion the claim declares.
442///
443/// `manifests` is the whole parsed store, because a nested reference may name
444/// a box in another manifest; `manifest_label` is the manifest being walked,
445/// which a relative `self#jumbf=c2pa.assertions/...` URI resolves against.
446/// Assertion values come from the claim's own bounded index, so nothing is
447/// decoded twice and nothing undeclared is walked.
448pub(super) fn verify_assertion_references(
449    context: AssertionReferenceContext<'_, '_>,
450    resources: &mut ResourceCache<'_>,
451    results: &mut ValidationResults,
452) -> ReferenceWalk {
453    let AssertionReferenceContext {
454        claim,
455        claim_refs,
456        generation,
457        profile,
458        manifest_label,
459        manifests,
460        budget,
461    } = context;
462    // The blanket reference rule is a 2.x requirement that 1.x-era generators
463    // could not follow: they wrote an action's `parameters.ingredient` hash
464    // before the assertion it names was final, so the digest in that position
465    // routinely disagrees with the assertion the claim itself binds correctly.
466    // The legacy digest tolerance never permits a reference to a redacted
467    // target. Walk those references under both policies, while leaving other
468    // v1 reference digests to the existing compatibility procedure.
469    let legacy = generation == ClaimGeneration::V1 && is_generous(profile);
470    if legacy
471        && claim_refs
472            .redactions
473            .as_deref()
474            .is_none_or(super::redaction::Index::is_empty)
475    {
476        return ReferenceWalk {
477            examined: 0,
478            truncated: false,
479        };
480    }
481    let claim_alg = claim.get("alg").and_then(Value::as_text);
482    let mut seen: Vec<&str> = Vec::new();
483    let mut examined = 0usize;
484    for reference in &claim_refs.references {
485        let Some(alabel) = reference.label else {
486            continue;
487        };
488        if seen.contains(&alabel) {
489            continue;
490        }
491        seen.push(alabel);
492        let fields = ref_fields(alabel);
493        if fields.is_empty() {
494            continue;
495        }
496        let Some(assertion) = claim_refs
497            .indexed(alabel)
498            .and_then(|indexed| indexed.decoded.as_ref())
499        else {
500            continue;
501        };
502        let assertion_url = format!("self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{alabel}");
503        let assertion_alg = assertion.get("alg").and_then(Value::as_text);
504        let mut located = Vec::new();
505        let mut capped = false;
506        let capacity = budget.saturating_sub(examined);
507        for field in fields {
508            // Ingredient relatedAssertions was introduced in 2.5. Earlier
509            // policies still walk action relatedAssertions.
510            if field.kind == Kind::Related
511                && base_label(alabel) == "c2pa.ingredient.v3"
512                && !super::spec25::applies(profile)
513            {
514                continue;
515            }
516            let mut path = String::new();
517            if locate(
518                assertion,
519                field.kind,
520                field.path,
521                0,
522                &mut path,
523                &mut located,
524                capacity,
525            ) {
526                capped = true;
527                break;
528            }
529        }
530        examined += located.len();
531        for located in &located {
532            let redacted = super::spec25::redacted_reference_target(
533                located.value,
534                manifest_label,
535                claim_refs.redactions.as_deref(),
536            );
537            if let Some(target) = redacted {
538                let exempt = super::spec25::applies(profile)
539                    && match located.kind {
540                        Kind::Related => target.same_manifest,
541                        Kind::Ingredient => {
542                            matches!(
543                                base_label(target.label),
544                                "c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3"
545                            ) && match located.action_kind {
546                                Some(
547                                    "c2pa.opened" | "c2pa.placed" | "c2pa.transcoded"
548                                    | "c2pa.repackaged",
549                                ) => target.same_manifest,
550                                Some("c2pa.deleted" | "c2pa.removed") => !target.same_manifest,
551                                _ => false,
552                            }
553                        }
554                        _ => false,
555                    };
556                if exempt {
557                    results.push_informational(
558                        super::spec25::ASSERTION_REDACTED,
559                        target.url.to_string(),
560                        "reference resolves to a redacted assertion".into(),
561                    );
562                    continue;
563                }
564                results.push_failure(
565                    HASHED_URI_MISSING,
566                    target.url.to_string(),
567                    format!(
568                        "{alabel} field '{}' references a redacted assertion",
569                        located.path
570                    ),
571                );
572                continue;
573            }
574            if legacy {
575                continue;
576            }
577            validate_reference(
578                located,
579                alabel,
580                &assertion_url,
581                generation,
582                manifest_label,
583                manifests,
584                assertion_alg.or(claim_alg),
585                resources,
586                results,
587            );
588        }
589        if capped {
590            return ReferenceWalk {
591                examined,
592                truncated: true,
593            };
594        }
595    }
596    ReferenceWalk {
597        examined,
598        truncated: false,
599    }
600}
601
602/// Locate network-resolvable, schema-declared references without validating
603/// their contents. The examined count matches [`verify_assertion_references`]
604/// so discovery and final verification consume the same reference budget.
605pub(super) fn locate_external_references(
606    claim_refs: &ClaimAssertionRefs<'_>,
607    generation: ClaimGeneration,
608    profile: EngineProfile,
609    budget: usize,
610) -> (Vec<String>, ReferenceWalk) {
611    if generation == ClaimGeneration::V1 && is_generous(profile) {
612        return (
613            Vec::new(),
614            ReferenceWalk {
615                examined: 0,
616                truncated: false,
617            },
618        );
619    }
620    let mut urls = Vec::new();
621    let mut seen = Vec::new();
622    let mut examined = 0usize;
623    for reference in &claim_refs.references {
624        let Some(label) = reference.label else {
625            continue;
626        };
627        if seen.contains(&label) {
628            continue;
629        }
630        seen.push(label);
631        let fields = ref_fields(label);
632        if fields.is_empty() {
633            continue;
634        }
635        let Some(assertion) = claim_refs
636            .indexed(label)
637            .and_then(|indexed| indexed.decoded.as_ref())
638        else {
639            continue;
640        };
641        let capacity = budget.saturating_sub(examined);
642        let mut located = Vec::new();
643        let mut capped = false;
644        for field in fields {
645            if field.kind == Kind::Related
646                && base_label(label) == "c2pa.ingredient.v3"
647                && !super::spec25::applies(profile)
648            {
649                continue;
650            }
651            let mut path = String::new();
652            if locate(
653                assertion,
654                field.kind,
655                field.path,
656                0,
657                &mut path,
658                &mut located,
659                capacity,
660            ) {
661                capped = true;
662                break;
663            }
664        }
665        examined = examined.saturating_add(located.len());
666        for reference in located {
667            let Some(url) = reference.value.get("url").and_then(Value::as_text) else {
668                continue;
669            };
670            if reference.kind == Kind::Either
671                && (url.starts_with("https://") || url.starts_with("http://"))
672            {
673                urls.push(url.to_string());
674            }
675        }
676        if capped {
677            return (
678                urls,
679                ReferenceWalk {
680                    examined,
681                    truncated: true,
682                },
683            );
684        }
685    }
686    (
687        urls,
688        ReferenceWalk {
689            examined,
690            truncated: false,
691        },
692    )
693}
694
695#[allow(clippy::too_many_arguments)] // one reference's full context; splitting it hides the flow
696fn validate_reference(
697    located: &Located<'_>,
698    alabel: &str,
699    assertion_url: &str,
700    generation: ClaimGeneration,
701    manifest_label: &str,
702    manifests: &[ParsedManifest<'_>],
703    inherited_alg: Option<&str>,
704    resources: &mut ResourceCache<'_>,
705    results: &mut ValidationResults,
706) {
707    let path = &located.path;
708    // "The destination of a `hashed_uri` is found in its `url` field. If the
709    // field is not present or the destination cannot be located [...] then it
710    // shall be treated as a validation failure with code `hashedURI.missing`."
711    let Some(url) = located.value.get("url").and_then(Value::as_text) else {
712        results.push_failure(
713            HASHED_URI_MISSING,
714            assertion_url.to_string(),
715            format!("{alabel} field '{path}' is not a hashed URI: no url"),
716        );
717        return;
718    };
719    if located.kind == Kind::Either && !url.starts_with("self#jumbf=") {
720        validate_external_reference(
721            located,
722            url,
723            alabel,
724            assertion_url,
725            inherited_alg,
726            resources,
727            results,
728        );
729        return;
730    }
731    let Some(destination) = resolve(url, manifest_label, manifests) else {
732        results.push_failure(
733            HASHED_URI_MISSING,
734            url.to_string(),
735            format!("{alabel} field '{path}' references '{url}', which cannot be located"),
736        );
737        return;
738    };
739
740    // "If there is an `alg` field in the `hashed_uri` structure, it shall be
741    // used [...] otherwise the nearest enclosing structure that contains an
742    // `alg` field [...] otherwise the `alg` field in the claim. If no `alg`
743    // field is present in any of these locations, the claim shall be rejected
744    // with a failure code of `algorithm.unsupported`."
745    let algorithm = located
746        .value
747        .get("alg")
748        .and_then(Value::as_text)
749        .or(inherited_alg);
750    let Some(algorithm) = algorithm else {
751        results.push_failure(
752            ALGORITHM_UNSUPPORTED,
753            url.to_string(),
754            format!("{alabel} field '{path}' has no hash algorithm"),
755        );
756        return;
757    };
758    if !is_allowed_hash_algorithm(algorithm) {
759        results.push_failure(
760            ALGORITHM_UNSUPPORTED,
761            url.to_string(),
762            format!("{alabel} field '{path}' uses unsupported hash algorithm '{algorithm}'"),
763        );
764        return;
765    }
766
767    // "Ensure that the `hash` field is present [...] If it is not, the claim
768    // shall be rejected with a failure code of `hashedURI.mismatch`." The same
769    // code covers a digest that does not match.
770    //
771    // The hashing domain is the assertion's JUMBF content, per Hashing JUMBF
772    // Boxes. 1.x-era claim generators hashed the bare assertion payload in
773    // this position instead, so a v1 claim also accepts that domain - the same
774    // legacy tolerance [`super::verify_assertion_bindings`] applies to the
775    // claim's own assertion list. A v2 claim gets no such latitude.
776    let expected = located.value.get("hash").and_then(Value::as_bytes);
777    let matches_domain = |bytes: Option<&[u8]>| {
778        matches!(
779            (bytes.and_then(|bytes| hash_bytes(algorithm, bytes)), expected),
780            (Some(actual), Some(expected)) if actual.as_slice() == expected
781        )
782    };
783    let matched = matches_domain(Some(destination.jumbf))
784        || (generation == ClaimGeneration::V1 && matches_domain(destination.payload));
785    if !matched {
786        results.push_failure(
787            HASHED_URI_MISMATCH,
788            url.to_string(),
789            format!("{alabel} field '{path}' hashed uri mismatch: {url}"),
790        );
791    }
792}
793
794#[allow(clippy::too_many_arguments)]
795fn validate_external_reference(
796    located: &Located<'_>,
797    url: &str,
798    alabel: &str,
799    assertion_url: &str,
800    inherited_alg: Option<&str>,
801    resources: &mut ResourceCache<'_>,
802    results: &mut ValidationResults,
803) {
804    const NOT_RETRIEVED: &str = "com.encypher.reference.externalNotRetrieved";
805    if !(url.starts_with("https://") || url.starts_with("http://")) {
806        results.push_failure(
807            HASHED_URI_MISSING,
808            assertion_url.to_string(),
809            format!(
810                "{alabel} field '{}' has an invalid external URL",
811                located.path
812            ),
813        );
814        return;
815    }
816    let algorithm = located
817        .value
818        .get("alg")
819        .and_then(Value::as_text)
820        .or(inherited_alg);
821    let Some(algorithm) = algorithm.filter(|value| is_allowed_hash_algorithm(value)) else {
822        results.push_failure(
823            ALGORITHM_UNSUPPORTED,
824            assertion_url.to_string(),
825            format!(
826                "{alabel} field '{}' has no supported hash algorithm",
827                located.path
828            ),
829        );
830        return;
831    };
832    let Some(expected) = located.value.get("hash").and_then(Value::as_bytes) else {
833        results.push_failure(
834            HASHED_URI_MISMATCH,
835            assertion_url.to_string(),
836            format!("{alabel} field '{}' has no hash", located.path),
837        );
838        return;
839    };
840    match resources.resolve(url) {
841        ResolvedResource::NotAttempted => results.push_informational(
842            NOT_RETRIEVED,
843            url.to_string(),
844            format!(
845                "{alabel} field '{}' references external data that was not retrieved",
846                located.path
847            ),
848        ),
849        ResolvedResource::Unavailable => results.push_failure(
850            HASHED_URI_MISSING,
851            url.to_string(),
852            format!(
853                "{alabel} field '{}' external data could not be retrieved",
854                located.path
855            ),
856        ),
857        ResolvedResource::Retrieved { bytes, media_type } => {
858            if let Some(declared) = located.value.get("size").and_then(|value| match value {
859                Value::Integer(value) => usize::try_from(*value).ok(),
860                _ => None,
861            }) {
862                if declared != bytes.len() {
863                    results.push_failure(
864                        HASHED_URI_MISMATCH,
865                        url.to_string(),
866                        format!(
867                            "{alabel} field '{}' declares {declared} bytes; retrieved {}",
868                            located.path,
869                            bytes.len()
870                        ),
871                    );
872                    return;
873                }
874            }
875            if let (Some(declared), Some(observed)) = (
876                located.value.get("dc:format").and_then(Value::as_text),
877                media_type,
878            ) {
879                if declared != observed {
880                    results.push_informational(
881                        NOT_RETRIEVED,
882                        url.to_string(),
883                        format!(
884                            "{alabel} field '{}' declares dc:format '{declared}' but was retrieved as '{observed}'",
885                            located.path
886                        ),
887                    );
888                }
889            }
890            if hash_bytes(algorithm, bytes).as_deref() == Some(expected) {
891                results.push_success(
892                    ASSERTION_HASHED_URI_MATCH,
893                    url.to_string(),
894                    format!("{alabel} field '{}' hashed uri matched", located.path),
895                );
896            } else {
897                results.push_failure(
898                    HASHED_URI_MISMATCH,
899                    url.to_string(),
900                    format!("{alabel} field '{}' hashed uri mismatch", located.path),
901                );
902            }
903        }
904    }
905}
906
907/// The bytes a `self#jumbf=` assertion URI names, resolved by exact manifest
908/// label and exact assertion label.
909///
910/// Only assertion boxes resolve here: the manifest box and the claim-signature
911/// box are named exclusively by the ingredient graph fields, which this module
912/// does not walk, and each of those has its own hashing domain.
913struct Destination<'a> {
914    /// The assertion's JUMBF content: description box plus content boxes,
915    /// without the superbox header. The domain C2PA hashes.
916    jumbf: &'a [u8],
917    /// The assertion's bare serialized payload, when it has one. Only a v1
918    /// claim may be satisfied by this domain.
919    payload: Option<&'a [u8]>,
920}
921
922fn resolve<'a>(
923    url: &str,
924    manifest_label: &str,
925    manifests: &'a [ParsedManifest<'_>],
926) -> Option<Destination<'a>> {
927    let rest = url.strip_prefix("self#jumbf=")?;
928    let (target_manifest, alabel) = match rest.strip_prefix("/c2pa/") {
929        Some(absolute) => absolute.split_once("/c2pa.assertions/")?,
930        None => (manifest_label, rest.strip_prefix("c2pa.assertions/")?),
931    };
932    if alabel.is_empty() || alabel.contains('/') {
933        return None;
934    }
935    let manifest = unique(manifests, |manifest| manifest.label == target_manifest)?;
936    let (_, jumbf) = unique(&manifest.assertion_jumbf, |(label, _)| label == alabel)?;
937    Some(Destination {
938        jumbf,
939        payload: unique(&manifest.assertions, |(label, _)| label == alabel)
940            .map(|(_, payload)| *payload),
941    })
942}
943
944/// The single matching entry, or `None` when there is none or more than one.
945/// An ambiguous label cannot be resolved to bytes a signature committed to.
946fn unique<T>(items: &[T], mut matches: impl FnMut(&T) -> bool) -> Option<&T> {
947    let mut found = items.iter().filter(|item| matches(item));
948    let first = found.next()?;
949    found.next().is_none().then_some(first)
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use crate::c2pa_cbor::{encode, Profile};
956    use crate::c2pa_core::jumbf::{assertion_box, superbox_content};
957    use sha2::{Digest, Sha256};
958
959    fn enc(value: &Value) -> Vec<u8> {
960        encode(value, Profile::CanonicalForHashedSubstructures).expect("encode")
961    }
962
963    fn vmap(fields: Vec<(&str, Value)>) -> Value {
964        Value::Map(
965            fields
966                .into_iter()
967                .map(|(key, value)| (Value::Text(key.into()), value))
968                .collect(),
969        )
970    }
971
972    fn sha(bytes: &[u8]) -> Vec<u8> {
973        Sha256::digest(bytes).to_vec()
974    }
975
976    const MANIFEST: &str = "urn:c2pa:active";
977
978    /// One manifest carrying `boxes` as its assertion store, and a claim that
979    /// declares every one of them.
980    struct Fixture {
981        jumbf: Vec<(String, Vec<u8>)>,
982        payloads: Vec<(String, Vec<u8>)>,
983    }
984
985    impl Fixture {
986        fn new(assertions: &[(&str, Value)]) -> Self {
987            let mut jumbf = Vec::new();
988            let mut payloads = Vec::new();
989            for (label, value) in assertions {
990                let cbor = enc(value);
991                let boxed = assertion_box(label, &cbor, None);
992                let content = superbox_content(&boxed).expect("content").to_vec();
993                jumbf.push(((*label).to_string(), content));
994                payloads.push(((*label).to_string(), cbor));
995            }
996            Self { jumbf, payloads }
997        }
998
999        fn manifest(&self) -> ParsedManifest<'_> {
1000            ParsedManifest {
1001                label: MANIFEST.into(),
1002                manifest_jumbf: &[],
1003                assertions: self
1004                    .payloads
1005                    .iter()
1006                    .map(|(label, bytes)| (label.clone(), bytes.as_slice()))
1007                    .collect(),
1008                assertion_jumbf: self
1009                    .jumbf
1010                    .iter()
1011                    .map(|(label, bytes)| (label.clone(), bytes.as_slice()))
1012                    .collect(),
1013                claim_cbor: None,
1014                signature_cose: None,
1015                claim_count: 1,
1016                claim_box_label: None,
1017            }
1018        }
1019
1020        fn content(&self, label: &str) -> &[u8] {
1021            self.jumbf
1022                .iter()
1023                .find(|(candidate, _)| candidate == label)
1024                .map(|(_, bytes)| bytes.as_slice())
1025                .expect("assertion")
1026        }
1027    }
1028
1029    fn claim_for(labels: &[&str]) -> Value {
1030        vmap(vec![(
1031            "created_assertions",
1032            Value::Array(
1033                labels
1034                    .iter()
1035                    .map(|label| {
1036                        vmap(vec![
1037                            (
1038                                "url",
1039                                Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
1040                            ),
1041                            ("hash", Value::Bytes(vec![0; 32])),
1042                        ])
1043                    })
1044                    .collect(),
1045            ),
1046        )])
1047    }
1048
1049    fn run(fixture: &Fixture, claim: &Value) -> ValidationResults {
1050        run_as(fixture, claim, ClaimGeneration::V2, EngineProfile::GENEROUS)
1051    }
1052
1053    fn run_as(
1054        fixture: &Fixture,
1055        claim: &Value,
1056        generation: ClaimGeneration,
1057        profile: EngineProfile,
1058    ) -> ValidationResults {
1059        let manifests = [fixture.manifest()];
1060        let refs = ClaimAssertionRefs::build(&manifests[0], claim, generation);
1061        let mut results = ValidationResults::default();
1062        let mut resources = ResourceCache::with_evidence(None, OnlineEvidence::default());
1063        verify_assertion_references(
1064            AssertionReferenceContext {
1065                claim,
1066                claim_refs: &refs,
1067                generation,
1068                profile,
1069                manifest_label: MANIFEST,
1070                manifests: &manifests,
1071                budget: usize::MAX,
1072            },
1073            &mut resources,
1074            &mut results,
1075        );
1076        results
1077    }
1078
1079    fn hashed_uri(label: &str, hash: Vec<u8>) -> Value {
1080        vmap(vec![
1081            (
1082                "url",
1083                Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
1084            ),
1085            ("hash", Value::Bytes(hash)),
1086            ("alg", Value::Text("sha256".into())),
1087        ])
1088    }
1089
1090    /// An ingredient thumbnail reference whose digest matches the thumbnail
1091    /// assertion in the store is silent; one that does not is
1092    /// `hashedURI.mismatch`. Nothing else in the pipeline reads this field, so
1093    /// without this walk a tampered thumbnail reference reads as valid.
1094    #[test]
1095    fn ingredient_thumbnail_reference_digest_is_enforced() {
1096        let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
1097        let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
1098        let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
1099
1100        let good = Fixture::new(&[
1101            ("c2pa.thumbnail.ingredient", thumb.clone()),
1102            (
1103                "c2pa.ingredient.v3",
1104                vmap(vec![
1105                    ("relationship", Value::Text("parentOf".into())),
1106                    (
1107                        "thumbnail",
1108                        hashed_uri("c2pa.thumbnail.ingredient", digest.clone()),
1109                    ),
1110                ]),
1111            ),
1112        ]);
1113        let results = run(
1114            &good,
1115            &claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
1116        );
1117        assert!(!results.has_failure(HASHED_URI_MISMATCH));
1118        assert!(!results.has_failure(HASHED_URI_MISSING));
1119
1120        let tampered = Fixture::new(&[
1121            ("c2pa.thumbnail.ingredient", thumb),
1122            (
1123                "c2pa.ingredient.v3",
1124                vmap(vec![
1125                    ("relationship", Value::Text("parentOf".into())),
1126                    (
1127                        "thumbnail",
1128                        hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
1129                    ),
1130                ]),
1131            ),
1132        ]);
1133        let results = run(
1134            &tampered,
1135            &claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
1136        );
1137        assert!(results.has_failure(HASHED_URI_MISMATCH));
1138    }
1139
1140    /// A reference naming a box the store does not carry is
1141    /// `hashedURI.missing`, and an `activeManifest` link is NOT walked here:
1142    /// C2PA 2.4 carves it out of this procedure by name.
1143    #[test]
1144    fn unresolvable_reference_reports_missing_and_active_manifest_is_exempt() {
1145        let fixture = Fixture::new(&[(
1146            "c2pa.ingredient.v3",
1147            vmap(vec![
1148                ("relationship", Value::Text("parentOf".into())),
1149                (
1150                    "thumbnail",
1151                    hashed_uri("c2pa.thumbnail.absent", vec![1; 32]),
1152                ),
1153                (
1154                    "activeManifest",
1155                    vmap(vec![
1156                        ("url", Value::Text("self#jumbf=/c2pa/urn:c2pa:gone".into())),
1157                        ("hash", Value::Bytes(vec![2; 32])),
1158                        ("alg", Value::Text("sha256".into())),
1159                    ]),
1160                ),
1161            ]),
1162        )]);
1163        let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
1164        assert_eq!(
1165            results
1166                .failure
1167                .iter()
1168                .filter(|status| status.code == HASHED_URI_MISSING)
1169                .count(),
1170            1
1171        );
1172        assert!(results
1173            .failure
1174            .iter()
1175            .all(|status| !status.explanation.contains("activeManifest")));
1176    }
1177
1178    /// A `softwareAgent.icon` inside a nested `related` action is still a
1179    /// reference field of a standard assertion, so a tampered one fails.
1180    #[test]
1181    fn nested_related_action_icon_is_walked() {
1182        let icon = vmap(vec![("data", Value::Bytes(vec![3; 8]))]);
1183        let related_action = vmap(vec![
1184            ("action", Value::Text("c2pa.edited".into())),
1185            (
1186                "softwareAgent",
1187                vmap(vec![
1188                    ("name", Value::Text("agent".into())),
1189                    ("icon", hashed_uri("c2pa.icon", vec![0xCD; 32])),
1190                ]),
1191            ),
1192        ]);
1193        let actions = vmap(vec![(
1194            "actions",
1195            Value::Array(vec![vmap(vec![
1196                ("action", Value::Text("c2pa.created".into())),
1197                ("related", Value::Array(vec![related_action])),
1198            ])]),
1199        )]);
1200        let fixture = Fixture::new(&[("c2pa.icon", icon), ("c2pa.actions.v2", actions)]);
1201        let results = run(&fixture, &claim_for(&["c2pa.icon", "c2pa.actions.v2"]));
1202        assert!(results.has_failure(HASHED_URI_MISMATCH));
1203    }
1204
1205    /// The `metadata` subtree carries its own `reference` hashed URI, and
1206    /// C2PA 2.4 forbids validating anything inside it. A deliberately broken
1207    /// one must stay silent.
1208    #[test]
1209    fn metadata_subtree_is_never_validated() {
1210        let fixture = Fixture::new(&[(
1211            "c2pa.ingredient.v3",
1212            vmap(vec![
1213                ("relationship", Value::Text("parentOf".into())),
1214                (
1215                    "metadata",
1216                    vmap(vec![("reference", hashed_uri("c2pa.absent", vec![9; 32]))]),
1217                ),
1218            ]),
1219        )]);
1220        let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
1221        assert!(results.failure.is_empty(), "{:?}", results.failure);
1222    }
1223
1224    /// An ingredient `data` field may be a `hashed_ext_uri`. External data is
1225    /// outside standard validation, so an http(s) URL yields nothing here,
1226    /// while a same-manifest `data` reference is enforced.
1227    #[test]
1228    fn external_data_reference_is_left_to_external_validation() {
1229        let external = Fixture::new(&[(
1230            "c2pa.ingredient.v3",
1231            vmap(vec![
1232                ("relationship", Value::Text("inputTo".into())),
1233                (
1234                    "data",
1235                    vmap(vec![
1236                        ("url", Value::Text("https://example.com/data.bin".into())),
1237                        ("hash", Value::Bytes(vec![4; 32])),
1238                        ("alg", Value::Text("sha256".into())),
1239                    ]),
1240                ),
1241            ]),
1242        )]);
1243        let results = run(&external, &claim_for(&["c2pa.ingredient.v3"]));
1244        assert!(results.failure.is_empty(), "{:?}", results.failure);
1245
1246        let internal = Fixture::new(&[
1247            (
1248                "c2pa.embedded-data",
1249                vmap(vec![("v", Value::Bytes(vec![5]))]),
1250            ),
1251            (
1252                "c2pa.ingredient.v3",
1253                vmap(vec![
1254                    ("relationship", Value::Text("inputTo".into())),
1255                    ("data", hashed_uri("c2pa.embedded-data", vec![6; 32])),
1256                ]),
1257            ),
1258        ]);
1259        let results = run(
1260            &internal,
1261            &claim_for(&["c2pa.embedded-data", "c2pa.ingredient.v3"]),
1262        );
1263        assert!(results.has_failure(HASHED_URI_MISMATCH));
1264    }
1265
1266    /// The reference's own `alg` wins; otherwise the assertion's, otherwise
1267    /// the claim's. An algorithm this engine cannot recompute is
1268    /// `algorithm.unsupported`, never a silent pass.
1269    #[test]
1270    fn hash_algorithm_is_inherited_then_checked() {
1271        let thumb = vmap(vec![("data", Value::Bytes(vec![8; 4]))]);
1272        let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
1273        let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
1274        let inherited = |alg: Option<&str>, claim_alg: Option<&str>| {
1275            let mut reference = vec![
1276                (
1277                    "url",
1278                    Value::Text("self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient".into()),
1279                ),
1280                ("hash", Value::Bytes(digest.clone())),
1281            ];
1282            if let Some(alg) = alg {
1283                reference.push(("alg", Value::Text(alg.into())));
1284            }
1285            let fixture = Fixture::new(&[
1286                ("c2pa.thumbnail.ingredient", thumb.clone()),
1287                (
1288                    "c2pa.ingredient.v3",
1289                    vmap(vec![
1290                        ("relationship", Value::Text("parentOf".into())),
1291                        ("thumbnail", vmap(reference)),
1292                    ]),
1293                ),
1294            ]);
1295            let mut claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
1296            if let (Some(alg), Value::Map(entries)) = (claim_alg, &mut claim) {
1297                entries.push((Value::Text("alg".into()), Value::Text(alg.into())));
1298            }
1299            run(&fixture, &claim)
1300        };
1301
1302        // Inherited from the claim: matches, so nothing is reported.
1303        let results = inherited(None, Some("sha256"));
1304        assert!(results.failure.is_empty(), "{:?}", results.failure);
1305        // No `alg` anywhere: the structure is invalid, there is no default.
1306        let results = inherited(None, None);
1307        assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
1308        // An algorithm outside the allowed list never passes silently.
1309        let results = inherited(Some("md5"), Some("sha256"));
1310        assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
1311        assert!(!results.has_failure(HASHED_URI_MISMATCH));
1312    }
1313
1314    /// Multi-instance labels resolve to their base schema entry, and a
1315    /// vendor assertion carrying a `{url, hash}` map is never walked.
1316    #[test]
1317    fn schema_lookup_handles_instances_and_ignores_vendor_assertions() {
1318        assert!(!ref_fields("c2pa.ingredient.v3__2").is_empty());
1319        assert!(ref_fields("c2pa.ingredient.v3__x").is_empty());
1320        assert!(ref_fields("com.example.thing").is_empty());
1321
1322        let fixture = Fixture::new(&[(
1323            "com.example.thing",
1324            vmap(vec![("thumbnail", hashed_uri("c2pa.absent", vec![1; 32]))]),
1325        )]);
1326        let results = run(&fixture, &claim_for(&["com.example.thing"]));
1327        assert!(results.failure.is_empty(), "{:?}", results.failure);
1328    }
1329
1330    /// 1.x-era generators wrote an action's `parameters.ingredient` digest
1331    /// before the assertion it names was final, so that reference routinely
1332    /// disagrees with an assertion the claim itself binds correctly (the
1333    /// c2pa-rs `legacy_ingredient_hash.jpg` fixture is exactly this). A v1
1334    /// claim is therefore held to the blanket reference rule only under the
1335    /// strict conformance posture, matching the existing v1 action-ingredient
1336    /// policy. A v2 claim is held to it in both postures.
1337    #[test]
1338    fn a_v1_claim_is_held_to_the_reference_rule_only_under_strict_conformance() {
1339        let ingredient = vmap(vec![("relationship", Value::Text("parentOf".into()))]);
1340        let actions = vmap(vec![(
1341            "actions",
1342            Value::Array(vec![vmap(vec![
1343                ("action", Value::Text("c2pa.opened".into())),
1344                (
1345                    "parameters",
1346                    vmap(vec![(
1347                        "ingredient",
1348                        hashed_uri("c2pa.ingredient", vec![0xEE; 32]),
1349                    )]),
1350                ),
1351            ])]),
1352        )]);
1353        let fixture = Fixture::new(&[("c2pa.ingredient", ingredient), ("c2pa.actions", actions)]);
1354        let claim = vmap(vec![(
1355            "assertions",
1356            Value::Array(
1357                ["c2pa.ingredient", "c2pa.actions"]
1358                    .iter()
1359                    .map(|label| {
1360                        vmap(vec![
1361                            (
1362                                "url",
1363                                Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
1364                            ),
1365                            ("hash", Value::Bytes(vec![0; 32])),
1366                        ])
1367                    })
1368                    .collect(),
1369            ),
1370        )]);
1371
1372        let generous = run_as(
1373            &fixture,
1374            &claim,
1375            ClaimGeneration::V1,
1376            EngineProfile::GENEROUS,
1377        );
1378        assert!(generous.failure.is_empty(), "{:?}", generous.failure);
1379
1380        let strict = run_as(
1381            &fixture,
1382            &claim,
1383            ClaimGeneration::V1,
1384            EngineProfile::CONFORMANCE_V2_2,
1385        );
1386        assert!(strict.has_failure(HASHED_URI_MISMATCH));
1387    }
1388
1389    /// The whole point of the walk is that a consumer sees it. A manifest
1390    /// whose ingredient thumbnail reference has been retargeted must come back
1391    /// from the verification pipeline carrying `hashedURI.mismatch`, in the
1392    /// default (generous) posture.
1393    #[test]
1394    fn a_retargeted_nested_reference_reaches_the_verification_report() {
1395        use super::super::{
1396            verify_manifest, CawgTrustInputs, StoreContext, VerifyInput,
1397            MAX_REPORT_DECODED_VALUE_NODES,
1398        };
1399        use crate::c2pa_core::EngineProfile;
1400        use crate::c2pa_formats::AssetFormat;
1401
1402        let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
1403        let fixture = Fixture::new(&[
1404            ("c2pa.thumbnail.ingredient", thumb),
1405            (
1406                "c2pa.ingredient.v3",
1407                vmap(vec![
1408                    ("relationship", Value::Text("parentOf".into())),
1409                    (
1410                        "thumbnail",
1411                        hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
1412                    ),
1413                ]),
1414            ),
1415        ]);
1416        let claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
1417        let claim_cbor = enc(&claim);
1418        let mut manifest = fixture.manifest();
1419        manifest.claim_cbor = Some(&claim_cbor);
1420        manifest.claim_box_label = Some("c2pa.claim.v2".into());
1421        let manifests = [manifest];
1422        let manifest_hashes = std::collections::HashMap::new();
1423        let input = VerifyInput {
1424            data: &[],
1425            mime: "application/c2pa",
1426            claim_signer_trust: None,
1427            tsa_trust: None,
1428            allowed_certs: None,
1429            validation_time: None,
1430            profile: EngineProfile::GENEROUS,
1431            evidence: Default::default(),
1432            cawg_strict_encoding: false,
1433        };
1434        let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
1435        let out = verify_manifest(
1436            &manifests[0],
1437            StoreContext {
1438                manifests: &manifests,
1439                manifest_hashes: &manifest_hashes,
1440            },
1441            &input,
1442            AssetFormat::C2paStore,
1443            &[],
1444            None,
1445            CawgTrustInputs::default(),
1446            &mut report_decode_nodes,
1447        );
1448        assert!(
1449            out.results.has_failure(HASHED_URI_MISMATCH),
1450            "{:?}",
1451            out.results.failure
1452        );
1453    }
1454
1455    #[test]
1456    fn resource_cache_borrows_and_memoizes_provider_payloads() {
1457        use std::cell::Cell;
1458
1459        struct Provider {
1460            calls: Cell<usize>,
1461            bytes: [u8; 4],
1462        }
1463
1464        impl ExternalResources for Provider {
1465            fn resolve(&self, url: &str) -> ExternalResource<'_> {
1466                assert_eq!(url, "https://example.test/icon");
1467                self.calls.set(self.calls.get() + 1);
1468                ExternalResource::Retrieved {
1469                    bytes: &self.bytes,
1470                    media_type: Some("image/png"),
1471                }
1472            }
1473        }
1474
1475        let provider = Provider {
1476            calls: Cell::new(0),
1477            bytes: [1, 2, 3, 4],
1478        };
1479        let mut cache = ResourceCache::with_evidence(Some(&provider), OnlineEvidence::default());
1480        for _ in 0..2 {
1481            let ResolvedResource::Retrieved { bytes, media_type } =
1482                cache.resolve("https://example.test/icon")
1483            else {
1484                panic!("provider payload was not returned");
1485            };
1486            assert_eq!(bytes.as_ptr(), provider.bytes.as_ptr());
1487            assert_eq!(media_type, Some("image/png"));
1488        }
1489        assert_eq!(provider.calls.get(), 1);
1490    }
1491
1492    #[test]
1493    fn resource_cache_gives_online_evidence_precedence_over_the_provider() {
1494        use std::cell::Cell;
1495
1496        struct Provider {
1497            calls: Cell<usize>,
1498        }
1499
1500        impl ExternalResources for Provider {
1501            fn resolve(&self, _: &str) -> ExternalResource<'_> {
1502                self.calls.set(self.calls.get() + 1);
1503                ExternalResource::Retrieved {
1504                    bytes: b"provider",
1505                    media_type: None,
1506                }
1507            }
1508        }
1509
1510        const URL: &str = "https://example.test/evidence";
1511        let provider = Provider {
1512            calls: Cell::new(0),
1513        };
1514        let bodies = HashMap::from([(URL.to_string(), b"evidence".to_vec())]);
1515        let media_types = HashMap::from([(URL.to_string(), "text/plain".to_string())]);
1516        let mut cache = ResourceCache::with_evidence(
1517            Some(&provider),
1518            OnlineEvidence {
1519                external_data: Some(&bodies),
1520                external_data_media_types: Some(&media_types),
1521                ..OnlineEvidence::default()
1522            },
1523        );
1524        let ResolvedResource::Retrieved { bytes, media_type } = cache.resolve(URL) else {
1525            panic!("online evidence was not returned");
1526        };
1527        assert_eq!(bytes, b"evidence");
1528        assert_eq!(media_type, Some("text/plain"));
1529        assert_eq!(provider.calls.get(), 0);
1530
1531        let unreachable = [URL.to_string()];
1532        let mut cache = ResourceCache::with_evidence(
1533            Some(&provider),
1534            OnlineEvidence {
1535                external_data_unreachable: Some(&unreachable),
1536                ..OnlineEvidence::default()
1537            },
1538        );
1539        assert!(matches!(cache.resolve(URL), ResolvedResource::Unavailable));
1540        assert_eq!(provider.calls.get(), 0);
1541    }
1542}