use crate::c2pa_formats::util::base64_decode;
#[cfg(test)]
use crate::c2pa_formats::util::base64_encode;
use crate::c2pa_formats::{AssetFormat, DataHashExclusion, FormatError};
const FMT: AssetFormat = AssetFormat::Svg;
const ELEM_OPEN_TAG: &str = "<c2pa:manifest";
const ELEM_CLOSE: &str = "</c2pa:manifest>";
const PI_OPEN: &str = "<?c2pa-manifest ";
const PI_CLOSE: &str = "?>";
const META_OPEN: &str = "<metadata>";
const META_CLOSE: &str = "</metadata>";
fn as_str(data: &[u8]) -> Result<&str, FormatError> {
std::str::from_utf8(data).map_err(|_| FormatError::InvalidStructure {
format: FMT,
detail: "SVG is not valid UTF-8",
})
}
pub(crate) fn extract(data: &[u8]) -> Result<Option<Vec<u8>>, FormatError> {
let text = as_str(data)?;
if let Some(open) = text.find(ELEM_OPEN_TAG) {
let after_open = &text[open + ELEM_OPEN_TAG.len()..];
if let Some(gt) = after_open.find('>') {
let body = &after_open[gt + 1..];
if let Some(close) = body.find(ELEM_CLOSE) {
let b64 = body[..close].trim();
let bytes = base64_decode(b64).ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "invalid base64 in c2pa:manifest element",
})?;
return Ok(Some(bytes));
}
}
}
if let Some(open) = text.find(PI_OPEN) {
let rest = &text[open + PI_OPEN.len()..];
let close = rest.find(PI_CLOSE).ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated c2pa PI",
})?;
let b64 = rest[..close].trim();
let bytes = base64_decode(b64).ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "invalid base64 in c2pa PI",
})?;
return Ok(Some(bytes));
}
Ok(None)
}
fn xml_tag_end(text: &str, start: usize) -> Option<usize> {
let mut quote = None;
for (offset, character) in text[start..].char_indices() {
match (quote, character) {
(Some(expected), actual) if actual == expected => quote = None,
(None, '"' | '\'') => quote = Some(character),
(None, '>') => return Some(start + offset + 1),
_ => {}
}
}
None
}
pub(crate) fn placement_error(data: &[u8]) -> Result<Option<&'static str>, FormatError> {
let text = as_str(data)?;
let mut stack: Vec<&str> = Vec::new();
let mut cursor = 0usize;
let mut saw_manifest_element = false;
while let Some(relative) = text[cursor..].find('<') {
let start = cursor + relative;
if text[start..].starts_with("<!--") {
let end = text[start + 4..]
.find("-->")
.map(|offset| start + 4 + offset + 3)
.ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated SVG comment",
})?;
cursor = end;
continue;
}
if text[start..].starts_with("<?") {
let end = text[start + 2..]
.find("?>")
.map(|offset| start + 2 + offset + 2)
.ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated SVG processing instruction",
})?;
cursor = end;
continue;
}
if text[start..].starts_with("<!") {
cursor = xml_tag_end(text, start + 2).ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated SVG declaration",
})?;
continue;
}
let closing = text.as_bytes().get(start + 1) == Some(&b'/');
let name_start = start + if closing { 2 } else { 1 };
let name_end = text[name_start..]
.char_indices()
.find(|(_, character)| {
!character.is_ascii_alphanumeric() && !matches!(character, ':' | '_' | '-' | '.')
})
.map(|(offset, _)| name_start + offset)
.unwrap_or(text.len());
if name_end == name_start {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "SVG contains an empty element name",
});
}
let name = &text[name_start..name_end];
let end = xml_tag_end(text, name_end).ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated SVG element",
})?;
if closing {
if stack.pop() != Some(name) {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "SVG element nesting is malformed",
});
}
} else {
if stack.is_empty() && name != "svg" {
return Err(FormatError::InvalidStructure {
format: FMT,
detail: "SVG root element is not svg",
});
}
if name == "c2pa:manifest" {
saw_manifest_element = true;
if text.find(ELEM_OPEN_TAG) != Some(start) {
return Ok(Some(
"SVG extractor-selected manifest carrier is not an XML element",
));
}
if stack.as_slice() != ["svg", "metadata"] {
return Ok(Some("SVG c2pa:manifest is not in root svg metadata"));
}
}
if !text[start..end - 1].trim_end().ends_with('/') {
stack.push(name);
}
}
cursor = end;
}
if !saw_manifest_element && (text.contains(ELEM_OPEN_TAG) || text.contains(PI_OPEN)) {
return Ok(Some(
"SVG manifest carrier is not a c2pa:manifest element in root metadata",
));
}
Ok(None)
}
#[cfg(test)]
pub(crate) fn strip(asset: &[u8]) -> Result<Vec<u8>, FormatError> {
let ex = exclusions(asset)?;
let Some(span) = ex.first() else {
return Ok(asset.to_vec());
};
let mut out = Vec::with_capacity(asset.len());
out.extend_from_slice(&asset[..span.start]);
out.extend_from_slice(&asset[span.start + span.length..]);
Ok(out)
}
#[cfg(test)]
pub(crate) fn embed(asset: &[u8], manifest_store: &[u8]) -> Result<Vec<u8>, FormatError> {
let clean = strip(asset)?;
let text = as_str(&clean)?;
let svg_open = text.find("<svg").ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "no <svg> root element",
})?;
let after_svg = &text[svg_open..];
let gt = after_svg.find('>').ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated <svg> tag",
})?;
let insert_at = svg_open + gt + 1;
let elem = format!(
"<metadata><c2pa:manifest xmlns:c2pa=\"http://c2pa.org/manifest\">{}</c2pa:manifest></metadata>",
base64_encode(manifest_store)
);
let mut out = Vec::with_capacity(clean.len() + elem.len());
out.extend_from_slice(&clean[..insert_at]);
out.extend_from_slice(elem.as_bytes());
out.extend_from_slice(&clean[insert_at..]);
Ok(out)
}
pub(crate) fn exclusions(data: &[u8]) -> Result<Vec<DataHashExclusion>, FormatError> {
let text = as_str(data)?;
if let Some(open) = text.find(ELEM_OPEN_TAG) {
let after_open = &text[open + ELEM_OPEN_TAG.len()..];
if let Some(gt) = after_open.find('>') {
let body_start = open + ELEM_OPEN_TAG.len() + gt + 1;
if let Some(rel_close) = text[body_start..].find(ELEM_CLOSE) {
let mut start = open;
let mut end = body_start + rel_close + ELEM_CLOSE.len();
if text[..start].ends_with(META_OPEN) {
start -= META_OPEN.len();
}
if text[end..].starts_with(META_CLOSE) {
end += META_CLOSE.len();
}
return Ok(vec![DataHashExclusion {
start,
length: end - start,
}]);
}
}
}
if let Some(open) = text.find(PI_OPEN) {
let rest = &text[open + PI_OPEN.len()..];
let close = rest.find(PI_CLOSE).ok_or(FormatError::InvalidStructure {
format: FMT,
detail: "unterminated c2pa PI",
})?;
let end = open + PI_OPEN.len() + close + PI_CLOSE.len();
return Ok(vec![DataHashExclusion {
start: open,
length: end - open,
}]);
}
Ok(Vec::new())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::c2pa_formats::tests::dummy_manifest_store;
fn tiny_svg() -> Vec<u8> {
br#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#
.to_vec()
}
#[test]
fn roundtrip() {
let store = dummy_manifest_store();
let embedded = embed(&tiny_svg(), &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_svg(), &first_store).unwrap();
let second = embed(&first, &second_store).unwrap();
assert_eq!(
extract(&second).unwrap().as_deref(),
Some(second_store.as_slice())
);
let text = std::str::from_utf8(&second).unwrap();
let occurrences = text.matches(ELEM_OPEN_TAG).count();
assert_eq!(
occurrences, 1,
"re-embed must leave exactly one c2pa:manifest element"
);
}
#[test]
fn manifest_element_is_svg_child() {
let store = dummy_manifest_store();
let embedded = embed(&tiny_svg(), &store).unwrap();
let text = std::str::from_utf8(&embedded).unwrap();
assert!(text.find("<svg").unwrap() < text.find(ELEM_OPEN_TAG).unwrap());
assert!(text.contains("</c2pa:manifest></metadata>"));
}
#[test]
fn extracts_live_style_element() {
let store = dummy_manifest_store();
let b64 = base64_encode(&store);
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><metadata><c2pa:manifest xmlns:c2pa=\"http://c2pa.org/manifest\">{b64}</c2pa:manifest></metadata></svg>"
);
assert_eq!(
extract(svg.as_bytes()).unwrap().as_deref(),
Some(store.as_slice())
);
assert_eq!(placement_error(svg.as_bytes()).unwrap(), None);
}
#[test]
fn roundtrip_no_xml_decl() {
let store = dummy_manifest_store();
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>".to_vec();
let embedded = embed(&svg, &store).unwrap();
assert_eq!(
extract(&embedded).unwrap().as_deref(),
Some(store.as_slice())
);
}
#[test]
fn bare_asset_has_no_manifest() {
assert_eq!(extract(&tiny_svg()).unwrap(), None);
}
#[test]
fn base64_roundtrips_arbitrary_bytes() {
for n in 0..32usize {
let data: Vec<u8> = (0..n).map(|i| (i * 7 + 3) as u8).collect();
let enc = base64_encode(&data);
assert_eq!(base64_decode(&enc).unwrap(), data, "len {n}");
}
}
#[test]
fn exclusions_cover_manifest_element() {
let store = dummy_manifest_store();
let asset = tiny_svg();
let embedded = embed(&asset, &store).unwrap();
let ex = exclusions(&embedded).unwrap();
assert_eq!(ex.len(), 1);
let DataHashExclusion { start, length } = ex[0];
let span = &embedded[start..start + length];
assert!(span.starts_with(b"<metadata><c2pa:manifest"));
assert!(span.ends_with(b"</c2pa:manifest></metadata>"));
let mut rebuilt = embedded[..start].to_vec();
rebuilt.extend_from_slice(&embedded[start + length..]);
assert_eq!(rebuilt, asset);
}
#[test]
fn exclusions_pi_fallback() {
let store = dummy_manifest_store();
let b64 = base64_encode(&store);
let svg = format!("<svg><?c2pa-manifest {b64}?></svg>");
let ex = exclusions(svg.as_bytes()).unwrap();
assert_eq!(ex.len(), 1);
let DataHashExclusion { start, length } = ex[0];
let span = &svg.as_bytes()[start..start + length];
assert!(span.starts_with(b"<?c2pa-manifest "));
assert!(span.ends_with(b"?>"));
}
#[test]
fn strict_placement_requires_root_metadata_parent() {
let b64 = base64_encode(&dummy_manifest_store());
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><g><metadata><c2pa:manifest>{b64}</c2pa:manifest></metadata></g></svg>"
);
assert!(placement_error(svg.as_bytes()).unwrap().is_some());
}
#[test]
fn exclusions_empty_without_manifest() {
assert!(exclusions(&tiny_svg()).unwrap().is_empty());
}
}