use std::{
fs::File,
io::{Cursor, Read, SeekFrom},
path::Path,
};
use byteorder::{BigEndian, ReadBytesExt};
use serde_bytes::ByteBuf;
use crate::{
assertions::{BoxMap, C2PA_BOXHASH},
asset_io::{
rename_or_move, AssetBoxHash, AssetIO, CAIRead, CAIReadWrite, CAIReader, CAIWriter,
ComposedManifestRef, HashBlockObjectType, HashObjectPositions, RemoteRefEmbed,
RemoteRefEmbedType,
},
error::{Error, Result},
utils::{
io_utils::{patch_stream, safe_vec, stream_len, tempfile_builder},
xmp_inmemory_utils::{add_provenance, MIN_XMP},
},
};
const JXL_CONTAINER_MAGIC: [u8; 12] = [
0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a, ];
const JXL_CONTAINER_MAGIC_LEN: u64 = 12;
const JXL_CODESTREAM_SIG: [u8; 2] = [0xff, 0x0a];
const BOX_JUMB: [u8; 4] = *b"jumb"; const BOX_XML: [u8; 4] = *b"xml "; const BOX_BROB: [u8; 4] = *b"brob"; const BOX_FTYP: [u8; 4] = *b"ftyp"; const BOX_JXLC: [u8; 4] = *b"jxlc"; const BOX_JXLP: [u8; 4] = *b"jxlp"; #[cfg(test)]
const BOX_EXIF: [u8; 4] = *b"Exif";
const BOX_HEADER_SIZE: u64 = 8; const BOX_HEADER_SIZE_LARGE: u64 = 16;
const MAX_JXL_BOX_COUNT: usize = 1024;
const JUMD_C2PA_LABEL_PEEK: u64 = 30;
static SUPPORTED_TYPES: [&str; 2] = ["jxl", "image/jxl"];
#[derive(Clone, Debug)]
struct JxlBoxInfo {
box_type: [u8; 4],
offset: u64,
header_size: u64,
total_size: u64, }
impl JxlBoxInfo {
fn type_str(&self) -> String {
String::from_utf8_lossy(&self.box_type).to_string()
}
fn data_offset(&self) -> u64 {
self.offset + self.header_size
}
fn data_size(&self, file_len: u64) -> u64 {
let total = if self.total_size == 0 {
file_len - self.offset
} else {
self.total_size
};
total.saturating_sub(self.header_size)
}
fn end(&self, file_len: u64) -> u64 {
if self.total_size == 0 {
file_len
} else {
self.offset.saturating_add(self.total_size)
}
}
}
fn is_jxl_container(reader: &mut dyn CAIRead) -> Result<bool> {
reader.rewind()?;
let mut magic = [0u8; 12];
match reader.read_exact(&mut magic) {
Ok(()) => Ok(magic == JXL_CONTAINER_MAGIC),
Err(_) => Ok(false),
}
}
fn is_naked_codestream(reader: &mut dyn CAIRead) -> Result<bool> {
reader.rewind()?;
let mut sig = [0u8; 2];
match reader.read_exact(&mut sig) {
Ok(()) => Ok(sig == JXL_CODESTREAM_SIG),
Err(_) => Ok(false),
}
}
fn read_box_header(reader: &mut dyn CAIRead) -> Result<Option<JxlBoxInfo>> {
let offset = reader.stream_position()?;
let size32 = match reader.read_u32::<BigEndian>() {
Ok(v) => v,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(Error::IoError(e)),
};
let mut box_type = [0u8; 4];
reader.read_exact(&mut box_type).map_err(Error::IoError)?;
let (header_size, total_size) = match size32 {
0 => (BOX_HEADER_SIZE, 0u64), 1 => {
let large_size = reader.read_u64::<BigEndian>().map_err(Error::IoError)?;
(BOX_HEADER_SIZE_LARGE, large_size)
}
s => (BOX_HEADER_SIZE, s as u64),
};
Ok(Some(JxlBoxInfo {
box_type,
offset,
header_size,
total_size,
}))
}
fn parse_all_boxes(reader: &mut dyn CAIRead) -> Result<Vec<JxlBoxInfo>> {
let file_len = stream_len(reader)?;
reader.rewind()?;
let mut boxes = Vec::new();
loop {
let pos = reader.stream_position()?;
if pos >= file_len {
break;
}
match read_box_header(reader)? {
Some(info) => {
let next_pos = if info.total_size == 0 {
file_len
} else {
info.offset.saturating_add(info.total_size)
};
if boxes.len() >= MAX_JXL_BOX_COUNT {
return Err(Error::InvalidAsset(
"Too many boxes in JPEG XL container".to_string(),
));
}
boxes.push(info);
if next_pos >= file_len {
break;
}
reader.seek(SeekFrom::Start(next_pos))?;
}
None => break,
}
}
Ok(boxes)
}
fn decompress_brob(reader: &mut dyn CAIRead, data_size: u64) -> Result<([u8; 4], Vec<u8>)> {
let mut original_type = [0u8; 4];
reader
.read_exact(&mut original_type)
.map_err(Error::IoError)?;
let compressed_size = data_size.saturating_sub(4);
let mut constrained_reader = reader.take(compressed_size);
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut constrained_reader, &mut decompressed)
.map_err(|_| Error::InvalidAsset("Failed to decompress brob box".to_string()))?;
Ok((original_type, decompressed))
}
fn jumb_data_has_c2pa_label(data: &[u8]) -> bool {
if data.len() < 25 {
return false;
}
if &data[4..8] != b"jumd" {
return false;
}
let toggles = data[24];
if toggles & 0x03 != 0x03 {
return false;
}
let label_bytes = &data[25..];
let label_end = label_bytes
.iter()
.position(|&b| b == 0)
.unwrap_or(label_bytes.len());
&label_bytes[..label_end] == b"c2pa"
}
fn compress_brob_box(inner_type: &[u8; 4], data: &[u8]) -> Result<Vec<u8>> {
let params = brotli::enc::BrotliEncoderParams::default();
let mut compressed = Vec::new();
brotli::BrotliCompress(&mut Cursor::new(data), &mut compressed, ¶ms)
.map_err(|_| Error::InvalidAsset("Failed to compress brob box".to_string()))?;
let mut brob_payload = Vec::with_capacity(4 + compressed.len());
brob_payload.extend_from_slice(inner_type);
brob_payload.extend_from_slice(&compressed);
Ok(build_box(&BOX_BROB, &brob_payload))
}
fn find_c2pa_jumb_location(
reader: &mut dyn CAIRead,
boxes: &[JxlBoxInfo],
file_len: u64,
) -> Result<Option<(u64, u64)>> {
let mut found: Option<(u64, u64)> = None;
for b in boxes {
if b.box_type != BOX_JUMB {
continue;
}
let peek = b.data_size(file_len).min(JUMD_C2PA_LABEL_PEEK);
reader.seek(SeekFrom::Start(b.data_offset()))?;
let mut header_peek = safe_vec(peek, Some(0u8))?;
reader
.read_exact(&mut header_peek)
.map_err(Error::IoError)?;
if jumb_data_has_c2pa_label(&header_peek) {
if found.is_some() {
return Err(Error::TooManyManifestStores);
}
let box_size = b.end(file_len).saturating_sub(b.offset);
found = Some((b.offset, box_size));
}
}
Ok(found)
}
fn find_jumb_data(reader: &mut dyn CAIRead) -> Result<Vec<u8>> {
let file_len = stream_len(reader)?;
if !is_jxl_container(reader)? {
if is_naked_codestream(reader)? {
return Err(Error::InvalidAsset(
"JPEG XL naked codestream cannot contain C2PA manifests".to_string(),
));
}
return Err(Error::InvalidAsset(
"Not a valid JPEG XL container".to_string(),
));
}
let boxes = parse_all_boxes(reader)?;
let (offset, box_size) =
find_c2pa_jumb_location(reader, &boxes, file_len)?.ok_or(Error::JumbfNotFound)?;
reader.seek(SeekFrom::Start(offset))?;
let mut complete_box = safe_vec(box_size, Some(0u8))?;
reader
.read_exact(&mut complete_box)
.map_err(Error::IoError)?;
Ok(complete_box)
}
fn find_xmp_data(reader: &mut dyn CAIRead) -> Option<String> {
let file_len = stream_len(reader).ok()?;
if !is_jxl_container(reader).ok()? {
return None;
}
let boxes = parse_all_boxes(reader).ok()?;
for b in &boxes {
if b.box_type == BOX_XML {
let ds = b.data_size(file_len);
reader.seek(SeekFrom::Start(b.data_offset())).ok()?;
let mut data = safe_vec(ds, Some(0u8)).ok()?;
reader.read_exact(&mut data).ok()?;
return String::from_utf8(data).ok();
} else if b.box_type == BOX_BROB {
reader.seek(SeekFrom::Start(b.data_offset())).ok()?;
let ds = b.data_size(file_len);
if ds >= 4 {
if let Ok((orig_type, decompressed)) = decompress_brob(reader, ds) {
if orig_type == BOX_XML {
return String::from_utf8(decompressed).ok();
}
}
}
}
}
None
}
fn find_jumb_insertion_offset(boxes: &[JxlBoxInfo]) -> u64 {
for (i, b) in boxes.iter().enumerate() {
if b.box_type == BOX_JXLC || b.box_type == BOX_JXLP {
return b.offset;
}
if b.box_type == BOX_FTYP {
if let Some(next) = boxes.get(i + 1) {
return next.offset;
}
}
}
if !boxes.is_empty() {
let first = &boxes[0];
if first.total_size > 0 {
return first.offset + first.total_size;
}
}
JXL_CONTAINER_MAGIC_LEN
}
fn build_box(box_type: &[u8; 4], data: &[u8]) -> Vec<u8> {
let total_size = BOX_HEADER_SIZE as usize + data.len();
if total_size <= u32::MAX as usize {
let mut buf = Vec::with_capacity(total_size);
buf.extend_from_slice(&(total_size as u32).to_be_bytes()); buf.extend_from_slice(box_type);
buf.extend_from_slice(data);
buf
} else {
let total_large = BOX_HEADER_SIZE_LARGE as usize + data.len();
let mut buf = Vec::with_capacity(total_large);
buf.extend_from_slice(&1u32.to_be_bytes()); buf.extend_from_slice(box_type);
buf.extend_from_slice(&(total_large as u64).to_be_bytes()); buf.extend_from_slice(data);
buf
}
}
fn remove_c2pa_jumb_box(reader: &mut dyn CAIRead, writer: &mut dyn CAIReadWrite) -> Result<()> {
let file_len = stream_len(reader)?;
if !is_jxl_container(reader)? {
return Err(Error::InvalidAsset(
"Not a valid JPEG XL container".to_string(),
));
}
let boxes = parse_all_boxes(reader)?;
let Some((c2pa_offset, c2pa_len)) = find_c2pa_jumb_location(reader, &boxes, file_len)? else {
patch_stream(reader, writer, file_len, 0, &[])?;
return Ok(());
};
patch_stream(reader, writer, c2pa_offset, c2pa_len, &[])?;
Ok(())
}
fn find_xmp_box_info(
reader: &mut dyn CAIRead,
boxes: &[JxlBoxInfo],
file_len: u64,
) -> Result<(u64, u64, bool)> {
for b in boxes {
if b.box_type == BOX_XML {
return Ok((b.offset, b.end(file_len).saturating_sub(b.offset), false));
} else if b.box_type == BOX_BROB && b.data_size(file_len) >= 4 {
reader.seek(SeekFrom::Start(b.data_offset()))?;
let mut orig_type = [0u8; 4];
reader.read_exact(&mut orig_type).map_err(Error::IoError)?;
if orig_type == BOX_XML {
return Ok((b.offset, b.end(file_len).saturating_sub(b.offset), true));
}
}
}
Ok((find_jumb_insertion_offset(boxes), 0, false))
}
pub struct JpegXlIO {}
impl CAIReader for JpegXlIO {
fn read_cai(&self, asset_reader: &mut dyn CAIRead) -> Result<Vec<u8>> {
find_jumb_data(asset_reader)
}
fn read_xmp(&self, asset_reader: &mut dyn CAIRead) -> Option<String> {
find_xmp_data(asset_reader)
}
}
impl CAIWriter for JpegXlIO {
fn write_cai(
&self,
input_stream: &mut dyn CAIRead,
output_stream: &mut dyn CAIReadWrite,
store_bytes: &[u8],
) -> Result<()> {
let file_len = stream_len(input_stream)?;
if !is_jxl_container(input_stream)? {
return Err(Error::InvalidAsset(
"Not a valid JPEG XL container".to_string(),
));
}
let boxes = parse_all_boxes(input_stream)?;
if let Some((offset, replace_len)) =
find_c2pa_jumb_location(input_stream, &boxes, file_len)?
{
patch_stream(
input_stream,
output_stream,
offset,
replace_len,
store_bytes,
)?;
} else {
let insert_offset = find_jumb_insertion_offset(&boxes);
patch_stream(input_stream, output_stream, insert_offset, 0, store_bytes)?;
}
Ok(())
}
fn get_object_locations_from_stream(
&self,
input_stream: &mut dyn CAIRead,
) -> Result<Vec<HashObjectPositions>> {
let mut output_stream = Cursor::new(Vec::<u8>::new());
add_required_jumb_to_stream(input_stream, &mut output_stream)?;
let file_len = stream_len(&mut output_stream)?;
let boxes = parse_all_boxes(&mut output_stream)?;
let c2pa_offset =
find_c2pa_jumb_location(&mut output_stream, &boxes, file_len)?.map(|(off, _)| off);
let positions = boxes
.iter()
.map(|b| {
let length = if b.total_size == 0 {
(file_len - b.offset) as usize
} else {
b.total_size as usize
};
let htype = if Some(b.offset) == c2pa_offset {
HashBlockObjectType::Cai
} else if b.box_type == BOX_XML {
HashBlockObjectType::Xmp
} else {
HashBlockObjectType::Other
};
HashObjectPositions {
offset: b.offset as usize,
length,
htype,
}
})
.collect();
Ok(positions)
}
fn remove_cai_store_from_stream(
&self,
input_stream: &mut dyn CAIRead,
output_stream: &mut dyn CAIReadWrite,
) -> Result<()> {
remove_c2pa_jumb_box(input_stream, output_stream)
}
}
fn build_c2pa_jumd_placeholder() -> Vec<u8> {
let mut jumd_payload = Vec::new();
jumd_payload.extend_from_slice(&[0u8; 16]); jumd_payload.push(0x03); jumd_payload.extend_from_slice(b"c2pa\0"); let jumd = build_box(b"jumd", &jumd_payload);
build_box(b"jumb", &jumd)
}
fn add_required_jumb_to_stream(
input_stream: &mut dyn CAIRead,
output_stream: &mut dyn CAIReadWrite,
) -> Result<()> {
let file_len = stream_len(input_stream)?;
if !is_jxl_container(input_stream)? {
return Err(Error::InvalidAsset(
"Not a valid JPEG XL container".to_string(),
));
}
let boxes = parse_all_boxes(input_stream)?;
let has_c2pa_jumb = find_c2pa_jumb_location(input_stream, &boxes, file_len)?.is_some();
if !has_c2pa_jumb {
let placeholder = build_c2pa_jumd_placeholder();
let insert_offset = find_jumb_insertion_offset(&boxes);
patch_stream(input_stream, output_stream, insert_offset, 0, &placeholder)?;
} else {
patch_stream(input_stream, output_stream, file_len, 0, &[])?;
}
Ok(())
}
impl AssetIO for JpegXlIO {
fn new(_asset_type: &str) -> Self
where
Self: Sized,
{
JpegXlIO {}
}
fn get_handler(&self, asset_type: &str) -> Box<dyn AssetIO> {
Box::new(JpegXlIO::new(asset_type))
}
fn get_reader(&self) -> &dyn CAIReader {
self
}
fn get_writer(&self, asset_type: &str) -> Option<Box<dyn CAIWriter>> {
Some(Box::new(JpegXlIO::new(asset_type)))
}
fn read_cai_store(&self, asset_path: &Path) -> Result<Vec<u8>> {
let mut f = File::open(asset_path)?;
self.read_cai(&mut f)
}
fn save_cai_store(&self, asset_path: &Path, store_bytes: &[u8]) -> Result<()> {
let mut input_stream = std::fs::OpenOptions::new()
.read(true)
.open(asset_path)
.map_err(Error::IoError)?;
let mut temp_file = tempfile_builder("c2pa_temp")?;
self.write_cai(&mut input_stream, &mut temp_file, store_bytes)?;
rename_or_move(temp_file, asset_path)
}
fn get_object_locations(&self, asset_path: &Path) -> Result<Vec<HashObjectPositions>> {
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(asset_path)
.map_err(Error::IoError)?;
self.get_object_locations_from_stream(&mut file)
}
fn remove_cai_store(&self, asset_path: &Path) -> Result<()> {
let mut input_stream = File::open(asset_path).map_err(Error::IoError)?;
let mut temp_file = tempfile_builder("c2pa_temp")?;
remove_c2pa_jumb_box(&mut input_stream, &mut temp_file)?;
rename_or_move(temp_file, asset_path)
}
fn supported_types(&self) -> &[&str] {
&SUPPORTED_TYPES
}
fn remote_ref_writer_ref(&self) -> Option<&dyn RemoteRefEmbed> {
Some(self)
}
fn asset_box_hash_ref(&self) -> Option<&dyn AssetBoxHash> {
Some(self)
}
fn composed_data_ref(&self) -> Option<&dyn ComposedManifestRef> {
Some(self)
}
}
impl RemoteRefEmbed for JpegXlIO {
#[allow(unused_variables)]
fn embed_reference(&self, asset_path: &Path, embed_ref: RemoteRefEmbedType) -> Result<()> {
match &embed_ref {
RemoteRefEmbedType::Xmp(_) => {
let mut file = File::open(asset_path)?;
let mut temp = Cursor::new(Vec::new());
self.embed_reference_to_stream(&mut file, &mut temp, embed_ref)?;
std::fs::write(asset_path, temp.into_inner()).map_err(Error::IoError)?;
Ok(())
}
RemoteRefEmbedType::StegoS(_) => Err(Error::UnsupportedType),
RemoteRefEmbedType::StegoB(_) => Err(Error::UnsupportedType),
RemoteRefEmbedType::Watermark(_) => Err(Error::UnsupportedType),
}
}
fn embed_reference_to_stream(
&self,
source_stream: &mut dyn CAIRead,
output_stream: &mut dyn CAIReadWrite,
embed_ref: RemoteRefEmbedType,
) -> Result<()> {
match embed_ref {
RemoteRefEmbedType::Xmp(manifest_uri) => {
let file_len = stream_len(source_stream)?;
if !is_jxl_container(source_stream)? {
return Err(Error::InvalidAsset(
"Not a valid JPEG XL container".to_string(),
));
}
let boxes = parse_all_boxes(source_stream)?;
let xmp = find_xmp_data(source_stream).unwrap_or_else(|| MIN_XMP.to_string());
let updated_xmp = add_provenance(&xmp, &manifest_uri)?;
let (xmp_offset, xmp_len, was_compressed) =
find_xmp_box_info(source_stream, &boxes, file_len)?;
let xmp_box = if was_compressed {
compress_brob_box(&BOX_XML, updated_xmp.as_bytes())?
} else {
build_box(&BOX_XML, updated_xmp.as_bytes())
};
patch_stream(source_stream, output_stream, xmp_offset, xmp_len, &xmp_box)?;
Ok(())
}
RemoteRefEmbedType::StegoS(_) => Err(Error::UnsupportedType),
RemoteRefEmbedType::StegoB(_) => Err(Error::UnsupportedType),
RemoteRefEmbedType::Watermark(_) => Err(Error::UnsupportedType),
}
}
}
impl AssetBoxHash for JpegXlIO {
fn get_box_map(&self, input_stream: &mut dyn CAIRead) -> Result<Vec<BoxMap>> {
let file_len = stream_len(input_stream)?;
if !is_jxl_container(input_stream)? {
return Err(Error::InvalidAsset(
"Not a valid JPEG XL container".to_string(),
));
}
let boxes = parse_all_boxes(input_stream)?;
let mut box_maps = Vec::new();
for b in &boxes {
let total = if b.total_size == 0 {
file_len - b.offset
} else {
b.total_size
};
let name = if b.box_type == BOX_JUMB {
C2PA_BOXHASH.to_string()
} else {
b.type_str()
};
box_maps.push(BoxMap {
names: vec![name],
alg: None,
hash: ByteBuf::from(Vec::new()),
excluded: None,
pad: ByteBuf::from(Vec::new()),
range_start: b.offset,
range_len: total,
});
}
if !box_maps
.iter()
.any(|m| m.names.contains(&C2PA_BOXHASH.to_string()))
{
let range_start = find_jumb_insertion_offset(&boxes);
let c2pa_box = BoxMap {
names: vec![C2PA_BOXHASH.to_string()],
alg: None,
hash: ByteBuf::from(Vec::new()),
excluded: None,
pad: ByteBuf::from(Vec::new()),
range_start, range_len: 0,
};
let ftyp_string = String::from("ftyp");
let insert_index = box_maps
.iter()
.position(|m| m.names.contains(&ftyp_string))
.ok_or_else(|| {
Error::InvalidAsset("invalid JPEG XL container: missing ftyp box".to_string())
})?;
box_maps.insert(insert_index + 1, c2pa_box);
}
Ok(box_maps)
}
}
impl ComposedManifestRef for JpegXlIO {
fn compose_manifest(&self, manifest_data: &[u8], _format: &str) -> Result<Vec<u8>> {
Ok(manifest_data.to_vec())
}
}
#[derive(Debug, thiserror::Error)]
#[allow(dead_code)]
pub enum JpegXlError {
#[error("invalid file signature: {reason}")]
InvalidFileSignature { reason: String },
#[error("naked codestream cannot embed C2PA manifests")]
NakedCodestream,
}
#[cfg(test)]
fn build_minimal_jxl_container() -> Vec<u8> {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let jxlc_data = &[0xff, 0x0a, 0x00]; let jxlc_box = build_box(&BOX_JXLC, jxlc_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&jxlc_box);
container
}
#[cfg(test)]
fn build_jxl_with_xmp(xmp_data: &str) -> Vec<u8> {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let xml_box = build_box(&BOX_XML, xmp_data.as_bytes());
let jxlc_data = &[0xff, 0x0a, 0x00];
let jxlc_box = build_box(&BOX_JXLC, jxlc_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&xml_box);
container.extend_from_slice(&jxlc_box);
container
}
#[cfg(test)]
fn build_jxl_with_brob_jumb(manifest_data: &[u8]) -> Result<Vec<u8>> {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let mut compressed = Vec::new();
{
let params = brotli::enc::BrotliEncoderParams::default();
brotli::BrotliCompress(&mut Cursor::new(manifest_data), &mut compressed, ¶ms)
.map_err(Error::IoError)?;
}
let mut brob_payload = Vec::new();
brob_payload.extend_from_slice(&BOX_JUMB);
brob_payload.extend_from_slice(&compressed);
let brob_box = build_box(&BOX_BROB, &brob_payload);
let jxlc_data = &[0xff, 0x0a, 0x00];
let jxlc_box = build_box(&BOX_JXLC, jxlc_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&brob_box);
container.extend_from_slice(&jxlc_box);
Ok(container)
}
#[cfg(test)]
fn build_jxl_with_brob_xmp(xmp_data: &str) -> Result<Vec<u8>> {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let mut compressed = Vec::new();
{
let params = brotli::enc::BrotliEncoderParams::default();
brotli::BrotliCompress(
&mut Cursor::new(xmp_data.as_bytes()),
&mut compressed,
¶ms,
)
.map_err(Error::IoError)?;
}
let mut brob_payload = Vec::new();
brob_payload.extend_from_slice(&BOX_XML);
brob_payload.extend_from_slice(&compressed);
let brob_box = build_box(&BOX_BROB, &brob_payload);
let jxlc_data = &[0xff, 0x0a, 0x00];
let jxlc_box = build_box(&BOX_JXLC, jxlc_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&brob_box);
container.extend_from_slice(&jxlc_box);
Ok(container)
}
#[cfg(test)]
pub mod tests {
#![allow(clippy::unwrap_used)]
use std::io::Seek;
use byteorder::WriteBytesExt;
use super::*;
use crate::{
utils::{io_utils::tempdirectory, test::test_context},
Builder, CallbackSigner, Reader, SigningAlg,
};
pub fn build_test_jxl_container() -> Vec<u8> {
build_minimal_jxl_container()
}
fn c2pa_store(extra: &[u8]) -> Vec<u8> {
let mut jumb_payload = build_jumd_box(b"c2pa\0");
jumb_payload.extend_from_slice(extra);
build_box(&BOX_JUMB, &jumb_payload)
}
#[test]
fn test_jxl_container_magic_validation() {
let container = build_minimal_jxl_container();
let mut cursor = Cursor::new(&container);
assert!(is_jxl_container(&mut cursor).unwrap());
}
#[test]
fn test_reject_invalid_magic() {
let bad_data = vec![0x00; 20];
let mut cursor = Cursor::new(&bad_data);
assert!(!is_jxl_container(&mut cursor).unwrap());
}
#[test]
fn test_detect_naked_codestream() {
let naked = vec![0xff, 0x0a, 0x00, 0x00, 0x00];
let mut cursor = Cursor::new(&naked);
assert!(is_naked_codestream(&mut cursor).unwrap());
}
#[test]
fn test_reject_naked_codestream_for_c2pa() {
let naked = vec![0xff, 0x0a, 0x00, 0x00, 0x00];
let mut cursor = Cursor::new(&naked);
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.read_cai(&mut cursor);
assert!(matches!(result, Err(Error::InvalidAsset(_))));
}
fn build_jumd_box(label: &[u8]) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&[0u8; 16]); payload.push(0x03); payload.extend_from_slice(label); build_box(b"jumd", &payload)
}
fn build_labeled_jumb(label: &[u8]) -> Vec<u8> {
build_box(&BOX_JUMB, &build_jumd_box(label))
}
#[test]
fn test_multiple_non_c2pa_jumb_boxes_returns_not_found() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let jumb_box1 = build_box(&BOX_JUMB, b"raw_payload_1");
let jumb_box2 = build_box(&BOX_JUMB, b"raw_payload_2");
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&jumb_box1);
container.extend_from_slice(&jumb_box2);
container.extend_from_slice(&jxlc_box);
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.read_cai(&mut cursor);
assert!(matches!(result, Err(Error::JumbfNotFound)));
}
#[test]
fn test_reject_two_c2pa_jumb_boxes() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let jumb_box1 = build_labeled_jumb(b"c2pa\0");
let jumb_box2 = build_labeled_jumb(b"c2pa\0");
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&jumb_box1);
container.extend_from_slice(&jumb_box2);
container.extend_from_slice(&jxlc_box);
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.read_cai(&mut cursor);
assert!(matches!(result, Err(Error::TooManyManifestStores)));
}
#[test]
fn test_read_c2pa_jumb_alongside_non_c2pa_jumb() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let exif_jumb = build_labeled_jumb(b"EXIF\0");
let mut c2pa_payload = build_jumd_box(b"c2pa\0");
c2pa_payload.extend_from_slice(b"fake_manifest_bytes");
let c2pa_jumb = build_box(&BOX_JUMB, &c2pa_payload);
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&exif_jumb);
container.extend_from_slice(&c2pa_jumb);
container.extend_from_slice(&jxlc_box);
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let data = jpegxl_io.read_cai(&mut cursor).unwrap();
assert_eq!(data, c2pa_jumb);
let mut input = Cursor::new(container.clone());
let mut output = Cursor::new(Vec::new());
jpegxl_io
.remove_cai_store_from_stream(&mut input, &mut output)
.unwrap();
output.rewind().unwrap();
let out_boxes = parse_all_boxes(&mut output).unwrap();
let jumb_count = out_boxes.iter().filter(|b| b.box_type == BOX_JUMB).count();
assert_eq!(
jumb_count, 1,
"EXIF jumb should be preserved after C2PA removal"
);
}
#[test]
fn test_write_cai_preserves_non_c2pa_jumb() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let exif_jumb = build_labeled_jumb(b"EXIF\0");
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&exif_jumb);
container.extend_from_slice(&jxlc_box);
let c2pa_payload = c2pa_store(b"stub_c2pa_data");
let jpegxl_io = JpegXlIO {};
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_payload)
.unwrap();
output.rewind().unwrap();
let out_boxes = parse_all_boxes(&mut output).unwrap();
let jumb_count = out_boxes.iter().filter(|b| b.box_type == BOX_JUMB).count();
assert_eq!(
jumb_count, 2,
"both EXIF and C2PA jumb boxes should be present"
);
}
#[test]
fn test_parse_minimal_container_boxes() {
let container = build_minimal_jxl_container();
let mut cursor = Cursor::new(&container);
let boxes = parse_all_boxes(&mut cursor).unwrap();
assert_eq!(boxes.len(), 3);
assert_eq!(boxes[0].box_type, *b"JXL ");
assert_eq!(boxes[1].box_type, BOX_FTYP);
assert_eq!(boxes[2].box_type, BOX_JXLC);
}
#[test]
fn test_parse_box_offsets_and_sizes() {
let container = build_minimal_jxl_container();
let mut cursor = Cursor::new(&container);
let boxes = parse_all_boxes(&mut cursor).unwrap();
assert_eq!(boxes[0].offset, 0);
assert_eq!(boxes[0].total_size, 12);
assert_eq!(boxes[0].header_size, BOX_HEADER_SIZE);
assert_eq!(boxes[1].offset, 12);
}
#[test]
fn test_last_box_extends_to_eof() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.write_u32::<BigEndian>(0).unwrap(); container.extend_from_slice(&BOX_JXLC);
container.extend_from_slice(&[0xff, 0x0a, 0x00, 0x01, 0x02]);
let file_len = container.len() as u64;
let mut cursor = Cursor::new(&container);
let boxes = parse_all_boxes(&mut cursor).unwrap();
let last = boxes.last().unwrap();
assert_eq!(last.box_type, BOX_JXLC);
assert_eq!(last.total_size, 0); assert_eq!(last.end(file_len), file_len);
}
#[test]
fn test_large_box_header() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
let payload = vec![0xaa; 10];
let large_total: u64 = BOX_HEADER_SIZE_LARGE + payload.len() as u64;
container.write_u32::<BigEndian>(1).unwrap();
container.extend_from_slice(&BOX_JXLC);
container.write_u64::<BigEndian>(large_total).unwrap();
container.extend_from_slice(&payload);
let mut cursor = Cursor::new(&container);
let boxes = parse_all_boxes(&mut cursor).unwrap();
let jxlc = boxes.iter().find(|b| b.box_type == BOX_JXLC).unwrap();
assert_eq!(jxlc.header_size, BOX_HEADER_SIZE_LARGE);
assert_eq!(jxlc.total_size, large_total);
}
#[test]
fn test_write_and_read_cai_roundtrip() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let store_bytes = c2pa_store(b"test_c2pa_manifest_store_data");
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &store_bytes)
.unwrap();
output.rewind().unwrap();
let read_back = jpegxl_io.read_cai(&mut output).unwrap();
assert_eq!(read_back, store_bytes);
}
#[test]
fn test_write_cai_replaces_existing() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut intermediate = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
let store1 = c2pa_store(b"first_manifest_store");
jpegxl_io
.write_cai(&mut input, &mut intermediate, &store1)
.unwrap();
intermediate.rewind().unwrap();
let mut final_output = Cursor::new(Vec::new());
let store2 = c2pa_store(b"second_manifest_store_replaced");
jpegxl_io
.write_cai(&mut intermediate, &mut final_output, &store2)
.unwrap();
final_output.rewind().unwrap();
let read_back = jpegxl_io.read_cai(&mut final_output).unwrap();
assert_eq!(read_back, store2);
}
#[test]
fn test_write_cai_maintains_container_validity() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
assert!(is_jxl_container(&mut output).unwrap());
output.rewind().unwrap();
let boxes = parse_all_boxes(&mut output).unwrap();
let types: Vec<[u8; 4]> = boxes.iter().map(|b| b.box_type).collect();
assert!(types.contains(b"JXL "));
assert!(types.contains(&BOX_FTYP));
assert!(types.contains(&BOX_JUMB));
assert!(types.contains(&BOX_JXLC));
}
#[test]
fn test_jumb_placement_before_codestream() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
let boxes = parse_all_boxes(&mut output).unwrap();
let jumb_idx = boxes.iter().position(|b| b.box_type == BOX_JUMB).unwrap();
let jxlc_idx = boxes.iter().position(|b| b.box_type == BOX_JXLC).unwrap();
let ftyp_idx = boxes.iter().position(|b| b.box_type == BOX_FTYP).unwrap();
assert!(jumb_idx > ftyp_idx, "jumb must come after ftyp");
assert!(jumb_idx < jxlc_idx, "jumb must come before jxlc");
}
#[test]
fn test_remove_cai_store() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut with_manifest = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut with_manifest, &c2pa_store(b""))
.unwrap();
with_manifest.rewind().unwrap();
let mut without_manifest = Cursor::new(Vec::new());
jpegxl_io
.remove_cai_store_from_stream(&mut with_manifest, &mut without_manifest)
.unwrap();
without_manifest.rewind().unwrap();
let result = jpegxl_io.read_cai(&mut without_manifest);
assert!(matches!(result, Err(Error::JumbfNotFound)));
without_manifest.rewind().unwrap();
assert!(is_jxl_container(&mut without_manifest).unwrap());
}
#[test]
fn test_remove_cai_from_container_without_manifest() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container.clone());
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.remove_cai_store_from_stream(&mut input, &mut output)
.unwrap();
output.rewind().unwrap();
assert!(is_jxl_container(&mut output).unwrap());
}
#[test]
fn test_read_xmp_from_xml_box() {
let xmp_content = "<x:xmpmeta>test xmp content</x:xmpmeta>";
let container = build_jxl_with_xmp(xmp_content);
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let xmp = jpegxl_io.read_xmp(&mut cursor);
assert_eq!(xmp.unwrap(), xmp_content);
}
#[test]
fn test_read_xmp_none_when_missing() {
let container = build_minimal_jxl_container();
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let xmp = jpegxl_io.read_xmp(&mut cursor);
assert!(xmp.is_none());
}
#[test]
fn test_read_xmp_from_brob_wrapped_xml() -> Result<()> {
let xmp_content = "<x:xmpmeta>brob-wrapped xmp content</x:xmpmeta>";
let container = build_jxl_with_brob_xmp(xmp_content)?;
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let xmp = jpegxl_io.read_xmp(&mut cursor);
assert_eq!(xmp.unwrap(), xmp_content);
Ok(())
}
#[test]
fn test_brob_wrapped_jumb_not_supported() -> Result<()> {
let manifest_data = b"brob_compressed_manifest_store";
let container = build_jxl_with_brob_jumb(manifest_data)?;
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.read_cai(&mut cursor);
assert!(
matches!(result, Err(Error::JumbfNotFound)),
"brob-wrapped jumb should not be read as a C2PA manifest"
);
Ok(())
}
#[test]
fn test_brob_decompression_basic() {
let original_data = b"Hello, JPEG XL Brotli world!";
let mut compressed = Vec::new();
let params = brotli::enc::BrotliEncoderParams::default();
brotli::BrotliCompress(
&mut Cursor::new(original_data.as_ref()),
&mut compressed,
¶ms,
)
.unwrap();
let mut brob_payload = Vec::new();
brob_payload.extend_from_slice(&BOX_XML);
brob_payload.extend_from_slice(&compressed);
let mut cursor = Cursor::new(&brob_payload);
let (orig_type, decompressed) =
decompress_brob(&mut cursor, brob_payload.len() as u64).unwrap();
assert_eq!(orig_type, BOX_XML);
assert_eq!(decompressed, original_data);
}
#[test]
fn test_remove_preserves_brob_wrapped_jumb() -> Result<()> {
let manifest_data = b"manifest_to_remove";
let container = build_jxl_with_brob_jumb(manifest_data)?;
let original_len = container.len();
let mut input = Cursor::new(&container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.remove_cai_store_from_stream(&mut input, &mut output)
.unwrap();
output.rewind().unwrap();
let boxes = parse_all_boxes(&mut output).unwrap();
assert!(
boxes.iter().any(|b| b.box_type == BOX_BROB),
"brob box should be preserved as opaque data"
);
assert_eq!(
output.get_ref().len(),
original_len,
"output should be same size since nothing was removed"
);
Ok(())
}
#[test]
fn test_object_locations_include_cai() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
let locations = jpegxl_io
.get_object_locations_from_stream(&mut output)
.unwrap();
let cai_loc = locations
.iter()
.find(|l| l.htype == HashBlockObjectType::Cai);
assert!(cai_loc.is_some(), "Should have a Cai hash object");
assert!(cai_loc.unwrap().length > 0);
}
#[test]
fn test_object_locations_non_overlapping() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
let locations = jpegxl_io
.get_object_locations_from_stream(&mut output)
.unwrap();
for (i, loc_a) in locations.iter().enumerate() {
for loc_b in locations.iter().skip(i + 1) {
let a_end = loc_a.offset + loc_a.length;
let b_end = loc_b.offset + loc_b.length;
assert!(
a_end <= loc_b.offset || b_end <= loc_a.offset,
"Hash object locations should not overlap: [{}, {}) vs [{}, {})",
loc_a.offset,
a_end,
loc_b.offset,
b_end
);
}
}
}
#[test]
fn test_box_map_contains_c2pa_entry() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
let box_map = jpegxl_io.get_box_map(&mut output).unwrap();
let c2pa_entry = box_map.iter().find(|bm| bm.names[0] == C2PA_BOXHASH);
assert!(c2pa_entry.is_some(), "Box map must contain C2PA entry");
assert!(c2pa_entry.unwrap().range_len > 0);
}
#[test]
fn test_box_map_covers_entire_file() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
let file_len = output.get_ref().len() as u64;
output.rewind().unwrap();
let box_map = jpegxl_io.get_box_map(&mut output).unwrap();
let total_coverage: u64 = box_map.iter().map(|bm| bm.range_len).sum();
assert_eq!(
total_coverage, file_len,
"Box map should cover the entire file"
);
}
#[test]
fn test_box_map_ordered_by_offset() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
let box_map = jpegxl_io.get_box_map(&mut output).unwrap();
for i in 1..box_map.len() {
assert!(
box_map[i].range_start >= box_map[i - 1].range_start + box_map[i - 1].range_len,
"Box map entries must be ordered by offset and non-overlapping"
);
}
}
#[test]
fn test_embed_xmp_reference_to_stream() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.embed_reference_to_stream(
&mut input,
&mut output,
RemoteRefEmbedType::Xmp("https://example.com/manifest".to_string()),
)
.unwrap();
output.rewind().unwrap();
let xmp = jpegxl_io.read_xmp(&mut output).unwrap();
assert!(xmp.contains("https://example.com/manifest"));
}
#[test]
fn test_embed_xmp_reference_updates_existing() {
let xmp_content = MIN_XMP;
let container = build_jxl_with_xmp(xmp_content);
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.embed_reference_to_stream(
&mut input,
&mut output,
RemoteRefEmbedType::Xmp("https://example.com/updated".to_string()),
)
.unwrap();
output.rewind().unwrap();
let xmp = jpegxl_io.read_xmp(&mut output).unwrap();
assert!(xmp.contains("https://example.com/updated"));
}
#[test]
fn test_embed_xmp_preserves_compression() -> Result<()> {
let original_xmp = MIN_XMP;
let container = build_jxl_with_brob_xmp(original_xmp)?;
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.embed_reference_to_stream(
&mut input,
&mut output,
RemoteRefEmbedType::Xmp("https://example.com/brob-preserved".to_string()),
)
.unwrap();
output.rewind().unwrap();
let out_buf = output.get_ref();
let boxes = parse_all_boxes(&mut Cursor::new(out_buf)).unwrap();
assert!(
!boxes.iter().any(|b| b.box_type == BOX_XML),
"output should not contain a plain xml box when source was compressed"
);
assert!(
boxes.iter().any(|b| b.box_type == BOX_BROB),
"output should contain a brob box preserving the original compression"
);
output.rewind().unwrap();
let xmp = jpegxl_io.read_xmp(&mut output).unwrap();
assert!(
xmp.contains("https://example.com/brob-preserved"),
"updated provenance URI should be readable from the compressed box"
);
Ok(())
}
#[test]
fn test_embed_stego_unsupported() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.embed_reference_to_stream(
&mut input,
&mut output,
RemoteRefEmbedType::StegoS("test".to_string()),
);
assert!(matches!(result, Err(Error::UnsupportedType)));
}
#[test]
fn test_composed_manifest() {
let manifest_data = c2pa_store(b"test_manifest_for_composition");
let jpegxl_io = JpegXlIO {};
let composed = jpegxl_io.compose_manifest(&manifest_data, "jxl").unwrap();
assert_eq!(composed, manifest_data);
}
#[test]
fn test_composed_manifest_roundtrip() {
let container = build_minimal_jxl_container();
let jpegxl_io = JpegXlIO {};
let original_manifest = c2pa_store(b"roundtrip_manifest_data");
let mut input = Cursor::new(container);
let mut with_manifest = Cursor::new(Vec::new());
jpegxl_io
.write_cai(&mut input, &mut with_manifest, &original_manifest)
.unwrap();
with_manifest.rewind().unwrap();
let curr_manifest = jpegxl_io.read_cai(&mut with_manifest).unwrap();
assert_eq!(curr_manifest, original_manifest);
let composed = jpegxl_io.compose_manifest(&curr_manifest, "jxl").unwrap();
assert_eq!(composed, curr_manifest);
}
#[test]
fn test_supported_types() {
let jpegxl_io = JpegXlIO {};
let types = jpegxl_io.supported_types();
assert!(types.contains(&"jxl"));
assert!(types.contains(&"image/jxl"));
}
#[test]
fn test_handler_provides_writer() {
let jpegxl_io = JpegXlIO {};
assert!(jpegxl_io.get_writer("jxl").is_some());
assert!(jpegxl_io.get_writer("image/jxl").is_some());
}
#[test]
fn test_handler_provides_box_hash() {
let jpegxl_io = JpegXlIO {};
assert!(jpegxl_io.asset_box_hash_ref().is_some());
}
#[test]
fn test_handler_provides_remote_ref() {
let jpegxl_io = JpegXlIO {};
assert!(jpegxl_io.remote_ref_writer_ref().is_some());
}
#[test]
fn test_handler_provides_composed_data() {
let jpegxl_io = JpegXlIO {};
assert!(jpegxl_io.composed_data_ref().is_some());
}
#[test]
fn test_minimal_manifest_store() {
let container = build_minimal_jxl_container();
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let minimal_store = c2pa_store(&[]);
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &minimal_store)
.unwrap();
output.rewind().unwrap();
let result = jpegxl_io.read_cai(&mut output).unwrap();
assert_eq!(result, minimal_store);
}
#[test]
fn test_read_from_container_without_manifest() {
let container = build_minimal_jxl_container();
let mut cursor = Cursor::new(&container);
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.read_cai(&mut cursor);
assert!(matches!(result, Err(Error::JumbfNotFound)));
}
#[test]
fn test_write_cai_to_invalid_data() {
let bad_data = vec![0x00; 20];
let mut input = Cursor::new(bad_data);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
let result = jpegxl_io.write_cai(&mut input, &mut output, b"test");
assert!(matches!(result, Err(Error::InvalidAsset(_))));
}
#[test]
fn test_container_with_exif_box() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let exif_box = build_box(&BOX_EXIF, b"exif_data_here");
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&exif_box);
container.extend_from_slice(&jxlc_box);
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let store = c2pa_store(b"manifest_with_exif");
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &store)
.unwrap();
output.rewind().unwrap();
let result = jpegxl_io.read_cai(&mut output).unwrap();
assert_eq!(result, store);
output.rewind().unwrap();
let boxes = parse_all_boxes(&mut output).unwrap();
assert!(boxes.iter().any(|b| b.box_type == BOX_EXIF));
}
#[test]
fn test_container_with_multiple_metadata_boxes() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let exif_box = build_box(&BOX_EXIF, b"exif_data");
let xml_box = build_box(&BOX_XML, b"<xmp>data</xmp>");
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&exif_box);
container.extend_from_slice(&xml_box);
container.extend_from_slice(&jxlc_box);
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b""))
.unwrap();
output.rewind().unwrap();
let boxes = parse_all_boxes(&mut output).unwrap();
assert!(boxes.iter().any(|b| b.box_type == BOX_EXIF));
assert!(boxes.iter().any(|b| b.box_type == BOX_XML));
assert!(boxes.iter().any(|b| b.box_type == BOX_JUMB));
assert!(boxes.iter().any(|b| b.box_type == BOX_JXLC));
}
#[test]
fn test_large_manifest_store() {
let container = build_minimal_jxl_container();
let large_manifest = c2pa_store(&vec![0xab; 256 * 1024]);
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &large_manifest)
.unwrap();
output.rewind().unwrap();
let result = jpegxl_io.read_cai(&mut output).unwrap();
assert_eq!(result, large_manifest);
}
#[test]
fn test_file_save_and_read() {
let container = build_minimal_jxl_container();
let temp_dir = tempdirectory().unwrap();
let test_path = temp_dir.path().join("test.jxl");
std::fs::write(&test_path, &container).unwrap();
let jpegxl_io = JpegXlIO {};
let store_bytes = c2pa_store(b"file_based_manifest_store");
jpegxl_io.save_cai_store(&test_path, &store_bytes).unwrap();
let read_back = jpegxl_io.read_cai_store(&test_path).unwrap();
assert_eq!(read_back, store_bytes);
}
#[test]
fn test_file_remove_cai_store() {
let container = build_minimal_jxl_container();
let temp_dir = tempdirectory().unwrap();
let test_path = temp_dir.path().join("test_remove.jxl");
std::fs::write(&test_path, &container).unwrap();
let jpegxl_io = JpegXlIO {};
jpegxl_io
.save_cai_store(&test_path, &c2pa_store(b"to_be_removed"))
.unwrap();
jpegxl_io.remove_cai_store(&test_path).unwrap();
let result = jpegxl_io.read_cai_store(&test_path);
assert!(matches!(result, Err(Error::JumbfNotFound)));
}
#[test]
fn test_file_object_locations() {
let container = build_minimal_jxl_container();
let temp_dir = tempdirectory().unwrap();
let test_path = temp_dir.path().join("test_locations.jxl");
std::fs::write(&test_path, &container).unwrap();
let jpegxl_io = JpegXlIO {};
jpegxl_io
.save_cai_store(&test_path, &c2pa_store(b"manifest_for_locations"))
.unwrap();
let locations = jpegxl_io.get_object_locations(&test_path).unwrap();
assert!(locations
.iter()
.any(|l| l.htype == HashBlockObjectType::Cai));
}
#[test]
fn test_container_with_jxlp_boxes() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let ftyp_box = build_box(&BOX_FTYP, ftyp_data);
let mut jxlp1_data = Vec::new();
jxlp1_data.write_u32::<BigEndian>(0).unwrap(); jxlp1_data.extend_from_slice(&[0xff, 0x0a]);
let jxlp1_box = build_box(&BOX_JXLP, &jxlp1_data);
let mut jxlp2_data = Vec::new();
jxlp2_data.write_u32::<BigEndian>(0x80000001).unwrap(); jxlp2_data.extend_from_slice(&[0x00, 0x01]);
let jxlp2_box = build_box(&BOX_JXLP, &jxlp2_data);
let mut container = Vec::new();
container.extend_from_slice(&JXL_CONTAINER_MAGIC);
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&jxlp1_box);
container.extend_from_slice(&jxlp2_box);
let mut input = Cursor::new(container);
let mut output = Cursor::new(Vec::new());
let jpegxl_io = JpegXlIO {};
jpegxl_io
.write_cai(&mut input, &mut output, &c2pa_store(b"manifest_with_jxlp"))
.unwrap();
output.rewind().unwrap();
let boxes = parse_all_boxes(&mut output).unwrap();
let jumb_idx = boxes.iter().position(|b| b.box_type == BOX_JUMB).unwrap();
let jxlp_idx = boxes.iter().position(|b| b.box_type == BOX_JXLP).unwrap();
assert!(jumb_idx < jxlp_idx);
output.rewind().unwrap();
let result = jpegxl_io.read_cai(&mut output).unwrap();
assert_eq!(result, c2pa_store(b"manifest_with_jxlp"));
}
#[test]
fn test_e2e_jpegxl_sign_read_validate() -> Result<()> {
static SAMPLE_JXL: &[u8] = include_bytes!("../../tests/fixtures/sample1.jxl");
static CERTS: &[u8] = include_bytes!("../../tests/fixtures/certs/ed25519.pub");
static PRIVATE_KEY: &[u8] = include_bytes!("../../tests/fixtures/certs/ed25519.pem");
fn ed_sign(data: &[u8], private_key: &[u8]) -> Result<Vec<u8>> {
use ed25519_dalek::{Signature, Signer, SigningKey};
use pem::parse;
let pem = parse(private_key).map_err(|e| Error::OtherError(Box::new(e)))?;
let key_bytes = &pem.contents()[16..];
let signing_key =
SigningKey::try_from(key_bytes).map_err(|e| Error::OtherError(Box::new(e)))?;
let signature: Signature = signing_key.sign(data);
Ok(signature.to_bytes().to_vec())
}
let context = test_context().into_shared();
let signer_fn = |_ctx: *const (), data: &[u8]| ed_sign(data, PRIVATE_KEY);
let signer = CallbackSigner::new(signer_fn, SigningAlg::Ed25519, CERTS);
let manifest_json = serde_json::json!({
"title": "E2E JPEG XL Integration Test",
"format": "image/jxl",
"claim_generator_info": [
{ "name": "jpegxl_e2e_test", "version": "0.1.0" }
],
"assertions": [
{
"label": "c2pa.actions",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType":
"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture",
"softwareAgent": {
"name": "jpegxl_e2e_test",
"version": "0.1.0"
}
}
]
}
},
{
"label": "c2pa.metadata",
"data": {
"@context": {
"exif": "http://ns.adobe.com/exif/1.0/"
},
"exif:GPSLatitude": "48,51.5N",
"exif:GPSLongitude": "2,17.8E"
},
"kind": "Json"
}
]
});
let mut builder =
Builder::from_shared_context(&context).with_definition(manifest_json.to_string())?;
let mut source = Cursor::new(SAMPLE_JXL);
let mut signed = Cursor::new(Vec::new());
builder.sign(&signer, "image/jxl", &mut source, &mut signed)?;
signed.rewind().unwrap();
let reader = Reader::from_shared_context(&context).with_stream("image/jxl", &mut signed)?;
let manifest = reader
.active_manifest()
.ok_or_else(|| Error::ClaimEncoding)?;
assert_eq!(
manifest.title().unwrap_or_default(),
"E2E JPEG XL Integration Test",
"manifest title must round-trip correctly"
);
let assertions = manifest.assertions();
assert!(
assertions
.iter()
.any(|a| a.label().starts_with("c2pa.actions")),
"manifest must contain a c2pa.actions assertion; got: {:?}",
assertions.iter().map(|a| a.label()).collect::<Vec<_>>()
);
if let Some(results) = reader.validation_results() {
if let Some(active) = results.active_manifest() {
let hard_failures: Vec<_> = active
.failure()
.iter()
.filter(|f| f.code() != "signingCredential.untrusted")
.collect();
assert!(
hard_failures.is_empty(),
"unexpected hard validation failures: {:?}",
hard_failures.iter().map(|f| f.code()).collect::<Vec<_>>()
);
let sig_validated = active
.success()
.iter()
.any(|s| s.code() == "claimSignature.validated");
assert!(
sig_validated,
"claimSignature.validated must be present in success codes"
);
}
}
let signed_bytes = signed.into_inner();
let file_len = signed_bytes.len() as u64;
let mut cursor = Cursor::new(&signed_bytes);
let jpegxl_io = JpegXlIO {};
let locations = jpegxl_io.get_object_locations_from_stream(&mut cursor)?;
let mut sorted_locs: Vec<_> = locations.iter().collect();
sorted_locs.sort_by_key(|l| l.offset);
let total_covered: usize = sorted_locs.iter().map(|l| l.length).sum();
assert_eq!(
total_covered, file_len as usize,
"object locations must cover the entire file ({file_len} bytes total); \
got {total_covered} bytes covered"
);
for window in sorted_locs.windows(2) {
let a_end = window[0].offset + window[0].length;
assert!(
a_end <= window[1].offset,
"hash object ranges must not overlap: [{}, {}) and [{}, {})",
window[0].offset,
a_end,
window[1].offset,
window[1].offset + window[1].length
);
}
let cai_count = sorted_locs
.iter()
.filter(|l| l.htype == HashBlockObjectType::Cai)
.count();
assert_eq!(
cai_count, 1,
"exactly one CAI hash object must be present; found {cai_count}"
);
for loc in &sorted_locs {
assert!(
matches!(
loc.htype,
HashBlockObjectType::Cai
| HashBlockObjectType::Xmp
| HashBlockObjectType::Other
| HashBlockObjectType::OtherExclusion
),
"unrecognised HashBlockObjectType {:?} at offset {}",
loc.htype,
loc.offset
);
}
cursor.rewind().unwrap();
let box_map = jpegxl_io.get_box_map(&mut cursor)?;
let total_bm: u64 = box_map.iter().map(|bm| bm.range_len).sum();
assert_eq!(
total_bm, file_len,
"box map must cover entire file ({file_len} bytes); got {total_bm} bytes"
);
for window in box_map.windows(2) {
assert!(
window[0].range_start + window[0].range_len <= window[1].range_start,
"box map entries must be ordered and non-overlapping: \
{:?} [{}, {}) vs {:?} [{}, {})",
window[0].names,
window[0].range_start,
window[0].range_start + window[0].range_len,
window[1].names,
window[1].range_start,
window[1].range_start + window[1].range_len,
);
}
let c2pa_entries: Vec<_> = box_map
.iter()
.filter(|bm| bm.names.first().is_some_and(|n| n == C2PA_BOXHASH))
.collect();
assert_eq!(
c2pa_entries.len(),
1,
"box map must contain exactly one {C2PA_BOXHASH} entry; \
found {}",
c2pa_entries.len()
);
assert!(
c2pa_entries[0].range_len > 0,
"C2PA box map entry must have a non-zero range length"
);
Ok(())
}
#[test]
fn test_find_jumb_data_rejects_oversized_box() {
let ftyp_box = build_box(&BOX_FTYP, b"jxl \0\0\0\0jxl ");
let jxlc_box = build_box(&BOX_JXLC, &[0xff, 0x0a, 0x00]);
let mut jumb_header = Vec::new();
jumb_header.extend_from_slice(&u32::MAX.to_be_bytes()); jumb_header.extend_from_slice(&BOX_JUMB);
let mut container = JXL_CONTAINER_MAGIC.to_vec();
container.extend_from_slice(&ftyp_box);
container.extend_from_slice(&jumb_header);
container.extend_from_slice(&jxlc_box);
let mut reader = Cursor::new(container);
let result = find_jumb_data(&mut reader);
assert!(
result.is_err(),
"oversized jumb box must be rejected, not cause OOM"
);
}
#[test]
fn test_parse_all_boxes_overflow_safe() {
let ftyp_data = b"jxl \0\0\0\0jxl ";
let mut overflow_box = Vec::new();
overflow_box.extend_from_slice(&1u32.to_be_bytes()); overflow_box.extend_from_slice(&BOX_FTYP); overflow_box.extend_from_slice(&u64::MAX.to_be_bytes()); overflow_box.extend_from_slice(ftyp_data);
let mut container = JXL_CONTAINER_MAGIC.to_vec();
container.extend_from_slice(&overflow_box);
let mut reader = Cursor::new(container);
let result = parse_all_boxes(&mut reader);
assert!(
result.is_ok(),
"parse_all_boxes should handle overflow-sized box gracefully: {result:?}"
);
}
#[test]
fn test_parse_all_boxes_count_limit() {
let mut container = JXL_CONTAINER_MAGIC.to_vec();
let empty_box = build_box(&BOX_FTYP, &[]);
for _ in 0..=MAX_JXL_BOX_COUNT {
container.extend_from_slice(&empty_box);
}
let mut reader = Cursor::new(container);
let result = parse_all_boxes(&mut reader);
assert!(
matches!(result, Err(Error::InvalidAsset(_))),
"container with > {MAX_JXL_BOX_COUNT} boxes must be rejected: {result:?}"
);
}
}