Skip to main content

modo/encoding/
base32.rs

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