Skip to main content

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}