Skip to main content

c2pa_structured_text/
codec.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//! Standard (RFC 4648) Base64 encode/decode with no external dependencies.
6//!
7//! Used for two purposes:
8//! - Encoding an embedded C2PA Manifest Store into a `data:application/c2pa;base64,`
9//!   reference and decoding it back out again ([`crate::extract`]).
10//! - Encoding the hard-binding hash value when serialising a `c2pa.hash.data`
11//!   assertion to JSON ([`crate::hardbinding`]).
12
13const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
14
15pub fn encode(input: &[u8]) -> String {
16    let mut out = Vec::with_capacity(input.len().div_ceil(3) * 4);
17    for chunk in input.chunks(3) {
18        let b0 = chunk[0] as u32;
19        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
20        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
21        let triple = (b0 << 16) | (b1 << 8) | b2;
22        out.push(CHARS[((triple >> 18) & 0x3F) as usize]);
23        out.push(CHARS[((triple >> 12) & 0x3F) as usize]);
24        if chunk.len() > 1 {
25            out.push(CHARS[((triple >> 6) & 0x3F) as usize]);
26        } else {
27            out.push(b'=');
28        }
29        if chunk.len() > 2 {
30            out.push(CHARS[(triple & 0x3F) as usize]);
31        } else {
32            out.push(b'=');
33        }
34    }
35    // Every byte pushed is drawn from `CHARS` or is `b'='`, so the result is ASCII.
36    String::from_utf8(out).expect("base64 alphabet is valid UTF-8")
37}
38
39/// Decode standard Base64. Rejects any character outside the alphabet (including
40/// URL-safe `-`/`_`) and any malformed padding. Whitespace is not permitted;
41/// callers must trim before decoding.
42pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
43    let bytes = input.as_bytes();
44    if !bytes.len().is_multiple_of(4) {
45        return Err(DecodeError::InvalidLength);
46    }
47    if bytes.is_empty() {
48        return Ok(Vec::new());
49    }
50
51    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
52    for (block_idx, block) in bytes.chunks(4).enumerate() {
53        let is_last = block_idx == bytes.len() / 4 - 1;
54        let mut acc = 0u32;
55        let mut pad = 0usize;
56        for (i, &c) in block.iter().enumerate() {
57            let value = match c {
58                b'A'..=b'Z' => c - b'A',
59                b'a'..=b'z' => c - b'a' + 26,
60                b'0'..=b'9' => c - b'0' + 52,
61                b'+' => 62,
62                b'/' => 63,
63                b'=' => {
64                    // Padding is only legal in the final block, and only in the
65                    // last two positions.
66                    if !is_last || i < 2 {
67                        return Err(DecodeError::InvalidPadding);
68                    }
69                    pad += 1;
70                    0
71                }
72                _ => return Err(DecodeError::InvalidCharacter(c)),
73            };
74            // A non-pad character after a pad character is malformed (e.g. "AB=C").
75            if c != b'=' && pad != 0 {
76                return Err(DecodeError::InvalidPadding);
77            }
78            acc = (acc << 6) | value as u32;
79        }
80        out.push((acc >> 16) as u8);
81        if pad < 2 {
82            out.push((acc >> 8) as u8);
83        }
84        if pad < 1 {
85            out.push(acc as u8);
86        }
87    }
88    Ok(out)
89}
90
91#[derive(Debug, PartialEq, Eq)]
92#[allow(clippy::enum_variant_names)]
93pub enum DecodeError {
94    InvalidLength,
95    InvalidPadding,
96    InvalidCharacter(u8),
97}
98
99impl core::fmt::Display for DecodeError {
100    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
101        match self {
102            Self::InvalidLength => write!(f, "base64 input length is not a multiple of 4"),
103            Self::InvalidPadding => write!(f, "base64 input has malformed padding"),
104            Self::InvalidCharacter(c) => write!(f, "base64 input has invalid character {c:#04x}"),
105        }
106    }
107}
108
109impl std::error::Error for DecodeError {}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn encode_known_vectors() {
117        assert_eq!(encode(b""), "");
118        assert_eq!(encode(b"f"), "Zg==");
119        assert_eq!(encode(b"fo"), "Zm8=");
120        assert_eq!(encode(b"foo"), "Zm9v");
121        assert_eq!(encode(b"foob"), "Zm9vYg==");
122        assert_eq!(encode(b"fooba"), "Zm9vYmE=");
123        assert_eq!(encode(b"foobar"), "Zm9vYmFy");
124    }
125
126    #[test]
127    fn decode_known_vectors() {
128        assert_eq!(decode("").unwrap(), b"");
129        assert_eq!(decode("Zg==").unwrap(), b"f");
130        assert_eq!(decode("Zm8=").unwrap(), b"fo");
131        assert_eq!(decode("Zm9v").unwrap(), b"foo");
132        assert_eq!(decode("Zm9vYg==").unwrap(), b"foob");
133        assert_eq!(decode("Zm9vYmE=").unwrap(), b"fooba");
134        assert_eq!(decode("Zm9vYmFy").unwrap(), b"foobar");
135    }
136
137    #[test]
138    fn round_trip_binary() {
139        let data: Vec<u8> = (0u16..=255).map(|b| b as u8).collect();
140        assert_eq!(decode(&encode(&data)).unwrap(), data);
141    }
142
143    #[test]
144    fn decode_rejects_bad_input() {
145        assert_eq!(decode("Zg="), Err(DecodeError::InvalidLength));
146        assert_eq!(decode("Zg=v"), Err(DecodeError::InvalidPadding));
147        assert_eq!(decode("Z-9v"), Err(DecodeError::InvalidCharacter(b'-')));
148        assert_eq!(decode("====").unwrap_err(), DecodeError::InvalidPadding);
149    }
150}