use crate::c2pa_formats::{AssetFormat, DataHashExclusion, FormatError};
const FMT: AssetFormat = AssetFormat::Gif;
const EXTENSION_INTRODUCER: u8 = 0x21;
const APP_EXTENSION_LABEL: u8 = 0xFF;
const IMAGE_SEPARATOR: u8 = 0x2C;
const TRAILER: u8 = 0x3B;
const APP_BLOCK_SIZE: u8 = 0x0B;
const APP_ID: &[u8; 11] = b"C2PA_GIF\x01\x00\x00";
fn check_header(data: &[u8]) -> Result<(), FormatError> {
if data.len() < 13 || (&data[..6] != b"GIF87a" && &data[..6] != b"GIF89a") {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "missing GIF signature",
});
}
Ok(())
}
fn first_block_offset(data: &[u8]) -> Result<usize, FormatError> {
check_header(data)?;
let packed = data[10];
let mut off = 13;
if packed & 0x80 != 0 {
let gct_size = 3 * (1usize << ((packed & 0x07) + 1));
off += gct_size;
}
if off > data.len() {
return Err(FormatError::Truncated(FMT));
}
Ok(off)
}
fn skip_subblocks(data: &[u8], mut pos: usize) -> Result<usize, FormatError> {
loop {
let len = *data.get(pos).ok_or(FormatError::Truncated(FMT))? as usize;
pos += 1;
if len == 0 {
return Ok(pos);
}
pos = pos
.checked_add(len)
.filter(|&e| e <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
}
}
fn read_subblocks(data: &[u8], mut pos: usize) -> Result<(Vec<u8>, usize), FormatError> {
let mut out = Vec::new();
loop {
let len = *data.get(pos).ok_or(FormatError::Truncated(FMT))? as usize;
pos += 1;
if len == 0 {
return Ok((out, pos));
}
let end = pos
.checked_add(len)
.filter(|&e| e <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
out.extend_from_slice(&data[pos..end]);
pos = end;
}
}
fn find_c2pa_extensions(data: &[u8]) -> Result<Vec<(usize, usize, Vec<u8>)>, FormatError> {
let mut pos = first_block_offset(data)?;
let mut found = Vec::new();
while pos < data.len() {
match data[pos] {
TRAILER => break,
IMAGE_SEPARATOR => {
let packed = *data.get(pos + 9).ok_or(FormatError::Truncated(FMT))?;
pos += 10;
if packed & 0x80 != 0 {
pos += 3 * (1usize << ((packed & 0x07) + 1));
}
pos = pos
.checked_add(1)
.filter(|&e| e <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
pos = skip_subblocks(data, pos)?;
}
EXTENSION_INTRODUCER => {
let block_start = pos;
let label = *data.get(pos + 1).ok_or(FormatError::Truncated(FMT))?;
let sub_start = pos + 2;
if label == APP_EXTENSION_LABEL {
let id_len = *data.get(sub_start).ok_or(FormatError::Truncated(FMT))? as usize;
let id_start = sub_start + 1;
let id_end = id_start
.checked_add(id_len)
.filter(|&e| e <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
if id_len == APP_BLOCK_SIZE as usize && &data[id_start..id_end] == APP_ID {
let (payload, block_end) = read_subblocks(data, id_end)?;
found.push((block_start, block_end, payload));
pos = block_end;
} else {
pos = skip_subblocks(data, id_end)?;
}
} else {
pos = skip_subblocks(data, sub_start)?;
}
}
_ => {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "unexpected GIF block",
})
}
}
}
Ok(found)
}
pub(crate) fn extract(data: &[u8]) -> Result<Option<Vec<u8>>, FormatError> {
Ok(find_c2pa_extensions(data)?
.into_iter()
.next()
.map(|(_, _, payload)| payload))
}
#[cfg(test)]
pub(crate) fn strip(asset: &[u8]) -> Result<Vec<u8>, FormatError> {
let spans = find_c2pa_extensions(asset)?;
if spans.is_empty() {
return Ok(asset.to_vec());
}
let mut out = Vec::with_capacity(asset.len());
let mut cursor = 0usize;
for (start, end, _) in spans {
out.extend_from_slice(&asset[cursor..start]);
cursor = end;
}
out.extend_from_slice(&asset[cursor..]);
Ok(out)
}
#[cfg(test)]
pub(crate) fn build_application_extension(manifest_store: &[u8]) -> Vec<u8> {
let mut extension = Vec::with_capacity(manifest_store.len() + 32);
extension.push(EXTENSION_INTRODUCER);
extension.push(APP_EXTENSION_LABEL);
extension.push(APP_BLOCK_SIZE);
extension.extend_from_slice(APP_ID);
for chunk in manifest_store.chunks(255) {
extension.push(chunk.len() as u8);
extension.extend_from_slice(chunk);
}
extension.push(0);
extension
}
#[cfg(test)]
pub(crate) fn embed(asset: &[u8], manifest_store: &[u8]) -> Result<Vec<u8>, FormatError> {
let clean = strip(asset)?;
let at = first_block_offset(&clean)?;
let ext = build_application_extension(manifest_store);
let mut out = Vec::with_capacity(clean.len() + ext.len());
out.extend_from_slice(&clean[..at]);
out.extend_from_slice(&ext);
out.extend_from_slice(&clean[at..]);
Ok(out)
}
pub(crate) fn exclusions(data: &[u8]) -> Result<Vec<DataHashExclusion>, FormatError> {
Ok(find_c2pa_extensions(data)?
.into_iter()
.map(|(start, end, _)| DataHashExclusion {
start,
length: end - start,
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::c2pa_formats::tests::dummy_manifest_store;
fn tiny_gif() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"GIF89a");
v.extend_from_slice(&[1, 0, 1, 0, 0x00, 0x00, 0x00]);
v.push(IMAGE_SEPARATOR);
v.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 0x00]);
v.push(0x02);
v.push(0x02);
v.extend_from_slice(&[0x44, 0x01]);
v.push(0x00);
v.push(TRAILER);
v
}
#[test]
fn roundtrip() {
let store = dummy_manifest_store();
let embedded = embed(&tiny_gif(), &store).unwrap();
assert_eq!(
extract(&embedded).unwrap().as_deref(),
Some(store.as_slice())
);
}
#[test]
fn re_embed_replaces_manifest() {
let first_store = dummy_manifest_store();
let second_assertion =
crate::c2pa_core::jumbf::assertion_box("c2pa.actions.v2", &[0xa0], None);
let second_manifest = crate::c2pa_core::jumbf::build_manifest(
"urn:c2pa:test:0002",
&[second_assertion],
&[0xa0],
&[0xd2, 0x84],
);
let second_store = crate::c2pa_core::jumbf::build_manifest_store(&[second_manifest]);
assert_ne!(first_store, second_store, "fixture stores must differ");
let first = embed(&tiny_gif(), &first_store).unwrap();
let second = embed(&first, &second_store).unwrap();
assert_eq!(
extract(&second).unwrap().as_deref(),
Some(second_store.as_slice())
);
assert_eq!(
find_c2pa_extensions(&second).unwrap().len(),
1,
"re-embed must leave exactly one C2PA extension"
);
}
#[test]
fn roundtrip_multi_subblock() {
let assertion =
crate::c2pa_core::jumbf::assertion_box("c2pa.big", &vec![0x5Au8; 1000], None);
let manifest = crate::c2pa_core::jumbf::build_manifest(
"urn:c2pa:big",
&[assertion],
&[0xa0],
&[0xd2, 0x84],
);
let store = crate::c2pa_core::jumbf::build_manifest_store(&[manifest]);
let embedded = embed(&tiny_gif(), &store).unwrap();
assert_eq!(
extract(&embedded).unwrap().as_deref(),
Some(store.as_slice())
);
}
#[test]
fn bare_asset_has_no_manifest() {
assert_eq!(extract(&tiny_gif()).unwrap(), None);
}
#[test]
fn rejects_non_gif() {
assert!(matches!(
extract(b"not a gif file"),
Err(FormatError::InvalidStructure { .. })
));
}
#[test]
fn exclusions_cover_app_extension() {
let store = dummy_manifest_store();
let asset = tiny_gif();
let embedded = embed(&asset, &store).unwrap();
let ex = exclusions(&embedded).unwrap();
assert_eq!(ex.len(), 1);
let DataHashExclusion { start, length } = ex[0];
assert_eq!(embedded[start], EXTENSION_INTRODUCER);
let mut rebuilt = embedded[..start].to_vec();
rebuilt.extend_from_slice(&embedded[start + length..]);
assert_eq!(rebuilt, asset);
}
#[test]
fn exclusions_cover_multi_subblock_extension() {
let assertion =
crate::c2pa_core::jumbf::assertion_box("c2pa.big", &vec![0x5Au8; 1000], None);
let manifest = crate::c2pa_core::jumbf::build_manifest(
"urn:c2pa:big",
&[assertion],
&[0xa0],
&[0xd2, 0x84],
);
let store = crate::c2pa_core::jumbf::build_manifest_store(&[manifest]);
let asset = tiny_gif();
let embedded = embed(&asset, &store).unwrap();
let ex = exclusions(&embedded).unwrap();
assert_eq!(ex.len(), 1);
let DataHashExclusion { start, length } = ex[0];
let mut rebuilt = embedded[..start].to_vec();
rebuilt.extend_from_slice(&embedded[start + length..]);
assert_eq!(rebuilt, asset);
}
#[test]
fn exclusions_empty_without_manifest() {
assert!(exclusions(&tiny_gif()).unwrap().is_empty());
}
}