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