use std::collections::HashMap;
use c2pa_cbor::Value;
use serde::{Deserialize, Serialize};
use super::labels;
use crate::{
assertion::{Assertion, AssertionBase, AssertionCbor},
assertions::region_of_interest::RegionOfInterest,
cbor_types::{DateT, UriT},
Result,
};
#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct SoftBinding {
#[serde(skip_serializing_if = "Option::is_none")]
pub alg: Option<String>,
pub blocks: Vec<SoftBindingBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "alg-params", skip_serializing_if = "Option::is_none")]
pub alg_params: Option<String>,
#[serde(default, with = "serde_bytes")]
pad: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pad2: Option<serde_bytes::ByteBuf>,
#[serde(rename = "bindingMetadata", skip_serializing_if = "Option::is_none")]
pub binding_metadata: Option<SoftBindingMetadata>,
#[serde(skip_serializing)]
url: Option<UriT>,
}
#[allow(unused)]
impl SoftBinding {
#[deprecated(
since = "0.59.0",
note = "Use the asset reference assertion instead. Will be removed in 0.92.0 (scheduled for mid-November 2026)."
)]
pub fn url(&self) -> Option<&UriT> {
self.url.as_ref()
}
pub fn pad(&self) -> &[u8] {
&self.pad
}
pub fn pad2(&self) -> Option<&[u8]> {
self.pad2.as_ref().map(|bytes| bytes.as_slice())
}
}
pub(crate) fn soft_binding_bytes_to_bstr(value: &mut Value) {
fn array_to_bytes(v: &mut Value) {
if let Value::Text(t) = v {
if let Some(bytes) = crate::crypto::base64::decode_b64_wrapped(t) {
*v = Value::Bytes(bytes);
}
return;
}
if let Value::Array(items) = v {
let bytes: Option<Vec<u8>> = items
.iter()
.map(|i| match i {
Value::Integer(n) => u8::try_from(*n).ok(),
_ => None,
})
.collect();
if let Some(bytes) = bytes {
*v = Value::Bytes(bytes);
}
}
}
let Value::Map(map) = value else {
return;
};
for key in ["pad", "pad2"] {
if let Some(v) = map.get_mut(&Value::Text(key.to_owned())) {
array_to_bytes(v);
}
}
if let Some(Value::Array(blocks)) = map.get_mut(&Value::Text("blocks".to_owned())) {
for block in blocks {
if let Value::Map(block) = block {
if let Some(v) = block.get_mut(&Value::Text("value".to_owned())) {
array_to_bytes(v);
}
}
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SoftBindingBlock {
pub scope: SoftBindingScope,
#[serde(default, with = "serde_bytes")]
pub value: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct SoftBindingScope {
#[serde(skip_serializing_if = "Option::is_none")]
pub timespan: Option<SoftBindingTimespan>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<RegionOfInterest>,
#[serde(skip_serializing)]
#[serde(default, with = "serde_bytes")]
extent: Option<serde_bytes::ByteBuf>,
}
#[allow(unused)]
impl SoftBindingScope {
#[deprecated(
since = "0.59.0",
note = "Use the `region` field instead. Will be removed in 0.92.0 (scheduled for mid-November 2026)."
)]
pub fn extent(&self) -> Option<&[u8]> {
self.extent.as_ref().map(|b| b.as_slice())
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SoftBindingTimespan {
pub start: u64,
pub end: u64,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SoftBindingMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
#[serde(rename = "informationalUrl", skip_serializing_if = "Option::is_none")]
pub informational_url: Option<String>,
#[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
pub additional_fields: HashMap<String, Value>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(transparent)]
#[allow(unused)]
struct SoftBindingList(pub Vec<SoftBindingAlgorithm>);
#[allow(unused)]
impl SoftBindingList {
pub fn from_json_str(json: &str) -> Result<Self> {
let list: Self = serde_json::from_str(json)?;
list.validate()?;
Ok(list)
}
fn validate(&self) -> Result<()> {
for algorithm in &self.0 {
algorithm.validate()?;
}
Ok(())
}
pub fn algorithm_strings(&self) -> Vec<String> {
self.0.iter().map(|alg| alg.alg.clone()).collect()
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[allow(unused)]
struct SoftBindingAlgorithm {
pub identifier: u16,
#[serde(default)]
pub deprecated: bool,
pub alg: String,
#[serde(rename = "type")]
pub alg_type: SoftBindingAlgorithmType,
#[serde(rename = "decodedMediaTypes", skip_serializing_if = "Option::is_none")]
pub decoded_media_types: Option<Vec<SoftBindingMediaType>>,
#[serde(rename = "encodedMediaTypes", skip_serializing_if = "Option::is_none")]
pub encoded_media_types: Option<Vec<String>>,
#[serde(rename = "entryMetadata")]
pub entry_metadata: SoftBindingEntryMetadata,
#[serde(
rename = "softBindingResolutionApis",
skip_serializing_if = "Option::is_none"
)]
pub soft_binding_resolution_apis: Option<Vec<UriT>>,
}
#[allow(unused)]
impl SoftBindingAlgorithm {
fn validate(&self) -> Result<()> {
if self
.decoded_media_types
.as_ref()
.map(Vec::is_empty)
.unwrap_or(false)
{
return Err(crate::error::Error::ValidationRule(
"decodedMediaTypes must be a non-empty array when present".to_owned(),
));
}
if self
.encoded_media_types
.as_ref()
.map(Vec::is_empty)
.unwrap_or(false)
{
return Err(crate::error::Error::ValidationRule(
"encodedMediaTypes must be a non-empty array when present".to_owned(),
));
}
if self.decoded_media_types.is_none() && self.encoded_media_types.is_none() {
return Err(crate::error::Error::ValidationRule(
"soft binding algorithm entry must include decodedMediaTypes or encodedMediaTypes"
.to_owned(),
));
}
if let Some(apis) = &self.soft_binding_resolution_apis {
if apis.is_empty() {
return Err(crate::error::Error::ValidationRule(
"softBindingResolutionApis must be a non-empty array when present".to_owned(),
));
}
for api in apis {
url::Url::parse(api.as_ref()).map_err(|_| {
crate::error::Error::ValidationRule(format!(
"softBindingResolutionApis contains invalid URI: {}",
api.as_ref()
))
})?;
}
}
self.entry_metadata.validate()
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[allow(unused)]
struct SoftBindingEntryMetadata {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories: Option<Vec<String>>,
#[serde(rename = "dateEntered")]
pub date_entered: DateT,
pub contact: String,
#[serde(rename = "informationalUrl")]
pub informational_url: UriT,
}
impl SoftBindingEntryMetadata {
fn validate(&self) -> Result<()> {
url::Url::parse(self.informational_url.as_ref()).map_err(|_| {
crate::error::Error::ValidationRule(format!(
"entryMetadata.informationalUrl is not a valid URI: {}",
self.informational_url.as_ref()
))
})?;
Ok(())
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase")]
#[allow(unused)]
enum SoftBindingAlgorithmType {
Watermark,
Fingerprint,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase")]
#[allow(unused)]
enum SoftBindingMediaType {
Application,
Audio,
Image,
Model,
Text,
Video,
}
impl SoftBinding {
pub const LABEL: &'static str = labels::SOFT_BINDING;
}
impl AssertionBase for SoftBinding {
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)
}
}
impl AssertionCbor for SoftBinding {}
#[cfg(test)]
pub mod tests {
#![allow(clippy::panic)]
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_json_round_trip() {
let json = serde_json::json!({
"alg": "phash",
"pad": [0],
"url": "http://example.c2pa.org/media.mp4",
"blocks": [
{
"scope": {
"timespan": {
"end": 133016,
"start": 0,
}
},
"value": "dmFsdWUxCg=="
},
{
"scope": {
"timespan": {
"end": 245009,
"start": 133017,
}
},
"value": "ZG1Gc2RXVXlDZz09=="
}
]
});
let mut original: SoftBinding = serde_json::from_value(json).unwrap();
let assertion = original.to_assertion().unwrap();
let result = SoftBinding::from_assertion(&assertion).unwrap();
original.url = None;
assert_eq!(result, original);
}
#[test]
fn test_soft_binding_list_json_parse() {
let json = r#"[
{
"identifier": 1,
"alg": "com.example.watermark.alg1",
"type": "watermark",
"decodedMediaTypes": ["image"],
"entryMetadata": {
"description": "Example watermarking algorithm",
"dateEntered": "2025-01-01T00:00:00Z",
"contact": "contact@example.com",
"informationalUrl": "https://example.com/softbinding/alg1"
}
}
]"#;
let list = SoftBindingList::from_json_str(json).unwrap();
assert_eq!(list.0.len(), 1);
let algorithm = &list.0[0];
assert_eq!(algorithm.identifier, 1);
assert_eq!(algorithm.alg, "com.example.watermark.alg1");
assert!(matches!(
algorithm.alg_type,
SoftBindingAlgorithmType::Watermark
));
assert_eq!(
algorithm.decoded_media_types.as_ref().unwrap(),
&[SoftBindingMediaType::Image]
);
let alg_strings = list.algorithm_strings();
assert_eq!(alg_strings, vec!["com.example.watermark.alg1"]);
}
}