Skip to main content

c2pa_html/
error.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
5use std::fmt;
6
7/// Errors from locating an HTML-embedded manifest or validating its hard
8/// binding.
9///
10/// # Multiple manifests is a reportable failure
11///
12/// A document shall carry at most one C2PA Manifest Store association. The
13/// specification requires a validator that encounters more than one manifest
14/// element to report a `manifest.html.multipleManifests` failure, so
15/// [`Error::MultipleManifests`] carries that code and is *not* a
16/// "no manifests located" outcome — the manifests were located, and the
17/// document is rejected for carrying too many.
18///
19/// The other two location outcomes carry no status code. Finding nothing is
20/// simply an unsigned document, and the specification names no code for an
21/// element that matches but yields no Manifest Store. Only [`Error::code`]
22/// speaks for the specification; [`Error::is_no_manifest_located`] draws the
23/// line a caller surfacing provenance state to a user actually needs.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Error {
26    /// The `head` carries no `script` with `type="application/c2pa"` and no
27    /// `link` with `rel="c2pa-manifest"`: the document simply carries no
28    /// manifest.
29    NotFound,
30    /// More than one C2PA manifest element was found.
31    ///
32    /// Covers all three prohibited shapes: two `script` elements, two `link`
33    /// elements, and a `script` alongside a `link`. Reported as
34    /// `manifest.html.multipleManifests`.
35    MultipleManifests,
36    /// Exactly one manifest element was found but it could not yield a Manifest
37    /// Store: a `script` whose content is not valid Base64, or a `link` with no
38    /// `href`.
39    ///
40    /// The specification does not name this case. Treating it as "no manifests
41    /// located" is the fail-safe reading: nothing was obtained, so there is
42    /// nothing to validate and nothing to report a failure against.
43    MalformedElement(&'static str),
44    /// The document has no `head` to insert into. Embedding needs an explicit
45    /// `</head>`; a document relying on HTML's implied `head` must be
46    /// serialized with one first.
47    NoHead,
48    /// The exclusion ranges are malformed: out of order, overlapping, extending
49    /// past the end of the document, or not matching the located manifest
50    /// element.
51    MalformedExclusion,
52    /// The recomputed data hash did not match the value in the assertion.
53    HashMismatch,
54    /// A hash algorithm identifier outside the C2PA allowed list was requested.
55    UnsupportedAlgorithm(String),
56}
57
58impl Error {
59    /// The registered C2PA validation status code for this error, or `None`
60    /// when the condition carries no status code.
61    pub fn code(&self) -> Option<&'static str> {
62        Some(match self {
63            Self::MultipleManifests => "manifest.html.multipleManifests",
64            // Finding nothing is not a failure, and the specification names no
65            // code for an element that matches but yields no Manifest Store.
66            Self::NotFound | Self::MalformedElement(_) => return None,
67            // An embed-time input error, not a validation outcome.
68            Self::NoHead => return None,
69            Self::MalformedExclusion => "assertion.dataHash.malformed",
70            Self::HashMismatch => "assertion.dataHash.mismatch",
71            Self::UnsupportedAlgorithm(_) => "algorithm.unsupported",
72        })
73    }
74
75    /// Whether this error means the document carries no provenance at all, as
76    /// opposed to provenance that was found and rejected.
77    ///
78    /// Note that [`Error::MultipleManifests`] is *not* one of these: the
79    /// manifests were located, and the document is rejected for carrying more
80    /// than one.
81    pub fn is_no_manifest_located(&self) -> bool {
82        matches!(self, Self::NotFound | Self::MalformedElement(_))
83    }
84}
85
86impl fmt::Display for Error {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::NotFound => write!(f, "no C2PA manifest element found in the document head"),
90            Self::MultipleManifests => write!(f, "more than one C2PA manifest element found"),
91            Self::MalformedElement(why) => {
92                write!(f, "the C2PA manifest element is malformed: {why}")
93            }
94            Self::NoHead => write!(f, "the document has no head element to embed into"),
95            Self::MalformedExclusion => write!(f, "data hash exclusion range is malformed"),
96            Self::HashMismatch => write!(f, "data hash does not match the document content"),
97            Self::UnsupportedAlgorithm(a) => write!(f, "unsupported hash algorithm: {a}"),
98        }
99    }
100}
101
102impl std::error::Error for Error {}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn all() -> Vec<Error> {
109        vec![
110            Error::NotFound,
111            Error::MultipleManifests,
112            Error::MalformedElement("content is not valid Base64"),
113            Error::NoHead,
114            Error::MalformedExclusion,
115            Error::HashMismatch,
116            Error::UnsupportedAlgorithm("sha1".into()),
117        ]
118    }
119
120    #[test]
121    fn display_composes_into_a_sentence_for_every_variant() {
122        for e in all() {
123            let s = e.to_string();
124            assert!(!s.is_empty(), "{e:?} rendered empty");
125            // Messages are embedded in larger sentences, so they must not start
126            // with a capital or end with a period.
127            assert!(!s.ends_with('.'), "{e:?} ends with a period: {s}");
128            let first = s.chars().next().expect("checked non-empty above");
129            assert!(!first.is_uppercase(), "{e:?} starts uppercase: {s}");
130        }
131    }
132
133    #[test]
134    fn display_carries_the_offending_value() {
135        assert!(Error::UnsupportedAlgorithm("sha1".into())
136            .to_string()
137            .contains("sha1"));
138        assert!(Error::MalformedElement("link has no href")
139            .to_string()
140            .contains("link has no href"));
141    }
142
143    #[test]
144    fn multiple_manifests_is_a_reportable_failure() {
145        // The specification requires the code, so a validator must be able to
146        // report it. It is a rejection, not an absence of provenance.
147        assert_eq!(
148            Error::MultipleManifests.code(),
149            Some("manifest.html.multipleManifests")
150        );
151        assert!(!Error::MultipleManifests.is_no_manifest_located());
152    }
153
154    #[test]
155    fn every_code_is_a_registered_identifier() {
156        for e in all() {
157            if let Some(code) = e.code() {
158                assert!(
159                    matches!(
160                        code,
161                        "manifest.html.multipleManifests"
162                            | "assertion.dataHash.malformed"
163                            | "assertion.dataHash.mismatch"
164                            | "algorithm.unsupported"
165                    ),
166                    "{e:?} reports an unregistered code: {code}"
167                );
168            }
169        }
170    }
171
172    #[test]
173    fn absence_of_provenance_carries_no_code() {
174        for e in [
175            Error::NotFound,
176            Error::MalformedElement("content is not valid Base64"),
177        ] {
178            assert_eq!(e.code(), None, "{e:?} must not report a status code");
179            assert!(
180                e.is_no_manifest_located(),
181                "{e:?} must classify as unsigned"
182            );
183        }
184        // Identical to a validator, distinct to an integrator: "carries none"
185        // versus "carried one that was mangled in transit".
186        assert_ne!(Error::MalformedElement("x"), Error::NotFound);
187    }
188
189    #[test]
190    fn binding_failures_are_not_no_manifest_located() {
191        // A located manifest that fails its binding is "invalid", never "unsigned".
192        for e in [
193            Error::MalformedExclusion,
194            Error::HashMismatch,
195            Error::UnsupportedAlgorithm("sha1".into()),
196        ] {
197            assert!(!e.is_no_manifest_located(), "{e:?} misclassified");
198            assert!(e.code().is_some(), "{e:?} should report a code");
199        }
200    }
201
202    #[test]
203    fn no_head_is_neither_a_location_outcome_nor_a_validation_failure() {
204        assert_eq!(Error::NoHead.code(), None);
205        assert!(!Error::NoHead.is_no_manifest_located());
206    }
207}