use crate::c2pa::{validate_c2pa_hard_bindings, C2paHardBinding};
use crate::error::{TrazaeoError, TrazaeoResult};
use crate::utils::Hash;
#[cfg(feature = "bao-range-proofs")]
use std::io::Read;
#[cfg(feature = "bao-range-proofs")]
use bao::decode::SliceDecoder;
#[cfg(feature = "bao-range-proofs")]
use bao::encode::SliceExtractor;
use serde::{Deserialize, Serialize};
pub const CONTENT_COMMITMENT_PROFILE_BLAKE3: &str = "blake3_root_v1";
pub const CONTENT_PROOF_TYPE_FULL_ROOT_V1: &str = "full_root_v1";
pub const CONTENT_PROOF_TYPE_BAO_OUTBOARD_V1: &str = "bao_outboard_v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageBinding {
pub binding_type: String,
pub uri: String,
pub binding_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContentDescriptor {
pub artifact_id: String,
pub content_root_hash: String,
pub content_commitment_profile: String,
pub chunk_size: usize,
pub leaf_count: usize,
pub byte_length: u64,
pub media_type: String,
pub created_at: String,
pub outboard_ref: Option<String>,
pub outboard_hash: Option<String>,
pub storage_bindings: Vec<StorageBinding>,
pub container_profile: Option<String>,
#[serde(default)]
pub c2pa_hard_bindings: Vec<C2paHardBinding>,
#[serde(default)]
pub c2pa_manifest_ref: Option<String>,
#[serde(default)]
pub c2pa_manifest_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VerifiedRange {
pub start: u64,
pub end: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RangeProofPackage {
pub proof_version: String,
pub artifact_id: String,
pub content_descriptor: ContentDescriptor,
pub content_proof_type: String,
pub content_proof_bytes: Vec<u8>,
pub verified_ranges: Vec<VerifiedRange>,
}
#[cfg(feature = "bao-range-proofs")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BaoOutboardData {
pub outboard_bytes: Vec<u8>,
pub outboard_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentDescriptorInput<'a> {
pub artifact_id: &'a str,
pub root: Hash,
pub chunk_size: usize,
pub leaf_count: usize,
pub byte_length: u64,
pub media_type: &'a str,
pub created_at: &'a str,
}
pub fn build_content_descriptor(input: ContentDescriptorInput<'_>) -> ContentDescriptor {
ContentDescriptor {
artifact_id: input.artifact_id.to_string(),
content_root_hash: hex::encode(input.root.0),
content_commitment_profile: CONTENT_COMMITMENT_PROFILE_BLAKE3.to_string(),
chunk_size: input.chunk_size,
leaf_count: input.leaf_count,
byte_length: input.byte_length,
media_type: input.media_type.to_string(),
created_at: input.created_at.to_string(),
outboard_ref: None,
outboard_hash: None,
storage_bindings: Vec::new(),
container_profile: None,
c2pa_hard_bindings: Vec::new(),
c2pa_manifest_ref: None,
c2pa_manifest_hash: None,
}
}
pub fn validate_content_descriptor(descriptor: &ContentDescriptor) -> TrazaeoResult<()> {
if descriptor.artifact_id.trim().is_empty() {
return Err(TrazaeoError::invalid_input(
"validate content descriptor",
"artifact_id must not be empty",
));
}
if descriptor.content_root_hash.trim().is_empty() {
return Err(TrazaeoError::invalid_input(
"validate content descriptor",
"content_root_hash must not be empty",
));
}
if descriptor.content_commitment_profile != CONTENT_COMMITMENT_PROFILE_BLAKE3 {
return Err(TrazaeoError::invalid_input(
"validate content descriptor",
"unsupported content_commitment_profile",
));
}
if descriptor.chunk_size == 0 {
return Err(TrazaeoError::invalid_input(
"validate content descriptor",
"chunk_size must be greater than zero",
));
}
if descriptor.media_type.trim().is_empty() {
return Err(TrazaeoError::invalid_input(
"validate content descriptor",
"media_type must not be empty",
));
}
let mut c2pa_errors = Vec::new();
validate_c2pa_hard_bindings(
&mut c2pa_errors,
"c2pa_hard_bindings",
&descriptor.c2pa_hard_bindings,
);
if !c2pa_errors.is_empty() {
return Err(TrazaeoError::validation(
"validate content descriptor",
c2pa_errors,
));
}
let has_manifest_ref = descriptor
.c2pa_manifest_ref
.as_ref()
.is_some_and(|value| !value.trim().is_empty());
let has_manifest_hash = descriptor
.c2pa_manifest_hash
.as_ref()
.is_some_and(|value| !value.trim().is_empty());
if has_manifest_ref ^ has_manifest_hash {
return Err(TrazaeoError::invalid_input(
"validate content descriptor",
"c2pa_manifest_ref and c2pa_manifest_hash must be set together",
));
}
Ok(())
}
pub fn compute_content_descriptor_hash(descriptor: &ContentDescriptor) -> Hash {
let payload =
serde_json::to_vec(descriptor).expect("content descriptor serialization should succeed");
Hash(*blake3::hash(&payload).as_bytes())
}
pub fn build_range_proof_package(
descriptor: &ContentDescriptor,
content_proof_type: &str,
content_proof_bytes: Vec<u8>,
verified_ranges: Vec<VerifiedRange>,
) -> RangeProofPackage {
RangeProofPackage {
proof_version: "1.0.0".to_string(),
artifact_id: descriptor.artifact_id.clone(),
content_descriptor: descriptor.clone(),
content_proof_type: content_proof_type.to_string(),
content_proof_bytes,
verified_ranges,
}
}
pub fn build_full_root_proof_package(
descriptor: &ContentDescriptor,
) -> TrazaeoResult<RangeProofPackage> {
validate_content_descriptor(descriptor)?;
Ok(build_range_proof_package(
descriptor,
CONTENT_PROOF_TYPE_FULL_ROOT_V1,
Vec::new(),
vec![VerifiedRange {
start: 0,
end: descriptor.byte_length,
}],
))
}
#[cfg(feature = "bao-range-proofs")]
pub fn generate_bao_outboard(data: &[u8]) -> BaoOutboardData {
let (outboard, _hash) = bao::encode::outboard(data);
BaoOutboardData {
outboard_hash: hex::encode(blake3::hash(&outboard).as_bytes()),
outboard_bytes: outboard,
}
}
#[cfg(feature = "bao-range-proofs")]
pub fn attach_bao_outboard(
descriptor: &ContentDescriptor,
outboard_ref: Option<&str>,
outboard: &BaoOutboardData,
) -> ContentDescriptor {
let mut updated = descriptor.clone();
updated.outboard_ref = outboard_ref.map(str::to_string);
updated.outboard_hash = Some(outboard.outboard_hash.clone());
updated
}
#[cfg(feature = "bao-range-proofs")]
pub fn build_bao_range_proof_package(
descriptor: &ContentDescriptor,
data: &[u8],
outboard: &[u8],
start: u64,
len: u64,
) -> TrazaeoResult<RangeProofPackage> {
validate_content_descriptor(descriptor)?;
let expected_root = hex::encode(blake3::hash(data).as_bytes());
if descriptor.content_root_hash != expected_root {
return Err(TrazaeoError::invalid_input(
"build bao range proof package",
"content_descriptor root does not match Bao content hash",
));
}
let mut extractor = SliceExtractor::new_outboard(
std::io::Cursor::new(data),
std::io::Cursor::new(outboard),
start,
len,
);
let mut proof_bytes = Vec::new();
extractor.read_to_end(&mut proof_bytes).map_err(|e| {
TrazaeoError::external(
"build bao range proof package",
format!("failed to extract bao slice: {e}"),
)
})?;
Ok(build_range_proof_package(
descriptor,
CONTENT_PROOF_TYPE_BAO_OUTBOARD_V1,
proof_bytes,
vec![VerifiedRange {
start,
end: start + len,
}],
))
}
pub fn verify_full_root_proof_package(
package: &RangeProofPackage,
artifact_bytes: &[u8],
) -> TrazaeoResult<Vec<u8>> {
validate_content_descriptor(&package.content_descriptor)?;
if package.content_proof_type != CONTENT_PROOF_TYPE_FULL_ROOT_V1 {
return Err(TrazaeoError::invalid_input(
"verify full root proof package",
"content_proof_type must be full_root_v1",
));
}
if !package.content_proof_bytes.is_empty() {
return Err(TrazaeoError::invalid_input(
"verify full root proof package",
"content_proof_bytes must be empty for full_root_v1",
));
}
if package.verified_ranges.len() != 1 {
return Err(TrazaeoError::invalid_input(
"verify full root proof package",
"verified_ranges must contain exactly one full-artifact range",
));
}
let range = &package.verified_ranges[0];
if range.start != 0 || range.end != package.content_descriptor.byte_length {
return Err(TrazaeoError::invalid_input(
"verify full root proof package",
"verified range must cover the entire artifact",
));
}
if artifact_bytes.len() as u64 != package.content_descriptor.byte_length {
return Err(TrazaeoError::invalid_input(
"verify full root proof package",
"artifact byte length does not match content descriptor",
));
}
let expected_root = hex::encode(blake3::hash(artifact_bytes).as_bytes());
if package.content_descriptor.content_root_hash != expected_root {
return Err(TrazaeoError::invalid_input(
"verify full root proof package",
"artifact bytes do not match content_root_hash",
));
}
Ok(artifact_bytes.to_vec())
}
#[cfg(feature = "bao-range-proofs")]
pub fn verify_bao_range_proof_package(package: &RangeProofPackage) -> TrazaeoResult<Vec<u8>> {
validate_content_descriptor(&package.content_descriptor)?;
if package.content_proof_type != CONTENT_PROOF_TYPE_BAO_OUTBOARD_V1 {
return Err(TrazaeoError::invalid_input(
"verify bao range proof package",
"content_proof_type must be bao_outboard_v1",
));
}
let Some(range) = package.verified_ranges.first() else {
return Err(TrazaeoError::invalid_input(
"verify bao range proof package",
"verified_ranges must not be empty",
));
};
let root = hex::decode(&package.content_descriptor.content_root_hash).map_err(|e| {
TrazaeoError::invalid_input(
"verify bao range proof package",
format!("invalid content_root_hash hex: {e}"),
)
})?;
let root: [u8; 32] = root.as_slice().try_into().map_err(|_| {
TrazaeoError::invalid_input(
"verify bao range proof package",
"content_root_hash must be exactly 32 bytes",
)
})?;
let mut decoder = SliceDecoder::new(
std::io::Cursor::new(&package.content_proof_bytes),
&bao::Hash::from(root),
range.start,
range.end - range.start,
);
let mut decoded = Vec::new();
decoder.read_to_end(&mut decoded).map_err(|e| {
TrazaeoError::external(
"verify bao range proof package",
format!("failed to verify bao slice: {e}"),
)
})?;
Ok(decoded)
}
pub fn encode_range_proof_package(package: &RangeProofPackage) -> Vec<u8> {
serde_json::to_vec(package).expect("range proof package serialization should succeed")
}
pub fn decode_range_proof_package(payload: &[u8]) -> TrazaeoResult<RangeProofPackage> {
let package: RangeProofPackage = serde_json::from_slice(payload).map_err(|_| {
TrazaeoError::serialization(
"decode range proof package",
"malformed range proof package",
)
})?;
validate_content_descriptor(&package.content_descriptor)?;
if package.artifact_id.trim().is_empty() {
return Err(TrazaeoError::invalid_input(
"decode range proof package",
"artifact_id must not be empty",
));
}
if package.content_proof_type.trim().is_empty() {
return Err(TrazaeoError::invalid_input(
"decode range proof package",
"content_proof_type must not be empty",
));
}
Ok(package)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn descriptor_validation_rejects_empty_artifact_id() {
let mut descriptor = build_content_descriptor(ContentDescriptorInput {
artifact_id: "artifact-1",
root: Hash([1u8; 32]),
chunk_size: 4,
leaf_count: 2,
byte_length: 8,
media_type: "application/octet-stream",
created_at: "2026-01-01T00:00:00Z",
});
descriptor.artifact_id.clear();
assert!(validate_content_descriptor(&descriptor).is_err());
}
#[test]
fn descriptor_validation_accepts_c2pa_sha256_hard_binding() {
let mut descriptor = build_content_descriptor(ContentDescriptorInput {
artifact_id: "artifact-1",
root: Hash([1u8; 32]),
chunk_size: 4,
leaf_count: 2,
byte_length: 8,
media_type: "application/octet-stream",
created_at: "2026-01-01T00:00:00Z",
});
descriptor.c2pa_hard_bindings = vec![crate::c2pa::C2paHardBinding {
alg: "sha256".to_string(),
hash: "a".repeat(64),
}];
assert!(validate_content_descriptor(&descriptor).is_ok());
}
#[test]
fn range_proof_package_roundtrip() {
let descriptor = build_content_descriptor(ContentDescriptorInput {
artifact_id: "artifact-1",
root: Hash([1u8; 32]),
chunk_size: 4,
leaf_count: 2,
byte_length: 8,
media_type: "application/octet-stream",
created_at: "2026-01-01T00:00:00Z",
});
let package = build_range_proof_package(
&descriptor,
CONTENT_PROOF_TYPE_FULL_ROOT_V1,
vec![1, 2, 3],
vec![VerifiedRange { start: 0, end: 4 }],
);
let decoded = decode_range_proof_package(&encode_range_proof_package(&package))
.expect("decode range proof package");
assert_eq!(decoded.artifact_id, "artifact-1");
}
#[test]
fn full_root_proof_package_roundtrip() {
let data = b"abcdefghijklmno";
let descriptor = build_content_descriptor(ContentDescriptorInput {
artifact_id: "artifact-1",
root: Hash(*blake3::hash(data).as_bytes()),
chunk_size: 1024,
leaf_count: 1,
byte_length: data.len() as u64,
media_type: "application/octet-stream",
created_at: "2026-01-01T00:00:00Z",
});
let package = build_full_root_proof_package(&descriptor).expect("full root package");
let decoded = decode_range_proof_package(&encode_range_proof_package(&package))
.expect("decode full root proof package");
let verified =
verify_full_root_proof_package(&decoded, data).expect("verify full root proof package");
assert_eq!(verified, data);
}
#[cfg(feature = "bao-range-proofs")]
#[test]
fn bao_outboard_and_range_proof_roundtrip() {
let data = b"abcdefghijklmno";
let descriptor = build_content_descriptor(ContentDescriptorInput {
artifact_id: "artifact-1",
root: Hash(*blake3::hash(data).as_bytes()),
chunk_size: 1024,
leaf_count: 1,
byte_length: data.len() as u64,
media_type: "application/octet-stream",
created_at: "2026-01-01T00:00:00Z",
});
let outboard = generate_bao_outboard(data);
let descriptor = attach_bao_outboard(&descriptor, Some("outboard://1"), &outboard);
let package =
build_bao_range_proof_package(&descriptor, data, &outboard.outboard_bytes, 0, 4)
.expect("bao package");
let decoded = verify_bao_range_proof_package(&package).expect("verify bao package");
assert_eq!(decoded, b"abcd");
}
}