qubit_codec_misc/base64_decoder.rs
1// =============================================================================
2// Copyright (c) 2026 Haixing Hu.
3//
4// SPDX-License-Identifier: Apache-2.0
5//
6// Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Streaming Base64 decoder configuration and state.
9
10use core::num::NonZeroUsize;
11
12use base64::DecodeError;
13use base64::DecodeSliceError;
14use base64::Engine;
15use qubit_codec::CapacityError;
16use qubit_codec::TranscodeDecodeError;
17use qubit_codec::TranscodeDecoder;
18use qubit_codec::TranscodeFailure;
19use qubit_codec::TranscodeProgress;
20use qubit_codec::Transcoder;
21
22use crate::MiscCodecError;
23use crate::internal::Base64Config;
24use crate::internal::Base64StreamPhase;
25use crate::internal::base64_decode_bound;
26
27/// Decodes Base64 incrementally using a strict configuration.
28///
29/// The decoder uses fixed-size state and does not allocate. Call `reset`
30/// before streaming. Incomplete non-EOF input remains caller-owned and must be
31/// supplied again with additional input or to `transcode_eof`. `Complete`
32/// describes the currently visible input and does not establish source EOF;
33/// call `transcode_eof` only after the source confirms EOF. A domain error may
34/// occur after earlier bytes in the same operation were written, and poisons
35/// the decoder; call `reset` before reusing it. If output capacity is
36/// insufficient, use the returned progress to retry with unconsumed input and
37/// a larger output buffer. `NeedOutput.required` is the minimum total capacity
38/// from the advanced output position (`output_index + progress.written()`) for
39/// the next step. When retaining the output buffer, its length must cover that
40/// position plus `required`; when switching to an empty buffer at index zero,
41/// provide at least `required` slots.
42///
43/// The outer transcoder error reports a position in the input slice passed to
44/// the current call. Its `MiscCodecError` and `base64::DecodeError` source keep
45/// the engine sub-slice position, which can vary with batching, EOF handling,
46/// and output capacity. Errors without an exact engine position use the
47/// failing segment's start in the outer error. These positions are not
48/// cumulative stream offsets.
49///
50/// # Examples
51///
52/// ```
53/// use qubit_codec::TranscodeStatus;
54/// use qubit_codec::Transcoder;
55/// use qubit_codec_misc::Base64Decoder;
56///
57/// let mut decoder = Base64Decoder::standard();
58/// let mut output = [0; 3];
59/// decoder.reset(&mut output, 0).unwrap();
60/// let progress = decoder.transcode(b"TQ", 0, &mut output, 0).unwrap();
61/// assert_eq!(progress.read(), 0);
62/// assert!(matches!(progress.status(), TranscodeStatus::NeedInput { .. }));
63/// // Keep the unconsumed TQ, append ==, and confirm EOF with the source.
64/// let progress = decoder.transcode_eof(b"TQ==", 0, &mut output, 0).unwrap();
65/// assert_eq!(progress.read(), 4);
66/// assert_eq!(&output[..progress.written()], b"M");
67/// assert_eq!(decoder.finish(&mut [], 0).unwrap(), 0);
68/// ```
69#[must_use]
70#[derive(Debug)]
71pub struct Base64Decoder {
72 /// Strict alphabet and padding policy used for decoding.
73 pub(crate) config: Base64Config,
74 /// Lifecycle state; domain errors poison the stream until reset.
75 pub(crate) phase: Base64StreamPhase,
76 /// Whether a successfully committed quantum contained terminal padding.
77 pub(crate) terminal_padding: bool,
78}
79
80impl Base64Decoder {
81 /// Creates a decoder for `config` in the uninitialized phase.
82 ///
83 /// Callers must reset the returned decoder before streaming.
84 ///
85 /// # Parameters
86 ///
87 /// - `config`: Alphabet and padding policy applied by the decoder.
88 ///
89 /// # Returns
90 ///
91 /// Returns an uninitialized decoder with the selected configuration.
92 #[inline]
93 pub(crate) fn from_config(config: Base64Config) -> Self {
94 Self {
95 config,
96 phase: Base64StreamPhase::Uninitialized,
97 terminal_padding: false,
98 }
99 }
100
101 /// Creates a strict standard Base64 decoder with padding.
102 ///
103 /// The decoder requires `reset` before streaming and does not allocate.
104 ///
105 /// # Returns
106 ///
107 /// Returns an uninitialized decoder using the standard alphabet and
108 /// required padding.
109 #[inline]
110 pub fn standard() -> Self {
111 Self::from_config(Base64Config::Standard)
112 }
113
114 /// Creates a strict standard Base64 decoder without padding.
115 ///
116 /// The decoder requires `reset` before streaming and does not allocate.
117 ///
118 /// # Returns
119 ///
120 /// Returns an uninitialized decoder using the standard alphabet without
121 /// required padding.
122 #[inline]
123 pub fn standard_no_pad() -> Self {
124 Self::from_config(Base64Config::StandardNoPad)
125 }
126
127 /// Creates a strict URL-safe Base64 decoder with padding.
128 ///
129 /// The decoder requires `reset` before streaming and does not allocate.
130 ///
131 /// # Returns
132 ///
133 /// Returns an uninitialized decoder using the URL-safe alphabet and
134 /// required padding.
135 #[inline]
136 pub fn url_safe() -> Self {
137 Self::from_config(Base64Config::UrlSafe)
138 }
139
140 /// Creates a strict URL-safe Base64 decoder without padding.
141 ///
142 /// The decoder requires `reset` before streaming and does not allocate.
143 ///
144 /// # Returns
145 ///
146 /// Returns an uninitialized decoder using the URL-safe alphabet without
147 /// required padding.
148 #[inline]
149 pub fn url_safe_no_pad() -> Self {
150 Self::from_config(Base64Config::UrlSafeNoPad)
151 }
152
153 /// Converts an engine error to the stream error, mapping its relative
154 /// position to the current call's input slice in the outer error while
155 /// preserving the engine sub-slice position in the domain error, then
156 /// poisons this stream.
157 ///
158 /// Errors without a precise engine position use the start of the current
159 /// segment, matching the existing decode error mapping.
160 ///
161 /// # Parameters
162 ///
163 /// - `source`: Engine error whose position is relative to the decoded
164 /// segment.
165 /// - `segment_start`: Segment start in the current call's input slice.
166 ///
167 /// # Returns
168 ///
169 /// Returns the mapped error with the decoder moved to the poisoned phase.
170 fn domain_error(&mut self, source: DecodeError, segment_start: usize) -> TranscodeDecodeError<MiscCodecError> {
171 let relative = match &source {
172 DecodeError::InvalidByte(index, _) => *index,
173 DecodeError::InvalidLastSymbol { offset, .. } => *offset,
174 DecodeError::InvalidLength(_) | DecodeError::InvalidPadding => 0,
175 };
176 self.phase = Base64StreamPhase::Poisoned;
177 TranscodeDecodeError::domain_main(MiscCodecError::from(source), segment_start + relative)
178 }
179
180 /// Decodes and commits one final or partial Base64 quantum.
181 ///
182 /// The decoded bytes are copied only after the destination capacity is
183 /// confirmed, so a capacity failure leaves the input position and padding
184 /// state unchanged.
185 fn decode_quantum(
186 &mut self,
187 input: &[u8],
188 input_index: usize,
189 output: &mut [u8],
190 output_index: usize,
191 take: usize,
192 ) -> Result<Result<(usize, usize), usize>, TranscodeDecodeError<MiscCodecError>> {
193 let segment = &input[input_index..input_index + take];
194 let mut scratch = [0; 3];
195 let written = match self.config.engine().decode_slice(segment, &mut scratch) {
196 Ok(written) => written,
197 Err(DecodeSliceError::DecodeError(source)) => {
198 return Err(self.domain_error(source, input_index));
199 }
200 // A Base64 quantum can produce at most three bytes.
201 Err(DecodeSliceError::OutputSliceTooSmall) => {
202 unreachable!("three-byte scratch holds one Base64 quantum")
203 }
204 };
205
206 if output.len() - output_index < written {
207 return Ok(Err(written));
208 }
209 output[output_index..output_index + written].copy_from_slice(&scratch[..written]);
210 self.terminal_padding = segment.contains(&b'=');
211 Ok(Ok((input_index + take, output_index + written)))
212 }
213
214 /// Decodes one visible input segment, optionally treating its end as EOF.
215 ///
216 /// At EOF, a sufficiently large output buffer permits a whole-suffix
217 /// engine call. Otherwise complete unpadded groups are decoded before a
218 /// final quantum is handled separately. Errors can follow writes from
219 /// earlier groups; a capacity failure on the final quantum leaves that
220 /// quantum unconsumed and its decoder state uncommitted.
221 ///
222 /// # Parameters
223 ///
224 /// - `input`: Visible encoded input.
225 /// - `input_index`: Absolute position where this call begins reading.
226 /// - `output`: Destination for decoded bytes.
227 /// - `output_index`: Absolute position where this call begins writing.
228 /// - `eof`: Whether the caller has confirmed that the input source ended.
229 ///
230 /// # Returns
231 ///
232 /// Returns progress relative to the supplied indices when decoding can
233 /// continue or the visible input is complete.
234 ///
235 /// # Errors
236 ///
237 /// Returns invalid-index or lifecycle errors before decoding, and mapped
238 /// domain errors for invalid Base64 input. A domain error may follow bytes
239 /// written by earlier groups and poisons the decoder.
240 ///
241 /// # Panics
242 ///
243 /// Internal assertions rely on non-empty valid Base64 quanta producing
244 /// output and on the checked decode bound covering the engine estimate.
245 fn decode_segment(
246 &mut self,
247 input: &[u8],
248 input_index: usize,
249 output: &mut [u8],
250 output_index: usize,
251 eof: bool,
252 ) -> Result<TranscodeProgress, TranscodeDecodeError<MiscCodecError>> {
253 TranscodeFailure::ensure_transcode_indices(input.len(), input_index, output.len(), output_index)?;
254 self.phase.ensure_transcode()?;
255 let mut i = input_index;
256 let mut o = output_index;
257
258 if self.terminal_padding && i < input.len() {
259 return Err(self.domain_error(DecodeError::InvalidByte(0, input[i]), i));
260 }
261
262 if eof
263 && !self.terminal_padding
264 && let Ok(bound) = base64_decode_bound(input.len() - i)
265 && output.len() - o >= bound
266 {
267 let written = match self.config.engine().decode_slice(&input[i..], &mut output[o..]) {
268 Ok(written) => written,
269 Err(DecodeSliceError::DecodeError(source)) => {
270 return Err(self.domain_error(source, i));
271 }
272 // The checked bound covers the engine's conservative
273 // decode estimate for the full input suffix.
274 Err(DecodeSliceError::OutputSliceTooSmall) => {
275 unreachable!("decoded upper bound covers engine estimate")
276 }
277 };
278 self.terminal_padding = input[i..].contains(&b'=');
279 return Ok(TranscodeProgress::complete(input.len() - i, written));
280 }
281
282 while i < input.len() {
283 if self.terminal_padding {
284 return Err(self.domain_error(DecodeError::InvalidByte(0, input[i]), i));
285 }
286
287 let remaining = input.len() - i;
288 if remaining < 4 && !eof {
289 return Ok(TranscodeProgress::need_input(
290 NonZeroUsize::new(4).expect("four is nonzero"),
291 i - input_index,
292 o - output_index,
293 ));
294 }
295
296 let groups_by_capacity = (remaining / 4).min((output.len() - o) / 3);
297 let candidate_len = groups_by_capacity * 4;
298 let groups_without_padding = input[i..i + candidate_len]
299 .iter()
300 .position(|byte| *byte == b'=')
301 .map_or(groups_by_capacity, |at| at / 4);
302
303 if groups_without_padding > 0 {
304 let take = groups_without_padding * 4;
305 let capacity = groups_without_padding * 3;
306 let written = match self
307 .config
308 .engine()
309 .decode_slice(&input[i..i + take], &mut output[o..o + capacity])
310 {
311 Ok(written) => written,
312 Err(DecodeSliceError::DecodeError(source)) => {
313 return Err(self.domain_error(source, i));
314 }
315 // Each complete unpadded quantum decodes to exactly three
316 // bytes, which the selected output range holds.
317 Err(DecodeSliceError::OutputSliceTooSmall) => {
318 unreachable!("three output bytes fit each full quantum")
319 }
320 };
321 debug_assert_eq!(written, capacity);
322 i += take;
323 o += written;
324 continue;
325 }
326
327 let take = remaining.min(4);
328 let (next_i, next_o) = match self.decode_quantum(input, i, output, o, take)? {
329 Ok(indices) => indices,
330 Err(written) => {
331 return Ok(TranscodeProgress::need_output(
332 NonZeroUsize::new(written).expect("valid nonempty quantum produces bytes"),
333 i - input_index,
334 o - output_index,
335 ));
336 }
337 };
338 i = next_i;
339 o = next_o;
340 }
341
342 Ok(TranscodeProgress::complete(i - input_index, o - output_index))
343 }
344}
345
346impl Default for Base64Decoder {
347 #[inline]
348 fn default() -> Self {
349 Self::standard()
350 }
351}
352
353impl TranscodeDecoder for Base64Decoder {
354 type DecodeError = MiscCodecError;
355}
356
357impl Transcoder for Base64Decoder {
358 type Input = u8;
359 type Output = u8;
360 type Error = TranscodeDecodeError<MiscCodecError>;
361
362 #[inline]
363 fn max_transcode_output_len(&self, input_len: usize) -> Result<usize, CapacityError> {
364 base64_decode_bound(input_len)
365 }
366
367 fn reset(&mut self, output: &mut [u8], output_index: usize) -> Result<usize, Self::Error> {
368 TranscodeFailure::ensure_output_index(output.len(), output_index)?;
369 self.phase = Base64StreamPhase::Active;
370 self.terminal_padding = false;
371 Ok(0)
372 }
373
374 fn transcode(
375 &mut self,
376 input: &[u8],
377 input_index: usize,
378 output: &mut [u8],
379 output_index: usize,
380 ) -> Result<TranscodeProgress, Self::Error> {
381 self.decode_segment(input, input_index, output, output_index, false)
382 }
383
384 fn transcode_eof(
385 &mut self,
386 input: &[u8],
387 input_index: usize,
388 output: &mut [u8],
389 output_index: usize,
390 ) -> Result<TranscodeProgress, Self::Error> {
391 self.decode_segment(input, input_index, output, output_index, true)
392 }
393
394 fn finish(&mut self, output: &mut [u8], output_index: usize) -> Result<usize, Self::Error> {
395 TranscodeFailure::ensure_output_index(output.len(), output_index)?;
396 self.phase.ensure_finish()?;
397 self.phase = Base64StreamPhase::Finished;
398 Ok(0)
399 }
400}