Skip to main content

modo/encoding/
base32.rs

1//! RFC 4648 base32 encoding and decoding without padding.
2
3const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
4
5/// Encodes `bytes` using RFC 4648 base32 (alphabet `A–Z`, `2–7`), without padding.
6///
7/// Returns an empty string when `bytes` is empty.
8///
9/// # Examples
10///
11/// ```rust
12/// use modo::encoding::base32;
13///
14/// assert_eq!(base32::encode(b"foobar"), "MZXW6YTBOI");
15/// assert_eq!(base32::encode(b""), "");
16/// ```
17pub fn encode(bytes: &[u8]) -> String {
18    if bytes.is_empty() {
19        return String::new();
20    }
21    let mut result = String::with_capacity((bytes.len() * 8).div_ceil(5));
22    let mut buffer: u64 = 0;
23    let mut bits_left = 0;
24
25    for &byte in bytes {
26        buffer = (buffer << 8) | byte as u64;
27        bits_left += 8;
28        while bits_left >= 5 {
29            bits_left -= 5;
30            let idx = ((buffer >> bits_left) & 0x1F) as usize;
31            result.push(ALPHABET[idx] as char);
32        }
33    }
34    if bits_left > 0 {
35        let idx = ((buffer << (5 - bits_left)) & 0x1F) as usize;
36        result.push(ALPHABET[idx] as char);
37    }
38    result
39}
40
41/// Decodes a base32-encoded string, accepting both upper- and lower-case input.
42///
43/// No padding characters are expected or accepted. Returns an empty `Vec` when
44/// `encoded` is empty.
45///
46/// # Errors
47///
48/// Returns [`crate::Error::bad_request`] if any character falls outside the
49/// RFC 4648 base32 alphabet (`A–Z`, `2–7`).
50///
51/// # Examples
52///
53/// ```rust
54/// use modo::encoding::base32;
55///
56/// assert_eq!(base32::decode("MZXW6YTBOI").unwrap(), b"foobar");
57/// // Decoding is case-insensitive
58/// assert_eq!(base32::decode("mzxw6ytboi").unwrap(), b"foobar");
59/// // Invalid characters yield an error
60/// assert!(base32::decode("MZXW1").is_err());
61/// ```
62pub fn decode(encoded: &str) -> crate::Result<Vec<u8>> {
63    if encoded.is_empty() {
64        return Ok(Vec::new());
65    }
66    let mut result = Vec::with_capacity(encoded.len() * 5 / 8);
67    let mut buffer: u64 = 0;
68    let mut bits_left = 0;
69
70    for ch in encoded.chars() {
71        let val = decode_char(ch.to_ascii_uppercase())?;
72        buffer = (buffer << 5) | val as u64;
73        bits_left += 5;
74        if bits_left >= 8 {
75            bits_left -= 8;
76            result.push((buffer >> bits_left) as u8);
77        }
78    }
79    Ok(result)
80}
81
82fn decode_char(ch: char) -> crate::Result<u8> {
83    match ch {
84        'A'..='Z' => Ok(ch as u8 - b'A'),
85        '2'..='7' => Ok(ch as u8 - b'2' + 26),
86        _ => Err(crate::Error::bad_request(format!(
87            "invalid base32 character: '{ch}'"
88        ))),
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn encode_empty() {
98        assert_eq!(encode(b""), "");
99    }
100
101    #[test]
102    fn encode_rfc4648_vectors() {
103        // RFC 4648 test vectors (without padding)
104        assert_eq!(encode(b"f"), "MY");
105        assert_eq!(encode(b"fo"), "MZXQ");
106        assert_eq!(encode(b"foo"), "MZXW6");
107        assert_eq!(encode(b"foob"), "MZXW6YQ");
108        assert_eq!(encode(b"fooba"), "MZXW6YTB");
109        assert_eq!(encode(b"foobar"), "MZXW6YTBOI");
110    }
111
112    #[test]
113    fn decode_rfc4648_vectors() {
114        assert_eq!(decode("MY").unwrap(), b"f");
115        assert_eq!(decode("MZXQ").unwrap(), b"fo");
116        assert_eq!(decode("MZXW6").unwrap(), b"foo");
117        assert_eq!(decode("MZXW6YQ").unwrap(), b"foob");
118        assert_eq!(decode("MZXW6YTB").unwrap(), b"fooba");
119        assert_eq!(decode("MZXW6YTBOI").unwrap(), b"foobar");
120    }
121
122    #[test]
123    fn decode_case_insensitive() {
124        assert_eq!(decode("mzxw6").unwrap(), b"foo");
125        assert_eq!(decode("Mzxw6").unwrap(), b"foo");
126    }
127
128    #[test]
129    fn roundtrip_random_bytes() {
130        let bytes: Vec<u8> = (0..=255).collect();
131        let encoded = encode(&bytes);
132        let decoded = decode(&encoded).unwrap();
133        assert_eq!(decoded, bytes);
134    }
135
136    #[test]
137    fn decode_invalid_char() {
138        assert!(decode("MZXW1").is_err()); // '1' not in base32 alphabet
139    }
140
141    #[test]
142    fn encode_20_byte_totp_secret() {
143        let secret = [0u8; 20];
144        let encoded = encode(&secret);
145        assert_eq!(encoded.len(), 32); // 20 bytes = 160 bits / 5 = 32 chars
146        let decoded = decode(&encoded).unwrap();
147        assert_eq!(decoded, secret);
148    }
149}