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}