Skip to main content

c2pa_html/
document.rs

1// Copyright 2026 WritersLogic. All rights reserved.
2// Licensed under the Apache License, Version 2.0 or the MIT license,
3// at your option.
4
5//! Discovering, embedding, and removing a C2PA manifest association in an HTML
6//! document.
7//!
8//! Two associations are specified, both scoped to the document `head`:
9//!
10//! - an **inline** manifest, the Base64 content of a
11//!   `<script type="application/c2pa">` element, and
12//! - an **external** manifest, referenced by a
13//!   `<link rel="c2pa-manifest" href="…">` element.
14//!
15//! The specification prefers the external form. A document shall carry at most
16//! one association: two `script` elements, two `link` elements, or one of each
17//! all mean the document is treated as if no manifests were located.
18
19use crate::base64;
20use crate::error::Error;
21use crate::scan::{self, Tag};
22use std::ops::Range;
23
24/// The `script` type that marks an inline Manifest Store.
25pub const SCRIPT_TYPE: &str = "application/c2pa";
26
27/// The IANA-registered link relation that marks an external Manifest Store.
28pub const LINK_REL: &str = "c2pa-manifest";
29
30/// A located C2PA manifest association.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Manifest {
33    /// An inline Manifest Store carried by a `script` element. `start` and
34    /// `length` cover the whole element, `<script` through `</script>`
35    /// inclusive — the exact span the hard binding excludes.
36    Embedded {
37        start: usize,
38        length: usize,
39        store: Vec<u8>,
40    },
41    /// A reference to an external Manifest Store carried by a `link` element.
42    /// `start` and `length` cover the whole element, which the hard binding
43    /// does *not* exclude.
44    Referenced {
45        start: usize,
46        length: usize,
47        href: String,
48    },
49}
50
51impl Manifest {
52    /// Byte offset of the element's opening `<`.
53    pub fn start(&self) -> usize {
54        match self {
55            Self::Embedded { start, .. } | Self::Referenced { start, .. } => *start,
56        }
57    }
58
59    /// Byte length of the whole element.
60    pub fn length(&self) -> usize {
61        match self {
62            Self::Embedded { length, .. } | Self::Referenced { length, .. } => *length,
63        }
64    }
65
66    /// The element's byte range in the document.
67    pub fn range(&self) -> Range<usize> {
68        self.start()..self.start() + self.length()
69    }
70
71    /// The decoded Manifest Store, for an inline manifest.
72    pub fn store(&self) -> Option<&[u8]> {
73        match self {
74            Self::Embedded { store, .. } => Some(store),
75            Self::Referenced { .. } => None,
76        }
77    }
78
79    /// The URI of the external Manifest Store, for a referenced manifest.
80    pub fn href(&self) -> Option<&str> {
81        match self {
82            Self::Referenced { href, .. } => Some(href),
83            Self::Embedded { .. } => None,
84        }
85    }
86}
87
88/// A manifest element found during discovery, before its content is decoded.
89///
90/// Multiplicity is decided on matching *elements*, not on whether they yield a
91/// Manifest Store, so candidates are counted before anything is decoded.
92struct Candidate {
93    range: Range<usize>,
94    kind: CandidateKind,
95}
96
97enum CandidateKind {
98    /// The byte range of the `script` element's text content.
99    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    // The `type` attribute should be present but is not required for discovery;
109    // the validator matches on `rel` alone.
110    !tag.is_end && tag.name == "link" && tag.attr_has_token("rel", LINK_REL)
111}
112
113/// Every C2PA manifest element in the document head, in source order.
114fn 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            // The scanner emits a raw-text element's end tag as the very next
130            // tag, so the element ends there. Without one the element runs to
131            // the end of the document, as a browser would treat it.
132            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
146/// The byte ranges of every C2PA manifest element in the document head.
147///
148/// Zero means the document carries no manifest; more than one means it is
149/// treated as if no manifests were located. Exposed for diagnostics — a caller
150/// reporting *why* a document has no provenance wants to say "it has three".
151pub fn locate_all(html: &[u8]) -> Vec<Range<usize>> {
152    candidates(html).into_iter().map(|c| c.range).collect()
153}
154
155/// Locate the document's single C2PA manifest association and decode it.
156///
157/// Fails with [`Error::NotFound`] when there is none, [`Error::MultipleManifests`]
158/// when there is more than one, and [`Error::MalformedElement`] when the one
159/// element present yields no Manifest Store. All three mean "no manifests
160/// located" to a validator; see [`Error::is_no_manifest_located`].
161pub 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            // The validator strips leading and trailing whitespace from the
173            // element's text content before Base64 decoding.
174            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
197/// Splice `element` into the head, immediately before the closing `</head>`.
198///
199/// The element is inserted and nothing else: no newline, no indentation. Every
200/// added byte lies *inside* the element, which is what makes an inline
201/// binding's covered bytes exactly the original document — see
202/// [`crate::hardbinding::inline_hash_before_embed`]. Adding so much as a
203/// newline outside the element would put a byte in the hash that the original
204/// document did not have.
205///
206/// The element therefore lands at whatever indentation the `</head>` line
207/// already carries, so the output stays readable without costing that property.
208fn 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
219/// Embed a Manifest Store inline, as a `script` element in the document head.
220///
221/// Any existing C2PA manifest elements are removed first, so embedding twice
222/// replaces rather than accumulates. Fails with [`Error::NoHead`] if the
223/// document has no `</head>`.
224///
225/// The specification prefers [`embed_reference`]; use this when the manifest
226/// must travel with the document.
227pub 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
236/// Reference an external Manifest Store, as a `link` element in the document
237/// head.
238///
239/// Any existing C2PA manifest elements are removed first. Fails with
240/// [`Error::NoHead`] if the document has no `</head>`.
241///
242/// `href` is written into an attribute verbatim except for `&`, `<`, and `"`,
243/// which are escaped. A URI containing those characters is unusual but a
244/// document that carries one must still parse.
245pub 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("&amp;"),
259            '<' => out.push_str("&lt;"),
260            '"' => out.push_str("&quot;"),
261            _ => out.push(c),
262        }
263    }
264    out
265}
266
267/// Remove every C2PA manifest element from the document.
268///
269/// A document that carries none is returned unchanged: removing nothing is not
270/// an error. [`embed`] adds no bytes outside the element, so removal is its
271/// exact inverse: `remove(embed(d, s)) == d`.
272pub 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        // Body content is untouched.
313        assert!(out.contains("<p>Content here.</p>"));
314    }
315
316    #[test]
317    fn embed_adds_no_bytes_outside_the_element() {
318        // The inline hard binding excludes exactly the element, so any byte
319        // added outside it would silently enter the hash.
320        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            // The parser returns the raw attribute value; entity expansion is the
377            // consumer's job, and the escaping is what keeps the tag well-formed.
378            Some("https://a.example/m?x=1&amp;y=&quot;2&quot;")
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        // Identical elements in the body are not an association, so neither the
465        // count nor the outcome changes.
466        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        // No `<head>` or `</head>`: the element sits in the implied head.
544        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        // Latin-1 bytes in the title: discovery is byte-oriented and must not
551        // require the document to be UTF-8.
552        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}