Skip to main content

c2pa_text_binding/
tag.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Unicode Tags carrier (the documented "ASCII smuggling" primitive).
4//!
5//! The Tags block (U+E0000-U+E007F) has a code point for each ASCII value:
6//! `U+E0000 + b`. Arbitrary bytes are hex-encoded to ASCII first, so one input
7//! byte costs two tag code points. Included purely as a comparison method for
8//! the survivability benchmark; tag characters are called out as having no
9//! legitimate use in C2PA text fields, so this is a carrier under study, not a
10//! recommended one.
11
12/// Carrier magic, hex-encoded and tag-mapped like the rest of the payload.
13pub const MAGIC: [u8; 4] = *b"C2TG";
14
15/// Map an ASCII byte (0x00-0x7F) to its tag code point.
16fn ascii_to_tag(b: u8) -> Option<char> {
17    (b <= 0x7F).then(|| char::from_u32(0xE0000 + b as u32).expect("tag code points are valid"))
18}
19
20/// Map a tag code point back to its ASCII byte, or `None` if `c` is not one.
21pub fn tag_to_ascii(c: char) -> Option<u8> {
22    match c as u32 {
23        cp @ 0xE0000..=0xE007F => Some((cp - 0xE0000) as u8),
24        _ => None,
25    }
26}
27
28/// Whether `c` is a tag character.
29pub fn is_tag(c: char) -> bool {
30    tag_to_ascii(c).is_some()
31}
32
33/// Encode `payload` as a tag-character run: `hex(magic || payload)`, each hex
34/// digit carried as its tag code point.
35pub fn encode(payload: &[u8]) -> String {
36    let mut bytes = MAGIC.to_vec();
37    bytes.extend_from_slice(payload);
38    hex::encode(bytes)
39        .bytes()
40        .filter_map(ascii_to_tag)
41        .collect()
42}
43
44/// Append a tag-character carrier for `payload` to the end of `text`.
45pub fn embed(text: &str, payload: &[u8]) -> String {
46    let mut s = String::with_capacity(text.len() + text.len());
47    s.push_str(text);
48    s.push_str(&encode(payload));
49    s
50}
51
52/// Decode outcome, mirroring [`crate::vs::Decoded`] for uniform classification.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Decoded {
55    None,
56    Corrupt,
57    Payload(Vec<u8>),
58}
59
60/// Recover a payload from the contiguous tag-character run in `text`.
61pub fn extract(text: &str) -> Decoded {
62    let ascii: Vec<u8> = text.chars().filter_map(tag_to_ascii).collect();
63    if ascii.is_empty() {
64        return Decoded::None;
65    }
66    match hex::decode(&ascii) {
67        Ok(bytes) if bytes.len() >= MAGIC.len() && bytes[..MAGIC.len()] == MAGIC => {
68            Decoded::Payload(bytes[MAGIC.len()..].to_vec())
69        }
70        _ => Decoded::Corrupt,
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn round_trip_recovers_payload() {
80        let payload = b"https://fabrikam.com/m/a1b2c3.c2pa";
81        let text = embed("A short document.", payload);
82        assert_eq!(extract(&text), Decoded::Payload(payload.to_vec()));
83    }
84
85    #[test]
86    fn two_tag_code_points_per_byte() {
87        // hex doubles the byte count; magic(4) + payload(3) = 7 bytes -> 14 tags.
88        assert_eq!(encode(b"abc").chars().count(), (MAGIC.len() + 3) * 2);
89    }
90
91    #[test]
92    fn stripping_tags_yields_none() {
93        let text = embed("hi", b"x");
94        let stripped: String = text.chars().filter(|c| !is_tag(*c)).collect();
95        assert_eq!(extract(&stripped), Decoded::None);
96    }
97
98    #[test]
99    fn odd_length_run_fails_safe() {
100        let mut chars: Vec<char> = embed("hi", b"payload").chars().collect();
101        chars.pop(); // one hex nibble lost -> odd length -> not decodable
102        let mangled: String = chars.into_iter().collect();
103        assert_eq!(extract(&mangled), Decoded::Corrupt);
104    }
105}