use super::{
ValidationResults, ASSERTION_ACTION_REDACTED, ASSERTION_HARD_BINDING_REDACTED,
ASSERTION_NOT_REDACTED, ASSERTION_SELF_REDACTED, CLAIM_MALFORMED,
};
use crate::c2pa_cbor::Value;
use crate::c2pa_core::jumbf::ParsedManifest;
#[derive(Default)]
pub(super) struct Index {
targets: std::collections::HashMap<
String,
std::collections::BTreeMap<String, std::collections::HashSet<String>>,
>,
nonzero: std::collections::HashMap<String, std::collections::HashSet<String>>,
}
impl Index {
pub(super) fn add_claim(
&mut self,
label: &str,
claim: &Value,
manifests: &[ParsedManifest<'_>],
) {
let Some(Value::Array(entries)) = claim.get("redacted_assertions") else {
return;
};
for target in entries
.iter()
.take(MAX_REDACTED_ASSERTIONS)
.filter_map(entry_url)
.filter_map(|url| match read_entry(url, label) {
Entry::Absolute(target) => Some(target),
_ => None,
})
{
self.targets
.entry(target.manifest.to_string())
.or_default()
.entry(target.assertion.to_string())
.or_default()
.insert(label.to_string());
if manifests
.iter()
.find(|manifest| manifest.label == target.manifest)
.and_then(|manifest| manifest.assertion_content_is_zeroed(target.assertion))
== Some(false)
{
self.nonzero
.entry(target.manifest.to_string())
.or_default()
.insert(target.assertion.to_string());
}
}
}
pub(super) fn is_empty(&self) -> bool {
self.targets.is_empty()
}
pub(super) fn names_manifest(&self, manifest: &str) -> bool {
self.targets.contains_key(manifest)
}
pub(super) fn contains(&self, manifest: &str, assertion: &str) -> bool {
self.targets
.get(manifest)
.is_some_and(|targets| targets.contains_key(assertion))
}
pub(super) fn declared_by(&self, declarant: &str, url: &str) -> bool {
let Entry::Absolute(target) = read_entry(url, declarant) else {
return false;
};
self.targets
.get(target.manifest)
.and_then(|targets| targets.get(target.assertion))
.is_some_and(|declarants| declarants.contains(declarant))
}
pub(super) fn verify_target(
&self,
manifest: &str,
assertion: &str,
url: &str,
results: &mut ValidationResults,
) {
if let Some(code) = non_redactable(assertion) {
results.push_failure(
code,
url.to_string(),
format!("assertion '{assertion}' may not be redacted"),
);
}
if self
.nonzero
.get(manifest)
.is_some_and(|targets| targets.contains(assertion))
{
results.push_failure(
ASSERTION_NOT_REDACTED,
url.to_string(),
"assertion declared redacted still contains non-zero content".into(),
);
}
}
pub(super) fn verify_manifest(&self, manifest: &str, results: &mut ValidationResults) {
if let Some(targets) = self.targets.get(manifest) {
for assertion in targets.keys() {
let url = format!("self#jumbf=/c2pa/{manifest}/c2pa.assertions/{assertion}");
self.verify_target(manifest, assertion, &url, results);
}
}
}
}
pub(super) const MAX_REDACTED_ASSERTIONS: usize = 4_096;
struct Target<'a> {
manifest: &'a str,
assertion: &'a str,
}
enum Entry<'a> {
Absolute(Target<'a>),
SelfScoped,
Malformed,
}
fn read_entry<'a>(url: &'a str, declaring_label: &str) -> Entry<'a> {
let Some(rest) = url.strip_prefix("self#jumbf=") else {
return Entry::Malformed;
};
let Some(absolute) = rest.strip_prefix("/c2pa/") else {
return match rest.strip_prefix("c2pa.assertions/") {
Some(assertion) if !assertion.is_empty() && !assertion.contains('/') => {
Entry::SelfScoped
}
_ => Entry::Malformed,
};
};
let Some((manifest, assertion)) = absolute.split_once("/c2pa.assertions/") else {
return Entry::Malformed;
};
if manifest.is_empty() || assertion.is_empty() || assertion.contains('/') {
return Entry::Malformed;
}
if manifest == declaring_label {
return Entry::SelfScoped;
}
Entry::Absolute(Target {
manifest,
assertion,
})
}
fn non_redactable(alabel: &str) -> Option<&'static str> {
let base = super::refs::base_label(alabel);
if base == "c2pa.actions" || base == "c2pa.actions.v2" {
return Some(ASSERTION_ACTION_REDACTED);
}
if base.starts_with("c2pa.hash.") {
return Some(ASSERTION_HARD_BINDING_REDACTED);
}
None
}
fn entry_url(entry: &Value) -> Option<&str> {
entry
.as_text()
.or_else(|| entry.get("url").and_then(Value::as_text))
}
pub(super) fn verify_claim_redactions(
declaring_label: &str,
claim: &Value,
manifests: &[ParsedManifest<'_>],
results: &mut ValidationResults,
) {
let Some(Value::Array(entries)) = claim.get("redacted_assertions") else {
return;
};
let claim_url = format!("self#jumbf=/c2pa/{declaring_label}/c2pa.claim.v2");
if entries.len() > MAX_REDACTED_ASSERTIONS {
results.push_failure(
CLAIM_MALFORMED,
claim_url,
format!("redacted_assertions count exceeds verifier bound ({MAX_REDACTED_ASSERTIONS})"),
);
return;
}
for entry in entries {
let Some(url) = entry_url(entry) else {
results.push_failure(
CLAIM_MALFORMED,
claim_url.clone(),
"redacted_assertions entry is not a JUMBF URI".into(),
);
continue;
};
let target = match read_entry(url, declaring_label) {
Entry::Absolute(target) => target,
Entry::SelfScoped => {
results.push_failure(
ASSERTION_SELF_REDACTED,
claim_url.clone(),
format!("claim redacts an assertion of its own manifest: '{url}'"),
);
continue;
}
Entry::Malformed => {
results.push_failure(
CLAIM_MALFORMED,
claim_url.clone(),
format!(
"redacted_assertions entry '{url}' is not an absolute JUMBF assertion URI"
),
);
continue;
}
};
if manifests
.iter()
.find(|manifest| manifest.label == target.manifest)
.and_then(|manifest| manifest.assertion_content_is_zeroed(target.assertion))
== Some(false)
{
results.push_failure(
ASSERTION_NOT_REDACTED,
url.to_string(),
"assertion declared redacted still contains non-zero content".into(),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::c2pa_cbor::{encode, Profile};
use crate::c2pa_core::jumbf::{assertion_box, redacted_assertion_box, superbox_content};
const PARENT: &str = "urn:c2pa:parent";
const CHILD: &str = "urn:c2pa:child";
fn vmap(fields: Vec<(&str, Value)>) -> Value {
Value::Map(
fields
.into_iter()
.map(|(key, value)| (Value::Text(key.into()), value))
.collect(),
)
}
fn claim_redacting(urls: &[String]) -> Value {
vmap(vec![(
"redacted_assertions",
Value::Array(urls.iter().map(|url| Value::Text(url.clone())).collect()),
)])
}
fn uri(manifest: &str, assertion: &str) -> String {
format!("self#jumbf=/c2pa/{manifest}/c2pa.assertions/{assertion}")
}
struct Store {
boxes: Vec<(String, Vec<u8>)>,
}
impl Store {
fn new(entries: Vec<(String, Vec<u8>)>) -> Self {
Self {
boxes: entries
.into_iter()
.map(|(label, raw)| {
(
label,
superbox_content(&raw).expect("superbox content").to_vec(),
)
})
.collect(),
}
}
fn child(&self) -> ParsedManifest<'_> {
ParsedManifest {
label: CHILD.into(),
manifest_jumbf: &[],
assertions: Vec::new(),
assertion_jumbf: self
.boxes
.iter()
.map(|(label, bytes)| (label.clone(), bytes.as_slice()))
.collect(),
claim_cbor: None,
signature_cose: None,
claim_count: 1,
claim_box_label: None,
}
}
}
fn intact(label: &str) -> (String, Vec<u8>) {
let cbor = encode(
&vmap(vec![("v", Value::Bytes(vec![1, 2, 3]))]),
Profile::CanonicalForHashedSubstructures,
)
.expect("encode");
(label.to_string(), assertion_box(label, &cbor, None))
}
fn emptied(label: &str) -> (String, Vec<u8>) {
(label.to_string(), redacted_assertion_box(label, 32))
}
fn run(claim: &Value, store: &Store) -> ValidationResults {
let manifests = [store.child()];
let mut results = ValidationResults::default();
verify_claim_redactions(PARENT, claim, &manifests, &mut results);
let mut index = Index::default();
index.add_claim(PARENT, claim, &manifests);
if let Some(targets) = index.targets.get(CHILD) {
for label in targets.keys() {
index.verify_target(CHILD, label, &uri(CHILD, label), &mut results);
}
}
results
}
#[test]
fn self_redaction_is_rejected() {
let store = Store::new(vec![intact("c2pa.metadata")]);
for url in [
uri(PARENT, "c2pa.metadata"),
"self#jumbf=c2pa.assertions/c2pa.metadata".to_string(),
] {
let results = run(&claim_redacting(&[url.clone()]), &store);
assert!(
results.has_failure(ASSERTION_SELF_REDACTED),
"{url}: {:?}",
results.failure
);
}
}
#[test]
fn actions_and_hard_bindings_may_not_be_redacted() {
let store = Store::new(vec![]);
for (label, code) in [
("c2pa.actions", ASSERTION_ACTION_REDACTED),
("c2pa.actions.v2__1", ASSERTION_ACTION_REDACTED),
("c2pa.hash.data", ASSERTION_HARD_BINDING_REDACTED),
("c2pa.hash.bmff.v3", ASSERTION_HARD_BINDING_REDACTED),
] {
let results = run(&claim_redacting(&[uri(CHILD, label)]), &store);
assert!(results.has_failure(code), "{label}: {:?}", results.failure);
}
let results = run(&claim_redacting(&[uri(CHILD, "c2pa.metadata")]), &store);
assert!(results.failure.is_empty(), "{:?}", results.failure);
}
#[test]
fn declared_redaction_must_have_happened() {
let still_there = Store::new(vec![intact("c2pa.metadata")]);
let results = run(
&claim_redacting(&[uri(CHILD, "c2pa.metadata")]),
&still_there,
);
assert!(results.has_failure(ASSERTION_NOT_REDACTED));
let zeroed = Store::new(vec![emptied("c2pa.metadata")]);
let results = run(&claim_redacting(&[uri(CHILD, "c2pa.metadata")]), &zeroed);
assert!(results.failure.is_empty(), "{:?}", results.failure);
}
#[test]
fn malformed_entries_are_rejected() {
let store = Store::new(vec![]);
for url in [
"https://example.com/assertion".to_string(),
format!("self#jumbf=/c2pa/{CHILD}"),
format!("self#jumbf=/c2pa/{CHILD}/c2pa.assertions/"),
format!("self#jumbf=/c2pa/{CHILD}/c2pa.signature"),
] {
let results = run(&claim_redacting(&[url.clone()]), &store);
assert!(
results.has_failure(CLAIM_MALFORMED),
"{url}: {:?}",
results.failure
);
}
}
#[test]
fn indexed_redactions_select_the_ingredient_method() {
for label in ["c2pa.metadata", "c2pa.actions", "c2pa.hash.data"] {
let mut index = Index::default();
index.add_claim(PARENT, &claim_redacting(&[uri(CHILD, label)]), &[]);
assert!(index.names_manifest(CHILD));
assert!(index.declared_by(PARENT, &uri(CHILD, label)));
}
let mut index = Index::default();
index.add_claim(CHILD, &claim_redacting(&[uri(CHILD, "c2pa.metadata")]), &[]);
assert!(!index.names_manifest(CHILD));
}
#[test]
fn a_self_redaction_reaches_the_verification_report() {
use super::super::{
verify_manifest, CawgTrustInputs, StoreContext, VerifyInput,
MAX_REPORT_DECODED_VALUE_NODES,
};
use crate::c2pa_core::EngineProfile;
use crate::c2pa_formats::AssetFormat;
let claim = claim_redacting(&[uri(PARENT, "c2pa.metadata")]);
let claim_cbor =
encode(&claim, Profile::CanonicalForHashedSubstructures).expect("encode claim");
let manifest = ParsedManifest {
label: PARENT.into(),
manifest_jumbf: &[],
assertions: Vec::new(),
assertion_jumbf: Vec::new(),
claim_cbor: Some(&claim_cbor),
signature_cose: None,
claim_count: 1,
claim_box_label: Some("c2pa.claim.v2".into()),
};
let manifests = [manifest];
let manifest_hashes = std::collections::HashMap::new();
let input = VerifyInput {
data: &[],
mime: "application/c2pa",
claim_signer_trust: None,
tsa_trust: None,
allowed_certs: None,
validation_time: None,
profile: EngineProfile::GENEROUS,
evidence: Default::default(),
cawg_strict_encoding: false,
};
let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
let out = verify_manifest(
&manifests[0],
StoreContext {
manifests: &manifests,
manifest_hashes: &manifest_hashes,
},
&input,
AssetFormat::C2paStore,
&[],
None,
CawgTrustInputs::default(),
&mut report_decode_nodes,
);
assert!(
out.results.has_failure(ASSERTION_SELF_REDACTED),
"{:?}",
out.results.failure
);
}
}