use serde::{Deserialize, Serialize};
use crate::{
assertion::{Assertion, AssertionBase, AssertionCbor},
assertions::{assertion_metadata::AssetType, labels, AssertionMetadata},
error::{Error, Result},
};
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct HashedExtUri {
pub url: String,
pub alg: String,
#[serde(with = "serde_bytes")]
pub hash: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_types: Option<Vec<AssetType>>,
}
impl HashedExtUri {
pub fn new(url: impl Into<String>, alg: impl Into<String>, hash: Vec<u8>) -> Self {
Self {
url: url.into(),
alg: alg.into(),
hash,
data_types: None,
}
}
pub fn set_data_types(mut self, data_types: Vec<AssetType>) -> Self {
self.data_types = Some(data_types);
self
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq)]
pub struct CloudData {
pub label: String,
pub size: u64,
pub location: HashedExtUri,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<AssertionMetadata>,
}
impl CloudData {
pub const LABEL: &'static str = labels::CLOUD_DATA;
pub fn new(label: impl Into<String>, size: u64, location: HashedExtUri) -> Self {
Self {
label: label.into(),
size,
location,
content_type: None,
metadata: None,
}
}
pub fn set_content_type(mut self, content_type: impl Into<String>) -> Self {
self.content_type = Some(content_type.into());
self
}
pub fn set_metadata(mut self, metadata: AssertionMetadata) -> Self {
self.metadata = Some(metadata);
self
}
pub(crate) fn is_hard_binding(&self) -> bool {
labels::is_hard_binding_label(&self.label)
}
pub(crate) fn is_actions(&self) -> bool {
labels::base(&self.label) == labels::ACTIONS
}
#[allow(unused)]
pub(crate) fn validate(&self) -> Result<()> {
if self.label.trim().is_empty() {
return Err(Error::ValidationRule(
"cloud data assertion label must not be empty".to_owned(),
));
}
if self.size < 1 {
return Err(Error::ValidationRule(
"cloud data assertion size must be >= 1".to_owned(),
));
}
if self.location.url.trim().is_empty() {
return Err(Error::ValidationRule(
"cloud data assertion location.url must not be empty".to_owned(),
));
}
if self.location.alg.trim().is_empty() {
return Err(Error::ValidationRule(
"cloud data assertion location.alg must not be empty".to_owned(),
));
}
if self.location.hash.is_empty() {
return Err(Error::ValidationRule(
"cloud data assertion location.hash must not be empty".to_owned(),
));
}
if self.is_forbidden().is_err() {
return Err(Error::ValidationRule(format!(
"cloud data assertion must not reference forbidden label '{}', per C2PA 2.4",
self.label
)));
}
if self.is_hard_binding() {
return Err(Error::ValidationRule(format!(
"cloud data assertion must not reference hard binding assertions '{}', per C2PA 2.4",
self.label
)));
}
Ok(())
}
pub fn is_forbidden(&self) -> Result<()> {
const FORBIDDEN: [&str; 7] = [
"c2pa.action",
"c2pa.actions",
"c2pa.actions.v2",
labels::CLOUD_DATA,
"c2pa.ingredient",
"c2pa.ingredient.v2",
"c2pa.ingredient.v3",
];
if FORBIDDEN.contains(&self.label.as_str()) {
return Err(Error::ValidationRule(format!(
"cloud data assertion must not reference forbidden label '{}', per C2PA 2.4",
self.label
)));
}
Ok(())
}
}
impl AssertionCbor for CloudData {}
impl AssertionBase for CloudData {
const LABEL: &'static str = Self::LABEL;
fn to_assertion(&self) -> Result<Assertion> {
Self::to_cbor_assertion(self)
}
fn from_assertion(assertion: &Assertion) -> Result<Self> {
Self::from_cbor_assertion(assertion)
}
}
#[cfg(test)]
pub mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
use super::*;
use crate::assertions::labels;
fn make_location() -> HashedExtUri {
HashedExtUri::new(
"https://example.com/assertion-data",
"sha256",
vec![0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe],
)
}
#[test]
fn test_round_trip_minimal() {
let original = CloudData::new("c2pa.metadata", 1024, make_location());
let assertion = original.to_assertion().expect("to_assertion");
assert_eq!(assertion.mime_type(), "application/cbor");
assert_eq!(assertion.label(), CloudData::LABEL);
let result = CloudData::from_assertion(&assertion).expect("from_assertion");
assert_eq!(result, original);
}
#[test]
fn test_round_trip_all_fields() {
let original = CloudData::new(
"c2pa.metadata",
4321,
HashedExtUri::new(
"https://storage.example.com/metadata.cbor",
"sha384",
vec![0xde, 0xad, 0xbe, 0xef],
),
)
.set_content_type("application/jumbf");
let assertion = original.to_assertion().expect("to_assertion");
let result = CloudData::from_assertion(&assertion).expect("from_assertion");
assert_eq!(result.label, original.label);
assert_eq!(result.size, original.size);
assert_eq!(result.location, original.location);
assert_eq!(result.content_type, original.content_type);
}
#[test]
fn test_is_hard_binding() {
let make = |label: &str| CloudData::new(label, 1, make_location());
assert!(make(labels::DATA_HASH).is_hard_binding());
assert!(make(labels::BMFF_HASH).is_hard_binding());
assert!(make("c2pa.hash.bmff.v2").is_hard_binding());
assert!(make("c2pa.hash.bmff.v3").is_hard_binding());
assert!(make(labels::BOX_HASH).is_hard_binding());
assert!(make(labels::COLLECTION_HASH).is_hard_binding());
assert!(make(labels::MULTI_ASSET_HASH).is_hard_binding());
assert!(!make(labels::METADATA).is_hard_binding());
}
#[test]
fn test_is_actions() {
let make = |label: &str| CloudData::new(label, 1, make_location());
assert!(make(labels::ACTIONS).is_actions());
assert!(make("c2pa.actions.v2").is_actions());
assert!(!make("c2pa.metadata").is_actions());
}
#[test]
fn test_validate_rejects_invalid_structure() {
assert!(CloudData::new("", 1, make_location()).validate().is_err());
assert!(CloudData::new("c2pa.metadata", 0, make_location())
.validate()
.is_err());
assert!(CloudData::new("c2pa.hash.data", 1, make_location())
.validate()
.is_err());
assert!(CloudData::new("c2pa.metadata", 1, make_location())
.validate()
.is_ok());
}
}