Skip to main content

encypher_c2pa/c2pa-validate/
external_discovery.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parse-only discovery of caller-resolved external verification resources.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8use crate::c2pa_cbor::{decode, Value};
9use crate::c2pa_core::jumbf::{expand_store, parse_manifest_store};
10use crate::c2pa_formats::{AppendOnlyFlavor, AssetFormat, CheckpointReference};
11use crate::{Error, VerifyOptions};
12
13use super::ingredient_graph::GraphLimits;
14use super::refs::{ExternalResource, ExternalResources};
15use super::versions;
16use super::{base_assertion_label, ClaimAssertionRefs, EngineProfile, SpecVersion};
17
18/// How final verification uses one discovered external resource.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum ExternalResourceKind {
21    /// Terminal content whose signed digest is checked without further traversal.
22    Opaque,
23    /// A C2PA manifest store that may introduce append-history predecessors.
24    SegmentManifestStore,
25}
26
27/// One external URI the caller may resolve before final verification.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ExternalResourceRequest {
30    /// Absolute external URI declared by signed manifest content.
31    pub uri: String,
32    /// Whether the body is terminal or may introduce another predecessor.
33    pub kind: ExternalResourceKind,
34}
35
36/// One DID document a network adapter may resolve before final verification.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ExternalDidDocumentRequest {
39    /// Primary DID, without a fragment or query.
40    pub did: String,
41    /// HTTPS URL defined by the `did:web` method.
42    pub url: String,
43}
44
45/// The graph ceiling that stopped parse-only discovery.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum GraphLimitKind {
48    /// Maximum traversal depth.
49    Depth,
50    /// Maximum distinct manifest nodes.
51    Nodes,
52    /// Maximum examined references.
53    References,
54    /// Maximum cumulative visited JUMBF bytes.
55    Bytes,
56}
57
58/// Why discovery could not reach a complete resource frontier.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ExternalResourceStopReason {
61    /// A caller-lowered or engine graph ceiling was reached.
62    GraphLimitExceeded {
63        /// Ceiling that stopped discovery.
64        limit: GraphLimitKind,
65    },
66    /// A required external manifest-store body was not available.
67    PredecessorUnavailable {
68        /// URI whose provider outcome was unavailable or not attempted.
69        uri: String,
70    },
71    /// A manifest store could not be parsed far enough to continue discovery.
72    ManifestMalformed {
73        /// External predecessor URI, or `None` for the initial or an inline store.
74        uri: Option<String>,
75    },
76}
77
78#[derive(Default)]
79struct UriState {
80    root: bool,
81    root_parsed: bool,
82    segment: bool,
83    segment_parsed: bool,
84    segment_depth: usize,
85}
86
87struct DiscoveryBudget {
88    limits: GraphLimits,
89    nodes: usize,
90    references: usize,
91    bytes: usize,
92}
93
94impl DiscoveryBudget {
95    fn new(limits: GraphLimits) -> Self {
96        Self {
97            limits: GraphLimits::lowered(
98                limits.max_depth,
99                limits.max_nodes,
100                limits.max_references,
101                limits.max_bytes,
102            ),
103            nodes: 0,
104            references: 0,
105            bytes: 0,
106        }
107    }
108
109    fn depth(&self, depth: usize) -> Result<(), GraphLimitKind> {
110        if depth > self.limits.max_depth {
111            Err(GraphLimitKind::Depth)
112        } else {
113            Ok(())
114        }
115    }
116
117    fn node(&mut self) -> Result<(), GraphLimitKind> {
118        if self.nodes >= self.limits.max_nodes {
119            return Err(GraphLimitKind::Nodes);
120        }
121        self.nodes += 1;
122        Ok(())
123    }
124
125    fn reference(&mut self) -> Result<(), GraphLimitKind> {
126        if self.references >= self.limits.max_references {
127            return Err(GraphLimitKind::References);
128        }
129        self.references += 1;
130        Ok(())
131    }
132
133    fn references(&mut self, count: usize) -> Result<(), GraphLimitKind> {
134        let total = self.references.saturating_add(count);
135        if total > self.limits.max_references {
136            self.references = self.limits.max_references;
137            return Err(GraphLimitKind::References);
138        }
139        self.references = total;
140        Ok(())
141    }
142
143    fn references_left(&self) -> usize {
144        self.limits.max_references.saturating_sub(self.references)
145    }
146
147    fn bytes(&mut self, count: usize) -> Result<(), GraphLimitKind> {
148        let total = self.bytes.saturating_add(count);
149        if total > self.limits.max_bytes {
150            return Err(GraphLimitKind::Bytes);
151        }
152        self.bytes = total;
153        Ok(())
154    }
155}
156
157struct DiscoverySession {
158    profile: EngineProfile,
159    flavor: Option<AppendOnlyFlavor>,
160    budget: DiscoveryBudget,
161    uris: HashMap<String, UriState>,
162    did_document_ids: HashSet<String>,
163    did_documents: Vec<ExternalDidDocumentRequest>,
164    stop_reason: Option<ExternalResourceStopReason>,
165}
166
167impl DiscoverySession {
168    fn stop(&mut self, reason: ExternalResourceStopReason) {
169        if self.stop_reason.is_none() {
170            self.stop_reason = Some(reason);
171        }
172    }
173
174    fn graph_stop(&mut self, limit: GraphLimitKind) {
175        self.stop(ExternalResourceStopReason::GraphLimitExceeded { limit });
176    }
177
178    fn stopped(&self) -> bool {
179        self.stop_reason.is_some()
180    }
181}
182
183/// A parse-only, bounded external-resource discovery session.
184///
185/// `resources` is the current ordered frontier. The caller resolves that
186/// frontier into an [`ExternalResources`] provider, then calls
187/// [`ExternalResourcePlan::continue_with`]. The method replaces it with the
188/// next frontier. The plan owns `asset`, so native bindings can retain their
189/// own zero-copy byte container without self-referential borrows.
190pub struct ExternalResourcePlan<A: AsRef<[u8]>> {
191    /// True once an embedded, supplied, or retrieved initial store was located,
192    /// before parsing it. A malformed embedded store therefore cannot trigger
193    /// a remote-manifest fallback. `false` authorizes a different root fallback
194    /// only when [`Self::stop_reason`] is `None` and [`Self::resources`] is empty.
195    pub manifest_store_present: bool,
196    /// Current first-seen resource frontier.
197    pub resources: Vec<ExternalResourceRequest>,
198    /// True when discovery terminated before proving the frontier complete.
199    pub truncated: bool,
200    /// Typed terminal reason when `truncated` is true.
201    pub stop_reason: Option<ExternalResourceStopReason>,
202    asset: A,
203    session: DiscoverySession,
204}
205
206impl<A: AsRef<[u8]>> ExternalResourcePlan<A> {
207    /// Borrow the original asset for the one final verification pass.
208    pub fn asset(&self) -> &[u8] {
209        self.asset.as_ref()
210    }
211
212    /// DID documents found by parse-only inspection of active ICA identities.
213    ///
214    /// The list accumulates as retrieved initial stores are parsed. Callers
215    /// must prefer a document already pinned in [`VerifyOptions`] and fetch
216    /// only entries that remain unresolved.
217    pub fn did_documents(&self) -> &[ExternalDidDocumentRequest] {
218        &self.session.did_documents
219    }
220
221    /// Consume the current frontier from caller-held outcomes and discover the
222    /// next manifest-store or append-history frontier. No network I/O or
223    /// verification occurs.
224    pub fn continue_with(&mut self, resources: &dyn ExternalResources) -> Result<(), Error> {
225        if self.stop_reason.is_some() {
226            self.resources.clear();
227            return Ok(());
228        }
229        let current = std::mem::take(&mut self.resources);
230        let asset = self.asset.as_ref();
231        for request in current {
232            if request.kind != ExternalResourceKind::SegmentManifestStore {
233                continue;
234            }
235            let is_root = self
236                .session
237                .uris
238                .get(&request.uri)
239                .is_some_and(|state| state.root);
240            let continued = if is_root {
241                resolve_root_uri(
242                    asset,
243                    &mut self.session,
244                    &mut self.resources,
245                    resources,
246                    &request.uri,
247                    &mut self.manifest_store_present,
248                )
249            } else {
250                resolve_segment_uri(
251                    asset,
252                    &mut self.session,
253                    &mut self.resources,
254                    resources,
255                    &request.uri,
256                )
257            };
258            if !continued {
259                break;
260            }
261        }
262        if let Some(reason) = self.session.stop_reason.take() {
263            self.stop(reason);
264        }
265        Ok(())
266    }
267
268    fn stop(&mut self, reason: ExternalResourceStopReason) {
269        self.resources.clear();
270        self.truncated = true;
271        self.stop_reason = Some(reason);
272    }
273}
274
275fn new_plan<A: AsRef<[u8]>>(
276    asset: A,
277    profile: EngineProfile,
278    flavor: Option<AppendOnlyFlavor>,
279    limits: GraphLimits,
280) -> ExternalResourcePlan<A> {
281    ExternalResourcePlan {
282        manifest_store_present: false,
283        resources: Vec::new(),
284        truncated: false,
285        stop_reason: None,
286        asset,
287        session: DiscoverySession {
288            profile,
289            flavor,
290            budget: DiscoveryBudget::new(limits),
291            uris: HashMap::new(),
292            did_document_ids: HashSet::new(),
293            did_documents: Vec::new(),
294            stop_reason: None,
295        },
296    }
297}
298
299/// Discover external resources from the same embedded store selected by final
300/// verification under the caller-selected policy. Only `spec_version` and
301/// `strict_conformance` are consulted; trust, evidence, clocks, and cryptography
302/// belong to final verification.
303pub fn locate_external_resources<A: AsRef<[u8]>>(
304    asset: A,
305    mime_type: &str,
306    options: &VerifyOptions,
307    limits: GraphLimits,
308) -> Result<ExternalResourcePlan<A>, Error> {
309    let profile =
310        crate::resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
311    let mime = if profile.version == SpecVersion::V2_5 {
312        let mime = crate::c2pa_core::spec::canonicalize_mime(mime_type);
313        if !crate::c2pa_formats::detached_mime_permitted(&mime, profile.version) {
314            return Err(Error::UnsupportedMime(mime));
315        }
316        mime
317    } else {
318        crate::resolve_mime(mime_type, profile)?
319    };
320    let format = match AssetFormat::from_canonical_mime(&mime) {
321        Some(format)
322            if crate::c2pa_core::spec::reader_permits(&mime, profile.version)
323                && crate::require_embedded_mime(&mime, profile).is_ok() =>
324        {
325            format
326        }
327        _ if profile.version == SpecVersion::V2_5 => AssetFormat::Opaque,
328        _ => return Err(Error::UnsupportedMime(mime)),
329    };
330    let format = super::spec25::select_format(format, &mime, asset.as_ref(), profile);
331    let flavor = match format {
332        AssetFormat::AppendOnlyText { flavor } => Some(flavor),
333        _ => None,
334    };
335    let store =
336        crate::c2pa_formats::extract_manifest_for_spec(format, asset.as_ref(), profile.version);
337    let mut plan = new_plan(asset, profile, flavor, limits);
338    match store {
339        Ok(Some(store)) => {
340            plan.manifest_store_present = true;
341            scan_initial_store(
342                plan.asset.as_ref(),
343                &store,
344                &mut plan.session,
345                &mut plan.resources,
346                None,
347                None,
348            );
349        }
350        Ok(None) => {
351            if format != AssetFormat::Opaque {
352                match crate::c2pa_formats::locate_remote_manifest(format, plan.asset.as_ref()) {
353                    Ok(crate::c2pa_formats::RemoteManifestLocation::Remote(remote)) => {
354                        add_root_manifest(&mut plan.session, &mut plan.resources, remote.uri);
355                    }
356                    Ok(crate::c2pa_formats::RemoteManifestLocation::Malformed) => {
357                        plan.session
358                            .stop(ExternalResourceStopReason::ManifestMalformed { uri: None });
359                    }
360                    Ok(crate::c2pa_formats::RemoteManifestLocation::Absent) | Err(_) => {}
361                }
362            }
363        }
364        Err(_) => plan
365            .session
366            .stop(ExternalResourceStopReason::ManifestMalformed { uri: None }),
367    }
368    if let Some(reason) = plan.session.stop_reason.take() {
369        plan.stop(reason);
370    }
371    Ok(plan)
372}
373
374/// Discover external resources from a caller-supplied initial store. The
375/// supplied store is authoritative even if `asset` also embeds one, matching
376/// [`crate::verify_with_manifest_store_and_graph_inputs`]. It is parsed during
377/// this call and is not retained by the returned plan. Only the caller's policy
378/// and carrier selection are consulted.
379pub fn locate_external_resources_with_manifest_store<A: AsRef<[u8]>>(
380    asset: A,
381    manifest_store: &[u8],
382    mime_type: &str,
383    options: &VerifyOptions,
384    limits: GraphLimits,
385) -> Result<ExternalResourcePlan<A>, Error> {
386    let profile =
387        crate::resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
388    let mime = crate::resolve_detached_mime(mime_type, profile)?;
389    locate_external_resources_with_canonical_manifest_store(
390        asset,
391        manifest_store,
392        &mime,
393        profile,
394        limits,
395    )
396}
397
398/// Detached discovery after the facade has normalized and admitted the MIME.
399pub(crate) fn locate_external_resources_with_canonical_manifest_store<A: AsRef<[u8]>>(
400    asset: A,
401    manifest_store: &[u8],
402    mime: &str,
403    profile: EngineProfile,
404    limits: GraphLimits,
405) -> Result<ExternalResourcePlan<A>, Error> {
406    let format = AssetFormat::from_detached_content_for_profile(mime, asset.as_ref(), profile)
407        .map_err(|error| Error::Verification(error.to_string()))?
408        .ok_or_else(|| Error::UnsupportedMime(mime.to_string()))?;
409    let format = super::spec25::select_format(format, mime, asset.as_ref(), profile);
410    let flavor = match format {
411        AssetFormat::AppendOnlyText { flavor } => Some(flavor),
412        _ => None,
413    };
414    let mut plan = new_plan(asset, profile, flavor, limits);
415    if manifest_store.is_empty() {
416        plan.session
417            .stop(ExternalResourceStopReason::ManifestMalformed { uri: None });
418    } else {
419        plan.manifest_store_present = true;
420        if manifest_store.len() > crate::MAX_MANIFEST_STORE_BYTES {
421            plan.session
422                .stop(ExternalResourceStopReason::ManifestMalformed { uri: None });
423        } else {
424            scan_initial_store(
425                plan.asset.as_ref(),
426                manifest_store,
427                &mut plan.session,
428                &mut plan.resources,
429                None,
430                None,
431            );
432        }
433    }
434    if let Some(reason) = plan.session.stop_reason.take() {
435        plan.stop(reason);
436    }
437    Ok(plan)
438}
439
440fn scan_initial_store(
441    asset: &[u8],
442    bytes: &[u8],
443    session: &mut DiscoverySession,
444    frontier: &mut Vec<ExternalResourceRequest>,
445    provider: Option<&dyn ExternalResources>,
446    uri: Option<&str>,
447) {
448    let malformed = || ExternalResourceStopReason::ManifestMalformed {
449        uri: uri.map(str::to_string),
450    };
451    let expanded = match expand_store(bytes) {
452        Ok(expanded) => expanded,
453        Err(_) => {
454            session.stop(malformed());
455            return;
456        }
457    };
458    let store = match parse_manifest_store(expanded.bytes()) {
459        Ok(store) => store,
460        Err(_) => {
461            session.stop(malformed());
462            return;
463        }
464    };
465    let Some(active_index) = store.manifests.len().checked_sub(1) else {
466        session.stop(malformed());
467        return;
468    };
469    let index: HashMap<&str, usize> = store
470        .manifests
471        .iter()
472        .enumerate()
473        .map(|(index, manifest)| (manifest.label.as_str(), index))
474        .collect();
475    let claims: Vec<Option<Value>> = store
476        .manifests
477        .iter()
478        .map(|manifest| manifest.claim_cbor.and_then(|claim| decode(claim).ok()))
479        .collect();
480    let active_manifest = &store.manifests[active_index];
481    let Some(active_claim) = claims[active_index].as_ref() else {
482        session.stop(malformed());
483        return;
484    };
485    let active_generation = versions::claim_generation(active_manifest, active_claim);
486    let active_refs = ClaimAssertionRefs::build(active_manifest, active_claim, active_generation);
487    for (did, url) in super::cawg::locate_identity_did_documents(&active_refs) {
488        // The did:web mapper emits HTTPS. Keep the check at this public
489        // discovery boundary so malformed future methods never become browser
490        // fetch instructions.
491        if url.starts_with("https://") && session.did_document_ids.insert(did.clone()) {
492            // The plan owns these strings because the parsed store may be a
493            // retrieved body borrowed only for this continuation call.
494            session
495                .did_documents
496                .push(ExternalDidDocumentRequest { did, url });
497        }
498    }
499
500    // Final verification spends this shared budget in the same order: active
501    // cloud/external data, linked resources, generic references, ingredient
502    // traversal, then append-history predecessors.
503    locate_external_data(&active_refs, session, frontier);
504    if session.stopped() {
505        return;
506    }
507    if session.profile.version == SpecVersion::V2_5 {
508        let (urls, walked) =
509            super::spec25::locate_linked_resources(&active_refs, session.budget.references_left());
510        if let Err(limit) = session.budget.references(walked.examined) {
511            session.graph_stop(limit);
512            return;
513        }
514        if walked.truncated {
515            session.graph_stop(GraphLimitKind::References);
516            return;
517        }
518        for url in urls {
519            add_opaque(session, frontier, url);
520        }
521    }
522    let (urls, walked) = super::refs::locate_external_references(
523        &active_refs,
524        active_generation,
525        session.profile,
526        session.budget.references_left(),
527    );
528    if let Err(limit) = session.budget.references(walked.examined) {
529        session.graph_stop(limit);
530        return;
531    }
532    if walked.truncated {
533        session.graph_stop(GraphLimitKind::References);
534        return;
535    }
536    for url in urls {
537        add_opaque(session, frontier, url);
538    }
539
540    let active_predecessor =
541        if session.profile.version == SpecVersion::V2_5 && session.flavor.is_some() {
542            let flavor = session.flavor.expect("append-only flavor checked");
543            let operative =
544                super::caller_binding::operative_reference(&active_refs, false, session.profile)
545                    .filter(|reference| {
546                        reference
547                            .label
548                            .is_some_and(super::spec25::is_segment_hash_label)
549                    });
550            Some(if operative.is_some() {
551                super::segment_hash::locate_predecessor_reference(
552                    &active_refs,
553                    active_claim,
554                    asset,
555                    flavor,
556                )
557            } else {
558                Err(())
559            })
560        } else {
561            None
562        };
563
564    let mut reached = HashSet::from([active_index]);
565    let mut queue = VecDeque::from([(active_index, 0usize)]);
566    while let Some((manifest_index, depth)) = queue.pop_front() {
567        if session.stopped() {
568            return;
569        }
570        let manifest = &store.manifests[manifest_index];
571        if let Err(limit) = session.budget.node() {
572            session.graph_stop(limit);
573            return;
574        }
575        if let Err(limit) = session.budget.bytes(manifest.manifest_jumbf.len()) {
576            session.graph_stop(limit);
577            return;
578        }
579        let Some(claim) = claims[manifest_index].as_ref() else {
580            session.stop(malformed());
581            return;
582        };
583        let is_active = manifest_index == active_index;
584        let generation = if is_active {
585            active_generation
586        } else {
587            versions::claim_generation(manifest, claim)
588        };
589        let reached_refs;
590        let claim_refs = if is_active {
591            &active_refs
592        } else {
593            reached_refs = ClaimAssertionRefs::build(manifest, claim, generation);
594            &reached_refs
595        };
596        if !is_active && super::ingredient_graph::is_time_stamp_manifest(claim_refs) {
597            continue;
598        }
599
600        if !is_active {
601            let (urls, walked) = super::refs::locate_external_references(
602                claim_refs,
603                generation,
604                session.profile,
605                session.budget.references_left(),
606            );
607            if let Err(limit) = session.budget.references(walked.examined) {
608                session.graph_stop(limit);
609                return;
610            }
611            if walked.truncated {
612                session.graph_stop(GraphLimitKind::References);
613                return;
614            }
615            for url in urls {
616                add_opaque(session, frontier, url);
617            }
618        }
619
620        enqueue_ingredients(claim_refs, depth, &index, &mut reached, &mut queue, session);
621    }
622    if session.stopped() {
623        return;
624    }
625    match active_predecessor {
626        Some(Ok(Some(reference))) => {
627            process_predecessor_reference(asset, session, frontier, provider, reference, 1);
628        }
629        Some(Ok(None)) | None => {}
630        Some(Err(())) => {
631            session.stop(malformed());
632        }
633    }
634}
635
636fn enqueue_ingredients(
637    claim_refs: &ClaimAssertionRefs<'_>,
638    depth: usize,
639    index: &HashMap<&str, usize>,
640    reached: &mut HashSet<usize>,
641    queue: &mut VecDeque<(usize, usize)>,
642    session: &mut DiscoverySession,
643) {
644    for reference in &claim_refs.references {
645        let Some(label) = reference
646            .label
647            .filter(|label| label.starts_with("c2pa.ingredient"))
648        else {
649            continue;
650        };
651        let Some(ingredient) = claim_refs
652            .indexed(label)
653            .and_then(|assertion| assertion.decoded.as_ref())
654        else {
655            continue;
656        };
657        let target = ingredient
658            .get("activeManifest")
659            .or_else(|| ingredient.get("c2pa_manifest"))
660            .and_then(|link| link.get("url"))
661            .and_then(Value::as_text)
662            .and_then(super::extract_manifest_label);
663        let Some(target) = target else {
664            continue;
665        };
666        if let Err(limit) = session.budget.reference() {
667            session.graph_stop(limit);
668            return;
669        }
670        let Some(target) = index.get(target).copied() else {
671            continue;
672        };
673        if !reached.insert(target) {
674            continue;
675        }
676        if let Err(limit) = session.budget.depth(depth.saturating_add(1)) {
677            session.graph_stop(limit);
678            return;
679        }
680        queue.push_back((target, depth + 1));
681    }
682}
683
684fn locate_external_data(
685    claim_refs: &ClaimAssertionRefs<'_>,
686    session: &mut DiscoverySession,
687    frontier: &mut Vec<ExternalResourceRequest>,
688) {
689    for reference in &claim_refs.references {
690        let Some(label) = reference.label else {
691            continue;
692        };
693        let kind = base_assertion_label(label);
694        if kind != "c2pa.cloud-data" && kind != "c2pa.external-reference" {
695            continue;
696        }
697        if let Err(limit) = session.budget.reference() {
698            session.graph_stop(limit);
699            return;
700        }
701        let Some(assertion) = claim_refs
702            .indexed(label)
703            .and_then(|assertion| assertion.decoded.as_ref())
704        else {
705            continue;
706        };
707        if kind == "c2pa.cloud-data"
708            && (assertion.get("label").and_then(Value::as_text).is_none()
709                || assertion.get("size").is_none()
710                || assertion
711                    .get("location")
712                    .and_then(|location| location.get("alg"))
713                    .and_then(Value::as_text)
714                    .is_none()
715                || assertion
716                    .get("location")
717                    .and_then(|location| location.get("hash"))
718                    .and_then(Value::as_bytes)
719                    .is_none())
720        {
721            continue;
722        }
723        if kind == "c2pa.external-reference" {
724            let Some(location) = assertion.get("location") else {
725                continue;
726            };
727            if location.get("alg").is_some() != location.get("hash").is_some() {
728                continue;
729            }
730        }
731        let uri = assertion
732            .get("location")
733            .and_then(|location| location.get("url"))
734            .and_then(Value::as_text);
735        if let Some(uri) =
736            uri.filter(|uri| uri.starts_with("https://") || uri.starts_with("http://"))
737        {
738            add_opaque(session, frontier, uri.to_string());
739        }
740    }
741}
742
743fn add_opaque(
744    session: &mut DiscoverySession,
745    frontier: &mut Vec<ExternalResourceRequest>,
746    uri: String,
747) {
748    if session.uris.contains_key(&uri) {
749        return;
750    }
751    session.uris.insert(uri.clone(), UriState::default());
752    frontier.push(ExternalResourceRequest {
753        uri,
754        kind: ExternalResourceKind::Opaque,
755    });
756}
757
758fn add_root_manifest(
759    session: &mut DiscoverySession,
760    frontier: &mut Vec<ExternalResourceRequest>,
761    uri: String,
762) {
763    let state = session.uris.entry(uri.clone()).or_default();
764    if state.root {
765        return;
766    }
767    state.root = true;
768    frontier.push(ExternalResourceRequest {
769        uri,
770        kind: ExternalResourceKind::SegmentManifestStore,
771    });
772}
773
774fn process_predecessor_reference(
775    asset: &[u8],
776    session: &mut DiscoverySession,
777    frontier: &mut Vec<ExternalResourceRequest>,
778    provider: Option<&dyn ExternalResources>,
779    reference: CheckpointReference,
780    depth: usize,
781) {
782    if let Err(limit) = session.budget.reference() {
783        session.graph_stop(limit);
784        return;
785    }
786    if let Err(limit) = session.budget.depth(depth) {
787        session.graph_stop(limit);
788        return;
789    }
790    match reference {
791        CheckpointReference::Inline(store) => {
792            if let Err(limit) = session.budget.node() {
793                session.graph_stop(limit);
794                return;
795            }
796            scan_predecessor_store(asset, session, frontier, provider, &store, None, depth);
797        }
798        CheckpointReference::External(uri) => {
799            let prior = session.uris.contains_key(&uri);
800            if session.uris.get(&uri).is_some_and(|state| state.segment) {
801                return;
802            }
803            if let Err(limit) = session.budget.node() {
804                session.graph_stop(limit);
805                return;
806            }
807            let state = session.uris.entry(uri.clone()).or_default();
808            state.segment = true;
809            state.segment_depth = depth;
810            if let Some(request) = frontier.iter_mut().find(|request| request.uri == uri) {
811                request.kind = ExternalResourceKind::SegmentManifestStore;
812                return;
813            }
814            if prior {
815                let Some(provider) = provider else {
816                    session.stop(ExternalResourceStopReason::PredecessorUnavailable { uri });
817                    return;
818                };
819                resolve_segment_uri(asset, session, frontier, provider, &uri);
820            } else {
821                frontier.push(ExternalResourceRequest {
822                    uri,
823                    kind: ExternalResourceKind::SegmentManifestStore,
824                });
825            }
826        }
827    }
828}
829
830fn resolve_root_uri(
831    asset: &[u8],
832    session: &mut DiscoverySession,
833    frontier: &mut Vec<ExternalResourceRequest>,
834    provider: &dyn ExternalResources,
835    uri: &str,
836    manifest_store_present: &mut bool,
837) -> bool {
838    let Some(state) = session.uris.get_mut(uri) else {
839        session.stop(ExternalResourceStopReason::PredecessorUnavailable {
840            uri: uri.to_string(),
841        });
842        return false;
843    };
844    if state.root_parsed {
845        return true;
846    }
847    state.root_parsed = true;
848    match provider.resolve(uri) {
849        ExternalResource::Retrieved { bytes, .. } => {
850            *manifest_store_present = true;
851            // This body has already been held and parsed as the initial store.
852            // If its predecessor points back to the same URI, global URI
853            // deduplication must not query or parse the body a second time.
854            let state = session
855                .uris
856                .get_mut(uri)
857                .expect("root resource state remains present");
858            state.segment = true;
859            state.segment_parsed = true;
860            scan_initial_store(asset, bytes, session, frontier, Some(provider), Some(uri));
861            !session.stopped()
862        }
863        ExternalResource::NotAttempted | ExternalResource::Unavailable => {
864            session.stop(ExternalResourceStopReason::PredecessorUnavailable {
865                uri: uri.to_string(),
866            });
867            false
868        }
869    }
870}
871
872fn resolve_segment_uri(
873    asset: &[u8],
874    session: &mut DiscoverySession,
875    frontier: &mut Vec<ExternalResourceRequest>,
876    provider: &dyn ExternalResources,
877    uri: &str,
878) -> bool {
879    let depth = {
880        let Some(state) = session.uris.get_mut(uri) else {
881            session.stop(ExternalResourceStopReason::PredecessorUnavailable {
882                uri: uri.to_string(),
883            });
884            return false;
885        };
886        if state.segment_parsed {
887            return true;
888        }
889        state.segment_parsed = true;
890        state.segment_depth
891    };
892    match provider.resolve(uri) {
893        ExternalResource::Retrieved { bytes, .. } => {
894            scan_predecessor_store(
895                asset,
896                session,
897                frontier,
898                Some(provider),
899                bytes,
900                Some(uri),
901                depth,
902            );
903            !session.stopped()
904        }
905        ExternalResource::NotAttempted | ExternalResource::Unavailable => {
906            session.stop(ExternalResourceStopReason::PredecessorUnavailable {
907                uri: uri.to_string(),
908            });
909            false
910        }
911    }
912}
913
914#[allow(clippy::too_many_arguments)]
915fn scan_predecessor_store(
916    asset: &[u8],
917    session: &mut DiscoverySession,
918    frontier: &mut Vec<ExternalResourceRequest>,
919    provider: Option<&dyn ExternalResources>,
920    bytes: &[u8],
921    uri: Option<&str>,
922    depth: usize,
923) {
924    let malformed = || ExternalResourceStopReason::ManifestMalformed {
925        uri: uri.map(str::to_string),
926    };
927    if bytes.len() > crate::MAX_MANIFEST_STORE_BYTES {
928        session.stop(malformed());
929        return;
930    }
931    let expanded = match expand_store(bytes) {
932        Ok(expanded) => expanded,
933        Err(_) => {
934            session.stop(malformed());
935            return;
936        }
937    };
938    let store = match parse_manifest_store(expanded.bytes()) {
939        Ok(store) => store,
940        Err(_) => {
941            session.stop(malformed());
942            return;
943        }
944    };
945    let Some(manifest) = store.manifests.last() else {
946        session.stop(malformed());
947        return;
948    };
949    if let Err(limit) = session.budget.bytes(manifest.manifest_jumbf.len()) {
950        session.graph_stop(limit);
951        return;
952    }
953    let Some(claim) = manifest.claim_cbor.and_then(|claim| decode(claim).ok()) else {
954        session.stop(malformed());
955        return;
956    };
957    let generation = versions::claim_generation(manifest, &claim);
958    let claim_refs = ClaimAssertionRefs::build(manifest, &claim, generation);
959    let Some(flavor) = session.flavor else {
960        session.stop(malformed());
961        return;
962    };
963    let operative = super::caller_binding::operative_reference(&claim_refs, false, session.profile)
964        .filter(|reference| {
965            reference
966                .label
967                .is_some_and(super::spec25::is_segment_hash_label)
968        });
969    if operative.is_none() {
970        session.stop(malformed());
971        return;
972    }
973    match super::segment_hash::locate_predecessor_reference(&claim_refs, &claim, asset, flavor) {
974        Ok(Some(reference)) => process_predecessor_reference(
975            asset,
976            session,
977            frontier,
978            provider,
979            reference,
980            depth.saturating_add(1),
981        ),
982        Ok(None) => {}
983        Err(()) => session.stop(malformed()),
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use crate::c2pa_cbor::Profile;
991    use crate::c2pa_core::jumbf::{assertion_box, build_manifest, build_manifest_store};
992    use sha2::{Digest, Sha256};
993
994    fn text(value: &str) -> Value {
995        Value::Text(value.to_string())
996    }
997
998    fn int(value: usize) -> Value {
999        Value::Integer(value as i128)
1000    }
1001
1002    fn map(entries: Vec<(&str, Value)>) -> Value {
1003        Value::Map(
1004            entries
1005                .into_iter()
1006                .map(|(key, value)| (text(key), value))
1007                .collect(),
1008        )
1009    }
1010
1011    fn encode(value: &Value) -> Vec<u8> {
1012        crate::c2pa_cbor::encode(value, Profile::CanonicalForHashedSubstructures)
1013            .expect("fixture CBOR")
1014    }
1015
1016    fn reference(label: &str) -> Value {
1017        map(vec![
1018            ("url", text(&format!("self#jumbf=c2pa.assertions/{label}"))),
1019            ("alg", text("sha256")),
1020            ("hash", Value::Bytes(vec![0; 32])),
1021        ])
1022    }
1023
1024    fn claim(labels: &[&str]) -> Vec<u8> {
1025        encode(&map(vec![
1026            ("alg", text("sha256")),
1027            (
1028                "created_assertions",
1029                Value::Array(labels.iter().map(|label| reference(label)).collect()),
1030            ),
1031        ]))
1032    }
1033
1034    fn segment(
1035        asset: &[u8],
1036        start: usize,
1037        end: usize,
1038        sentinel_length: usize,
1039        predecessor: Option<(usize, usize)>,
1040    ) -> Value {
1041        let mut entries = vec![
1042            ("version", int(1)),
1043            ("alg", text("sha256")),
1044            (
1045                "hash",
1046                Value::Bytes(Sha256::digest(&asset[start..end]).to_vec()),
1047            ),
1048            ("segmentStart", int(start)),
1049            ("segmentEnd", int(end)),
1050            ("sentinelLength", int(sentinel_length)),
1051        ];
1052        if let Some((predecessor_start, predecessor_length)) = predecessor {
1053            entries.push((
1054                "predecessorSentinel",
1055                map(vec![
1056                    ("start", int(predecessor_start)),
1057                    ("length", int(predecessor_length)),
1058                    (
1059                        "hash",
1060                        Value::Bytes(
1061                            Sha256::digest(
1062                                &asset[predecessor_start..predecessor_start + predecessor_length],
1063                            )
1064                            .to_vec(),
1065                        ),
1066                    ),
1067                ]),
1068            ));
1069        }
1070        map(entries)
1071    }
1072
1073    fn sentinel(uri: &str) -> String {
1074        format!("-----BEGIN C2PA CHECKPOINT----- {uri} -----END C2PA CHECKPOINT-----\n")
1075    }
1076
1077    fn store(label: &str, assertions: Vec<(&str, Value)>) -> Vec<u8> {
1078        let labels: Vec<&str> = assertions.iter().map(|(label, _)| *label).collect();
1079        let boxes: Vec<Vec<u8>> = assertions
1080            .into_iter()
1081            .map(|(label, value)| assertion_box(label, &encode(&value), None))
1082            .collect();
1083        let manifest = build_manifest(label, &boxes, &claim(&labels), &[0xd2, 0x84]);
1084        build_manifest_store(&[manifest])
1085    }
1086
1087    #[test]
1088    fn unknown_policy_fails_before_locator_parsing() {
1089        let options = VerifyOptions {
1090            spec_version: Some("next".into()),
1091            ..VerifyOptions::default()
1092        };
1093        let error = match locate_external_resources(
1094            b"not a jpeg".to_vec(),
1095            "image/jpeg",
1096            &options,
1097            GraphLimits::default(),
1098        ) {
1099            Ok(_) => panic!("unknown policy accepted"),
1100            Err(error) => error,
1101        };
1102        assert!(matches!(error, Error::UnsupportedSpecVersion(_)));
1103    }
1104
1105    #[test]
1106    fn external_only_mime_is_admitted_for_25_discovery_but_not_older_policy() {
1107        let options = VerifyOptions {
1108            spec_version: Some("2.5".into()),
1109            ..VerifyOptions::default()
1110        };
1111        let plan = locate_external_resources(
1112            b"external-only".to_vec(),
1113            "application/vnd.example.future",
1114            &options,
1115            GraphLimits::default(),
1116        )
1117        .expect("2.5 external-only discovery");
1118        assert!(!plan.manifest_store_present);
1119        assert!(plan.resources.is_empty());
1120        assert_eq!(plan.stop_reason, None);
1121
1122        let old = locate_external_resources(
1123            b"external-only".to_vec(),
1124            "application/vnd.example.future",
1125            &VerifyOptions::default(),
1126            GraphLimits::default(),
1127        );
1128        assert!(matches!(old, Err(Error::UnsupportedMime(_))));
1129
1130        let malformed = locate_external_resources(
1131            b"external-only".to_vec(),
1132            "not a mime",
1133            &options,
1134            GraphLimits::default(),
1135        );
1136        assert!(matches!(malformed, Err(Error::UnsupportedMime(_))));
1137    }
1138
1139    #[test]
1140    fn extraction_error_is_a_typed_no_fallback_stop_without_presence() {
1141        let options = VerifyOptions {
1142            spec_version: Some("2.5".into()),
1143            ..VerifyOptions::default()
1144        };
1145        let plan = locate_external_resources(
1146            b"not a jpeg".to_vec(),
1147            "image/jpeg",
1148            &options,
1149            GraphLimits::default(),
1150        )
1151        .expect("typed extraction stop");
1152        assert!(!plan.manifest_store_present);
1153        assert!(plan.truncated);
1154        assert_eq!(
1155            plan.stop_reason,
1156            Some(ExternalResourceStopReason::ManifestMalformed { uri: None })
1157        );
1158        assert!(plan.resources.is_empty());
1159    }
1160
1161    #[test]
1162    fn malformed_initial_store_is_present_and_blocks_fallback() {
1163        let options = VerifyOptions {
1164            spec_version: Some("2.5".into()),
1165            ..VerifyOptions::default()
1166        };
1167        let plan = locate_external_resources_with_manifest_store(
1168            Vec::from(&b"asset"[..]),
1169            b"not a manifest store",
1170            "text/plain",
1171            &options,
1172            GraphLimits::default(),
1173        )
1174        .expect("parse-only plan");
1175        assert!(plan.manifest_store_present);
1176        assert!(plan.truncated);
1177        assert_eq!(
1178            plan.stop_reason,
1179            Some(ExternalResourceStopReason::ManifestMalformed { uri: None })
1180        );
1181        assert!(plan.resources.is_empty());
1182        assert_eq!(plan.asset(), b"asset");
1183    }
1184
1185    #[test]
1186    fn graph_node_limit_is_typed_for_the_initial_manifest() {
1187        let initial = store("urn:c2pa:active", Vec::new());
1188        let plan = locate_external_resources_with_manifest_store(
1189            &b"asset"[..],
1190            &initial,
1191            "text/plain",
1192            &VerifyOptions::default(),
1193            GraphLimits::lowered(32, 0, usize::MAX, usize::MAX),
1194        )
1195        .expect("parse-only plan");
1196        assert!(plan.manifest_store_present);
1197        assert_eq!(
1198            plan.stop_reason,
1199            Some(ExternalResourceStopReason::GraphLimitExceeded {
1200                limit: GraphLimitKind::Nodes,
1201            })
1202        );
1203        assert!(plan.resources.is_empty());
1204    }
1205
1206    #[test]
1207    fn opaque_uri_promotes_from_an_earlier_frontier_without_refetching() {
1208        const EARLY_URI: &str = "https://e.example/early.c2pa";
1209        const MIDDLE_URI: &str = "https://e.example/middle.c2pa";
1210        const ACTIVE_URI: &str = "https://e.example/active.c2pa";
1211
1212        let mut asset = b"alpha\n".to_vec();
1213        let early_line = sentinel(EARLY_URI);
1214        asset.extend_from_slice(early_line.as_bytes());
1215        let middle_start = asset.len();
1216        asset.extend_from_slice(b"bravo\n");
1217        let middle_end = asset.len();
1218        let middle_line = sentinel(MIDDLE_URI);
1219        asset.extend_from_slice(middle_line.as_bytes());
1220        let active_start = asset.len();
1221        asset.extend_from_slice(b"charlie\n");
1222        let active_end = asset.len();
1223        let active_line = sentinel(ACTIVE_URI);
1224        asset.extend_from_slice(active_line.as_bytes());
1225
1226        let active_segment = segment(
1227            &asset,
1228            active_start,
1229            active_end,
1230            active_line.len(),
1231            Some((middle_end, middle_line.len())),
1232        );
1233        let cloud = map(vec![
1234            ("label", text("c2pa.metadata")),
1235            ("size", int(1)),
1236            (
1237                "location",
1238                map(vec![
1239                    ("url", text(EARLY_URI)),
1240                    ("alg", text("sha256")),
1241                    ("hash", Value::Bytes(vec![0; 32])),
1242                ]),
1243            ),
1244        ]);
1245        let active_store = store(
1246            "urn:c2pa:active",
1247            vec![
1248                ("c2pa.cloud-data", cloud),
1249                ("c2pa.hash.segment", active_segment),
1250            ],
1251        );
1252        let middle_segment = segment(
1253            &asset,
1254            middle_start,
1255            middle_end,
1256            middle_line.len(),
1257            Some((6, early_line.len())),
1258        );
1259        let middle_store = store(
1260            "urn:c2pa:middle",
1261            vec![("c2pa.hash.segment", middle_segment)],
1262        );
1263        let early_segment = segment(&asset, 0, 6, early_line.len(), None);
1264        let early_store = store("urn:c2pa:early", vec![("c2pa.hash.segment", early_segment)]);
1265        let options = VerifyOptions {
1266            spec_version: Some("2.5".into()),
1267            ..VerifyOptions::default()
1268        };
1269        let mut plan = locate_external_resources_with_manifest_store(
1270            asset,
1271            &active_store,
1272            "text/plain",
1273            &options,
1274            GraphLimits::default(),
1275        )
1276        .expect("initial discovery");
1277        assert_eq!(
1278            plan.resources,
1279            vec![
1280                ExternalResourceRequest {
1281                    uri: EARLY_URI.into(),
1282                    kind: ExternalResourceKind::Opaque,
1283                },
1284                ExternalResourceRequest {
1285                    uri: MIDDLE_URI.into(),
1286                    kind: ExternalResourceKind::SegmentManifestStore,
1287                },
1288            ]
1289        );
1290
1291        struct Provider<'a> {
1292            early: &'a [u8],
1293            middle: &'a [u8],
1294            calls: std::cell::RefCell<Vec<String>>,
1295        }
1296        impl ExternalResources for Provider<'_> {
1297            fn resolve(&self, uri: &str) -> ExternalResource<'_> {
1298                self.calls.borrow_mut().push(uri.to_string());
1299                let bytes = match uri {
1300                    EARLY_URI => self.early,
1301                    MIDDLE_URI => self.middle,
1302                    _ => panic!("unexpected URI {uri}"),
1303                };
1304                ExternalResource::Retrieved {
1305                    bytes,
1306                    media_type: Some("application/c2pa"),
1307                }
1308            }
1309        }
1310        let provider = Provider {
1311            early: &early_store,
1312            middle: &middle_store,
1313            calls: std::cell::RefCell::new(Vec::new()),
1314        };
1315        plan.continue_with(&provider).expect("continue discovery");
1316        assert!(plan.resources.is_empty());
1317        assert!(!plan.truncated);
1318        assert_eq!(plan.stop_reason, None);
1319        assert_eq!(
1320            provider.calls.into_inner(),
1321            vec![MIDDLE_URI.to_string(), EARLY_URI.to_string()]
1322        );
1323    }
1324
1325    #[test]
1326    fn append_only_root_uri_uses_the_format_locator_and_continues_as_an_initial_store() {
1327        const ROOT_URI: &str = "https://e.example/active.c2pa";
1328        let root_line = sentinel(ROOT_URI);
1329        let prefix = b"root\n";
1330        let mut asset = prefix.to_vec();
1331        asset.extend_from_slice(root_line.as_bytes());
1332        let root_store = store(
1333            "urn:c2pa:root",
1334            vec![(
1335                "c2pa.hash.segment",
1336                segment(&asset, 0, prefix.len(), root_line.len(), None),
1337            )],
1338        );
1339        let options = VerifyOptions {
1340            spec_version: Some("2.5".into()),
1341            ..VerifyOptions::default()
1342        };
1343        let mut plan =
1344            locate_external_resources(asset, "text/plain", &options, GraphLimits::default())
1345                .expect("root discovery");
1346        assert!(!plan.manifest_store_present);
1347        assert_eq!(
1348            plan.resources,
1349            vec![ExternalResourceRequest {
1350                uri: ROOT_URI.into(),
1351                kind: ExternalResourceKind::SegmentManifestStore,
1352            }]
1353        );
1354
1355        struct RootProvider<'a> {
1356            store: &'a [u8],
1357            calls: std::cell::Cell<usize>,
1358        }
1359        impl ExternalResources for RootProvider<'_> {
1360            fn resolve(&self, uri: &str) -> ExternalResource<'_> {
1361                assert_eq!(uri, ROOT_URI);
1362                self.calls.set(self.calls.get() + 1);
1363                ExternalResource::Retrieved {
1364                    bytes: self.store,
1365                    media_type: Some("application/c2pa"),
1366                }
1367            }
1368        }
1369        let provider = RootProvider {
1370            store: &root_store,
1371            calls: std::cell::Cell::new(0),
1372        };
1373        plan.continue_with(&provider).expect("root continuation");
1374        assert!(plan.manifest_store_present);
1375        assert!(plan.resources.is_empty());
1376        assert!(!plan.truncated);
1377        assert_eq!(plan.stop_reason, None);
1378        assert_eq!(provider.calls.get(), 1);
1379    }
1380
1381    #[test]
1382    fn malformed_terminal_checkpoint_never_falls_back_to_an_older_external_root() {
1383        let asset = format!(
1384            "body\n{}{}",
1385            sentinel("https://e.example/older.c2pa"),
1386            sentinel("data:application/c2pa;base64,***")
1387        );
1388        let options = VerifyOptions {
1389            spec_version: Some("2.5".into()),
1390            ..VerifyOptions::default()
1391        };
1392        let plan = locate_external_resources(
1393            asset.into_bytes(),
1394            "text/plain",
1395            &options,
1396            GraphLimits::default(),
1397        )
1398        .expect("terminal selection");
1399        assert!(!plan.manifest_store_present);
1400        assert!(plan.truncated);
1401        assert!(plan.resources.is_empty());
1402        assert_eq!(
1403            plan.stop_reason,
1404            Some(ExternalResourceStopReason::ManifestMalformed { uri: None })
1405        );
1406    }
1407
1408    #[test]
1409    fn unavailable_predecessor_is_typed_without_flattening_provider_outcome() {
1410        struct Denied;
1411        impl ExternalResources for Denied {
1412            fn resolve(&self, _: &str) -> ExternalResource<'_> {
1413                ExternalResource::NotAttempted
1414            }
1415        }
1416
1417        let mut plan = new_plan(
1418            Vec::<u8>::new(),
1419            EngineProfile::GENEROUS,
1420            Some(AppendOnlyFlavor::PlainText),
1421            GraphLimits::default(),
1422        );
1423        let uri = "https://e.example/predecessor.c2pa".to_string();
1424        plan.session.uris.insert(
1425            uri.clone(),
1426            UriState {
1427                segment: true,
1428                segment_depth: 1,
1429                ..UriState::default()
1430            },
1431        );
1432        plan.resources.push(ExternalResourceRequest {
1433            uri: uri.clone(),
1434            kind: ExternalResourceKind::SegmentManifestStore,
1435        });
1436        plan.continue_with(&Denied).expect("typed stop");
1437        assert!(plan.resources.is_empty());
1438        assert_eq!(
1439            plan.stop_reason,
1440            Some(ExternalResourceStopReason::PredecessorUnavailable { uri })
1441        );
1442    }
1443}