use crate::error::Error;
pub const REL: &str = "c2pa-manifest";
const JUMBF_PREFIX: &str = "jumbf=";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestLink {
pub uri: String,
pub jumbf: Option<String>,
}
impl ManifestLink {
pub fn is_embedded(&self) -> bool {
self.jumbf.is_some()
}
}
pub fn locate_all<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<ManifestLink> {
let mut out = Vec::new();
for value in values {
for raw in split_unquoted(value, b',') {
let Some((target, params)) = parse_link_value(raw) else {
continue;
};
let Some(rel) = params.iter().find(|(k, _)| k == "rel").map(|(_, v)| v) else {
continue;
};
if !rel
.split(|c: char| c.is_ascii_whitespace())
.any(|t| t.eq_ignore_ascii_case(REL))
{
continue;
}
let (uri, jumbf) = split_jumbf(target);
out.push(ManifestLink { uri, jumbf });
}
}
out
}
pub fn extract<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<ManifestLink, Error> {
let mut found = locate_all(values);
found.dedup_by(|a, b| a == b);
if found.len() > 1 {
let first = &found[0];
if found.iter().any(|l| l != first) {
return Err(Error::MultipleLinks);
}
found.truncate(1);
}
found.pop().ok_or(Error::NotFound)
}
pub fn format(uri: &str) -> Result<String, Error> {
if uri.is_empty() {
return Err(Error::Malformed("target URI is empty"));
}
Ok(std::format!("<{}>; rel=\"{REL}\"", encode_target(uri)))
}
pub fn format_strict(uri: &str) -> Result<String, Error> {
if uri.is_empty() {
return Err(Error::Malformed("target URI is empty"));
}
if uri.as_bytes().iter().copied().any(must_encode) {
return Err(Error::Malformed(
"target URI contains characters that a URI must percent-encode",
));
}
Ok(std::format!("<{uri}>; rel=\"{REL}\""))
}
fn must_encode(b: u8) -> bool {
b <= 0x20
|| b >= 0x7F
|| matches!(
b,
b'"' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}'
)
}
pub fn encode_target(uri: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(uri.len());
for &b in uri.as_bytes() {
if must_encode(b) {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0F) as usize] as char);
} else {
out.push(b as char);
}
}
out
}
fn split_unquoted(s: &str, sep: u8) -> Vec<&str> {
let b = s.as_bytes();
let mut out = Vec::new();
let (mut start, mut i) = (0usize, 0usize);
let (mut in_angle, mut in_quote) = (false, false);
while i < b.len() {
match b[i] {
b'\\' if in_quote => i += 1, b'"' => in_quote = !in_quote,
b'<' if !in_quote => in_angle = true,
b'>' if !in_quote => in_angle = false,
c if c == sep && !in_quote && !in_angle => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
i += 1;
}
out.push(&s[start..]);
out
}
fn parse_link_value(value: &str) -> Option<(&str, Vec<(String, String)>)> {
let value = value.trim();
let open = value.find('<')?;
let close = open + 1 + value[open + 1..].find('>')?;
let target = value[open + 1..close].trim();
if target.is_empty() {
return None;
}
let mut params = Vec::new();
for param in split_unquoted(&value[close + 1..], b';') {
let param = param.trim();
if param.is_empty() {
continue;
}
match param.find('=') {
Some(eq) => params.push((
param[..eq].trim().to_ascii_lowercase(),
unquote(param[eq + 1..].trim()),
)),
None => params.push((param.to_ascii_lowercase(), String::new())),
}
}
Some((target, params))
}
fn unquote(s: &str) -> String {
let Some(inner) = s
.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
.filter(|_| s.len() >= 2)
else {
return s.to_string();
};
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.extend(chars.next()),
_ => out.push(c),
}
}
out
}
fn split_jumbf(target: &str) -> (String, Option<String>) {
let Some(hash) = target.find('#') else {
return (target.to_string(), None);
};
let (base, fragment) = (&target[..hash], &target[hash + 1..]);
if fragment.len() < JUMBF_PREFIX.len()
|| !fragment[..JUMBF_PREFIX.len()].eq_ignore_ascii_case(JUMBF_PREFIX)
{
return (target.to_string(), None);
}
let store = fragment[JUMBF_PREFIX.len()..]
.split('/')
.next()
.unwrap_or_default();
if store.is_empty() {
return (target.to_string(), None);
}
(
std::format!("{base}#{JUMBF_PREFIX}{store}"),
Some(store.to_string()),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn one(header: &str) -> ManifestLink {
extract([header]).expect("expected exactly one c2pa-manifest link")
}
#[test]
fn parses_a_quoted_relation() {
let l = one(r#"<https://a.example/m.c2pa>; rel="c2pa-manifest""#);
assert_eq!(l.uri, "https://a.example/m.c2pa");
assert_eq!(l.jumbf, None);
assert!(!l.is_embedded());
}
#[test]
fn parses_an_unquoted_relation() {
assert_eq!(
one("<https://a.example/m.c2pa>; rel=c2pa-manifest").uri,
"https://a.example/m.c2pa"
);
}
#[test]
fn relation_matching_is_case_insensitive() {
assert_eq!(
one(r#"<m.c2pa>; REL="C2PA-Manifest""#).uri,
"m.c2pa",
"rel name and value are both case-insensitive"
);
}
#[test]
fn a_relation_token_list_containing_the_relation_matches() {
assert_eq!(
one(r#"<m.c2pa>; rel="preload c2pa-manifest""#).uri,
"m.c2pa"
);
}
#[test]
fn a_near_miss_relation_is_not_a_match() {
for header in [
r#"<m.c2pa>; rel="c2pa-manifest-x""#,
r#"<m.c2pa>; rel="x-c2pa-manifest""#,
r#"<m.c2pa>; rel="stylesheet""#,
"<m.c2pa>",
] {
assert_eq!(extract([header]), Err(Error::NotFound), "{header}");
}
}
#[test]
fn picks_the_c2pa_link_out_of_a_multi_value_header() {
let h = r#"</style.css>; rel=preload, <https://a.example/m.c2pa>; rel="c2pa-manifest", </next>; rel=next"#;
assert_eq!(one(h).uri, "https://a.example/m.c2pa");
}
#[test]
fn searches_across_several_header_fields() {
let l = extract(["</a>; rel=preload", r#"<m.c2pa>; rel="c2pa-manifest""#]).unwrap();
assert_eq!(l.uri, "m.c2pa");
}
#[test]
fn a_comma_inside_the_target_does_not_split_the_value() {
let h = r#"<https://a.example/m.c2pa?ids=1,2,3>; rel="c2pa-manifest""#;
assert_eq!(one(h).uri, "https://a.example/m.c2pa?ids=1,2,3");
}
#[test]
fn a_comma_or_semicolon_inside_a_quoted_param_does_not_split() {
let h = r#"<m.c2pa>; title="a, b; c"; rel="c2pa-manifest""#;
assert_eq!(one(h).uri, "m.c2pa");
}
#[test]
fn an_escaped_quote_inside_a_param_is_handled() {
let h = r#"<m.c2pa>; title="say \"hi\", ok"; rel="c2pa-manifest""#;
assert_eq!(one(h).uri, "m.c2pa");
}
#[test]
fn only_the_first_rel_parameter_counts() {
assert_eq!(
one(r#"<m.c2pa>; rel="c2pa-manifest"; rel="next""#).uri,
"m.c2pa"
);
assert_eq!(
extract([r#"<m.c2pa>; rel="next"; rel="c2pa-manifest""#]),
Err(Error::NotFound),
"a later rel must not rescue a non-matching first one"
);
}
#[test]
fn a_jumbf_fragment_names_an_embedded_store() {
let l = one(r#"<https://a.example/image.jpg#jumbf=c2pa>; rel="c2pa-manifest""#);
assert_eq!(l.uri, "https://a.example/image.jpg#jumbf=c2pa");
assert_eq!(l.jumbf.as_deref(), Some("c2pa"));
assert!(l.is_embedded());
}
#[test]
fn a_jumbf_childlabel_is_discarded() {
let l = one(
r#"<https://a.example/i.jpg#jumbf=c2pa/urn:uuid:1234/c2pa.assertions>; rel="c2pa-manifest""#,
);
assert_eq!(l.uri, "https://a.example/i.jpg#jumbf=c2pa");
assert_eq!(l.jumbf.as_deref(), Some("c2pa"));
}
#[test]
fn a_non_jumbf_fragment_is_left_alone() {
let l = one(r#"<https://a.example/m.c2pa#section>; rel="c2pa-manifest""#);
assert_eq!(l.uri, "https://a.example/m.c2pa#section");
assert_eq!(l.jumbf, None);
}
#[test]
fn duplicate_identical_links_are_not_a_conflict() {
let h = r#"<m.c2pa>; rel="c2pa-manifest", <m.c2pa>; rel="c2pa-manifest""#;
assert_eq!(one(h).uri, "m.c2pa");
}
#[test]
fn competing_targets_are_rejected() {
let h = r#"<a.c2pa>; rel="c2pa-manifest", <b.c2pa>; rel="c2pa-manifest""#;
assert_eq!(extract([h]), Err(Error::MultipleLinks));
assert_eq!(locate_all([h]).len(), 2);
}
#[test]
fn malformed_values_are_skipped_not_fatal() {
let h = r#"no-brackets; rel=whatever, <m.c2pa>; rel="c2pa-manifest""#;
assert_eq!(one(h).uri, "m.c2pa");
assert_eq!(
extract(["<unterminated; rel=c2pa-manifest"]),
Err(Error::NotFound)
);
assert_eq!(extract(["<>; rel=c2pa-manifest"]), Err(Error::NotFound));
assert_eq!(extract([""]), Err(Error::NotFound));
}
#[test]
fn whitespace_around_the_delimiters_is_tolerated() {
let h = " <m.c2pa> ; rel = c2pa-manifest ";
assert_eq!(one(h).uri, "m.c2pa");
}
#[test]
fn format_round_trips_through_the_parser() {
let header = format("https://a.example/m.c2pa").unwrap();
assert_eq!(header, r#"<https://a.example/m.c2pa>; rel="c2pa-manifest""#);
assert_eq!(one(&header).uri, "https://a.example/m.c2pa");
}
#[test]
fn format_neutralises_header_injection_rather_than_refusing() {
for hostile in [
"https://a.example/\r\nX-Injected: yes",
"https://a.example/\nX-Injected: yes",
"https://a.example/\r",
"https://a.example/m>; rel=\"evil\", <b",
"https://a.example/\u{7}bell",
"https://a.example/a b",
] {
let header = format(hostile).expect("encoding must never reject");
assert!(
!header.contains('\r') && !header.contains('\n'),
"a line break survived: {header:?}"
);
assert_eq!(header.matches('<').count(), 1, "{header:?}");
assert_eq!(header.matches('>').count(), 1, "{header:?}");
assert_eq!(locate_all([header.as_str()]).len(), 1, "{header:?}");
}
assert!(matches!(format(""), Err(Error::Malformed(_))));
}
#[test]
fn an_injected_header_name_becomes_part_of_the_uri() {
let header = format("https://a.example/\r\nX-Injected: yes").unwrap();
assert!(header.contains("%0D%0A"), "{header}");
let found = extract([header.as_str()]).unwrap();
assert_eq!(found.uri, "https://a.example/%0D%0AX-Injected:%20yes");
}
#[test]
fn encoding_covers_exactly_the_characters_a_uri_excludes() {
assert_eq!(encode_target("a b"), "a%20b");
assert_eq!(encode_target("a\r\nb"), "a%0D%0Ab");
assert_eq!(encode_target("a<b>c"), "a%3Cb%3Ec");
assert_eq!(
encode_target("a\"b\\c^d`e{f|g}h"),
"a%22b%5Cc%5Ed%60e%7Bf%7Cg%7Dh"
);
assert_eq!(encode_target("a\u{7F}b"), "a%7Fb");
assert_eq!(encode_target("café"), "caf%C3%A9");
}
#[test]
fn encoding_preserves_a_uri_that_is_already_correct() {
for good in [
"https://a.example/m.c2pa",
"https://user@a.example:8443/p/q?x=1&y=2#frag",
"https://a.example/i.jpg#jumbf=c2pa",
"https://a.example/a~b_c-d.e!$&'()*+,;=:@/f",
] {
assert_eq!(encode_target(good), good, "mangled a valid URI");
}
}
#[test]
fn encoding_is_idempotent() {
let once = encode_target("a b");
assert_eq!(encode_target(&once), once);
assert_eq!(encode_target("%20"), "%20");
}
#[test]
fn format_strict_reports_what_format_would_have_repaired() {
assert!(format_strict("https://a.example/m.c2pa").is_ok());
for needs_repair in ["https://a.example/a b", "https://a.example/\r\n", "café"] {
assert!(
matches!(format_strict(needs_repair), Err(Error::Malformed(_))),
"strict mode accepted {needs_repair:?}"
);
assert!(format(needs_repair).is_ok());
}
assert!(matches!(format_strict(""), Err(Error::Malformed(_))));
}
#[test]
fn format_accepts_a_jumbf_target() {
let header = format("https://a.example/i.jpg#jumbf=c2pa").unwrap();
assert!(one(&header).is_embedded());
}
#[test]
fn the_scanner_terminates_on_adversarial_input() {
for h in [
"<<<<",
"\"\"\"",
"<a\"b>; rel=c2pa-manifest",
";;;;",
",,,,",
"<a>;rel=",
"\\",
"<a>; rel=\"unterminated",
] {
let _ = locate_all([h]);
}
}
#[test]
fn multibyte_targets_do_not_split_a_character() {
let h = "<https://a.example/café/münchen.c2pa>; rel=c2pa-manifest";
assert_eq!(one(h).uri, "https://a.example/café/münchen.c2pa");
}
}