1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Base64Variant {
6 Standard,
7 UrlSafe,
8 StandardNoPadding,
9 UrlSafeNoPadding,
10}
11
12#[must_use]
13pub fn base64_encode(input: &[u8]) -> String {
14 encode_base64(input, Base64Variant::Standard)
15}
16
17pub fn base64_decode(input: &str) -> Option<Vec<u8>> {
18 decode_base64(input, false)
19}
20
21#[must_use]
22pub fn base64_url_encode(input: &[u8]) -> String {
23 encode_base64(input, Base64Variant::UrlSafe)
24}
25
26pub fn base64_url_decode(input: &str) -> Option<Vec<u8>> {
27 decode_base64(input, true)
28}
29
30#[must_use]
31pub fn looks_like_base64(input: &str) -> bool {
32 normalize_base64_input(input).is_some()
33 && input
34 .trim_end_matches('=')
35 .chars()
36 .all(|c| is_base64_char(c) || is_base64_url_char(c))
37}
38
39#[must_use]
40pub fn is_base64_char(c: char) -> bool {
41 c.is_ascii_alphanumeric() || matches!(c, '+' | '/')
42}
43
44#[must_use]
45pub fn is_base64_url_char(c: char) -> bool {
46 c.is_ascii_alphanumeric() || matches!(c, '-' | '_')
47}
48
49#[must_use]
50pub fn base64_padding_len(input: &str) -> usize {
51 input.chars().rev().take_while(|&c| c == '=').count()
52}
53
54#[must_use]
55pub fn normalize_base64_padding(input: &str) -> String {
56 if input.is_empty() {
57 return String::new();
58 }
59
60 if let Some(position) = input.find('=') {
61 let suffix = &input[position..];
62 if suffix.chars().all(|c| c == '=') && input.len().is_multiple_of(4) && suffix.len() <= 2 {
63 return input.to_string();
64 }
65
66 return input.to_string();
67 }
68
69 match input.len() % 4 {
70 0 => input.to_string(),
71 2 => format!("{input}=="),
72 3 => format!("{input}="),
73 _ => input.to_string(),
74 }
75}
76
77fn encode_base64(input: &[u8], variant: Base64Variant) -> String {
78 let (alphabet, padding) = match variant {
79 Base64Variant::Standard => (STANDARD_ALPHABET, true),
80 Base64Variant::UrlSafe => (URL_SAFE_ALPHABET, true),
81 Base64Variant::StandardNoPadding => (STANDARD_ALPHABET, false),
82 Base64Variant::UrlSafeNoPadding => (URL_SAFE_ALPHABET, false),
83 };
84 let mut output = String::with_capacity(input.len().div_ceil(3) * 4);
85
86 for chunk in input.chunks(3) {
87 let first = chunk[0];
88 let second = chunk.get(1).copied().unwrap_or(0);
89 let third = chunk.get(2).copied().unwrap_or(0);
90 let combined = (u32::from(first) << 16) | (u32::from(second) << 8) | u32::from(third);
91
92 output.push(alphabet[((combined >> 18) & 0x3f) as usize] as char);
93 output.push(alphabet[((combined >> 12) & 0x3f) as usize] as char);
94
95 if chunk.len() > 1 {
96 output.push(alphabet[((combined >> 6) & 0x3f) as usize] as char);
97 } else if padding {
98 output.push('=');
99 }
100
101 if chunk.len() > 2 {
102 output.push(alphabet[(combined & 0x3f) as usize] as char);
103 } else if padding {
104 output.push('=');
105 }
106 }
107
108 output
109}
110
111fn decode_base64(input: &str, url_safe: bool) -> Option<Vec<u8>> {
112 let normalized = normalize_base64_input(input)?;
113 let bytes = normalized.as_bytes();
114 let chunk_count = bytes.len() / 4;
115 let mut output = Vec::with_capacity((bytes.len() / 4) * 3);
116
117 for (chunk_index, chunk) in bytes.chunks(4).enumerate() {
118 let last_chunk = chunk_index + 1 == chunk_count;
119 let third_is_padding = chunk[2] == b'=';
120 let fourth_is_padding = chunk[3] == b'=';
121
122 if third_is_padding && !fourth_is_padding {
123 return None;
124 }
125
126 if (third_is_padding || fourth_is_padding) && !last_chunk {
127 return None;
128 }
129
130 let first = u32::from(decode_base64_byte(chunk[0], url_safe)?);
131 let second = u32::from(decode_base64_byte(chunk[1], url_safe)?);
132 let third = if third_is_padding {
133 0
134 } else {
135 u32::from(decode_base64_byte(chunk[2], url_safe)?)
136 };
137 let fourth = if fourth_is_padding {
138 0
139 } else {
140 u32::from(decode_base64_byte(chunk[3], url_safe)?)
141 };
142
143 let combined = (first << 18) | (second << 12) | (third << 6) | fourth;
144 output.push(((combined >> 16) & 0xff) as u8);
145 if !third_is_padding {
146 output.push(((combined >> 8) & 0xff) as u8);
147 }
148 if !fourth_is_padding {
149 output.push((combined & 0xff) as u8);
150 }
151 }
152
153 Some(output)
154}
155
156fn normalize_base64_input(input: &str) -> Option<String> {
157 if !input.is_ascii() {
158 return None;
159 }
160
161 if input.is_empty() {
162 return Some(String::new());
163 }
164
165 if let Some(position) = input.find('=') {
166 let suffix = &input[position..];
167 if !input.len().is_multiple_of(4) || suffix.len() > 2 || !suffix.chars().all(|c| c == '=') {
168 return None;
169 }
170
171 return Some(input.to_string());
172 }
173
174 match input.len() % 4 {
175 0 => Some(input.to_string()),
176 2 => Some(format!("{input}==")),
177 3 => Some(format!("{input}=")),
178 _ => None,
179 }
180}
181
182fn decode_base64_byte(byte: u8, url_safe: bool) -> Option<u8> {
183 match byte {
184 b'A'..=b'Z' => Some(byte - b'A'),
185 b'a'..=b'z' => Some(byte - b'a' + 26),
186 b'0'..=b'9' => Some(byte - b'0' + 52),
187 b'+' if !url_safe => Some(62),
188 b'/' if !url_safe => Some(63),
189 b'-' if url_safe => Some(62),
190 b'_' if url_safe => Some(63),
191 _ => None,
192 }
193}
194
195const STANDARD_ALPHABET: &[u8; 64] =
196 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
197const URL_SAFE_ALPHABET: &[u8; 64] =
198 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";