trazaeo 0.5.6

Open-source provenance SDK and specification for verifiable EO and climate data workflows
Documentation
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")));
    }
}