1use crate::base64;
20use crate::error::Error;
21use crate::scan::{self, Tag};
22use std::ops::Range;
23
24pub const SCRIPT_TYPE: &str = "application/c2pa";
26
27pub const LINK_REL: &str = "c2pa-manifest";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Manifest {
33 Embedded {
37 start: usize,
38 length: usize,
39 store: Vec<u8>,
40 },
41 Referenced {
45 start: usize,
46 length: usize,
47 href: String,
48 },
49}
50
51impl Manifest {
52 pub fn start(&self) -> usize {
54 match self {
55 Self::Embedded { start, .. } | Self::Referenced { start, .. } => *start,
56 }
57 }
58
59 pub fn length(&self) -> usize {
61 match self {
62 Self::Embedded { length, .. } | Self::Referenced { length, .. } => *length,
63 }
64 }
65
66 pub fn range(&self) -> Range<usize> {
68 self.start()..self.start() + self.length()
69 }
70
71 pub fn store(&self) -> Option<&[u8]> {
73 match self {
74 Self::Embedded { store, .. } => Some(store),
75 Self::Referenced { .. } => None,
76 }
77 }
78
79 pub fn href(&self) -> Option<&str> {
81 match self {
82 Self::Referenced { href, .. } => Some(href),
83 Self::Embedded { .. } => None,
84 }
85 }
86}
87
88struct Candidate {
93 range: Range<usize>,
94 kind: CandidateKind,
95}
96
97enum CandidateKind {
98 Script(Range<usize>),
100 Link(Option<String>),
101}
102
103fn is_script(tag: &Tag) -> bool {
104 !tag.is_end && tag.name == "script" && tag.attr_is("type", SCRIPT_TYPE)
105}
106
107fn is_link(tag: &Tag) -> bool {
108 !tag.is_end && tag.name == "link" && tag.attr_has_token("rel", LINK_REL)
111}
112
113fn candidates(html: &[u8]) -> Vec<Candidate> {
115 let tags = scan::tags(html);
116 let head = scan::head(html, &tags);
117 let mut out = Vec::new();
118
119 for (i, tag) in tags.iter().enumerate() {
120 if tag.start < head.content.start || tag.end > head.content.end {
121 continue;
122 }
123 if is_link(tag) {
124 out.push(Candidate {
125 range: tag.start..tag.end,
126 kind: CandidateKind::Link(tag.attr("href").map(str::to_string)),
127 });
128 } else if is_script(tag) {
129 let close = tags
133 .get(i + 1)
134 .filter(|t| t.is_end && t.name == "script")
135 .map(|t| (t.start, t.end))
136 .unwrap_or((html.len(), html.len()));
137 out.push(Candidate {
138 range: tag.start..close.1,
139 kind: CandidateKind::Script(tag.end..close.0),
140 });
141 }
142 }
143 out
144}
145
146pub fn locate_all(html: &[u8]) -> Vec<Range<usize>> {
152 candidates(html).into_iter().map(|c| c.range).collect()
153}
154
155pub fn extract(html: &[u8]) -> Result<Manifest, Error> {
162 let mut found = candidates(html);
163 match found.len() {
164 0 => return Err(Error::NotFound),
165 1 => {}
166 _ => return Err(Error::MultipleManifests),
167 }
168 let c = found.pop().expect("length checked above");
169 let (start, length) = (c.range.start, c.range.len());
170 match c.kind {
171 CandidateKind::Script(content) => {
172 let text = scan::trim(&html[content]);
175 let store = base64::decode(text).ok_or(Error::MalformedElement(
176 "script content is not valid Base64",
177 ))?;
178 Ok(Manifest::Embedded {
179 start,
180 length,
181 store,
182 })
183 }
184 CandidateKind::Link(href) => {
185 let href = href
186 .filter(|h| !h.is_empty())
187 .ok_or(Error::MalformedElement("link has no href to resolve"))?;
188 Ok(Manifest::Referenced {
189 start,
190 length,
191 href,
192 })
193 }
194 }
195}
196
197fn insert(html: &[u8], element: &str) -> Result<Vec<u8>, Error> {
209 let tags = scan::tags(html);
210 let at = scan::head(html, &tags).end_tag.ok_or(Error::NoHead)?;
211
212 let mut out = Vec::with_capacity(html.len() + element.len());
213 out.extend_from_slice(&html[..at]);
214 out.extend_from_slice(element.as_bytes());
215 out.extend_from_slice(&html[at..]);
216 Ok(out)
217}
218
219pub fn embed(html: &[u8], store: &[u8]) -> Result<Vec<u8>, Error> {
228 let cleaned = remove(html)?;
229 let element = format!(
230 "<script type=\"{SCRIPT_TYPE}\">{}</script>",
231 base64::encode(store)
232 );
233 insert(&cleaned, &element)
234}
235
236pub fn embed_reference(html: &[u8], href: &str) -> Result<Vec<u8>, Error> {
246 let cleaned = remove(html)?;
247 let element = format!(
248 "<link rel=\"{LINK_REL}\" href=\"{}\" type=\"{SCRIPT_TYPE}\">",
249 escape_attribute(href)
250 );
251 insert(&cleaned, &element)
252}
253
254fn escape_attribute(value: &str) -> String {
255 let mut out = String::with_capacity(value.len());
256 for c in value.chars() {
257 match c {
258 '&' => out.push_str("&"),
259 '<' => out.push_str("<"),
260 '"' => out.push_str("""),
261 _ => out.push(c),
262 }
263 }
264 out
265}
266
267pub fn remove(html: &[u8]) -> Result<Vec<u8>, Error> {
273 let ranges = locate_all(html);
274 if ranges.is_empty() {
275 return Ok(html.to_vec());
276 }
277 let mut out = Vec::with_capacity(html.len());
278 let mut cursor = 0usize;
279 for range in ranges {
280 out.extend_from_slice(&html[cursor..range.start]);
281 cursor = range.end;
282 }
283 out.extend_from_slice(&html[cursor..]);
284 Ok(out)
285}
286
287#[cfg(test)]
288pub(crate) mod tests {
289 use super::*;
290
291 pub const DOC: &[u8] = b"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <title>Example</title>\n</head>\n<body>\n <p>Content here.</p>\n</body>\n</html>\n";
292
293 const STORE: &[u8] = b"\x00\x01\x02manifest-store\xFF";
294
295 fn utf8(bytes: &[u8]) -> String {
296 String::from_utf8(bytes.to_vec()).expect("output stays UTF-8")
297 }
298
299 #[test]
300 fn embed_then_extract_round_trips() {
301 let out = embed(DOC, STORE).unwrap();
302 assert_eq!(extract(&out).unwrap().store(), Some(STORE));
303 }
304
305 #[test]
306 fn embed_places_the_script_in_the_head_just_before_the_closing_tag() {
307 let out = utf8(&embed(DOC, b"hi").unwrap());
308 assert!(
309 out.contains("<script type=\"application/c2pa\">aGk=</script></head>"),
310 "{out}"
311 );
312 assert!(out.contains("<p>Content here.</p>"));
314 }
315
316 #[test]
317 fn embed_adds_no_bytes_outside_the_element() {
318 let out = embed(DOC, b"hi").unwrap();
321 let m = extract(&out).unwrap();
322 let mut without = out[..m.start()].to_vec();
323 without.extend_from_slice(&out[m.start() + m.length()..]);
324 assert_eq!(without, DOC);
325 }
326
327 #[test]
328 fn remove_is_the_exact_inverse_of_embed() {
329 assert_eq!(remove(&embed(DOC, STORE).unwrap()).unwrap(), DOC);
330 assert_eq!(
331 remove(&embed_reference(DOC, "https://a.example/m.c2pa").unwrap()).unwrap(),
332 DOC
333 );
334 }
335
336 #[test]
337 fn remove_on_a_document_without_a_manifest_changes_nothing() {
338 assert_eq!(remove(DOC).unwrap(), DOC);
339 }
340
341 #[test]
342 fn embedding_twice_replaces_rather_than_accumulates() {
343 let once = embed(DOC, b"first").unwrap();
344 let twice = embed(&once, b"second").unwrap();
345 assert_eq!(locate_all(&twice).len(), 1);
346 assert_eq!(extract(&twice).unwrap().store(), Some(&b"second"[..]));
347 }
348
349 #[test]
350 fn embedding_a_reference_replaces_an_inline_manifest() {
351 let inline = embed(DOC, STORE).unwrap();
352 let referenced = embed_reference(&inline, "https://a.example/m.c2pa").unwrap();
353 assert_eq!(locate_all(&referenced).len(), 1);
354 assert_eq!(
355 extract(&referenced).unwrap().href(),
356 Some("https://a.example/m.c2pa")
357 );
358 }
359
360 #[test]
361 fn embed_reference_writes_a_discoverable_link() {
362 let out = utf8(&embed_reference(DOC, "https://a.example/m.c2pa").unwrap());
363 assert!(
364 out.contains(
365 "<link rel=\"c2pa-manifest\" href=\"https://a.example/m.c2pa\" type=\"application/c2pa\">"
366 ),
367 "{out}"
368 );
369 }
370
371 #[test]
372 fn a_reference_href_is_attribute_escaped() {
373 let out = embed_reference(DOC, "https://a.example/m?x=1&y=\"2\"").unwrap();
374 assert_eq!(
375 extract(&out).unwrap().href(),
376 Some("https://a.example/m?x=1&y="2"")
379 );
380 }
381
382 #[test]
383 fn range_covers_the_whole_element() {
384 let out = embed(DOC, b"hi").unwrap();
385 let m = extract(&out).unwrap();
386 let element = &out[m.range()];
387 assert!(element.starts_with(b"<script"));
388 assert!(element.ends_with(b"</script>"));
389 }
390
391 #[test]
392 fn a_document_with_no_head_cannot_be_embedded_into() {
393 assert_eq!(embed(b"<p>bare</p>", b"x"), Err(Error::NoHead));
394 assert_eq!(embed_reference(b"<p>bare</p>", "u"), Err(Error::NoHead));
395 }
396
397 #[test]
398 fn a_document_without_a_manifest_is_not_found() {
399 assert_eq!(extract(DOC), Err(Error::NotFound));
400 }
401
402 #[test]
403 fn two_scripts_are_treated_as_no_manifest_located() {
404 let html = b"<head><script type=\"application/c2pa\">aGk=</script><script type=\"application/c2pa\">aGk=</script></head>";
405 assert_eq!(locate_all(html).len(), 2);
406 assert_eq!(extract(html), Err(Error::MultipleManifests));
407 }
408
409 #[test]
410 fn two_links_are_treated_as_no_manifest_located() {
411 let html = b"<head><link rel=c2pa-manifest href=a><link rel=c2pa-manifest href=b></head>";
412 assert_eq!(extract(html), Err(Error::MultipleManifests));
413 }
414
415 #[test]
416 fn a_script_alongside_a_link_is_treated_as_no_manifest_located() {
417 let html =
418 b"<head><link rel=c2pa-manifest href=a><script type=application/c2pa>aGk=</script></head>";
419 assert_eq!(extract(html), Err(Error::MultipleManifests));
420 }
421
422 #[test]
423 fn discovery_accepts_all_three_attribute_quoting_forms() {
424 for head in [
425 &b"<script type=\"application/c2pa\">aGk=</script>"[..],
426 &b"<script type='application/c2pa'>aGk=</script>"[..],
427 &b"<script type=application/c2pa>aGk=</script>"[..],
428 ] {
429 let mut html = b"<head>".to_vec();
430 html.extend_from_slice(head);
431 html.extend_from_slice(b"</head>");
432 assert_eq!(
433 extract(&html).unwrap().store(),
434 Some(&b"hi"[..]),
435 "{}",
436 utf8(head)
437 );
438 }
439 for head in [
440 &b"<link rel=\"c2pa-manifest\" href=\"m.c2pa\">"[..],
441 &b"<link rel='c2pa-manifest' href='m.c2pa'>"[..],
442 &b"<link rel=c2pa-manifest href=m.c2pa>"[..],
443 ] {
444 let mut html = b"<head>".to_vec();
445 html.extend_from_slice(head);
446 html.extend_from_slice(b"</head>");
447 assert_eq!(
448 extract(&html).unwrap().href(),
449 Some("m.c2pa"),
450 "{}",
451 utf8(head)
452 );
453 }
454 }
455
456 #[test]
457 fn a_link_is_discovered_on_rel_alone_without_a_type() {
458 let html = b"<head><link rel=c2pa-manifest href=m.c2pa></head>";
459 assert_eq!(extract(html).unwrap().href(), Some("m.c2pa"));
460 }
461
462 #[test]
463 fn discovery_is_scoped_to_the_head() {
464 let html = b"<html><head><meta></head><body><script type=application/c2pa>aGk=</script><link rel=c2pa-manifest href=a></body></html>";
467 assert_eq!(locate_all(html), Vec::<Range<usize>>::new());
468 assert_eq!(extract(html), Err(Error::NotFound));
469 }
470
471 #[test]
472 fn a_body_element_does_not_make_a_head_element_ambiguous() {
473 let html = b"<html><head><link rel=c2pa-manifest href=good></head><body><link rel=c2pa-manifest href=ignored></body></html>";
474 assert_eq!(extract(html).unwrap().href(), Some("good"));
475 }
476
477 #[test]
478 fn markup_inside_another_script_is_not_discovered() {
479 let html =
480 b"<head><script>var s = \"<link rel=c2pa-manifest href=x>\";</script><meta></head>";
481 assert_eq!(extract(html), Err(Error::NotFound));
482 }
483
484 #[test]
485 fn a_commented_out_element_is_not_discovered() {
486 let html = b"<head><!-- <link rel=c2pa-manifest href=x> --><meta></head>";
487 assert_eq!(extract(html), Err(Error::NotFound));
488 }
489
490 #[test]
491 fn leading_and_trailing_whitespace_is_stripped_before_decoding() {
492 let html = b"<head><script type=\"application/c2pa\">\n aGk=\n </script></head>";
493 assert_eq!(extract(html).unwrap().store(), Some(&b"hi"[..]));
494 }
495
496 #[test]
497 fn an_undecodable_script_is_malformed_not_a_hash_failure() {
498 let html = b"<head><script type=application/c2pa>not base64!</script></head>";
499 let err = extract(html).unwrap_err();
500 assert!(matches!(err, Error::MalformedElement(_)));
501 assert!(err.is_no_manifest_located());
502 assert_eq!(err.code(), None);
503 }
504
505 #[test]
506 fn a_link_without_an_href_is_malformed() {
507 let html = b"<head><link rel=c2pa-manifest></head>";
508 assert!(matches!(extract(html), Err(Error::MalformedElement(_))));
509 let html = b"<head><link rel=c2pa-manifest href=\"\"></head>";
510 assert!(matches!(extract(html), Err(Error::MalformedElement(_))));
511 }
512
513 #[test]
514 fn a_near_miss_type_or_rel_is_not_a_manifest() {
515 for html in [
516 &b"<head><script type=application/c2pa+json>aGk=</script></head>"[..],
517 &b"<head><script type=application/json>aGk=</script></head>"[..],
518 &b"<head><script>aGk=</script></head>"[..],
519 &b"<head><link rel=c2pa-manifest-x href=a></head>"[..],
520 &b"<head><link rel=stylesheet href=a></head>"[..],
521 &b"<head><link href=a></head>"[..],
522 ] {
523 assert_eq!(extract(html), Err(Error::NotFound), "{}", utf8(html));
524 }
525 }
526
527 #[test]
528 fn matching_is_case_insensitive_on_names_types_and_relations() {
529 let html = b"<HEAD><SCRIPT TYPE=\"APPLICATION/C2PA\">aGk=</SCRIPT></HEAD>";
530 assert_eq!(extract(html).unwrap().store(), Some(&b"hi"[..]));
531 let html = b"<head><LINK REL=\"C2PA-Manifest\" HREF=\"m\"></head>";
532 assert_eq!(extract(html).unwrap().href(), Some("m"));
533 }
534
535 #[test]
536 fn a_rel_token_list_containing_the_relation_matches() {
537 let html = b"<head><link rel=\"alternate c2pa-manifest\" href=m></head>";
538 assert_eq!(extract(html).unwrap().href(), Some("m"));
539 }
540
541 #[test]
542 fn an_implied_head_is_still_searched() {
543 let html = b"<html><link rel=c2pa-manifest href=m><body><p>x</p></body></html>";
545 assert_eq!(extract(html).unwrap().href(), Some("m"));
546 }
547
548 #[test]
549 fn a_non_utf8_document_still_scans() {
550 let mut html =
553 b"<head><title>caf\xE9</title><link rel=c2pa-manifest href=m></head>".to_vec();
554 assert_eq!(extract(&html).unwrap().href(), Some("m"));
555 html.extend_from_slice(b"<body>\xFF\xFE</body>");
556 assert_eq!(extract(&html).unwrap().href(), Some("m"));
557 }
558
559 #[test]
560 fn an_empty_store_round_trips() {
561 let out = embed(DOC, b"").unwrap();
562 assert_eq!(extract(&out).unwrap().store(), Some(&b""[..]));
563 }
564
565 #[test]
566 fn every_byte_value_survives_the_base64_round_trip() {
567 let store: Vec<u8> = (0..=255).collect();
568 let out = embed(DOC, &store).unwrap();
569 assert_eq!(extract(&out).unwrap().store(), Some(&store[..]));
570 }
571}