1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum ExternalResourceKind {
21 Opaque,
23 SegmentManifestStore,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ExternalResourceRequest {
30 pub uri: String,
32 pub kind: ExternalResourceKind,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ExternalDidDocumentRequest {
39 pub did: String,
41 pub url: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum GraphLimitKind {
48 Depth,
50 Nodes,
52 References,
54 Bytes,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ExternalResourceStopReason {
61 GraphLimitExceeded {
63 limit: GraphLimitKind,
65 },
66 PredecessorUnavailable {
68 uri: String,
70 },
71 ManifestMalformed {
73 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
183pub struct ExternalResourcePlan<A: AsRef<[u8]>> {
191 pub manifest_store_present: bool,
196 pub resources: Vec<ExternalResourceRequest>,
198 pub truncated: bool,
200 pub stop_reason: Option<ExternalResourceStopReason>,
202 asset: A,
203 session: DiscoverySession,
204}
205
206impl<A: AsRef<[u8]>> ExternalResourcePlan<A> {
207 pub fn asset(&self) -> &[u8] {
209 self.asset.as_ref()
210 }
211
212 pub fn did_documents(&self) -> &[ExternalDidDocumentRequest] {
218 &self.session.did_documents
219 }
220
221 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
299pub 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
374pub 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
398pub(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 if url.starts_with("https://") && session.did_document_ids.insert(did.clone()) {
492 session
495 .did_documents
496 .push(ExternalDidDocumentRequest { did, url });
497 }
498 }
499
500 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 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}