1use base64ct::{Base64UrlUnpadded, Encoding};
8
9#[derive(Debug, thiserror::Error, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Base64UrlError {
13 #[error("base64url padding is forbidden in AITP")]
15 PaddingNotAllowed,
16
17 #[error("invalid base64url character")]
19 InvalidChar,
20
21 #[error("invalid length")]
23 InvalidLength,
24}
25
26pub fn encode(bytes: &[u8]) -> String {
28 Base64UrlUnpadded::encode_string(bytes)
29}
30
31pub fn decode_strict(s: &str) -> Result<Vec<u8>, Base64UrlError> {
33 if s.contains('=') {
34 return Err(Base64UrlError::PaddingNotAllowed);
35 }
36 Base64UrlUnpadded::decode_vec(s).map_err(|_| Base64UrlError::InvalidChar)
37}
38
39pub fn decode_strict_exact<const N: usize>(s: &str) -> Result<[u8; N], Base64UrlError> {
41 let bytes = decode_strict(s)?;
42 if bytes.len() != N {
43 return Err(Base64UrlError::InvalidLength);
44 }
45 let mut out = [0u8; N];
46 out.copy_from_slice(&bytes);
47 Ok(out)
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 #[test]
55 fn rejects_padded_input() {
56 assert_eq!(
57 decode_strict("AA=="),
58 Err(Base64UrlError::PaddingNotAllowed)
59 );
60 }
61
62 #[test]
63 fn round_trips_arbitrary_bytes() {
64 let cases: &[&[u8]] = &[&[], b"a", b"hello", &[0xff; 32], &[0x00; 64]];
65 for input in cases {
66 let s = encode(input);
67 assert!(!s.contains('='));
68 assert_eq!(decode_strict(&s).unwrap(), *input);
69 }
70 }
71
72 #[test]
73 fn rejects_invalid_chars() {
74 assert!(matches!(
75 decode_strict("AA!A"),
76 Err(Base64UrlError::InvalidChar)
77 ));
78 }
79
80 #[test]
81 fn decode_strict_exact_enforces_length() {
82 let s = encode(&[0u8; 16]);
83 assert!(decode_strict_exact::<16>(&s).is_ok());
84 assert_eq!(
85 decode_strict_exact::<32>(&s),
86 Err(Base64UrlError::InvalidLength)
87 );
88 }
89}