Skip to main content

c2pa_unstructured_text/
wrapper.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//! The `C2PATextManifestWrapper` frame (C2PA 2.4 Appendix A.8).
6//!
7//! A wrapper is a `U+FEFF` marker followed by the variation-selector encoding of
8//! `magic(8) + version(1) + big-endian length(4) + payload + optional padding`.
9//! The marker is part of the wrapper for content binding, so the byte range this
10//! module reports covers the marker together with the selector run.
11
12use crate::error::Error;
13use crate::vs::{byte_to_vs, decode_run, vs_to_byte};
14
15/// Wrapper identifier, `"C2PATXT\0"`.
16pub const MAGIC: [u8; 8] = *b"C2PATXT\0";
17/// Frame version defined by A.8.
18pub const VERSION: u8 = 1;
19/// Zero-Width No-Break Space marking the start of a wrapper.
20pub const MARKER: char = '\u{FEFF}';
21/// `magic(8) + version(1) + length(4)`.
22pub const HEADER_LEN: usize = 13;
23
24/// Version 2 frame: the v1 frame followed by a truncated hash over it, so a
25/// mangled carrier is rejected rather than decoded to wrong bytes. A
26/// WritersLogic extension, not part of A.8.
27#[cfg(feature = "checksum-v2")]
28pub const VERSION_V2: u8 = 2;
29#[cfg(feature = "checksum-v2")]
30const CHECKSUM_LEN: usize = 4;
31
32/// A located wrapper and the byte range it occupies in the host text.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Wrapper {
35    /// The Manifest Store bytes, excluding any trailing padding.
36    pub payload: Vec<u8>,
37    /// The frame version that decoded.
38    pub version: u8,
39    /// Byte offset of the `U+FEFF` marker in the host text as stored, before
40    /// any normalization.
41    pub start: usize,
42    /// Byte length from the marker through the end of the selector run,
43    /// including any trailing padding.
44    pub length: usize,
45}
46
47impl Wrapper {
48    /// The half-open byte range `start..start + length`.
49    pub fn range(&self) -> core::ops::Range<usize> {
50        self.start..self.start + self.length
51    }
52}
53
54/// Encode `payload` as a v1 wrapper.
55pub fn encode(payload: &[u8]) -> Result<String, Error> {
56    encode_with_padding(payload, &[])
57}
58
59fn encode_with_padding(payload: &[u8], padding: &[u8]) -> Result<String, Error> {
60    let len = u32::try_from(payload.len()).map_err(|_| Error::PayloadTooLarge(payload.len()))?;
61    let mut framed = Vec::with_capacity(HEADER_LEN + payload.len() + padding.len());
62    framed.extend_from_slice(&MAGIC);
63    framed.push(VERSION);
64    framed.extend_from_slice(&len.to_be_bytes());
65    framed.extend_from_slice(payload);
66    framed.extend_from_slice(padding);
67    Ok(carry(&framed))
68}
69
70fn carry(framed: &[u8]) -> String {
71    let mut out = String::with_capacity(1 + framed.len() * 4);
72    out.push(MARKER);
73    out.extend(framed.iter().map(|&b| byte_to_vs(b)));
74    out
75}
76
77/// Append a v1 wrapper to `text`. A.8 places the wrapper at the end of the
78/// visible content.
79pub fn embed(text: &str, payload: &[u8]) -> Result<String, Error> {
80    Ok(format!("{text}{}", encode(payload)?))
81}
82
83/// The deterministic target UTF-8 byte length for a manifest of
84/// `manifest_len` bytes: `3 + (13 + M) * 4 + 6`.
85///
86/// The margin of 6 keeps the gap between this target and the actual unpadded
87/// length expressible as `3a + 4b`, which the values 1, 2 and 5 are not.
88pub fn target_length(manifest_len: usize) -> usize {
89    3 + (HEADER_LEN + manifest_len) * 4 + 6
90}
91
92/// Padding bytes whose selector encoding totals exactly `gap` UTF-8 bytes.
93///
94/// The decomposition is fixed by the specification so that compliant generators
95/// emit byte-identical wrappers for the same manifest: `(gap - 4 * (gap mod 3)) / 3`
96/// bytes of `0x00`, then `gap mod 3` bytes of `0x10`.
97pub fn padding(gap: usize) -> Result<Vec<u8>, Error> {
98    if gap == 0 {
99        return Ok(Vec::new());
100    }
101    // 4 = 1 (mod 3), so b = gap mod 3 makes `gap - 4b` divisible by 3.
102    let b = gap % 3;
103    if gap < 4 * b {
104        // Only 1, 2 and 5 are not expressible; the +6 margin excludes them.
105        return Err(Error::UnrepresentableGap(gap));
106    }
107    let a = (gap - 4 * b) / 3;
108    let mut out = vec![0x00u8; a];
109    out.extend(core::iter::repeat_n(0x10u8, b));
110    Ok(out)
111}
112
113/// Encode `payload` padded to [`target_length`], so the wrapper's byte length
114/// depends only on the manifest size and not on its byte distribution.
115pub fn encode_padded(payload: &[u8]) -> Result<String, Error> {
116    let target = target_length(payload.len());
117    let base = encode(payload)?;
118    let gap = target
119        .checked_sub(base.len())
120        .ok_or(Error::UnrepresentableGap(0))?;
121    encode_with_padding(payload, &padding(gap)?)
122}
123
124/// Decode one framed byte run into a wrapper, or `None` if it does not decode.
125fn decode_frame(run: &[u8], start: usize, length: usize) -> Option<Wrapper> {
126    let (body_end, declared_ok) = frame_bounds(run)?;
127    if run[8] != VERSION || !declared_ok {
128        return None;
129    }
130    Some(Wrapper {
131        payload: run[HEADER_LEN..body_end].to_vec(),
132        version: VERSION,
133        start,
134        length,
135    })
136}
137
138/// Common header parse: returns the end of the declared body and whether the
139/// run is long enough to contain it.
140fn frame_bounds(run: &[u8]) -> Option<(usize, bool)> {
141    if run.len() < HEADER_LEN || run[..MAGIC.len()] != MAGIC {
142        return None;
143    }
144    let declared = u32::from_be_bytes([run[9], run[10], run[11], run[12]]) as usize;
145    let body_end = HEADER_LEN.checked_add(declared)?;
146    Some((body_end, run.len() >= body_end))
147}
148
149/// Visit every `U+FEFF`-prefixed selector run in `text` as `(run, start, length)`.
150fn scan(text: &str, mut visit: impl FnMut(&[u8], usize, usize)) {
151    let mut from = 0;
152    while let Some(rel) = text[from..].find(MARKER) {
153        let start = from + rel;
154        let run_start = start + MARKER.len_utf8();
155        let (run, consumed) = decode_run(&text[run_start..]);
156        let end = run_start + consumed;
157        visit(&run, start, end - start);
158        // Resume after the run, so a marker inside it is not rescanned.
159        from = end.max(run_start);
160    }
161}
162
163/// Every valid v1 wrapper in `text`, in order of appearance.
164///
165/// A candidate whose magic matches but whose frame does not decode is not a
166/// valid wrapper and is skipped, so a mangled run beside a good one does not
167/// discard the asset.
168pub fn locate_all(text: &str) -> Vec<Wrapper> {
169    let mut found = Vec::new();
170    scan(text, |run, start, length| {
171        if let Some(w) = decode_frame(run, start, length) {
172            found.push(w);
173        }
174    });
175    found
176}
177
178/// The single valid wrapper in `text`.
179///
180/// Zero valid wrappers and no candidate at all is [`Error::NotFound`], the only
181/// outcome meaning the text carries no provenance. More than one valid wrapper
182/// is [`Error::MultipleWrappers`] (`manifest.text.multipleWrappers`), and a
183/// candidate that fails to decode when no valid wrapper was found is
184/// [`Error::CorruptedWrapper`] (`manifest.text.corruptedWrapper`) — both are
185/// reportable failures. See [`Error::is_no_manifest_located`].
186///
187/// A candidate that fails to decode *beside* a valid wrapper is skipped rather
188/// than reported. The corrupted-wrapper code describes text whose only wrapper
189/// is mangled; letting stray bytes carrying the magic invalidate an otherwise
190/// good wrapper would hand anyone who can append to the text a denial of
191/// service.
192pub fn extract(text: &str) -> Result<Wrapper, Error> {
193    let mut found = locate_all(text);
194    match found.len() {
195        1 => Ok(found.remove(0)),
196        0 if has_candidate(text) => Err(Error::CorruptedWrapper),
197        0 => Err(Error::NotFound),
198        _ => Err(Error::MultipleWrappers),
199    }
200}
201
202/// Whether any marker is followed by a selector run bearing the magic, whether
203/// or not the rest of the frame decodes.
204fn has_candidate(text: &str) -> bool {
205    let mut seen = false;
206    scan(text, |run, _, _| {
207        if run.len() >= MAGIC.len() && run[..MAGIC.len()] == MAGIC {
208            seen = true;
209        }
210    });
211    seen
212}
213
214/// The v2 frame: the v1 layout with `version = 2` and a truncated SHA-256 over
215/// `magic + version + length + payload` appended.
216///
217/// A WritersLogic extension, not part of the specified frame. The specification
218/// requires a candidate that does not decode to be ignored, which makes a
219/// mangled carrier indistinguishable from an absent one. v2 closes that gap for
220/// generators that control both ends: a corrupted run fails its checksum and is
221/// rejected, rather than decoding to wrong bytes or vanishing silently.
222///
223/// A v2 wrapper is not a valid A.8 wrapper to a conformant validator, so use it
224/// only where both sides opt in.
225#[cfg(feature = "checksum-v2")]
226pub mod v2 {
227    use super::{
228        carry, frame_bounds, has_candidate, scan, Error, Wrapper, CHECKSUM_LEN, HEADER_LEN, MAGIC,
229        VERSION_V2,
230    };
231    use crate::hardbinding::{Algorithm, Hasher};
232
233    fn framed(payload: &[u8], hasher: &impl Hasher) -> Result<Vec<u8>, Error> {
234        let len =
235            u32::try_from(payload.len()).map_err(|_| Error::PayloadTooLarge(payload.len()))?;
236        let mut v = Vec::with_capacity(HEADER_LEN + payload.len() + CHECKSUM_LEN);
237        v.extend_from_slice(&MAGIC);
238        v.push(VERSION_V2);
239        v.extend_from_slice(&len.to_be_bytes());
240        v.extend_from_slice(payload);
241        let sum = hasher.digest(Algorithm::Sha256, &v);
242        v.extend_from_slice(&sum[..CHECKSUM_LEN]);
243        Ok(v)
244    }
245
246    /// Encode `payload` as a v2 wrapper.
247    pub fn encode(payload: &[u8], hasher: &impl Hasher) -> Result<String, Error> {
248        Ok(carry(&framed(payload, hasher)?))
249    }
250
251    /// Append a v2 wrapper to `text`.
252    pub fn embed(text: &str, payload: &[u8], hasher: &impl Hasher) -> Result<String, Error> {
253        Ok(format!("{text}{}", encode(payload, hasher)?))
254    }
255
256    fn decode(run: &[u8], start: usize, length: usize, hasher: &impl Hasher) -> Option<Wrapper> {
257        let (body_end, _) = frame_bounds(run)?;
258        if run[8] != VERSION_V2 || run.len() < body_end + CHECKSUM_LEN {
259            return None;
260        }
261        let expected = hasher.digest(Algorithm::Sha256, &run[..body_end]);
262        if run[body_end..body_end + CHECKSUM_LEN] != expected[..CHECKSUM_LEN] {
263            return None;
264        }
265        Some(Wrapper {
266            payload: run[HEADER_LEN..body_end].to_vec(),
267            version: VERSION_V2,
268            start,
269            length,
270        })
271    }
272
273    /// Every valid v2 wrapper in `text`, checksum verified.
274    pub fn locate_all(text: &str, hasher: &impl Hasher) -> Vec<Wrapper> {
275        let mut found = Vec::new();
276        scan(text, |run, start, length| {
277            if let Some(w) = decode(run, start, length, hasher) {
278                found.push(w);
279            }
280        });
281        found
282    }
283
284    /// The single valid wrapper in `text`, accepting either frame version.
285    ///
286    /// Tries v1 first, since that is the specified frame. Use this where the
287    /// carrier may have been produced by either generator; use [`super::extract`]
288    /// where only the specified frame is acceptable.
289    pub fn extract_any(text: &str, hasher: &impl Hasher) -> Result<Wrapper, Error> {
290        match super::extract(text) {
291            Ok(w) => Ok(w),
292            Err(v1) => match extract(text, hasher) {
293                Ok(w) => Ok(w),
294                // Neither frame decoded; keep whichever actually saw a candidate.
295                Err(Error::NotFound) => Err(v1),
296                Err(v2) => Err(v2),
297            },
298        }
299    }
300
301    /// The single valid v2 wrapper in `text`.
302    ///
303    /// A run whose checksum fails yields [`Error::CorruptedWrapper`] rather than
304    /// [`Error::NotFound`], which is the distinction v2 exists to provide.
305    pub fn extract(text: &str, hasher: &impl Hasher) -> Result<Wrapper, Error> {
306        let mut found = locate_all(text, hasher);
307        match found.len() {
308            1 => Ok(found.remove(0)),
309            0 if has_candidate(text) => Err(Error::CorruptedWrapper),
310            0 => Err(Error::NotFound),
311            _ => Err(Error::MultipleWrappers),
312        }
313    }
314}
315
316/// Remove the wrapper occupying `range` from `text`, returning the remaining
317/// bytes. The caller normalizes afterwards; see [`crate::hardbinding`].
318pub fn strip(text: &str, range: core::ops::Range<usize>) -> Result<String, Error> {
319    if range.end > text.len() || range.start > range.end {
320        return Err(Error::MalformedExclusion);
321    }
322    if !text.is_char_boundary(range.start) || !text.is_char_boundary(range.end) {
323        return Err(Error::MalformedExclusion);
324    }
325    let mut out = String::with_capacity(text.len() - (range.end - range.start));
326    out.push_str(&text[..range.start]);
327    out.push_str(&text[range.end..]);
328    Ok(out)
329}
330
331/// Decode a bare selector run into bytes, rejecting any non-selector.
332pub fn decode_exact(run: &str) -> Result<Vec<u8>, Error> {
333    run.chars()
334        .map(|c| vs_to_byte(c).ok_or(Error::CorruptedWrapper))
335        .collect()
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    const HOST: &str = "This sentence carries an invisible C2PA text manifest wrapper at its end.";
343    const PAYLOAD: &[u8] = b"c2pa-manifest-01";
344
345    #[test]
346    fn round_trip_locates_the_payload_and_its_range() {
347        let asset = embed(HOST, PAYLOAD).unwrap();
348        let w = extract(&asset).unwrap();
349        assert_eq!(w.payload, PAYLOAD);
350        assert_eq!(w.version, VERSION);
351        assert_eq!(w.start, HOST.len());
352        assert_eq!(&asset[w.range()], &asset[HOST.len()..]);
353        // The excluded range begins at the marker.
354        assert!(asset[w.range()].starts_with(MARKER));
355    }
356
357    #[test]
358    fn stripping_the_range_leaves_the_visible_text() {
359        let asset = embed(HOST, PAYLOAD).unwrap();
360        let w = extract(&asset).unwrap();
361        assert_eq!(strip(&asset, w.range()).unwrap(), HOST);
362    }
363
364    #[test]
365    fn padding_uses_the_specified_decomposition() {
366        assert_eq!(padding(0).unwrap(), Vec::<u8>::new());
367        assert_eq!(padding(6).unwrap(), vec![0x00, 0x00]);
368        assert_eq!(padding(7).unwrap(), vec![0x00, 0x10]);
369        assert_eq!(padding(8).unwrap(), vec![0x10, 0x10]);
370        // 12 admits four 3-byte selectors or three 4-byte ones; the specified
371        // decomposition is four 0x00.
372        assert_eq!(padding(12).unwrap(), vec![0x00; 4]);
373        for gap in [1usize, 2, 5] {
374            assert!(padding(gap).is_err(), "gap {gap} should be rejected");
375        }
376    }
377
378    #[test]
379    fn padded_wrapper_hits_the_deterministic_target() {
380        for m in [0usize, 1, 16, 200] {
381            let payload = vec![0xABu8; m];
382            let padded = encode_padded(&payload).unwrap();
383            assert_eq!(padded.len(), target_length(m), "manifest of {m} bytes");
384            // Padding is ignored on decode.
385            let w = extract(&format!("{HOST}{padded}")).unwrap();
386            assert_eq!(w.payload, payload);
387        }
388    }
389
390    #[test]
391    fn known_vector_matches_the_published_test_file() {
392        // 16-byte payload: E_target 125, unpadded 114, gap 11 -> one 0x00, two 0x10.
393        let unpadded = encode(PAYLOAD).unwrap();
394        assert_eq!(unpadded.len(), 114);
395        assert_eq!(target_length(PAYLOAD.len()), 125);
396        assert_eq!(padding(125 - 114).unwrap(), vec![0x00, 0x10, 0x10]);
397        assert_eq!(encode_padded(PAYLOAD).unwrap().len(), 125);
398    }
399
400    #[test]
401    fn no_wrapper_is_absence_but_many_is_a_reportable_failure() {
402        assert_eq!(extract(HOST), Err(Error::NotFound));
403        assert!(Error::NotFound.is_no_manifest_located());
404
405        let one = embed(HOST, PAYLOAD).unwrap();
406        let two = embed(&one, PAYLOAD).unwrap();
407        assert_eq!(extract(&two), Err(Error::MultipleWrappers));
408        assert_eq!(locate_all(&two).len(), 2);
409        // Two wrappers were located, so this is a rejection, not an absence.
410        assert!(!Error::MultipleWrappers.is_no_manifest_located());
411        assert_eq!(
412            Error::MultipleWrappers.code(),
413            Some("manifest.text.multipleWrappers")
414        );
415    }
416
417    #[test]
418    fn a_mangled_candidate_beside_a_valid_one_is_ignored() {
419        // Wrong version: the candidate does not decode, so it is skipped.
420        let mut framed = MAGIC.to_vec();
421        framed.push(9);
422        framed.extend_from_slice(&16u32.to_be_bytes());
423        framed.extend_from_slice(PAYLOAD);
424        let bad = carry(&framed);
425        let good = encode(PAYLOAD).unwrap();
426        let asset = format!("{HOST}{bad}{good}");
427        let w = extract(&asset).expect("the valid wrapper is still located");
428        assert_eq!(w.payload, PAYLOAD);
429        assert_eq!(locate_all(&asset).len(), 1);
430    }
431
432    #[test]
433    fn a_lone_mangled_candidate_reports_corruption_not_absence() {
434        let mut framed = MAGIC.to_vec();
435        framed.push(VERSION);
436        framed.extend_from_slice(&99u32.to_be_bytes()); // declares more than it carries
437        framed.extend_from_slice(PAYLOAD);
438        let asset = format!("{HOST}{}", carry(&framed));
439        let err = extract(&asset).unwrap_err();
440        assert_eq!(err, Error::CorruptedWrapper);
441        // A magic number was detected, so this is a reportable failure rather
442        // than an unsigned asset.
443        assert!(!err.is_no_manifest_located());
444        assert_eq!(err.code(), Some("manifest.text.corruptedWrapper"));
445    }
446
447    #[test]
448    fn a_bad_magic_is_not_a_candidate_at_all() {
449        // Final magic byte is 0x01 rather than the required 0x00.
450        let mut v = b"C2PATXT\x01".to_vec();
451        v.push(VERSION);
452        v.extend_from_slice(&16u32.to_be_bytes());
453        v.extend_from_slice(PAYLOAD);
454        let asset = format!("{HOST}{}", carry(&v));
455        // Detection keys on the magic, so this is absence, not corruption.
456        assert_eq!(extract(&asset), Err(Error::NotFound));
457    }
458
459    #[test]
460    fn payload_larger_than_the_length_field_is_rejected() {
461        // Constructing 4 GiB is impractical; assert the boundary arithmetic holds.
462        assert!(u32::try_from(u32::MAX as usize).is_ok());
463        assert!(u32::try_from(u32::MAX as usize + 1).is_err());
464    }
465
466    /// Text that legitimately contains variation selectors must not be read as
467    /// carrying provenance. Emoji presentation selectors and CJK ideographic
468    /// variation sequences are ordinary content, and a bare `U+FEFF` is a common
469    /// byte-order mark.
470    #[test]
471    fn legitimate_selectors_in_clean_text_are_not_payloads() {
472        let clean = [
473            "A perfectly ordinary paragraph with no hidden provenance whatsoever.",
474            "Emoji carry legitimate variation selectors: a smiley \u{263A}\u{FE0F} and a heart \u{2764}\u{FE0F}.",
475            "CJK ideographic variation sequence: \u{845B}\u{E0100} is a valid rendering hint.",
476            "A stray zero-width joiner \u{200D} and no-break space \u{FEFF} without any magic.",
477            "\u{FEFF}A leading byte-order mark followed by ordinary prose.",
478            // A marker followed by selectors that are too short to be a header.
479            "\u{FEFF}\u{FE00}\u{FE01}",
480            "",
481        ];
482        for s in clean {
483            assert_eq!(
484                extract(s),
485                Err(Error::NotFound),
486                "hallucinated provenance in {s:?}"
487            );
488            assert!(locate_all(s).is_empty());
489        }
490    }
491
492    #[test]
493    fn a_marker_inside_ordinary_text_does_not_shadow_a_real_wrapper() {
494        let host = "Quoting a BOM \u{FEFF} mid-sentence, and an emoji \u{2764}\u{FE0F}.";
495        let asset = embed(host, PAYLOAD).unwrap();
496        let w = extract(&asset).unwrap();
497        assert_eq!(w.payload, PAYLOAD);
498        assert_eq!(w.start, host.len());
499    }
500
501    #[cfg(feature = "checksum-v2")]
502    mod checksum_v2 {
503        use super::*;
504        use crate::hardbinding::{Algorithm, Hasher};
505
506        /// Deterministic stand-in so the frame is testable without pulling in a
507        /// real digest.
508        struct TestHasher;
509        impl Hasher for TestHasher {
510            fn digest(&self, _: Algorithm, data: &[u8]) -> Vec<u8> {
511                let mut acc: u32 = 0x811C_9DC5;
512                for &b in data {
513                    acc = (acc ^ b as u32).wrapping_mul(0x0100_0193);
514                }
515                acc.to_be_bytes().to_vec()
516            }
517        }
518
519        #[test]
520        fn round_trips_and_reports_version_two() {
521            let asset = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
522            let w = v2::extract(&asset, &TestHasher).unwrap();
523            assert_eq!(w.payload, PAYLOAD);
524            assert_eq!(w.version, VERSION_V2);
525            assert_eq!(strip(&asset, w.range()).unwrap(), HOST);
526        }
527
528        #[test]
529        fn a_corrupted_payload_is_rejected_rather_than_decoded() {
530            let asset = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
531            // Flip one payload byte by re-encoding a mutated payload under the
532            // original checksum: rebuild the run with a stale sum.
533            let mut mutated = PAYLOAD.to_vec();
534            mutated[0] ^= 0x01;
535            let good = v2::encode(PAYLOAD, &TestHasher).unwrap();
536            let bad = v2::encode(&mutated, &TestHasher).unwrap();
537            // Splice the good checksum onto the mutated body: last 4 selectors.
538            let good_tail: String = good
539                .chars()
540                .rev()
541                .take(4)
542                .collect::<Vec<_>>()
543                .into_iter()
544                .rev()
545                .collect();
546            let bad_body: String = bad.chars().take(bad.chars().count() - 4).collect();
547            let spliced = format!("{HOST}{bad_body}{good_tail}");
548            assert_eq!(
549                v2::extract(&spliced, &TestHasher),
550                Err(Error::CorruptedWrapper),
551                "a stale checksum must fail closed"
552            );
553            assert!(!asset.is_empty());
554        }
555
556        #[test]
557        fn a_v1_wrapper_is_not_a_v2_wrapper_and_the_reverse() {
558            let v1 = embed(HOST, PAYLOAD).unwrap();
559            assert_eq!(v2::extract(&v1, &TestHasher), Err(Error::CorruptedWrapper));
560            let two = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
561            // v1 detection sees a candidate it cannot decode, so it fails safe.
562            assert_eq!(extract(&two), Err(Error::CorruptedWrapper));
563        }
564
565        #[test]
566        fn clean_text_is_still_not_a_payload() {
567            assert_eq!(v2::extract(HOST, &TestHasher), Err(Error::NotFound));
568        }
569
570        #[test]
571        fn extract_any_accepts_either_frame() {
572            let v1 = embed(HOST, PAYLOAD).unwrap();
573            let two = v2::embed(HOST, PAYLOAD, &TestHasher).unwrap();
574            for asset in [&v1, &two] {
575                let w = v2::extract_any(asset, &TestHasher).unwrap();
576                assert_eq!(w.payload, PAYLOAD);
577            }
578            assert_eq!(v2::extract_any(&v1, &TestHasher).unwrap().version, VERSION);
579            assert_eq!(
580                v2::extract_any(&two, &TestHasher).unwrap().version,
581                VERSION_V2
582            );
583            assert_eq!(
584                v2::extract_any(HOST, &TestHasher),
585                Err(Error::NotFound),
586                "clean text is absence, not corruption"
587            );
588        }
589    }
590
591    #[test]
592    fn strip_rejects_ranges_that_split_a_character() {
593        let asset = format!("café{}", encode(PAYLOAD).unwrap());
594        // Byte 4 is inside the two-byte 'é'.
595        assert_eq!(
596            strip(&asset, 4..asset.len()),
597            Err(Error::MalformedExclusion)
598        );
599        assert_eq!(
600            strip(&asset, 0..asset.len() + 1),
601            Err(Error::MalformedExclusion)
602        );
603    }
604}