use sha2::{Digest, Sha256};
pub const C2PA_HASH_ALG_SHA256: &str = "sha256";
pub const C2PA_HASH_ALG_SHA384: &str = "sha384";
pub const C2PA_HASH_ALG_SHA512: &str = "sha512";
pub const C2PA_RELATIONSHIP_INPUT_TO: &str = "inputTo";
pub const C2PA_ACTION_TRANSFORMED: &str = "trazaeo.transformed";
pub const C2PA_ACTION_PUBLISHED: &str = "trazaeo.published";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct C2paHardBinding {
pub alg: String,
pub hash: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct C2paIngredient {
pub relationship: String,
pub artifact_id: Option<String>,
pub artifact_ref: Option<String>,
pub manifest_ref: Option<String>,
#[serde(default)]
pub content_hash_alg: Option<String>,
#[serde(default)]
pub content_hash: Option<String>,
pub media_type: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct C2paAction {
pub action: String,
pub when: String,
pub software_agent: String,
pub parameters_ref: Option<String>,
pub parameters_hash: Option<String>,
pub description: Option<String>,
}
pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
pub fn sha256_hard_binding(data: &[u8]) -> C2paHardBinding {
C2paHardBinding {
alg: C2PA_HASH_ALG_SHA256.to_string(),
hash: sha256_hex(data),
}
}
fn is_hex(value: &str) -> bool {
value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn expected_hash_len(alg: &str) -> Option<usize> {
match alg {
C2PA_HASH_ALG_SHA256 => Some(64),
C2PA_HASH_ALG_SHA384 => Some(96),
C2PA_HASH_ALG_SHA512 => Some(128),
_ => None,
}
}
fn validate_hash_alg_value(errors: &mut Vec<String>, field_name: &str, alg: &str, hash: &str) {
let Some(expected_len) = expected_hash_len(alg) else {
errors.push(format!(
"{field_name}.content_hash_alg must be one of: sha256, sha384, sha512"
));
return;
};
if hash.len() != expected_len || !is_hex(hash) {
errors.push(format!(
"{field_name}.content_hash must be hex with length {expected_len}"
));
}
}
pub fn validate_c2pa_hard_bindings(
errors: &mut Vec<String>,
field_name: &str,
bindings: &[C2paHardBinding],
) {
for (idx, binding) in bindings.iter().enumerate() {
let prefix = format!("{field_name}[{idx}]");
let Some(expected_len) = expected_hash_len(binding.alg.as_str()) else {
errors.push(format!(
"{prefix}.alg must be one of: sha256, sha384, sha512"
));
continue;
};
if binding.hash.len() != expected_len || !is_hex(&binding.hash) {
errors.push(format!(
"{prefix}.hash must be hex with length {expected_len}"
));
}
}
}
pub fn validate_c2pa_ingredients(
errors: &mut Vec<String>,
field_name: &str,
ingredients: &[C2paIngredient],
) {
for (idx, ingredient) in ingredients.iter().enumerate() {
let prefix = format!("{field_name}[{idx}]");
if ingredient.relationship.trim().is_empty() {
errors.push(format!("{prefix}.relationship must not be empty"));
}
if ingredient.artifact_id.as_deref().is_none_or(str::is_empty)
&& ingredient.artifact_ref.as_deref().is_none_or(str::is_empty)
&& ingredient.manifest_ref.as_deref().is_none_or(str::is_empty)
{
errors.push(format!(
"{prefix} must identify an artifact_id, artifact_ref, or manifest_ref"
));
}
match (
ingredient.content_hash_alg.as_deref(),
ingredient.content_hash.as_deref(),
) {
(Some(alg), Some(hash)) if !alg.trim().is_empty() && !hash.trim().is_empty() => {
validate_hash_alg_value(errors, &prefix, alg, hash);
}
(None, None) => {}
(Some(alg), None) if alg.trim().is_empty() => {}
(None, Some(hash)) if hash.trim().is_empty() => {}
_ => errors.push(format!(
"{prefix}.content_hash_alg and {prefix}.content_hash must be set together"
)),
}
}
}
pub fn validate_c2pa_actions(errors: &mut Vec<String>, field_name: &str, actions: &[C2paAction]) {
for (idx, action) in actions.iter().enumerate() {
let prefix = format!("{field_name}[{idx}]");
if action.action.trim().is_empty() {
errors.push(format!("{prefix}.action must not be empty"));
}
if action.when.trim().is_empty() {
errors.push(format!("{prefix}.when must not be empty"));
}
if action.software_agent.trim().is_empty() {
errors.push(format!("{prefix}.software_agent must not be empty"));
}
}
}
pub fn input_ref_ingredient(input_ref: &str) -> C2paIngredient {
C2paIngredient {
relationship: C2PA_RELATIONSHIP_INPUT_TO.to_string(),
artifact_id: None,
artifact_ref: Some(input_ref.to_string()),
manifest_ref: None,
content_hash_alg: None,
content_hash: None,
media_type: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_sha256_hard_binding() {
let mut errors = Vec::new();
validate_c2pa_hard_bindings(
&mut errors,
"bindings",
&[C2paHardBinding {
alg: C2PA_HASH_ALG_SHA256.to_string(),
hash: "ab".repeat(32),
}],
);
assert!(errors.is_empty());
}
#[test]
fn rejects_content_hash_without_hash_alg() {
let mut errors = Vec::new();
validate_c2pa_ingredients(
&mut errors,
"ingredients",
&[C2paIngredient {
relationship: C2PA_RELATIONSHIP_INPUT_TO.to_string(),
artifact_id: Some("artifact-1".to_string()),
artifact_ref: None,
manifest_ref: None,
content_hash_alg: None,
content_hash: Some("ab".repeat(32)),
media_type: None,
}],
);
assert!(errors
.iter()
.any(|error| error.contains("content_hash_alg")));
}
}