1use super::{
66 hash_bytes, is_allowed_hash_algorithm, is_generous, ClaimAssertionRefs, ClaimGeneration,
67 EngineProfile, OnlineEvidence, ValidationResults, ALGORITHM_UNSUPPORTED,
68 ASSERTION_HASHED_URI_MATCH, HASHED_URI_MISMATCH, HASHED_URI_MISSING,
69};
70use crate::c2pa_cbor::Value;
71use crate::c2pa_core::jumbf::ParsedManifest;
72use std::collections::HashMap;
73
74pub enum ExternalResource<'a> {
76 NotAttempted,
78 Unavailable,
80 Retrieved {
82 bytes: &'a [u8],
84 media_type: Option<&'a str>,
86 },
87}
88
89pub trait ExternalResources {
91 fn resolve(&self, url: &str) -> ExternalResource<'_>;
93}
94
95enum CachedResource<'a> {
96 NotAttempted,
97 Unavailable,
98 Retrieved {
99 bytes: &'a [u8],
100 media_type: Option<&'a str>,
101 },
102}
103
104pub(super) enum ResolvedResource<'a> {
105 NotAttempted,
106 Unavailable,
107 Retrieved {
108 bytes: &'a [u8],
109 media_type: Option<&'a str>,
110 },
111}
112
113pub(super) struct ResourceCache<'a> {
114 provider: Option<&'a dyn ExternalResources>,
115 evidence: OnlineEvidence<'a>,
116 outcomes: HashMap<String, CachedResource<'a>>,
117}
118
119impl<'a> ResourceCache<'a> {
120 pub(super) fn with_evidence(
121 provider: Option<&'a dyn ExternalResources>,
122 evidence: OnlineEvidence<'a>,
123 ) -> Self {
124 Self {
125 provider,
126 evidence,
127 outcomes: HashMap::new(),
128 }
129 }
130
131 pub(super) fn resolve(&mut self, url: &str) -> ResolvedResource<'_> {
132 if !self.outcomes.contains_key(url) {
133 let supplied = self
134 .evidence
135 .external_data
136 .and_then(|resources| resources.get(url));
137 let outcome = if let Some(bytes) = supplied {
138 let media_type = self
139 .evidence
140 .external_data_media_types
141 .and_then(|media_types| media_types.get(url))
142 .map(String::as_str);
143 CachedResource::Retrieved {
144 bytes: bytes.as_slice(),
145 media_type,
146 }
147 } else if self.evidence.external_is_unreachable(url) {
148 CachedResource::Unavailable
149 } else {
150 match self.provider {
151 None => CachedResource::NotAttempted,
152 Some(provider) => Self::fetch(provider, url),
153 }
154 };
155 self.outcomes.insert(url.to_string(), outcome);
156 }
157 match self.outcomes.get(url).expect("resource outcome inserted") {
158 CachedResource::NotAttempted => ResolvedResource::NotAttempted,
159 CachedResource::Unavailable => ResolvedResource::Unavailable,
160 CachedResource::Retrieved { bytes, media_type } => ResolvedResource::Retrieved {
161 bytes,
162 media_type: *media_type,
163 },
164 }
165 }
166
167 fn fetch(provider: &'a dyn ExternalResources, url: &str) -> CachedResource<'a> {
171 match provider.resolve(url) {
172 ExternalResource::NotAttempted => CachedResource::NotAttempted,
173 ExternalResource::Unavailable => CachedResource::Unavailable,
174 ExternalResource::Retrieved { bytes, media_type } => {
175 CachedResource::Retrieved { bytes, media_type }
176 }
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(super) struct ReferenceWalk {
183 pub(super) examined: usize,
184 pub(super) truncated: bool,
185}
186
187const MAX_RELATED_DEPTH: usize = 8;
191
192#[derive(Clone, Copy)]
194enum Step {
195 Field(&'static str),
197 Each,
199 EachRelated,
202}
203
204#[derive(Clone, Copy, PartialEq, Eq)]
206enum Kind {
207 Internal,
209 Ingredient,
211 Related,
213 Either,
216}
217
218struct RefField {
220 path: &'static [Step],
221 kind: Kind,
222}
223
224const fn field(path: &'static [Step], kind: Kind) -> RefField {
225 RefField { path, kind }
226}
227
228const INGREDIENT_V1: &[RefField] = &[field(&[Step::Field("thumbnail")], Kind::Internal)];
230
231const INGREDIENT_V2: &[RefField] = &[
233 field(&[Step::Field("thumbnail")], Kind::Internal),
234 field(&[Step::Field("data")], Kind::Either),
235];
236
237const INGREDIENT_V3: &[RefField] = &[
239 field(&[Step::Field("thumbnail")], Kind::Internal),
240 field(&[Step::Field("data")], Kind::Either),
241 field(
242 &[Step::Field("relatedAssertions"), Step::Each],
243 Kind::Related,
244 ),
245];
246
247const ACTIONS: &[RefField] = &[
251 field(
252 &[
253 Step::Field("actions"),
254 Step::Each,
255 Step::EachRelated,
256 Step::Field("parameters"),
257 Step::Field("ingredient"),
258 ],
259 Kind::Ingredient,
260 ),
261 field(
262 &[
263 Step::Field("actions"),
264 Step::Each,
265 Step::EachRelated,
266 Step::Field("parameters"),
267 Step::Field("ingredients"),
268 Step::Each,
269 ],
270 Kind::Ingredient,
271 ),
272 field(
273 &[
274 Step::Field("actions"),
275 Step::Each,
276 Step::EachRelated,
277 Step::Field("parameters"),
278 Step::Field("relatedAssertions"),
279 Step::Each,
280 ],
281 Kind::Related,
282 ),
283 field(
284 &[
285 Step::Field("actions"),
286 Step::Each,
287 Step::EachRelated,
288 Step::Field("softwareAgent"),
289 Step::Field("icon"),
290 ],
291 Kind::Either,
292 ),
293 field(
294 &[
295 Step::Field("softwareAgents"),
296 Step::Each,
297 Step::Field("icon"),
298 ],
299 Kind::Either,
300 ),
301 field(
302 &[Step::Field("templates"), Step::Each, Step::Field("icon")],
303 Kind::Internal,
304 ),
305];
306
307fn ref_fields(label: &str) -> &'static [RefField] {
313 match base_label(label) {
314 "c2pa.ingredient" => INGREDIENT_V1,
315 "c2pa.ingredient.v2" => INGREDIENT_V2,
316 "c2pa.ingredient.v3" => INGREDIENT_V3,
317 "c2pa.actions" | "c2pa.actions.v2" => ACTIONS,
318 _ => &[],
319 }
320}
321
322pub(super) fn base_label(label: &str) -> &str {
324 match label.split_once("__") {
325 Some((base, instance))
326 if !instance.is_empty() && instance.bytes().all(|b| b.is_ascii_digit()) =>
327 {
328 base
329 }
330 _ => label,
331 }
332}
333
334struct Located<'v> {
336 kind: Kind,
337 value: &'v Value,
338 path: String,
339 action_kind: Option<&'v str>,
340}
341
342fn locate<'v>(
347 value: &'v Value,
348 kind: Kind,
349 steps: &[Step],
350 depth: usize,
351 path: &mut String,
352 out: &mut Vec<Located<'v>>,
353 limit: usize,
354) -> bool {
355 match steps.split_first() {
356 None => {
357 if out.len() >= limit {
358 return true;
359 }
360 out.push(Located {
361 kind,
362 value,
363 path: path.clone(),
364 action_kind: None,
365 });
366 false
367 }
368 Some((Step::Field(name), rest)) => {
369 let Some(child) = value.get(name) else {
370 return false;
371 };
372 let mark = path.len();
373 if !path.is_empty() {
374 path.push('.');
375 }
376 path.push_str(name);
377 let capped = locate(child, kind, rest, depth, path, out, limit);
378 path.truncate(mark);
379 capped
380 }
381 Some((Step::Each, rest)) => {
382 let Value::Array(items) = value else {
383 return false;
384 };
385 for (index, item) in items.iter().enumerate() {
386 let mark = path.len();
387 path.push('[');
388 path.push_str(&index.to_string());
389 path.push(']');
390 let capped = locate(item, kind, rest, depth, path, out, limit);
391 path.truncate(mark);
392 if capped {
393 return true;
394 }
395 }
396 false
397 }
398 Some((Step::EachRelated, rest)) => {
399 let start = out.len();
400 let capped = locate(value, kind, rest, depth, path, out, limit);
401 for located in &mut out[start..] {
402 located.action_kind = value.get("action").and_then(Value::as_text);
403 }
404 if capped {
405 return true;
406 }
407 if depth >= MAX_RELATED_DEPTH {
408 return false;
409 }
410 let Some(Value::Array(items)) = value.get("related") else {
411 return false;
412 };
413 for (index, item) in items.iter().enumerate() {
414 let mark = path.len();
415 path.push_str(".related[");
416 path.push_str(&index.to_string());
417 path.push(']');
418 let capped = locate(item, kind, steps, depth + 1, path, out, limit);
419 path.truncate(mark);
420 if capped {
421 return true;
422 }
423 }
424 false
425 }
426 }
427}
428
429pub(super) struct AssertionReferenceContext<'a, 'm> {
431 pub claim: &'a Value,
432 pub claim_refs: &'a ClaimAssertionRefs<'m>,
433 pub generation: ClaimGeneration,
434 pub profile: EngineProfile,
435 pub manifest_label: &'a str,
436 pub manifests: &'a [ParsedManifest<'m>],
437 pub budget: usize,
438}
439
440pub(super) fn verify_assertion_references(
449 context: AssertionReferenceContext<'_, '_>,
450 resources: &mut ResourceCache<'_>,
451 results: &mut ValidationResults,
452) -> ReferenceWalk {
453 let AssertionReferenceContext {
454 claim,
455 claim_refs,
456 generation,
457 profile,
458 manifest_label,
459 manifests,
460 budget,
461 } = context;
462 let legacy = generation == ClaimGeneration::V1 && is_generous(profile);
470 if legacy
471 && claim_refs
472 .redactions
473 .as_deref()
474 .is_none_or(super::redaction::Index::is_empty)
475 {
476 return ReferenceWalk {
477 examined: 0,
478 truncated: false,
479 };
480 }
481 let claim_alg = claim.get("alg").and_then(Value::as_text);
482 let mut seen: Vec<&str> = Vec::new();
483 let mut examined = 0usize;
484 for reference in &claim_refs.references {
485 let Some(alabel) = reference.label else {
486 continue;
487 };
488 if seen.contains(&alabel) {
489 continue;
490 }
491 seen.push(alabel);
492 let fields = ref_fields(alabel);
493 if fields.is_empty() {
494 continue;
495 }
496 let Some(assertion) = claim_refs
497 .indexed(alabel)
498 .and_then(|indexed| indexed.decoded.as_ref())
499 else {
500 continue;
501 };
502 let assertion_url = format!("self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{alabel}");
503 let assertion_alg = assertion.get("alg").and_then(Value::as_text);
504 let mut located = Vec::new();
505 let mut capped = false;
506 let capacity = budget.saturating_sub(examined);
507 for field in fields {
508 if field.kind == Kind::Related
511 && base_label(alabel) == "c2pa.ingredient.v3"
512 && !super::spec25::applies(profile)
513 {
514 continue;
515 }
516 let mut path = String::new();
517 if locate(
518 assertion,
519 field.kind,
520 field.path,
521 0,
522 &mut path,
523 &mut located,
524 capacity,
525 ) {
526 capped = true;
527 break;
528 }
529 }
530 examined += located.len();
531 for located in &located {
532 let redacted = super::spec25::redacted_reference_target(
533 located.value,
534 manifest_label,
535 claim_refs.redactions.as_deref(),
536 );
537 if let Some(target) = redacted {
538 let exempt = super::spec25::applies(profile)
539 && match located.kind {
540 Kind::Related => target.same_manifest,
541 Kind::Ingredient => {
542 matches!(
543 base_label(target.label),
544 "c2pa.ingredient" | "c2pa.ingredient.v2" | "c2pa.ingredient.v3"
545 ) && match located.action_kind {
546 Some(
547 "c2pa.opened" | "c2pa.placed" | "c2pa.transcoded"
548 | "c2pa.repackaged",
549 ) => target.same_manifest,
550 Some("c2pa.deleted" | "c2pa.removed") => !target.same_manifest,
551 _ => false,
552 }
553 }
554 _ => false,
555 };
556 if exempt {
557 results.push_informational(
558 super::spec25::ASSERTION_REDACTED,
559 target.url.to_string(),
560 "reference resolves to a redacted assertion".into(),
561 );
562 continue;
563 }
564 results.push_failure(
565 HASHED_URI_MISSING,
566 target.url.to_string(),
567 format!(
568 "{alabel} field '{}' references a redacted assertion",
569 located.path
570 ),
571 );
572 continue;
573 }
574 if legacy {
575 continue;
576 }
577 validate_reference(
578 located,
579 alabel,
580 &assertion_url,
581 generation,
582 manifest_label,
583 manifests,
584 assertion_alg.or(claim_alg),
585 resources,
586 results,
587 );
588 }
589 if capped {
590 return ReferenceWalk {
591 examined,
592 truncated: true,
593 };
594 }
595 }
596 ReferenceWalk {
597 examined,
598 truncated: false,
599 }
600}
601
602pub(super) fn locate_external_references(
606 claim_refs: &ClaimAssertionRefs<'_>,
607 generation: ClaimGeneration,
608 profile: EngineProfile,
609 budget: usize,
610) -> (Vec<String>, ReferenceWalk) {
611 if generation == ClaimGeneration::V1 && is_generous(profile) {
612 return (
613 Vec::new(),
614 ReferenceWalk {
615 examined: 0,
616 truncated: false,
617 },
618 );
619 }
620 let mut urls = Vec::new();
621 let mut seen = Vec::new();
622 let mut examined = 0usize;
623 for reference in &claim_refs.references {
624 let Some(label) = reference.label else {
625 continue;
626 };
627 if seen.contains(&label) {
628 continue;
629 }
630 seen.push(label);
631 let fields = ref_fields(label);
632 if fields.is_empty() {
633 continue;
634 }
635 let Some(assertion) = claim_refs
636 .indexed(label)
637 .and_then(|indexed| indexed.decoded.as_ref())
638 else {
639 continue;
640 };
641 let capacity = budget.saturating_sub(examined);
642 let mut located = Vec::new();
643 let mut capped = false;
644 for field in fields {
645 if field.kind == Kind::Related
646 && base_label(label) == "c2pa.ingredient.v3"
647 && !super::spec25::applies(profile)
648 {
649 continue;
650 }
651 let mut path = String::new();
652 if locate(
653 assertion,
654 field.kind,
655 field.path,
656 0,
657 &mut path,
658 &mut located,
659 capacity,
660 ) {
661 capped = true;
662 break;
663 }
664 }
665 examined = examined.saturating_add(located.len());
666 for reference in located {
667 let Some(url) = reference.value.get("url").and_then(Value::as_text) else {
668 continue;
669 };
670 if reference.kind == Kind::Either
671 && (url.starts_with("https://") || url.starts_with("http://"))
672 {
673 urls.push(url.to_string());
674 }
675 }
676 if capped {
677 return (
678 urls,
679 ReferenceWalk {
680 examined,
681 truncated: true,
682 },
683 );
684 }
685 }
686 (
687 urls,
688 ReferenceWalk {
689 examined,
690 truncated: false,
691 },
692 )
693}
694
695#[allow(clippy::too_many_arguments)] fn validate_reference(
697 located: &Located<'_>,
698 alabel: &str,
699 assertion_url: &str,
700 generation: ClaimGeneration,
701 manifest_label: &str,
702 manifests: &[ParsedManifest<'_>],
703 inherited_alg: Option<&str>,
704 resources: &mut ResourceCache<'_>,
705 results: &mut ValidationResults,
706) {
707 let path = &located.path;
708 let Some(url) = located.value.get("url").and_then(Value::as_text) else {
712 results.push_failure(
713 HASHED_URI_MISSING,
714 assertion_url.to_string(),
715 format!("{alabel} field '{path}' is not a hashed URI: no url"),
716 );
717 return;
718 };
719 if located.kind == Kind::Either && !url.starts_with("self#jumbf=") {
720 validate_external_reference(
721 located,
722 url,
723 alabel,
724 assertion_url,
725 inherited_alg,
726 resources,
727 results,
728 );
729 return;
730 }
731 let Some(destination) = resolve(url, manifest_label, manifests) else {
732 results.push_failure(
733 HASHED_URI_MISSING,
734 url.to_string(),
735 format!("{alabel} field '{path}' references '{url}', which cannot be located"),
736 );
737 return;
738 };
739
740 let algorithm = located
746 .value
747 .get("alg")
748 .and_then(Value::as_text)
749 .or(inherited_alg);
750 let Some(algorithm) = algorithm else {
751 results.push_failure(
752 ALGORITHM_UNSUPPORTED,
753 url.to_string(),
754 format!("{alabel} field '{path}' has no hash algorithm"),
755 );
756 return;
757 };
758 if !is_allowed_hash_algorithm(algorithm) {
759 results.push_failure(
760 ALGORITHM_UNSUPPORTED,
761 url.to_string(),
762 format!("{alabel} field '{path}' uses unsupported hash algorithm '{algorithm}'"),
763 );
764 return;
765 }
766
767 let expected = located.value.get("hash").and_then(Value::as_bytes);
777 let matches_domain = |bytes: Option<&[u8]>| {
778 matches!(
779 (bytes.and_then(|bytes| hash_bytes(algorithm, bytes)), expected),
780 (Some(actual), Some(expected)) if actual.as_slice() == expected
781 )
782 };
783 let matched = matches_domain(Some(destination.jumbf))
784 || (generation == ClaimGeneration::V1 && matches_domain(destination.payload));
785 if !matched {
786 results.push_failure(
787 HASHED_URI_MISMATCH,
788 url.to_string(),
789 format!("{alabel} field '{path}' hashed uri mismatch: {url}"),
790 );
791 }
792}
793
794#[allow(clippy::too_many_arguments)]
795fn validate_external_reference(
796 located: &Located<'_>,
797 url: &str,
798 alabel: &str,
799 assertion_url: &str,
800 inherited_alg: Option<&str>,
801 resources: &mut ResourceCache<'_>,
802 results: &mut ValidationResults,
803) {
804 const NOT_RETRIEVED: &str = "com.encypher.reference.externalNotRetrieved";
805 if !(url.starts_with("https://") || url.starts_with("http://")) {
806 results.push_failure(
807 HASHED_URI_MISSING,
808 assertion_url.to_string(),
809 format!(
810 "{alabel} field '{}' has an invalid external URL",
811 located.path
812 ),
813 );
814 return;
815 }
816 let algorithm = located
817 .value
818 .get("alg")
819 .and_then(Value::as_text)
820 .or(inherited_alg);
821 let Some(algorithm) = algorithm.filter(|value| is_allowed_hash_algorithm(value)) else {
822 results.push_failure(
823 ALGORITHM_UNSUPPORTED,
824 assertion_url.to_string(),
825 format!(
826 "{alabel} field '{}' has no supported hash algorithm",
827 located.path
828 ),
829 );
830 return;
831 };
832 let Some(expected) = located.value.get("hash").and_then(Value::as_bytes) else {
833 results.push_failure(
834 HASHED_URI_MISMATCH,
835 assertion_url.to_string(),
836 format!("{alabel} field '{}' has no hash", located.path),
837 );
838 return;
839 };
840 match resources.resolve(url) {
841 ResolvedResource::NotAttempted => results.push_informational(
842 NOT_RETRIEVED,
843 url.to_string(),
844 format!(
845 "{alabel} field '{}' references external data that was not retrieved",
846 located.path
847 ),
848 ),
849 ResolvedResource::Unavailable => results.push_failure(
850 HASHED_URI_MISSING,
851 url.to_string(),
852 format!(
853 "{alabel} field '{}' external data could not be retrieved",
854 located.path
855 ),
856 ),
857 ResolvedResource::Retrieved { bytes, media_type } => {
858 if let Some(declared) = located.value.get("size").and_then(|value| match value {
859 Value::Integer(value) => usize::try_from(*value).ok(),
860 _ => None,
861 }) {
862 if declared != bytes.len() {
863 results.push_failure(
864 HASHED_URI_MISMATCH,
865 url.to_string(),
866 format!(
867 "{alabel} field '{}' declares {declared} bytes; retrieved {}",
868 located.path,
869 bytes.len()
870 ),
871 );
872 return;
873 }
874 }
875 if let (Some(declared), Some(observed)) = (
876 located.value.get("dc:format").and_then(Value::as_text),
877 media_type,
878 ) {
879 if declared != observed {
880 results.push_informational(
881 NOT_RETRIEVED,
882 url.to_string(),
883 format!(
884 "{alabel} field '{}' declares dc:format '{declared}' but was retrieved as '{observed}'",
885 located.path
886 ),
887 );
888 }
889 }
890 if hash_bytes(algorithm, bytes).as_deref() == Some(expected) {
891 results.push_success(
892 ASSERTION_HASHED_URI_MATCH,
893 url.to_string(),
894 format!("{alabel} field '{}' hashed uri matched", located.path),
895 );
896 } else {
897 results.push_failure(
898 HASHED_URI_MISMATCH,
899 url.to_string(),
900 format!("{alabel} field '{}' hashed uri mismatch", located.path),
901 );
902 }
903 }
904 }
905}
906
907struct Destination<'a> {
914 jumbf: &'a [u8],
917 payload: Option<&'a [u8]>,
920}
921
922fn resolve<'a>(
923 url: &str,
924 manifest_label: &str,
925 manifests: &'a [ParsedManifest<'_>],
926) -> Option<Destination<'a>> {
927 let rest = url.strip_prefix("self#jumbf=")?;
928 let (target_manifest, alabel) = match rest.strip_prefix("/c2pa/") {
929 Some(absolute) => absolute.split_once("/c2pa.assertions/")?,
930 None => (manifest_label, rest.strip_prefix("c2pa.assertions/")?),
931 };
932 if alabel.is_empty() || alabel.contains('/') {
933 return None;
934 }
935 let manifest = unique(manifests, |manifest| manifest.label == target_manifest)?;
936 let (_, jumbf) = unique(&manifest.assertion_jumbf, |(label, _)| label == alabel)?;
937 Some(Destination {
938 jumbf,
939 payload: unique(&manifest.assertions, |(label, _)| label == alabel)
940 .map(|(_, payload)| *payload),
941 })
942}
943
944fn unique<T>(items: &[T], mut matches: impl FnMut(&T) -> bool) -> Option<&T> {
947 let mut found = items.iter().filter(|item| matches(item));
948 let first = found.next()?;
949 found.next().is_none().then_some(first)
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use crate::c2pa_cbor::{encode, Profile};
956 use crate::c2pa_core::jumbf::{assertion_box, superbox_content};
957 use sha2::{Digest, Sha256};
958
959 fn enc(value: &Value) -> Vec<u8> {
960 encode(value, Profile::CanonicalForHashedSubstructures).expect("encode")
961 }
962
963 fn vmap(fields: Vec<(&str, Value)>) -> Value {
964 Value::Map(
965 fields
966 .into_iter()
967 .map(|(key, value)| (Value::Text(key.into()), value))
968 .collect(),
969 )
970 }
971
972 fn sha(bytes: &[u8]) -> Vec<u8> {
973 Sha256::digest(bytes).to_vec()
974 }
975
976 const MANIFEST: &str = "urn:c2pa:active";
977
978 struct Fixture {
981 jumbf: Vec<(String, Vec<u8>)>,
982 payloads: Vec<(String, Vec<u8>)>,
983 }
984
985 impl Fixture {
986 fn new(assertions: &[(&str, Value)]) -> Self {
987 let mut jumbf = Vec::new();
988 let mut payloads = Vec::new();
989 for (label, value) in assertions {
990 let cbor = enc(value);
991 let boxed = assertion_box(label, &cbor, None);
992 let content = superbox_content(&boxed).expect("content").to_vec();
993 jumbf.push(((*label).to_string(), content));
994 payloads.push(((*label).to_string(), cbor));
995 }
996 Self { jumbf, payloads }
997 }
998
999 fn manifest(&self) -> ParsedManifest<'_> {
1000 ParsedManifest {
1001 label: MANIFEST.into(),
1002 manifest_jumbf: &[],
1003 assertions: self
1004 .payloads
1005 .iter()
1006 .map(|(label, bytes)| (label.clone(), bytes.as_slice()))
1007 .collect(),
1008 assertion_jumbf: self
1009 .jumbf
1010 .iter()
1011 .map(|(label, bytes)| (label.clone(), bytes.as_slice()))
1012 .collect(),
1013 claim_cbor: None,
1014 signature_cose: None,
1015 claim_count: 1,
1016 claim_box_label: None,
1017 }
1018 }
1019
1020 fn content(&self, label: &str) -> &[u8] {
1021 self.jumbf
1022 .iter()
1023 .find(|(candidate, _)| candidate == label)
1024 .map(|(_, bytes)| bytes.as_slice())
1025 .expect("assertion")
1026 }
1027 }
1028
1029 fn claim_for(labels: &[&str]) -> Value {
1030 vmap(vec![(
1031 "created_assertions",
1032 Value::Array(
1033 labels
1034 .iter()
1035 .map(|label| {
1036 vmap(vec![
1037 (
1038 "url",
1039 Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
1040 ),
1041 ("hash", Value::Bytes(vec![0; 32])),
1042 ])
1043 })
1044 .collect(),
1045 ),
1046 )])
1047 }
1048
1049 fn run(fixture: &Fixture, claim: &Value) -> ValidationResults {
1050 run_as(fixture, claim, ClaimGeneration::V2, EngineProfile::GENEROUS)
1051 }
1052
1053 fn run_as(
1054 fixture: &Fixture,
1055 claim: &Value,
1056 generation: ClaimGeneration,
1057 profile: EngineProfile,
1058 ) -> ValidationResults {
1059 let manifests = [fixture.manifest()];
1060 let refs = ClaimAssertionRefs::build(&manifests[0], claim, generation);
1061 let mut results = ValidationResults::default();
1062 let mut resources = ResourceCache::with_evidence(None, OnlineEvidence::default());
1063 verify_assertion_references(
1064 AssertionReferenceContext {
1065 claim,
1066 claim_refs: &refs,
1067 generation,
1068 profile,
1069 manifest_label: MANIFEST,
1070 manifests: &manifests,
1071 budget: usize::MAX,
1072 },
1073 &mut resources,
1074 &mut results,
1075 );
1076 results
1077 }
1078
1079 fn hashed_uri(label: &str, hash: Vec<u8>) -> Value {
1080 vmap(vec![
1081 (
1082 "url",
1083 Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
1084 ),
1085 ("hash", Value::Bytes(hash)),
1086 ("alg", Value::Text("sha256".into())),
1087 ])
1088 }
1089
1090 #[test]
1095 fn ingredient_thumbnail_reference_digest_is_enforced() {
1096 let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
1097 let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
1098 let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
1099
1100 let good = Fixture::new(&[
1101 ("c2pa.thumbnail.ingredient", thumb.clone()),
1102 (
1103 "c2pa.ingredient.v3",
1104 vmap(vec![
1105 ("relationship", Value::Text("parentOf".into())),
1106 (
1107 "thumbnail",
1108 hashed_uri("c2pa.thumbnail.ingredient", digest.clone()),
1109 ),
1110 ]),
1111 ),
1112 ]);
1113 let results = run(
1114 &good,
1115 &claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
1116 );
1117 assert!(!results.has_failure(HASHED_URI_MISMATCH));
1118 assert!(!results.has_failure(HASHED_URI_MISSING));
1119
1120 let tampered = Fixture::new(&[
1121 ("c2pa.thumbnail.ingredient", thumb),
1122 (
1123 "c2pa.ingredient.v3",
1124 vmap(vec![
1125 ("relationship", Value::Text("parentOf".into())),
1126 (
1127 "thumbnail",
1128 hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
1129 ),
1130 ]),
1131 ),
1132 ]);
1133 let results = run(
1134 &tampered,
1135 &claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
1136 );
1137 assert!(results.has_failure(HASHED_URI_MISMATCH));
1138 }
1139
1140 #[test]
1144 fn unresolvable_reference_reports_missing_and_active_manifest_is_exempt() {
1145 let fixture = Fixture::new(&[(
1146 "c2pa.ingredient.v3",
1147 vmap(vec![
1148 ("relationship", Value::Text("parentOf".into())),
1149 (
1150 "thumbnail",
1151 hashed_uri("c2pa.thumbnail.absent", vec![1; 32]),
1152 ),
1153 (
1154 "activeManifest",
1155 vmap(vec![
1156 ("url", Value::Text("self#jumbf=/c2pa/urn:c2pa:gone".into())),
1157 ("hash", Value::Bytes(vec![2; 32])),
1158 ("alg", Value::Text("sha256".into())),
1159 ]),
1160 ),
1161 ]),
1162 )]);
1163 let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
1164 assert_eq!(
1165 results
1166 .failure
1167 .iter()
1168 .filter(|status| status.code == HASHED_URI_MISSING)
1169 .count(),
1170 1
1171 );
1172 assert!(results
1173 .failure
1174 .iter()
1175 .all(|status| !status.explanation.contains("activeManifest")));
1176 }
1177
1178 #[test]
1181 fn nested_related_action_icon_is_walked() {
1182 let icon = vmap(vec![("data", Value::Bytes(vec![3; 8]))]);
1183 let related_action = vmap(vec![
1184 ("action", Value::Text("c2pa.edited".into())),
1185 (
1186 "softwareAgent",
1187 vmap(vec![
1188 ("name", Value::Text("agent".into())),
1189 ("icon", hashed_uri("c2pa.icon", vec![0xCD; 32])),
1190 ]),
1191 ),
1192 ]);
1193 let actions = vmap(vec![(
1194 "actions",
1195 Value::Array(vec![vmap(vec![
1196 ("action", Value::Text("c2pa.created".into())),
1197 ("related", Value::Array(vec![related_action])),
1198 ])]),
1199 )]);
1200 let fixture = Fixture::new(&[("c2pa.icon", icon), ("c2pa.actions.v2", actions)]);
1201 let results = run(&fixture, &claim_for(&["c2pa.icon", "c2pa.actions.v2"]));
1202 assert!(results.has_failure(HASHED_URI_MISMATCH));
1203 }
1204
1205 #[test]
1209 fn metadata_subtree_is_never_validated() {
1210 let fixture = Fixture::new(&[(
1211 "c2pa.ingredient.v3",
1212 vmap(vec![
1213 ("relationship", Value::Text("parentOf".into())),
1214 (
1215 "metadata",
1216 vmap(vec![("reference", hashed_uri("c2pa.absent", vec![9; 32]))]),
1217 ),
1218 ]),
1219 )]);
1220 let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
1221 assert!(results.failure.is_empty(), "{:?}", results.failure);
1222 }
1223
1224 #[test]
1228 fn external_data_reference_is_left_to_external_validation() {
1229 let external = Fixture::new(&[(
1230 "c2pa.ingredient.v3",
1231 vmap(vec![
1232 ("relationship", Value::Text("inputTo".into())),
1233 (
1234 "data",
1235 vmap(vec![
1236 ("url", Value::Text("https://example.com/data.bin".into())),
1237 ("hash", Value::Bytes(vec![4; 32])),
1238 ("alg", Value::Text("sha256".into())),
1239 ]),
1240 ),
1241 ]),
1242 )]);
1243 let results = run(&external, &claim_for(&["c2pa.ingredient.v3"]));
1244 assert!(results.failure.is_empty(), "{:?}", results.failure);
1245
1246 let internal = Fixture::new(&[
1247 (
1248 "c2pa.embedded-data",
1249 vmap(vec![("v", Value::Bytes(vec![5]))]),
1250 ),
1251 (
1252 "c2pa.ingredient.v3",
1253 vmap(vec![
1254 ("relationship", Value::Text("inputTo".into())),
1255 ("data", hashed_uri("c2pa.embedded-data", vec![6; 32])),
1256 ]),
1257 ),
1258 ]);
1259 let results = run(
1260 &internal,
1261 &claim_for(&["c2pa.embedded-data", "c2pa.ingredient.v3"]),
1262 );
1263 assert!(results.has_failure(HASHED_URI_MISMATCH));
1264 }
1265
1266 #[test]
1270 fn hash_algorithm_is_inherited_then_checked() {
1271 let thumb = vmap(vec![("data", Value::Bytes(vec![8; 4]))]);
1272 let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
1273 let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
1274 let inherited = |alg: Option<&str>, claim_alg: Option<&str>| {
1275 let mut reference = vec![
1276 (
1277 "url",
1278 Value::Text("self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient".into()),
1279 ),
1280 ("hash", Value::Bytes(digest.clone())),
1281 ];
1282 if let Some(alg) = alg {
1283 reference.push(("alg", Value::Text(alg.into())));
1284 }
1285 let fixture = Fixture::new(&[
1286 ("c2pa.thumbnail.ingredient", thumb.clone()),
1287 (
1288 "c2pa.ingredient.v3",
1289 vmap(vec![
1290 ("relationship", Value::Text("parentOf".into())),
1291 ("thumbnail", vmap(reference)),
1292 ]),
1293 ),
1294 ]);
1295 let mut claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
1296 if let (Some(alg), Value::Map(entries)) = (claim_alg, &mut claim) {
1297 entries.push((Value::Text("alg".into()), Value::Text(alg.into())));
1298 }
1299 run(&fixture, &claim)
1300 };
1301
1302 let results = inherited(None, Some("sha256"));
1304 assert!(results.failure.is_empty(), "{:?}", results.failure);
1305 let results = inherited(None, None);
1307 assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
1308 let results = inherited(Some("md5"), Some("sha256"));
1310 assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
1311 assert!(!results.has_failure(HASHED_URI_MISMATCH));
1312 }
1313
1314 #[test]
1317 fn schema_lookup_handles_instances_and_ignores_vendor_assertions() {
1318 assert!(!ref_fields("c2pa.ingredient.v3__2").is_empty());
1319 assert!(ref_fields("c2pa.ingredient.v3__x").is_empty());
1320 assert!(ref_fields("com.example.thing").is_empty());
1321
1322 let fixture = Fixture::new(&[(
1323 "com.example.thing",
1324 vmap(vec![("thumbnail", hashed_uri("c2pa.absent", vec![1; 32]))]),
1325 )]);
1326 let results = run(&fixture, &claim_for(&["com.example.thing"]));
1327 assert!(results.failure.is_empty(), "{:?}", results.failure);
1328 }
1329
1330 #[test]
1338 fn a_v1_claim_is_held_to_the_reference_rule_only_under_strict_conformance() {
1339 let ingredient = vmap(vec![("relationship", Value::Text("parentOf".into()))]);
1340 let actions = vmap(vec![(
1341 "actions",
1342 Value::Array(vec![vmap(vec![
1343 ("action", Value::Text("c2pa.opened".into())),
1344 (
1345 "parameters",
1346 vmap(vec![(
1347 "ingredient",
1348 hashed_uri("c2pa.ingredient", vec![0xEE; 32]),
1349 )]),
1350 ),
1351 ])]),
1352 )]);
1353 let fixture = Fixture::new(&[("c2pa.ingredient", ingredient), ("c2pa.actions", actions)]);
1354 let claim = vmap(vec![(
1355 "assertions",
1356 Value::Array(
1357 ["c2pa.ingredient", "c2pa.actions"]
1358 .iter()
1359 .map(|label| {
1360 vmap(vec![
1361 (
1362 "url",
1363 Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
1364 ),
1365 ("hash", Value::Bytes(vec![0; 32])),
1366 ])
1367 })
1368 .collect(),
1369 ),
1370 )]);
1371
1372 let generous = run_as(
1373 &fixture,
1374 &claim,
1375 ClaimGeneration::V1,
1376 EngineProfile::GENEROUS,
1377 );
1378 assert!(generous.failure.is_empty(), "{:?}", generous.failure);
1379
1380 let strict = run_as(
1381 &fixture,
1382 &claim,
1383 ClaimGeneration::V1,
1384 EngineProfile::CONFORMANCE_V2_2,
1385 );
1386 assert!(strict.has_failure(HASHED_URI_MISMATCH));
1387 }
1388
1389 #[test]
1394 fn a_retargeted_nested_reference_reaches_the_verification_report() {
1395 use super::super::{
1396 verify_manifest, CawgTrustInputs, StoreContext, VerifyInput,
1397 MAX_REPORT_DECODED_VALUE_NODES,
1398 };
1399 use crate::c2pa_core::EngineProfile;
1400 use crate::c2pa_formats::AssetFormat;
1401
1402 let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
1403 let fixture = Fixture::new(&[
1404 ("c2pa.thumbnail.ingredient", thumb),
1405 (
1406 "c2pa.ingredient.v3",
1407 vmap(vec![
1408 ("relationship", Value::Text("parentOf".into())),
1409 (
1410 "thumbnail",
1411 hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
1412 ),
1413 ]),
1414 ),
1415 ]);
1416 let claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
1417 let claim_cbor = enc(&claim);
1418 let mut manifest = fixture.manifest();
1419 manifest.claim_cbor = Some(&claim_cbor);
1420 manifest.claim_box_label = Some("c2pa.claim.v2".into());
1421 let manifests = [manifest];
1422 let manifest_hashes = std::collections::HashMap::new();
1423 let input = VerifyInput {
1424 data: &[],
1425 mime: "application/c2pa",
1426 claim_signer_trust: None,
1427 tsa_trust: None,
1428 allowed_certs: None,
1429 validation_time: None,
1430 profile: EngineProfile::GENEROUS,
1431 evidence: Default::default(),
1432 cawg_strict_encoding: false,
1433 };
1434 let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
1435 let out = verify_manifest(
1436 &manifests[0],
1437 StoreContext {
1438 manifests: &manifests,
1439 manifest_hashes: &manifest_hashes,
1440 },
1441 &input,
1442 AssetFormat::C2paStore,
1443 &[],
1444 None,
1445 CawgTrustInputs::default(),
1446 &mut report_decode_nodes,
1447 );
1448 assert!(
1449 out.results.has_failure(HASHED_URI_MISMATCH),
1450 "{:?}",
1451 out.results.failure
1452 );
1453 }
1454
1455 #[test]
1456 fn resource_cache_borrows_and_memoizes_provider_payloads() {
1457 use std::cell::Cell;
1458
1459 struct Provider {
1460 calls: Cell<usize>,
1461 bytes: [u8; 4],
1462 }
1463
1464 impl ExternalResources for Provider {
1465 fn resolve(&self, url: &str) -> ExternalResource<'_> {
1466 assert_eq!(url, "https://example.test/icon");
1467 self.calls.set(self.calls.get() + 1);
1468 ExternalResource::Retrieved {
1469 bytes: &self.bytes,
1470 media_type: Some("image/png"),
1471 }
1472 }
1473 }
1474
1475 let provider = Provider {
1476 calls: Cell::new(0),
1477 bytes: [1, 2, 3, 4],
1478 };
1479 let mut cache = ResourceCache::with_evidence(Some(&provider), OnlineEvidence::default());
1480 for _ in 0..2 {
1481 let ResolvedResource::Retrieved { bytes, media_type } =
1482 cache.resolve("https://example.test/icon")
1483 else {
1484 panic!("provider payload was not returned");
1485 };
1486 assert_eq!(bytes.as_ptr(), provider.bytes.as_ptr());
1487 assert_eq!(media_type, Some("image/png"));
1488 }
1489 assert_eq!(provider.calls.get(), 1);
1490 }
1491
1492 #[test]
1493 fn resource_cache_gives_online_evidence_precedence_over_the_provider() {
1494 use std::cell::Cell;
1495
1496 struct Provider {
1497 calls: Cell<usize>,
1498 }
1499
1500 impl ExternalResources for Provider {
1501 fn resolve(&self, _: &str) -> ExternalResource<'_> {
1502 self.calls.set(self.calls.get() + 1);
1503 ExternalResource::Retrieved {
1504 bytes: b"provider",
1505 media_type: None,
1506 }
1507 }
1508 }
1509
1510 const URL: &str = "https://example.test/evidence";
1511 let provider = Provider {
1512 calls: Cell::new(0),
1513 };
1514 let bodies = HashMap::from([(URL.to_string(), b"evidence".to_vec())]);
1515 let media_types = HashMap::from([(URL.to_string(), "text/plain".to_string())]);
1516 let mut cache = ResourceCache::with_evidence(
1517 Some(&provider),
1518 OnlineEvidence {
1519 external_data: Some(&bodies),
1520 external_data_media_types: Some(&media_types),
1521 ..OnlineEvidence::default()
1522 },
1523 );
1524 let ResolvedResource::Retrieved { bytes, media_type } = cache.resolve(URL) else {
1525 panic!("online evidence was not returned");
1526 };
1527 assert_eq!(bytes, b"evidence");
1528 assert_eq!(media_type, Some("text/plain"));
1529 assert_eq!(provider.calls.get(), 0);
1530
1531 let unreachable = [URL.to_string()];
1532 let mut cache = ResourceCache::with_evidence(
1533 Some(&provider),
1534 OnlineEvidence {
1535 external_data_unreachable: Some(&unreachable),
1536 ..OnlineEvidence::default()
1537 },
1538 );
1539 assert!(matches!(cache.resolve(URL), ResolvedResource::Unavailable));
1540 assert_eq!(provider.calls.get(), 0);
1541 }
1542}