Skip to main content

modo/encoding/
base64url.rs

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