use crate::c2pa_formats::util::be_u32;
use crate::c2pa_formats::{AssetFormat, DataHashExclusion, FormatError};
const FMT: AssetFormat = AssetFormat::Font;
const TAG_C2PA: &[u8; 4] = b"C2PA";
fn is_sfnt_version(v: &[u8]) -> bool {
matches!(v, [0x00, 0x01, 0x00, 0x00] | b"OTTO" | b"true" | b"typ1")
}
const C2PA_TABLE_RECORD_LEN: usize = 20;
fn c2pa_table(data: &[u8]) -> Result<Option<&[u8]>, FormatError> {
if data.len() < 12 || !is_sfnt_version(&data[0..4]) {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "not an SFNT font",
});
}
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
12usize
.checked_add(
num_tables
.checked_mul(16)
.ok_or(FormatError::Truncated(FMT))?,
)
.filter(|end| *end <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
for index in 0..num_tables {
let record = 12 + index * 16;
if &data[record..record + 4] != TAG_C2PA {
continue;
}
let offset = be_u32(data, record + 8).ok_or(FormatError::Truncated(FMT))? as usize;
let length = be_u32(data, record + 12).ok_or(FormatError::Truncated(FMT))? as usize;
let end = offset
.checked_add(length)
.filter(|end| *end <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
return Ok(Some(&data[offset..end]));
}
Ok(None)
}
fn table_range<'a>(
table: &'a [u8],
offset: usize,
length: usize,
detail: &'static str,
) -> Result<Option<&'a [u8]>, FormatError> {
if offset == 0 && length == 0 {
return Ok(None);
}
if offset < C2PA_TABLE_RECORD_LEN || length == 0 {
return Err(FormatError::InvalidStructure {
format: FMT,
detail,
});
}
let end = offset
.checked_add(length)
.filter(|end| *end <= table.len())
.ok_or(FormatError::InvalidStructure {
format: FMT,
detail,
})?;
Ok(Some(&table[offset..end]))
}
fn is_legacy_raw_store(table: &[u8]) -> bool {
table.len() >= 8 && &table[4..8] == b"jumb"
}
pub(crate) fn extract(data: &[u8]) -> Result<Option<Vec<u8>>, FormatError> {
let Some(table) = c2pa_table(data)? else {
return Ok(None);
};
if is_legacy_raw_store(table) {
return Ok(Some(table.to_vec()));
}
if table.len() < C2PA_TABLE_RECORD_LEN {
return Err(FormatError::Truncated(FMT));
}
let offset = be_u32(table, 12).ok_or(FormatError::Truncated(FMT))? as usize;
let length = be_u32(table, 16).ok_or(FormatError::Truncated(FMT))? as usize;
Ok(table_range(
table,
offset,
length,
"invalid C2PA font-table manifest-store range",
)?
.map(ToOwned::to_owned))
}
pub(crate) fn remote_manifest_uri(data: &[u8]) -> Result<Option<&str>, FormatError> {
let Some(table) = c2pa_table(data)? else {
return Ok(None);
};
if is_legacy_raw_store(table) {
return Ok(None);
}
if table.len() < C2PA_TABLE_RECORD_LEN {
return Err(FormatError::Truncated(FMT));
}
let offset = be_u32(table, 4).ok_or(FormatError::Truncated(FMT))? as usize;
let length = u16::from_be_bytes([table[8], table[9]]) as usize;
let Some(uri) = table_range(
table,
offset,
length,
"invalid C2PA font-table active-manifest URI range",
)?
else {
return Ok(None);
};
std::str::from_utf8(uri)
.map(Some)
.map_err(|_| FormatError::InvalidStructure {
format: FMT,
detail: "C2PA font-table active-manifest URI is not UTF-8",
})
}
pub(crate) fn box_spans(data: &[u8]) -> Result<Vec<crate::c2pa_formats::BoxSpan>, FormatError> {
if data.len() < 12 || !is_sfnt_version(&data[0..4]) {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "not an SFNT font",
});
}
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
12usize
.checked_add(
num_tables
.checked_mul(16)
.ok_or(FormatError::Truncated(FMT))?,
)
.filter(|end| *end <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
let mut spans = Vec::with_capacity(num_tables);
for index in 0..num_tables {
let record = 12 + index * 16;
let tag = &data[record..record + 4];
let start = be_u32(data, record + 8).ok_or(FormatError::Truncated(FMT))? as usize;
let length = be_u32(data, record + 12).ok_or(FormatError::Truncated(FMT))? as usize;
let end = start
.checked_add(length)
.filter(|end| *end <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
spans.push(crate::c2pa_formats::BoxSpan::contiguous(
if tag == TAG_C2PA {
"C2PA".to_string()
} else {
String::from_utf8_lossy(tag).into_owned()
},
start,
end,
));
}
Ok(spans)
}
#[cfg(test)]
fn table_checksum(data: &[u8]) -> u32 {
let mut sum = 0u32;
let mut i = 0;
while i < data.len() {
let mut word = [0u8; 4];
let n = core::cmp::min(4, data.len() - i);
word[..n].copy_from_slice(&data[i..i + n]);
sum = sum.wrapping_add(u32::from_be_bytes(word));
i += 4;
}
sum
}
#[cfg(test)]
fn search_params(n: usize) -> (u16, u16, u16) {
let mut es = 0u16;
let mut pow = 1usize;
while pow * 2 <= n {
pow *= 2;
es += 1;
}
let search_range = (pow * 16) as u16;
let range_shift = (n * 16) as u16 - search_range;
(search_range, es, range_shift)
}
#[cfg(test)]
pub(crate) fn embed(asset: &[u8], manifest_store: &[u8]) -> Result<Vec<u8>, FormatError> {
if asset.len() < 12 || !is_sfnt_version(&asset[0..4]) {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "not an SFNT font",
});
}
let mut sfnt_version = [0u8; 4];
sfnt_version.copy_from_slice(&asset[0..4]);
let num_tables = u16::from_be_bytes([asset[4], asset[5]]) as usize;
let dir_start: usize = 12;
dir_start
.checked_add(num_tables * 16)
.filter(|&e| e <= asset.len())
.ok_or(FormatError::Truncated(FMT))?;
let mut tables: Vec<([u8; 4], Vec<u8>)> = Vec::new();
for i in 0..num_tables {
let rec = dir_start + i * 16;
let mut tag = [0u8; 4];
tag.copy_from_slice(&asset[rec..rec + 4]);
if &tag == TAG_C2PA {
continue;
}
let offset = be_u32(asset, rec + 8).ok_or(FormatError::Truncated(FMT))? as usize;
let length = be_u32(asset, rec + 12).ok_or(FormatError::Truncated(FMT))? as usize;
let end = offset
.checked_add(length)
.filter(|&e| e <= asset.len())
.ok_or(FormatError::Truncated(FMT))?;
tables.push((tag, asset[offset..end].to_vec()));
}
let mut c2pa_table = Vec::with_capacity(C2PA_TABLE_RECORD_LEN + manifest_store.len());
c2pa_table.extend_from_slice(&1u16.to_be_bytes());
c2pa_table.extend_from_slice(&0u16.to_be_bytes());
c2pa_table.extend_from_slice(&0u32.to_be_bytes());
c2pa_table.extend_from_slice(&0u16.to_be_bytes());
c2pa_table.extend_from_slice(&0u16.to_be_bytes());
c2pa_table.extend_from_slice(&(C2PA_TABLE_RECORD_LEN as u32).to_be_bytes());
c2pa_table.extend_from_slice(&(manifest_store.len() as u32).to_be_bytes());
c2pa_table.extend_from_slice(manifest_store);
tables.push((*TAG_C2PA, c2pa_table));
tables.sort_by_key(|a| a.0);
let new_num = tables.len();
let dir_size = 12 + new_num * 16;
let mut offsets = Vec::with_capacity(new_num);
let mut cursor = dir_size;
for (_, d) in &tables {
offsets.push(cursor);
cursor += (d.len() + 3) & !3;
}
let mut out = vec![0u8; cursor];
out[0..4].copy_from_slice(&sfnt_version);
out[4..6].copy_from_slice(&(new_num as u16).to_be_bytes());
let (sr, es, rs) = search_params(new_num);
out[6..8].copy_from_slice(&sr.to_be_bytes());
out[8..10].copy_from_slice(&es.to_be_bytes());
out[10..12].copy_from_slice(&rs.to_be_bytes());
let mut head_off = None;
for (i, (tag, d)) in tables.iter().enumerate() {
let off = offsets[i];
out[off..off + d.len()].copy_from_slice(d);
if tag == b"head" {
head_off = Some(off);
}
}
if let Some(off) = head_off {
if cursor >= off + 12 {
out[off + 8..off + 12].copy_from_slice(&[0, 0, 0, 0]);
}
}
for (i, (tag, d)) in tables.iter().enumerate() {
let rec = 12 + i * 16;
let off = offsets[i];
let padded = (d.len() + 3) & !3;
let checksum = table_checksum(&out[off..off + padded]);
out[rec..rec + 4].copy_from_slice(tag);
out[rec + 4..rec + 8].copy_from_slice(&checksum.to_be_bytes());
out[rec + 8..rec + 12].copy_from_slice(&(off as u32).to_be_bytes());
out[rec + 12..rec + 16].copy_from_slice(&(d.len() as u32).to_be_bytes());
}
if let Some(off) = head_off {
if cursor >= off + 12 {
let whole = table_checksum(&out);
let adj = 0xB1B0AFBAu32.wrapping_sub(whole);
out[off + 8..off + 12].copy_from_slice(&adj.to_be_bytes());
}
}
Ok(out)
}
pub(crate) fn exclusions(data: &[u8]) -> Result<Vec<DataHashExclusion>, FormatError> {
if data.len() < 12 || !is_sfnt_version(&data[0..4]) {
return Ok(Vec::new());
}
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
let dir_start: usize = 12;
dir_start
.checked_add(num_tables * 16)
.filter(|&e| e <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
let mut out: Vec<DataHashExclusion> = Vec::new();
for i in 0..num_tables {
let rec = dir_start + i * 16;
let tag = &data[rec..rec + 4];
if tag == TAG_C2PA {
let table_offset = be_u32(data, rec + 8).ok_or(FormatError::Truncated(FMT))? as usize;
let table_length = be_u32(data, rec + 12).ok_or(FormatError::Truncated(FMT))? as usize;
let table_end = table_offset
.checked_add(table_length)
.filter(|end| *end <= data.len())
.ok_or(FormatError::Truncated(FMT))?;
let table = &data[table_offset..table_end];
let (manifest_offset, manifest_length) = if is_legacy_raw_store(table) {
(table_offset, table_length)
} else {
if table.len() < C2PA_TABLE_RECORD_LEN {
return Err(FormatError::Truncated(FMT));
}
let offset = be_u32(table, 12).ok_or(FormatError::Truncated(FMT))? as usize;
let length = be_u32(table, 16).ok_or(FormatError::Truncated(FMT))? as usize;
table_range(
table,
offset,
length,
"invalid C2PA font-table manifest-store range",
)?;
(table_offset + offset, length)
};
out.push(DataHashExclusion {
start: rec + 4,
length: 4,
});
if manifest_length != 0 {
out.push(DataHashExclusion {
start: manifest_offset,
length: manifest_length,
});
}
} else if tag == b"head" {
let offset = be_u32(data, rec + 8).ok_or(FormatError::Truncated(FMT))? as usize;
if offset
.checked_add(12)
.filter(|&e| e <= data.len())
.is_some()
{
out.push(DataHashExclusion {
start: offset + 8,
length: 4,
}); }
}
}
out.sort_by_key(|e| e.start);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn font_with_manifest(manifest: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]); v.extend_from_slice(&1u16.to_be_bytes()); v.extend_from_slice(&[0, 0, 0, 0, 0, 0]); let table_off = 12 + 16; v.extend_from_slice(TAG_C2PA);
v.extend_from_slice(&0u32.to_be_bytes()); v.extend_from_slice(&(table_off as u32).to_be_bytes());
v.extend_from_slice(&(manifest.len() as u32).to_be_bytes());
v.extend_from_slice(manifest);
v
}
fn font_with_table_record(manifest: &[u8], remote_uri: Option<&str>) -> Vec<u8> {
const RECORD_LEN: usize = 20;
let uri = remote_uri.unwrap_or_default().as_bytes();
let uri_offset = if uri.is_empty() { 0 } else { RECORD_LEN as u32 };
let store_offset = RECORD_LEN + uri.len();
let mut table = Vec::with_capacity(store_offset + manifest.len());
table.extend_from_slice(&1u16.to_be_bytes());
table.extend_from_slice(&0u16.to_be_bytes());
table.extend_from_slice(&uri_offset.to_be_bytes());
table.extend_from_slice(&(uri.len() as u16).to_be_bytes());
table.extend_from_slice(&0u16.to_be_bytes());
table.extend_from_slice(
&(if manifest.is_empty() {
0
} else {
store_offset as u32
})
.to_be_bytes(),
);
table.extend_from_slice(&(manifest.len() as u32).to_be_bytes());
table.extend_from_slice(uri);
table.extend_from_slice(manifest);
font_with_manifest(&table)
}
fn bare_font() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
v.extend_from_slice(&0u16.to_be_bytes());
v.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
v
}
#[test]
fn box_hash_spans_follow_font_table_directory_order() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let embedded = embed(&font_with_head(), &store).unwrap();
let spans = box_spans(&embedded).unwrap();
assert_eq!(
spans
.iter()
.map(|span| span.name.as_str())
.collect::<Vec<_>>(),
["C2PA", "head"]
);
for span in spans {
assert!(span.start() < span.end());
assert!(span.end() <= embedded.len());
}
}
#[test]
fn exclusions_cover_c2pa_table() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let embedded = embed(&bare_font(), &store).unwrap();
let ex = exclusions(&embedded).unwrap();
assert_eq!(ex.len(), 2);
let payload = ex.iter().max_by_key(|e| e.length).unwrap();
assert_eq!(
&embedded[payload.start..payload.start + payload.length],
store.as_slice()
);
assert!(exclusions(&bare_font()).unwrap().is_empty());
}
#[test]
fn data_hash_stable_across_manifest_swap() {
let placeholder = vec![0u8; 64];
let real = vec![0xABu8; 64];
let fa = embed(&font_with_head(), &placeholder).unwrap();
let fb = embed(&font_with_head(), &real).unwrap();
assert_eq!(fa.len(), fb.len());
let ex = exclusions(&fa).unwrap();
let excluded = |i: usize| ex.iter().any(|e| i >= e.start && i < e.start + e.length);
for i in 0..fa.len() {
if !excluded(i) {
assert_eq!(
fa[i], fb[i],
"non-excluded byte {i} changed across the manifest swap"
);
}
}
assert_eq!(ex.len(), 3);
}
fn font_with_head() -> Vec<u8> {
let head_len = 54usize;
let head_off = 12 + 16;
let total = head_off + ((head_len + 3) & !3);
let mut v = vec![0u8; total];
v[0..4].copy_from_slice(&[0x00, 0x01, 0x00, 0x00]);
v[4..6].copy_from_slice(&1u16.to_be_bytes()); v[6..8].copy_from_slice(&16u16.to_be_bytes()); v[12..16].copy_from_slice(b"head");
v[20..24].copy_from_slice(&(head_off as u32).to_be_bytes()); v[24..28].copy_from_slice(&(head_len as u32).to_be_bytes()); v[head_off + 12..head_off + 16].copy_from_slice(&0x5F0F3CF5u32.to_be_bytes());
v
}
#[test]
fn extract_tagged_manifest() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let asset = font_with_manifest(&store);
assert_eq!(extract(&asset).unwrap().as_deref(), Some(store.as_slice()));
}
#[test]
fn extracts_manifest_from_c2pa_2_4_table_record() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let asset = font_with_table_record(&store, Some("https://example.test/active.c2pa"));
assert_eq!(extract(&asset).unwrap().as_deref(), Some(store.as_slice()));
}
#[test]
fn reports_remote_uri_from_remote_only_table_record() {
let asset = font_with_table_record(&[], Some("https://example.test/active.c2pa"));
assert_eq!(extract(&asset).unwrap(), None);
assert_eq!(
remote_manifest_uri(&asset).unwrap(),
Some("https://example.test/active.c2pa")
);
}
#[test]
fn bare_asset_has_no_manifest() {
assert_eq!(extract(&bare_font()).unwrap(), None);
}
#[test]
fn embed_round_trips() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let out = embed(&font_with_head(), &store).unwrap();
assert_eq!(extract(&out).unwrap().as_deref(), Some(store.as_slice()));
}
#[test]
fn embed_recomputes_checksum_adjustment() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let out = embed(&font_with_head(), &store).unwrap();
assert_eq!(table_checksum(&out), 0xB1B0AFBA);
}
#[test]
fn embed_into_bare_round_trips() {
let store = crate::c2pa_formats::tests::dummy_manifest_store();
let out = embed(&bare_font(), &store).unwrap();
assert_eq!(extract(&out).unwrap().as_deref(), Some(store.as_slice()));
}
#[test]
fn re_embed_replaces_manifest() {
let first = embed(&font_with_head(), b"FIRST-manifest-store").unwrap();
let second = embed(&first, b"SECOND-manifest-store").unwrap();
assert_eq!(
extract(&second).unwrap().as_deref(),
Some(&b"SECOND-manifest-store"[..])
);
let num = u16::from_be_bytes([second[4], second[5]]) as usize;
let c2pa = (0..num)
.filter(|&i| &second[12 + i * 16..16 + i * 16] == TAG_C2PA)
.count();
assert_eq!(c2pa, 1);
}
#[test]
fn rejects_non_font() {
assert!(extract(b"%PDF-1.7\x00\x00\x00\x00").is_err());
}
}