use crate::c2pa_formats::util::{be_u16, be_u32, le_u32, walk_iso_boxes};
use crate::c2pa_formats::AssetFormat;
const DCTERMS_NS: &str = "http://purl.org/dc/terms/";
const DEFAULT_PREFIX: &str = "dcterms";
const MAX_PACKETS: usize = 8;
const MAX_PACKET_BYTES: usize = 4 * 1024 * 1024;
const MAX_URI_BYTES: usize = 4096;
const JPEG_XMP_SIGNATURE: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
const PNG_XMP_KEYWORD: &[u8] = b"XML:com.adobe.xmp";
const BMFF_XMP_UUID: [u8; 16] = [
0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac,
];
const GIF_XMP_APP_ID: &[u8; 11] = b"XMP DataXMP";
pub(crate) const TIFF_XMP_TAG: u16 = 0x02BC;
pub(crate) fn provenance_uri(format: AssetFormat, data: &[u8]) -> Option<String> {
let mut packets = Vec::new();
collect_packets(format, data, &mut packets);
packets
.into_iter()
.filter(|packet| packet.len() <= MAX_PACKET_BYTES)
.find_map(provenance_from_packet)
}
fn collect_packets<'a>(format: AssetFormat, data: &'a [u8], out: &mut Vec<&'a [u8]>) {
match format {
AssetFormat::Jpeg => jpeg_packets(data, out),
AssetFormat::Png => png_packets(data, out),
AssetFormat::Bmff | AssetFormat::Jxl => iso_packets(format, data, out),
AssetFormat::Riff => riff_packets(data, out),
AssetFormat::Gif => gif_packets(data, out),
AssetFormat::Tiff => out.extend(crate::c2pa_formats::tiff::xmp_packet(data)),
AssetFormat::Svg => out.push(data),
_ => {}
}
}
fn jpeg_packets<'a>(data: &'a [u8], out: &mut Vec<&'a [u8]>) {
if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
return;
}
let mut pos = 2;
while pos + 4 <= data.len() && out.len() < MAX_PACKETS {
if data[pos] != 0xFF {
return;
}
let marker = data[pos + 1];
if marker == 0xFF {
pos += 1;
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
pos += 2;
continue;
}
if marker == 0xDA || marker == 0xD9 {
return;
}
let Some(length) = be_u16(data, pos + 2).map(usize::from) else {
return;
};
if length < 2 {
return;
}
let Some(end) = pos.checked_add(2 + length).filter(|&e| e <= data.len()) else {
return;
};
let payload = &data[pos + 4..end];
if marker == 0xE1 {
if let Some(packet) = payload.strip_prefix(JPEG_XMP_SIGNATURE) {
out.push(packet);
}
}
pos = end;
}
}
fn png_packets<'a>(data: &'a [u8], out: &mut Vec<&'a [u8]>) {
const SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
if data.len() < 8 || data[..8] != SIGNATURE {
return;
}
let mut pos = 8;
while pos + 8 <= data.len() && out.len() < MAX_PACKETS {
let Some(length) = be_u32(data, pos).and_then(|len| usize::try_from(len).ok()) else {
return;
};
let type_code = &data[pos + 4..pos + 8];
let Some(end) = pos
.checked_add(12)
.and_then(|base| base.checked_add(length))
.filter(|&e| e <= data.len())
else {
return;
};
if type_code == b"iTXt" {
let body = &data[pos + 8..end - 4];
if let Some(packet) = png_itxt_xmp(body) {
out.push(packet);
}
}
if type_code == b"IEND" {
return;
}
pos = end;
}
}
fn png_itxt_xmp(body: &[u8]) -> Option<&[u8]> {
let rest = body.strip_prefix(PNG_XMP_KEYWORD)?;
let rest = rest.strip_prefix(b"\0")?;
if *rest.first()? != 0 {
return None;
}
let rest = rest.get(2..)?;
let language_end = rest.iter().position(|&b| b == 0)?;
let rest = rest.get(language_end + 1..)?;
let translated_end = rest.iter().position(|&b| b == 0)?;
rest.get(translated_end + 1..)
}
fn iso_packets<'a>(format: AssetFormat, data: &'a [u8], out: &mut Vec<&'a [u8]>) {
let _ = walk_iso_boxes(data, format, |iso| {
if out.len() >= MAX_PACKETS || iso.payload_start > iso.end || iso.end > data.len() {
return;
}
let payload = &data[iso.payload_start..iso.end];
if &iso.box_type == b"uuid" {
if payload.len() > 16 && payload[..16] == BMFF_XMP_UUID {
out.push(&payload[16..]);
}
} else if &iso.box_type == b"xml " {
out.push(payload);
}
});
}
fn riff_packets<'a>(data: &'a [u8], out: &mut Vec<&'a [u8]>) {
if data.len() < 12 || &data[..4] != b"RIFF" {
return;
}
let mut pos = 12;
while pos + 8 <= data.len() && out.len() < MAX_PACKETS {
let Some(size) = le_u32(data, pos + 4).and_then(|len| usize::try_from(len).ok()) else {
return;
};
let Some(end) = pos.checked_add(8 + size).filter(|&e| e <= data.len()) else {
return;
};
if &data[pos..pos + 4] == b"XMP " {
out.push(&data[pos + 8..end]);
}
pos = end + (end & 1);
}
}
fn gif_packets<'a>(data: &'a [u8], out: &mut Vec<&'a [u8]>) {
if data.len() < 13 || !(data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
return;
}
let mut pos = 0;
while pos + 14 <= data.len() && out.len() < MAX_PACKETS {
if data[pos] == 0x21
&& data[pos + 1] == 0xFF
&& data[pos + 2] == 0x0B
&& &data[pos + 3..pos + 14] == GIF_XMP_APP_ID
{
let body = &data[pos + 14..];
let end = body
.windows(3)
.position(|window| window == [0x01, 0xFF, 0xFE])
.unwrap_or(body.len());
out.push(&body[..end]);
}
pos += 1;
}
}
fn provenance_from_packet(packet: &[u8]) -> Option<String> {
let text = std::str::from_utf8(packet).ok()?;
for prefix in dcterms_prefixes(text) {
if let Some(uri) = provenance_for_prefix(text, &prefix) {
return Some(uri);
}
}
None
}
fn dcterms_prefixes(text: &str) -> Vec<String> {
let mut prefixes = Vec::new();
let mut rest = text;
while let Some(at) = rest.find("xmlns:") {
rest = &rest[at + "xmlns:".len()..];
let Some(equals) = rest.find('=') else { break };
let prefix = rest[..equals].trim();
let after = rest[equals + 1..].trim_start();
let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else {
continue;
};
let Some(close) = after[1..].find(quote) else {
continue;
};
if &after[1..1 + close] == DCTERMS_NS
&& !prefix.is_empty()
&& !prefix.contains(char::is_whitespace)
&& !prefixes.iter().any(|known| known == prefix)
{
prefixes.push(prefix.to_string());
}
}
if !prefixes.iter().any(|known| known == DEFAULT_PREFIX) {
prefixes.push(DEFAULT_PREFIX.to_string());
}
prefixes
}
fn provenance_for_prefix(text: &str, prefix: &str) -> Option<String> {
let needle = format!("{prefix}:provenance");
let mut search = 0usize;
while let Some(at) = text[search..].find(&needle) {
let start = search + at;
search = start + needle.len();
let after = &text[search..];
if after
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
{
continue;
}
let opened_element = text[..start].ends_with('<');
let trimmed = after.trim_start();
if !opened_element {
if let Some(value) = trimmed.strip_prefix('=') {
if let Some(uri) = quoted_value(value.trim_start()) {
return Some(uri);
}
}
continue;
}
if let Some(uri) = element_value(after, &needle) {
return Some(uri);
}
}
None
}
fn element_value(after: &str, needle: &str) -> Option<String> {
let tag_end = after.find('>')?;
let attributes = &after[..tag_end];
if let Some(at) = attributes.find("rdf:resource") {
let rest = attributes[at + "rdf:resource".len()..].trim_start();
if let Some(value) = rest.strip_prefix('=') {
if let Some(uri) = quoted_value(value.trim_start()) {
return Some(uri);
}
}
}
if attributes.trim_end().ends_with('/') {
return None;
}
let body = &after[tag_end + 1..];
let close = format!("</{needle}>");
let body_end = body.find(&close)?;
accept_uri(&body[..body_end])
}
fn quoted_value(text: &str) -> Option<String> {
let quote = text.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &text[1..];
let close = rest.find(quote)?;
accept_uri(&rest[..close])
}
fn accept_uri(raw: &str) -> Option<String> {
if raw.len() > MAX_URI_BYTES {
return None;
}
let uri = unescape_xml(raw.trim());
if uri.contains(['<', '>']) {
return None;
}
let scheme = uri.split_once("://")?.0;
(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")).then_some(uri)
}
fn unescape_xml(raw: &str) -> String {
if !raw.contains('&') {
return raw.to_string();
}
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
let tail = &rest[at..];
let Some(semi) = tail.find(';').filter(|&semi| semi <= 12) else {
out.push('&');
rest = &tail[1..];
continue;
};
let entity = &tail[1..semi];
let resolved = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => entity
.strip_prefix('#')
.and_then(|digits| match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => digits.parse::<u32>().ok(),
})
.and_then(char::from_u32),
};
match resolved {
Some(character) => {
out.push(character);
rest = &tail[semi + 1..];
}
None => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
const PACKET: &str = concat!(
r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>"#,
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF"#,
r#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
r#"<rdf:Description xmlns:dcterms="http://purl.org/dc/terms/""#,
r#" dcterms:provenance="https://example.test/a.c2pa?v=1&k=2"/>"#,
r#"</rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#,
);
#[test]
fn attribute_form_resolves_entities() {
assert_eq!(
provenance_from_packet(PACKET.as_bytes()).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
}
#[test]
fn element_form_and_rdf_resource_are_read() {
let element = r#"<rdf:Description xmlns:dcterms="http://purl.org/dc/terms/">
<dcterms:provenance>https://example.test/b.c2pa</dcterms:provenance>
</rdf:Description>"#;
assert_eq!(
provenance_from_packet(element.as_bytes()).as_deref(),
Some("https://example.test/b.c2pa")
);
let resource = r#"<rdf:Description xmlns:dcterms="http://purl.org/dc/terms/">
<dcterms:provenance rdf:resource="https://example.test/c.c2pa"/>
</rdf:Description>"#;
assert_eq!(
provenance_from_packet(resource.as_bytes()).as_deref(),
Some("https://example.test/c.c2pa")
);
}
#[test]
fn a_non_conventional_prefix_bound_to_the_namespace_is_honored() {
let packet = r#"<rdf:Description xmlns:terms="http://purl.org/dc/terms/"
terms:provenance="https://example.test/d.c2pa"/>"#;
assert_eq!(
provenance_from_packet(packet.as_bytes()).as_deref(),
Some("https://example.test/d.c2pa")
);
}
#[test]
fn a_packet_without_the_key_yields_nothing() {
let packet = r#"<rdf:Description xmlns:dcterms="http://purl.org/dc/terms/"
dcterms:provenanceNote="not a URI" dcterms:created="2026-01-01"/>"#;
assert_eq!(provenance_from_packet(packet.as_bytes()), None);
}
#[test]
fn a_legacy_reference_to_an_embedded_manifest_is_not_a_remote_manifest() {
for value in [
"self#jumbf=/c2pa/adobe:urn:uuid:5f5bba8c-9184-44c6-b9a7-62cfda37b343/c2pa.claim",
"manifests/relative.c2pa",
"urn:uuid:54281c07-ad34-430e-bea5-112a18facf0b",
"file:///etc/passwd",
"",
] {
let packet = format!(
r#"<rdf:Description xmlns:dcterms="http://purl.org/dc/terms/"
dcterms:provenance="{value}"/>"#
);
assert_eq!(provenance_from_packet(packet.as_bytes()), None, "{value}");
}
let packet = r#"<rdf:Description xmlns:dcterms="http://purl.org/dc/terms/"
dcterms:provenance="HTTP://store.test/a.c2pa"/>"#;
assert_eq!(
provenance_from_packet(packet.as_bytes()).as_deref(),
Some("HTTP://store.test/a.c2pa")
);
}
#[test]
fn jpeg_app1_packet_is_located() {
let mut jpeg = vec![0xFF, 0xD8];
let mut payload = JPEG_XMP_SIGNATURE.to_vec();
payload.extend_from_slice(PACKET.as_bytes());
jpeg.extend_from_slice(&[0xFF, 0xE1]);
jpeg.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
jpeg.extend_from_slice(&payload);
jpeg.extend_from_slice(&[0xFF, 0xD9]);
assert_eq!(
provenance_uri(AssetFormat::Jpeg, &jpeg).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
}
#[test]
fn png_itxt_packet_is_located_and_compressed_packets_are_skipped() {
let png = png_with_itxt(0);
assert_eq!(
provenance_uri(AssetFormat::Png, &png).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
assert_eq!(provenance_uri(AssetFormat::Png, &png_with_itxt(1)), None);
}
fn png_with_itxt(compression_flag: u8) -> Vec<u8> {
let mut body = PNG_XMP_KEYWORD.to_vec();
body.push(0);
body.push(compression_flag);
body.push(0);
body.push(0); body.push(0); body.extend_from_slice(PACKET.as_bytes());
let mut png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
png.extend_from_slice(&u32::try_from(body.len()).unwrap().to_be_bytes());
png.extend_from_slice(b"iTXt");
png.extend_from_slice(&body);
png.extend_from_slice(&[0, 0, 0, 0]);
png.extend_from_slice(&[0, 0, 0, 0]);
png.extend_from_slice(b"IEND");
png.extend_from_slice(&[0, 0, 0, 0]);
png
}
#[test]
fn bmff_uuid_packet_is_located() {
let mut payload = BMFF_XMP_UUID.to_vec();
payload.extend_from_slice(PACKET.as_bytes());
let mut bmff = Vec::new();
bmff.extend_from_slice(&20u32.to_be_bytes());
bmff.extend_from_slice(b"ftyp");
bmff.extend_from_slice(b"isom\0\0\0\0isom");
bmff.extend_from_slice(&u32::try_from(payload.len() + 8).unwrap().to_be_bytes());
bmff.extend_from_slice(b"uuid");
bmff.extend_from_slice(&payload);
assert_eq!(
provenance_uri(AssetFormat::Bmff, &bmff).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
}
#[test]
fn riff_xmp_chunk_is_located() {
let packet = PACKET.as_bytes();
let mut riff = b"RIFF".to_vec();
riff.extend_from_slice(&u32::try_from(packet.len() + 12).unwrap().to_le_bytes());
riff.extend_from_slice(b"WEBP");
riff.extend_from_slice(b"XMP ");
riff.extend_from_slice(&u32::try_from(packet.len()).unwrap().to_le_bytes());
riff.extend_from_slice(packet);
assert_eq!(
provenance_uri(AssetFormat::Riff, &riff).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
}
#[test]
fn gif_application_extension_packet_is_located() {
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&[0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]);
gif.extend_from_slice(&[0x21, 0xFF, 0x0B]);
gif.extend_from_slice(GIF_XMP_APP_ID);
gif.extend_from_slice(PACKET.as_bytes());
gif.extend_from_slice(&[0x01, 0xFF, 0xFE, 0x00, 0x00, 0x3B]);
assert_eq!(
provenance_uri(AssetFormat::Gif, &gif).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
}
#[test]
fn svg_inline_xmp_is_located() {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg"><metadata>{PACKET}</metadata></svg>"#
);
assert_eq!(
provenance_uri(AssetFormat::Svg, svg.as_bytes()).as_deref(),
Some("https://example.test/a.c2pa?v=1&k=2")
);
}
#[test]
fn a_format_without_a_standard_xmp_location_reports_nothing() {
assert_eq!(provenance_uri(AssetFormat::Zip, PACKET.as_bytes()), None);
assert_eq!(
provenance_uri(AssetFormat::TextUnstructured, PACKET.as_bytes()),
None
);
}
#[test]
fn truncated_containers_are_not_errors() {
assert_eq!(provenance_uri(AssetFormat::Jpeg, &[0xFF, 0xD8, 0xFF]), None);
assert_eq!(
provenance_uri(AssetFormat::Png, b"\x89PNG\r\n\x1a\n\x00"),
None
);
assert_eq!(
provenance_uri(AssetFormat::Riff, b"RIFF\xff\xff\xff\xffWEBP"),
None
);
}
}