c2pa_unstructured_text/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 a wrapper or validating an unstructured-text hard
8/// binding.
9///
10/// # Two wrapper outcomes are reportable failures
11///
12/// The specification defines two failure codes for wrapper location:
13///
14/// - `manifest.text.corruptedWrapper` — a magic number was detected but the
15/// wrapper was malformed or incomplete. A candidate that does not decode is
16/// *not* silently ignored; it is reported.
17/// - `manifest.text.multipleWrappers` — more than one *valid* wrapper was found.
18///
19/// Only [`Error::NotFound`] means the text carries no provenance at all, and
20/// only it carries no status code. [`Error::is_no_manifest_located`] draws that
21/// line for a caller surfacing provenance state to a user: "unsigned" versus
22/// "carried provenance that was rejected".
23///
24/// # A known tension in the specification
25///
26/// Placement rule 5 says a validator "may encounter multiple wrappers" and that
27/// "selection of the intended wrapper is governed by the `exclusions` field of
28/// the `c2pa.hash.data` assertion", which reads as though multiple wrappers are
29/// recoverable. The Validation Status Codes section says more than one valid
30/// wrapper is a `manifest.text.multipleWrappers` failure. These cannot both be
31/// followed.
32///
33/// This crate follows the explicit failure code, because that is the normative
34/// statement a validator is judged against, and because text that accreted a
35/// second wrapper has also changed the bytes the hard binding covers — so the
36/// "recoverable" reading would let a hash pass over text the claim never
37/// described.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Error {
40 /// No valid `C2PATextManifestWrapper` was located and no candidate was
41 /// detected either: the text simply carries no wrapper.
42 NotFound,
43 /// One or more candidates were detected (a `U+FEFF` followed by a
44 /// variation-selector run whose leading bytes match the magic) but none
45 /// fully decoded.
46 ///
47 /// Reported as `manifest.text.corruptedWrapper`: the magic number was
48 /// detected but the wrapper was malformed or incomplete. This is also the
49 /// *fail-safe* outcome — the codec rejected a mangled carrier rather than
50 /// decoding it to wrong bytes.
51 CorruptedWrapper,
52 /// More than one valid wrapper was located.
53 ///
54 /// Reported as `manifest.text.multipleWrappers`. Not a disambiguation
55 /// opportunity: text that accreted a second wrapper has also changed the
56 /// bytes covered by the hard binding.
57 MultipleWrappers,
58 /// The payload exceeds the `u32` `manifestLength` field of the wrapper frame.
59 PayloadTooLarge(usize),
60 /// A padding gap that is not expressible as `3a + 4b`, i.e. 1, 2 or 5. The
61 /// margin of 6 in the deterministic target keeps real wrappers clear of
62 /// these, so this indicates a hand-built target length.
63 UnrepresentableGap(usize),
64 /// The exclusion ranges are malformed: out of order, overlapping, extending
65 /// past the end of the asset, splitting a UTF-8 sequence, or not matching
66 /// the byte range of the located wrapper.
67 MalformedExclusion,
68 /// The recomputed data hash did not match the value in the assertion.
69 HashMismatch,
70 /// A hash algorithm identifier outside the C2PA allowed list was requested.
71 UnsupportedAlgorithm(String),
72}
73
74impl Error {
75 /// The registered C2PA validation status code for this error, or `None` when
76 /// the condition carries no status code.
77 pub fn code(&self) -> Option<&'static str> {
78 Some(match self {
79 Self::CorruptedWrapper => "manifest.text.corruptedWrapper",
80 Self::MultipleWrappers => "manifest.text.multipleWrappers",
81 // Carrying no wrapper at all is an unsigned asset, not a failure.
82 Self::NotFound => return None,
83 // Embed-time input errors, not validation outcomes.
84 Self::PayloadTooLarge(_) | Self::UnrepresentableGap(_) => return None,
85 Self::MalformedExclusion => "assertion.dataHash.malformed",
86 Self::HashMismatch => "assertion.dataHash.mismatch",
87 Self::UnsupportedAlgorithm(_) => "algorithm.unsupported",
88 })
89 }
90
91 /// Whether this error means the asset carries no provenance at all, as
92 /// opposed to provenance that was found and rejected. Callers that surface
93 /// provenance state to a user need this distinction: the former is
94 /// "unsigned", the latter is "invalid".
95 ///
96 /// Only [`Error::NotFound`] qualifies. A corrupted or duplicated wrapper is
97 /// a reportable failure, not an absence.
98 pub fn is_no_manifest_located(&self) -> bool {
99 matches!(self, Self::NotFound)
100 }
101}
102
103impl fmt::Display for Error {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::NotFound => write!(f, "no C2PA text manifest wrapper found"),
107 Self::CorruptedWrapper => {
108 write!(f, "wrapper candidate detected but it did not fully decode")
109 }
110 Self::MultipleWrappers => write!(f, "more than one valid wrapper found"),
111 Self::PayloadTooLarge(n) => {
112 write!(
113 f,
114 "payload of {n} bytes exceeds the u32 manifestLength field"
115 )
116 }
117 Self::UnrepresentableGap(n) => {
118 write!(
119 f,
120 "a padding gap of {n} bytes is not expressible as 3a + 4b"
121 )
122 }
123 Self::MalformedExclusion => write!(f, "data hash exclusion range is malformed"),
124 Self::HashMismatch => write!(f, "data hash does not match the asset content"),
125 Self::UnsupportedAlgorithm(a) => write!(f, "unsupported hash algorithm: {a}"),
126 }
127 }
128}
129
130impl std::error::Error for Error {}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 fn all() -> Vec<Error> {
137 vec![
138 Error::NotFound,
139 Error::CorruptedWrapper,
140 Error::MultipleWrappers,
141 Error::PayloadTooLarge(1 << 33),
142 Error::UnrepresentableGap(5),
143 Error::MalformedExclusion,
144 Error::HashMismatch,
145 Error::UnsupportedAlgorithm("sha1".into()),
146 ]
147 }
148
149 #[test]
150 fn display_composes_into_a_sentence_for_every_variant() {
151 for e in all() {
152 let s = e.to_string();
153 assert!(!s.is_empty(), "{e:?} rendered empty");
154 // Messages are embedded in larger sentences, so they must not start
155 // with a capital or end with a period.
156 assert!(!s.ends_with('.'), "{e:?} ends with a period: {s}");
157 let first = s.chars().next().expect("checked non-empty above");
158 assert!(!first.is_uppercase(), "{e:?} starts uppercase: {s}");
159 }
160 }
161
162 #[test]
163 fn display_carries_the_offending_value() {
164 assert!(Error::UnsupportedAlgorithm("sha1".into())
165 .to_string()
166 .contains("sha1"));
167 assert!(Error::PayloadTooLarge(4294967296)
168 .to_string()
169 .contains("4294967296"));
170 }
171
172 #[test]
173 fn the_two_wrapper_failure_codes_are_emitted() {
174 // Both are registered in the specification's validation-codes registry,
175 // so a validator built on this crate must be able to report them.
176 assert_eq!(
177 Error::CorruptedWrapper.code(),
178 Some("manifest.text.corruptedWrapper")
179 );
180 assert_eq!(
181 Error::MultipleWrappers.code(),
182 Some("manifest.text.multipleWrappers")
183 );
184 }
185
186 #[test]
187 fn every_code_is_a_registered_identifier() {
188 for e in all() {
189 if let Some(code) = e.code() {
190 assert!(
191 matches!(
192 code,
193 "manifest.text.corruptedWrapper"
194 | "manifest.text.multipleWrappers"
195 | "assertion.dataHash.malformed"
196 | "assertion.dataHash.mismatch"
197 | "algorithm.unsupported"
198 ),
199 "{e:?} reports an unregistered code: {code}"
200 );
201 }
202 }
203 }
204
205 #[test]
206 fn only_an_absent_wrapper_means_unsigned() {
207 assert_eq!(Error::NotFound.code(), None);
208 assert!(Error::NotFound.is_no_manifest_located());
209
210 // A corrupted or duplicated wrapper is provenance that was found and
211 // rejected, so reporting it as "unsigned" would understate the problem.
212 for e in [Error::CorruptedWrapper, Error::MultipleWrappers] {
213 assert!(
214 !e.is_no_manifest_located(),
215 "{e:?} must not classify as unsigned"
216 );
217 assert!(e.code().is_some(), "{e:?} should report a code");
218 }
219 }
220
221 #[test]
222 fn binding_failures_are_not_no_manifest_located() {
223 // A located manifest that fails its binding is "invalid", never "unsigned".
224 for e in [
225 Error::MalformedExclusion,
226 Error::HashMismatch,
227 Error::UnsupportedAlgorithm("sha1".into()),
228 ] {
229 assert!(!e.is_no_manifest_located(), "{e:?} misclassified");
230 assert!(e.code().is_some(), "{e:?} should report a code");
231 }
232 }
233}