1use crate::error::Error;
29
30pub const REL: &str = "c2pa-manifest";
32
33const JUMBF_PREFIX: &str = "jumbf=";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ManifestLink {
39 pub uri: String,
45 pub jumbf: Option<String>,
48}
49
50impl ManifestLink {
51 pub fn is_embedded(&self) -> bool {
54 self.jumbf.is_some()
55 }
56}
57
58pub fn locate_all<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<ManifestLink> {
63 let mut out = Vec::new();
64 for value in values {
65 for raw in split_unquoted(value, b',') {
66 let Some((target, params)) = parse_link_value(raw) else {
67 continue;
68 };
69 let Some(rel) = params.iter().find(|(k, _)| k == "rel").map(|(_, v)| v) else {
71 continue;
72 };
73 if !rel
74 .split(|c: char| c.is_ascii_whitespace())
75 .any(|t| t.eq_ignore_ascii_case(REL))
76 {
77 continue;
78 }
79 let (uri, jumbf) = split_jumbf(target);
80 out.push(ManifestLink { uri, jumbf });
81 }
82 }
83 out
84}
85
86pub fn extract<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<ManifestLink, Error> {
92 let mut found = locate_all(values);
93 found.dedup_by(|a, b| a == b);
94 if found.len() > 1 {
95 let first = &found[0];
97 if found.iter().any(|l| l != first) {
98 return Err(Error::MultipleLinks);
99 }
100 found.truncate(1);
101 }
102 found.pop().ok_or(Error::NotFound)
103}
104
105pub fn format(uri: &str) -> Result<String, Error> {
116 if uri.is_empty() {
117 return Err(Error::Malformed("target URI is empty"));
118 }
119 Ok(std::format!("<{}>; rel=\"{REL}\"", encode_target(uri)))
120}
121
122pub fn format_strict(uri: &str) -> Result<String, Error> {
129 if uri.is_empty() {
130 return Err(Error::Malformed("target URI is empty"));
131 }
132 if uri.as_bytes().iter().copied().any(must_encode) {
133 return Err(Error::Malformed(
134 "target URI contains characters that a URI must percent-encode",
135 ));
136 }
137 Ok(std::format!("<{uri}>; rel=\"{REL}\""))
138}
139
140fn must_encode(b: u8) -> bool {
149 b <= 0x20
150 || b >= 0x7F
151 || matches!(
152 b,
153 b'"' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}'
154 )
155}
156
157pub fn encode_target(uri: &str) -> String {
170 const HEX: &[u8; 16] = b"0123456789ABCDEF";
171 let mut out = String::with_capacity(uri.len());
172 for &b in uri.as_bytes() {
173 if must_encode(b) {
174 out.push('%');
175 out.push(HEX[(b >> 4) as usize] as char);
176 out.push(HEX[(b & 0x0F) as usize] as char);
177 } else {
178 out.push(b as char);
180 }
181 }
182 out
183}
184
185fn split_unquoted(s: &str, sep: u8) -> Vec<&str> {
187 let b = s.as_bytes();
188 let mut out = Vec::new();
189 let (mut start, mut i) = (0usize, 0usize);
190 let (mut in_angle, mut in_quote) = (false, false);
191 while i < b.len() {
192 match b[i] {
193 b'\\' if in_quote => i += 1, b'"' => in_quote = !in_quote,
195 b'<' if !in_quote => in_angle = true,
196 b'>' if !in_quote => in_angle = false,
197 c if c == sep && !in_quote && !in_angle => {
198 out.push(&s[start..i]);
199 start = i + 1;
200 }
201 _ => {}
202 }
203 i += 1;
204 }
205 out.push(&s[start..]);
206 out
207}
208
209fn parse_link_value(value: &str) -> Option<(&str, Vec<(String, String)>)> {
212 let value = value.trim();
213 let open = value.find('<')?;
214 let close = open + 1 + value[open + 1..].find('>')?;
215 let target = value[open + 1..close].trim();
216 if target.is_empty() {
217 return None;
218 }
219
220 let mut params = Vec::new();
221 for param in split_unquoted(&value[close + 1..], b';') {
222 let param = param.trim();
223 if param.is_empty() {
224 continue;
225 }
226 match param.find('=') {
227 Some(eq) => params.push((
228 param[..eq].trim().to_ascii_lowercase(),
229 unquote(param[eq + 1..].trim()),
230 )),
231 None => params.push((param.to_ascii_lowercase(), String::new())),
232 }
233 }
234 Some((target, params))
235}
236
237fn unquote(s: &str) -> String {
239 let Some(inner) = s
240 .strip_prefix('"')
241 .and_then(|r| r.strip_suffix('"'))
242 .filter(|_| s.len() >= 2)
243 else {
244 return s.to_string();
245 };
246 let mut out = String::with_capacity(inner.len());
247 let mut chars = inner.chars();
248 while let Some(c) = chars.next() {
249 match c {
250 '\\' => out.extend(chars.next()),
251 _ => out.push(c),
252 }
253 }
254 out
255}
256
257fn split_jumbf(target: &str) -> (String, Option<String>) {
259 let Some(hash) = target.find('#') else {
260 return (target.to_string(), None);
261 };
262 let (base, fragment) = (&target[..hash], &target[hash + 1..]);
263 if fragment.len() < JUMBF_PREFIX.len()
264 || !fragment[..JUMBF_PREFIX.len()].eq_ignore_ascii_case(JUMBF_PREFIX)
265 {
266 return (target.to_string(), None);
267 }
268 let store = fragment[JUMBF_PREFIX.len()..]
271 .split('/')
272 .next()
273 .unwrap_or_default();
274 if store.is_empty() {
275 return (target.to_string(), None);
276 }
277 (
278 std::format!("{base}#{JUMBF_PREFIX}{store}"),
279 Some(store.to_string()),
280 )
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn one(header: &str) -> ManifestLink {
288 extract([header]).expect("expected exactly one c2pa-manifest link")
289 }
290
291 #[test]
292 fn parses_a_quoted_relation() {
293 let l = one(r#"<https://a.example/m.c2pa>; rel="c2pa-manifest""#);
294 assert_eq!(l.uri, "https://a.example/m.c2pa");
295 assert_eq!(l.jumbf, None);
296 assert!(!l.is_embedded());
297 }
298
299 #[test]
300 fn parses_an_unquoted_relation() {
301 assert_eq!(
302 one("<https://a.example/m.c2pa>; rel=c2pa-manifest").uri,
303 "https://a.example/m.c2pa"
304 );
305 }
306
307 #[test]
308 fn relation_matching_is_case_insensitive() {
309 assert_eq!(
310 one(r#"<m.c2pa>; REL="C2PA-Manifest""#).uri,
311 "m.c2pa",
312 "rel name and value are both case-insensitive"
313 );
314 }
315
316 #[test]
317 fn a_relation_token_list_containing_the_relation_matches() {
318 assert_eq!(
319 one(r#"<m.c2pa>; rel="preload c2pa-manifest""#).uri,
320 "m.c2pa"
321 );
322 }
323
324 #[test]
325 fn a_near_miss_relation_is_not_a_match() {
326 for header in [
327 r#"<m.c2pa>; rel="c2pa-manifest-x""#,
328 r#"<m.c2pa>; rel="x-c2pa-manifest""#,
329 r#"<m.c2pa>; rel="stylesheet""#,
330 "<m.c2pa>",
331 ] {
332 assert_eq!(extract([header]), Err(Error::NotFound), "{header}");
333 }
334 }
335
336 #[test]
337 fn picks_the_c2pa_link_out_of_a_multi_value_header() {
338 let h = r#"</style.css>; rel=preload, <https://a.example/m.c2pa>; rel="c2pa-manifest", </next>; rel=next"#;
339 assert_eq!(one(h).uri, "https://a.example/m.c2pa");
340 }
341
342 #[test]
343 fn searches_across_several_header_fields() {
344 let l = extract(["</a>; rel=preload", r#"<m.c2pa>; rel="c2pa-manifest""#]).unwrap();
345 assert_eq!(l.uri, "m.c2pa");
346 }
347
348 #[test]
349 fn a_comma_inside_the_target_does_not_split_the_value() {
350 let h = r#"<https://a.example/m.c2pa?ids=1,2,3>; rel="c2pa-manifest""#;
352 assert_eq!(one(h).uri, "https://a.example/m.c2pa?ids=1,2,3");
353 }
354
355 #[test]
356 fn a_comma_or_semicolon_inside_a_quoted_param_does_not_split() {
357 let h = r#"<m.c2pa>; title="a, b; c"; rel="c2pa-manifest""#;
358 assert_eq!(one(h).uri, "m.c2pa");
359 }
360
361 #[test]
362 fn an_escaped_quote_inside_a_param_is_handled() {
363 let h = r#"<m.c2pa>; title="say \"hi\", ok"; rel="c2pa-manifest""#;
364 assert_eq!(one(h).uri, "m.c2pa");
365 }
366
367 #[test]
368 fn only_the_first_rel_parameter_counts() {
369 assert_eq!(
371 one(r#"<m.c2pa>; rel="c2pa-manifest"; rel="next""#).uri,
372 "m.c2pa"
373 );
374 assert_eq!(
375 extract([r#"<m.c2pa>; rel="next"; rel="c2pa-manifest""#]),
376 Err(Error::NotFound),
377 "a later rel must not rescue a non-matching first one"
378 );
379 }
380
381 #[test]
382 fn a_jumbf_fragment_names_an_embedded_store() {
383 let l = one(r#"<https://a.example/image.jpg#jumbf=c2pa>; rel="c2pa-manifest""#);
384 assert_eq!(l.uri, "https://a.example/image.jpg#jumbf=c2pa");
385 assert_eq!(l.jumbf.as_deref(), Some("c2pa"));
386 assert!(l.is_embedded());
387 }
388
389 #[test]
390 fn a_jumbf_childlabel_is_discarded() {
391 let l = one(
394 r#"<https://a.example/i.jpg#jumbf=c2pa/urn:uuid:1234/c2pa.assertions>; rel="c2pa-manifest""#,
395 );
396 assert_eq!(l.uri, "https://a.example/i.jpg#jumbf=c2pa");
397 assert_eq!(l.jumbf.as_deref(), Some("c2pa"));
398 }
399
400 #[test]
401 fn a_non_jumbf_fragment_is_left_alone() {
402 let l = one(r#"<https://a.example/m.c2pa#section>; rel="c2pa-manifest""#);
403 assert_eq!(l.uri, "https://a.example/m.c2pa#section");
404 assert_eq!(l.jumbf, None);
405 }
406
407 #[test]
408 fn duplicate_identical_links_are_not_a_conflict() {
409 let h = r#"<m.c2pa>; rel="c2pa-manifest", <m.c2pa>; rel="c2pa-manifest""#;
410 assert_eq!(one(h).uri, "m.c2pa");
411 }
412
413 #[test]
414 fn competing_targets_are_rejected() {
415 let h = r#"<a.c2pa>; rel="c2pa-manifest", <b.c2pa>; rel="c2pa-manifest""#;
417 assert_eq!(extract([h]), Err(Error::MultipleLinks));
418 assert_eq!(locate_all([h]).len(), 2);
419 }
420
421 #[test]
422 fn malformed_values_are_skipped_not_fatal() {
423 let h = r#"no-brackets; rel=whatever, <m.c2pa>; rel="c2pa-manifest""#;
425 assert_eq!(one(h).uri, "m.c2pa");
426 assert_eq!(
427 extract(["<unterminated; rel=c2pa-manifest"]),
428 Err(Error::NotFound)
429 );
430 assert_eq!(extract(["<>; rel=c2pa-manifest"]), Err(Error::NotFound));
431 assert_eq!(extract([""]), Err(Error::NotFound));
432 }
433
434 #[test]
435 fn whitespace_around_the_delimiters_is_tolerated() {
436 let h = " <m.c2pa> ; rel = c2pa-manifest ";
437 assert_eq!(one(h).uri, "m.c2pa");
438 }
439
440 #[test]
441 fn format_round_trips_through_the_parser() {
442 let header = format("https://a.example/m.c2pa").unwrap();
443 assert_eq!(header, r#"<https://a.example/m.c2pa>; rel="c2pa-manifest""#);
444 assert_eq!(one(&header).uri, "https://a.example/m.c2pa");
445 }
446
447 #[test]
448 fn format_neutralises_header_injection_rather_than_refusing() {
449 for hostile in [
452 "https://a.example/\r\nX-Injected: yes",
453 "https://a.example/\nX-Injected: yes",
454 "https://a.example/\r",
455 "https://a.example/m>; rel=\"evil\", <b",
456 "https://a.example/\u{7}bell",
457 "https://a.example/a b",
458 ] {
459 let header = format(hostile).expect("encoding must never reject");
460 assert!(
461 !header.contains('\r') && !header.contains('\n'),
462 "a line break survived: {header:?}"
463 );
464 assert_eq!(header.matches('<').count(), 1, "{header:?}");
467 assert_eq!(header.matches('>').count(), 1, "{header:?}");
468 assert_eq!(locate_all([header.as_str()]).len(), 1, "{header:?}");
470 }
471 assert!(matches!(format(""), Err(Error::Malformed(_))));
472 }
473
474 #[test]
475 fn an_injected_header_name_becomes_part_of_the_uri() {
476 let header = format("https://a.example/\r\nX-Injected: yes").unwrap();
479 assert!(header.contains("%0D%0A"), "{header}");
480 let found = extract([header.as_str()]).unwrap();
481 assert_eq!(found.uri, "https://a.example/%0D%0AX-Injected:%20yes");
482 }
483
484 #[test]
485 fn encoding_covers_exactly_the_characters_a_uri_excludes() {
486 assert_eq!(encode_target("a b"), "a%20b");
487 assert_eq!(encode_target("a\r\nb"), "a%0D%0Ab");
488 assert_eq!(encode_target("a<b>c"), "a%3Cb%3Ec");
489 assert_eq!(
490 encode_target("a\"b\\c^d`e{f|g}h"),
491 "a%22b%5Cc%5Ed%60e%7Bf%7Cg%7Dh"
492 );
493 assert_eq!(encode_target("a\u{7F}b"), "a%7Fb");
494 assert_eq!(encode_target("café"), "caf%C3%A9");
496 }
497
498 #[test]
499 fn encoding_preserves_a_uri_that_is_already_correct() {
500 for good in [
503 "https://a.example/m.c2pa",
504 "https://user@a.example:8443/p/q?x=1&y=2#frag",
505 "https://a.example/i.jpg#jumbf=c2pa",
506 "https://a.example/a~b_c-d.e!$&'()*+,;=:@/f",
507 ] {
508 assert_eq!(encode_target(good), good, "mangled a valid URI");
509 }
510 }
511
512 #[test]
513 fn encoding_is_idempotent() {
514 let once = encode_target("a b");
517 assert_eq!(encode_target(&once), once);
518 assert_eq!(encode_target("%20"), "%20");
519 }
520
521 #[test]
522 fn format_strict_reports_what_format_would_have_repaired() {
523 assert!(format_strict("https://a.example/m.c2pa").is_ok());
524 for needs_repair in ["https://a.example/a b", "https://a.example/\r\n", "café"] {
525 assert!(
526 matches!(format_strict(needs_repair), Err(Error::Malformed(_))),
527 "strict mode accepted {needs_repair:?}"
528 );
529 assert!(format(needs_repair).is_ok());
531 }
532 assert!(matches!(format_strict(""), Err(Error::Malformed(_))));
533 }
534
535 #[test]
536 fn format_accepts_a_jumbf_target() {
537 let header = format("https://a.example/i.jpg#jumbf=c2pa").unwrap();
538 assert!(one(&header).is_embedded());
539 }
540
541 #[test]
542 fn the_scanner_terminates_on_adversarial_input() {
543 for h in [
545 "<<<<",
546 "\"\"\"",
547 "<a\"b>; rel=c2pa-manifest",
548 ";;;;",
549 ",,,,",
550 "<a>;rel=",
551 "\\",
552 "<a>; rel=\"unterminated",
553 ] {
554 let _ = locate_all([h]);
555 }
556 }
557
558 #[test]
559 fn multibyte_targets_do_not_split_a_character() {
560 let h = "<https://a.example/café/münchen.c2pa>; rel=c2pa-manifest";
561 assert_eq!(one(h).uri, "https://a.example/café/münchen.c2pa");
562 }
563}