Skip to main content

modo/encoding/
base64url.rs

1//! RFC 4648 base64url encoding and decoding without padding.
2
3const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
4
5/// Encodes `bytes` using RFC 4648 base64url (alphabet `A–Za–z0–9-_`), without padding.
6///
7/// The output uses `-` and `_` instead of `+` and `/`, making it safe for use
8/// in URLs, HTTP headers, and cookie values without percent-encoding.
9/// Returns an empty string when `bytes` is empty.
10///
11/// # Examples
12///
13/// ```rust
14/// use modo::encoding::base64url;
15///
16/// assert_eq!(base64url::encode(b"Hello"), "SGVsbG8");
17/// assert_eq!(base64url::encode(b""), "");
18/// ```
19pub fn encode(bytes: &[u8]) -> String {
20    if bytes.is_empty() {
21        return String::new();
22    }
23    let mut result = String::with_capacity((bytes.len() * 4).div_ceil(3));
24    let mut buffer: u32 = 0;
25    let mut bits_left = 0;
26
27    for &byte in bytes {
28        buffer = (buffer << 8) | byte as u32;
29        bits_left += 8;
30        while bits_left >= 6 {
31            bits_left -= 6;
32            let idx = ((buffer >> bits_left) & 0x3F) as usize;
33            result.push(ALPHABET[idx] as char);
34        }
35    }
36    if bits_left > 0 {
37        let idx = ((buffer << (6 - bits_left)) & 0x3F) as usize;
38        result.push(ALPHABET[idx] as char);
39    }
40    result
41}
42
43/// Decodes a base64url-encoded string.
44///
45/// No padding characters (`=`) are expected or accepted. Returns an empty `Vec`
46/// when `encoded` is empty.
47///
48/// # Errors
49///
50/// Returns [`crate::Error::bad_request`] if any character falls outside the
51/// RFC 4648 base64url alphabet (`A–Za–z0–9-_`).
52///
53/// # Examples
54///
55/// ```rust
56/// use modo::encoding::base64url;
57///
58/// assert_eq!(base64url::decode("SGVsbG8").unwrap(), b"Hello");
59/// // Invalid characters yield an error
60/// assert!(base64url::decode("SGVs!G8").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() * 3 / 4);
67    let mut buffer: u32 = 0;
68    let mut bits_left = 0;
69
70    for ch in encoded.chars() {
71        let val = decode_char(ch)?;
72        buffer = (buffer << 6) | val as u32;
73        bits_left += 6;
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        'a'..='z' => Ok(ch as u8 - b'a' + 26),
86        '0'..='9' => Ok(ch as u8 - b'0' + 52),
87        '-' => Ok(62),
88        '_' => Ok(63),
89        _ => Err(crate::Error::bad_request(format!(
90            "invalid base64url character: '{ch}'"
91        ))),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn encode_empty() {
101        assert_eq!(encode(b""), "");
102    }
103
104    #[test]
105    fn encode_basic() {
106        // Standard base64 of "Hello" is "SGVsbG8=", base64url no-pad is "SGVsbG8"
107        assert_eq!(encode(b"Hello"), "SGVsbG8");
108    }
109
110    #[test]
111    fn encode_uses_url_safe_chars() {
112        // Bytes that produce '+' and '/' in standard base64
113        let bytes = [0xfb, 0xff, 0xfe];
114        let encoded = encode(&bytes);
115        assert!(!encoded.contains('+'), "should use - not +");
116        assert!(!encoded.contains('/'), "should use _ not /");
117        assert!(encoded.contains('-') || encoded.contains('_'));
118    }
119
120    #[test]
121    fn decode_basic() {
122        assert_eq!(decode("SGVsbG8").unwrap(), b"Hello");
123    }
124
125    #[test]
126    fn roundtrip_random_bytes() {
127        let bytes: Vec<u8> = (0..=255).collect();
128        let encoded = encode(&bytes);
129        let decoded = decode(&encoded).unwrap();
130        assert_eq!(decoded, bytes);
131    }
132
133    #[test]
134    fn decode_invalid_char() {
135        assert!(decode("SGVs!G8").is_err());
136    }
137
138    #[test]
139    fn encode_32_bytes_pkce() {
140        let bytes = [0xABu8; 32];
141        let encoded = encode(&bytes);
142        let decoded = decode(&encoded).unwrap();
143        assert_eq!(decoded, bytes);
144    }
145}