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 ///
186 /// # Parameters
187 ///
188 /// - `input`: Encoded input containing the quantum to decode.
189 /// - `input_index`: Start of the quantum in `input`.
190 /// - `output`: Destination buffer for decoded bytes.
191 /// - `output_index`: Start of the writable region in `output`.
192 /// - `take`: Number of input bytes in this quantum.
193 ///
194 /// # Returns
195 ///
196 /// Returns the next input and output indices when decoded bytes fit, or the
197 /// required output capacity when they do not.
198 ///
199 /// # Errors
200 ///
201 /// Returns the mapped Base64 decoding error when the quantum is invalid.
202 fn decode_quantum(
203 &mut self,
204 input: &[u8],
205 input_index: usize,
206 output: &mut [u8],
207 output_index: usize,
208 take: usize,
209 ) -> Result<Result<(usize, usize), usize>, TranscodeDecodeError<MiscCodecError>> {
210 let segment = &input[input_index..input_index + take];
211 let mut scratch = [0; 3];
212 let written = match self.config.engine().decode_slice(segment, &mut scratch) {
213 Ok(written) => written,
214 Err(DecodeSliceError::DecodeError(source)) => {
215 return Err(self.domain_error(source, input_index));
216 }
217 // A Base64 quantum can produce at most three bytes.
218 Err(DecodeSliceError::OutputSliceTooSmall) => {
219 unreachable!("three-byte scratch holds one Base64 quantum")
220 }
221 };
222
223 if output.len() - output_index < written {
224 return Ok(Err(written));
225 }
226 output[output_index..output_index + written].copy_from_slice(&scratch[..written]);
227 self.terminal_padding = segment.contains(&b'=');
228 Ok(Ok((input_index + take, output_index + written)))
229 }
230
231 /// Decodes one visible input segment, optionally treating its end as EOF.
232 ///
233 /// At EOF, a sufficiently large output buffer permits a whole-suffix
234 /// engine call. Otherwise complete unpadded groups are decoded before a
235 /// final quantum is handled separately. Errors can follow writes from
236 /// earlier groups; a capacity failure on the final quantum leaves that
237 /// quantum unconsumed and its decoder state uncommitted.
238 ///
239 /// # Parameters
240 ///
241 /// - `input`: Visible encoded input.
242 /// - `input_index`: Absolute position where this call begins reading.
243 /// - `output`: Destination for decoded bytes.
244 /// - `output_index`: Absolute position where this call begins writing.
245 /// - `eof`: Whether the caller has confirmed that the input source ended.
246 ///
247 /// # Returns
248 ///
249 /// Returns progress relative to the supplied indices when decoding can
250 /// continue or the visible input is complete.
251 ///
252 /// # Errors
253 ///
254 /// Returns invalid-index or lifecycle errors before decoding, and mapped
255 /// domain errors for invalid Base64 input. A domain error may follow bytes
256 /// written by earlier groups and poisons the decoder.
257 ///
258 /// # Panics
259 ///
260 /// Internal assertions rely on non-empty valid Base64 quanta producing
261 /// output and on the checked decode bound covering the engine estimate.
262 fn decode_segment(
263 &mut self,
264 input: &[u8],
265 input_index: usize,
266 output: &mut [u8],
267 output_index: usize,
268 eof: bool,
269 ) -> Result<TranscodeProgress, TranscodeDecodeError<MiscCodecError>> {
270 TranscodeFailure::ensure_transcode_indices(input.len(), input_index, output.len(), output_index)?;
271 self.phase.ensure_transcode()?;
272 let mut i = input_index;
273 let mut o = output_index;
274
275 if self.terminal_padding && i < input.len() {
276 return Err(self.domain_error(DecodeError::InvalidByte(0, input[i]), i));
277 }
278
279 if eof
280 && !self.terminal_padding
281 && let Ok(bound) = base64_decode_bound(input.len() - i)
282 && output.len() - o >= bound
283 {
284 let written = match self.config.engine().decode_slice(&input[i..], &mut output[o..]) {
285 Ok(written) => written,
286 Err(DecodeSliceError::DecodeError(source)) => {
287 return Err(self.domain_error(source, i));
288 }
289 // The checked bound covers the engine's conservative
290 // decode estimate for the full input suffix.
291 Err(DecodeSliceError::OutputSliceTooSmall) => {
292 unreachable!("decoded upper bound covers engine estimate")
293 }
294 };
295 self.terminal_padding = input[i..].contains(&b'=');
296 return Ok(TranscodeProgress::complete(input.len() - i, written));
297 }
298
299 while i < input.len() {
300 if self.terminal_padding {
301 return Err(self.domain_error(DecodeError::InvalidByte(0, input[i]), i));
302 }
303
304 let remaining = input.len() - i;
305 if remaining < 4 && !eof {
306 return Ok(TranscodeProgress::need_input(
307 NonZeroUsize::new(4).expect("four is nonzero"),
308 i - input_index,
309 o - output_index,
310 ));
311 }
312
313 let groups_by_capacity = (remaining / 4).min((output.len() - o) / 3);
314 let candidate_len = groups_by_capacity * 4;
315 let groups_without_padding = input[i..i + candidate_len]
316 .iter()
317 .position(|byte| *byte == b'=')
318 .map_or(groups_by_capacity, |at| at / 4);
319
320 if groups_without_padding > 0 {
321 let take = groups_without_padding * 4;
322 let capacity = groups_without_padding * 3;
323 let written = match self
324 .config
325 .engine()
326 .decode_slice(&input[i..i + take], &mut output[o..o + capacity])
327 {
328 Ok(written) => written,
329 Err(DecodeSliceError::DecodeError(source)) => {
330 return Err(self.domain_error(source, i));
331 }
332 // Each complete unpadded quantum decodes to exactly three
333 // bytes, which the selected output range holds.
334 Err(DecodeSliceError::OutputSliceTooSmall) => {
335 unreachable!("three output bytes fit each full quantum")
336 }
337 };
338 debug_assert_eq!(written, capacity);
339 i += take;
340 o += written;
341 continue;
342 }
343
344 let take = remaining.min(4);
345 let (next_i, next_o) = match self.decode_quantum(input, i, output, o, take)? {
346 Ok(indices) => indices,
347 Err(written) => {
348 return Ok(TranscodeProgress::need_output(
349 NonZeroUsize::new(written).expect("valid nonempty quantum produces bytes"),
350 i - input_index,
351 o - output_index,
352 ));
353 }
354 };
355 i = next_i;
356 o = next_o;
357 }
358
359 Ok(TranscodeProgress::complete(i - input_index, o - output_index))
360 }
361}
362
363impl Default for Base64Decoder {
364 /// Creates an uninitialized decoder using the strict standard alphabet.
365 #[inline]
366 fn default() -> Self {
367 Self::standard()
368 }
369}
370
371impl TranscodeDecoder for Base64Decoder {
372 /// Domain error type produced when Base64 input cannot be decoded.
373 type DecodeError = MiscCodecError;
374}
375
376impl Transcoder for Base64Decoder {
377 /// Encoded input element type.
378 type Input = u8;
379 /// Decoded output element type.
380 type Output = u8;
381 /// Errors produced by invalid input or invalid stream state.
382 type Error = TranscodeDecodeError<MiscCodecError>;
383
384 /// Returns a checked upper bound for decoding `input_len` encoded bytes.
385 ///
386 /// # Parameters
387 ///
388 /// - `input_len`: Number of encoded input bytes to decode.
389 ///
390 /// # Returns
391 ///
392 /// Returns the maximum number of decoded bytes required for that input
393 /// length.
394 ///
395 /// # Errors
396 ///
397 /// Returns [`CapacityError`] when the output bound overflows `usize`.
398 #[inline]
399 fn max_transcode_output_len(&self, input_len: usize) -> Result<usize, CapacityError> {
400 base64_decode_bound(input_len)
401 }
402
403 /// Starts a fresh stream and clears terminal-padding state.
404 ///
405 /// # Parameters
406 ///
407 /// - `output`: Output buffer available to the reset phase.
408 /// - `output_index`: Starting position in `output`; must not exceed its
409 /// length.
410 ///
411 /// # Returns
412 ///
413 /// Returns the number of output elements written during reset (zero).
414 ///
415 /// # Errors
416 ///
417 /// Returns an invalid-output-index error when `output_index` exceeds the
418 /// buffer length.
419 fn reset(&mut self, output: &mut [u8], output_index: usize) -> Result<usize, Self::Error> {
420 TranscodeFailure::ensure_output_index(output.len(), output_index)?;
421 self.phase = Base64StreamPhase::Active;
422 self.terminal_padding = false;
423 Ok(0)
424 }
425
426 /// Decodes the visible input without treating its end as source EOF.
427 ///
428 /// # Parameters
429 ///
430 /// - `input`: Visible encoded bytes; unconsumed bytes must be supplied
431 /// again on a later call.
432 /// - `input_index`: Starting position in `input`; must not exceed its
433 /// length.
434 /// - `output`: Destination buffer for decoded bytes.
435 /// - `output_index`: Starting position in `output`; must not exceed its
436 /// length.
437 ///
438 /// # Returns
439 ///
440 /// Returns progress describing consumed input, written output, and whether
441 /// more input or output capacity is needed.
442 ///
443 /// # Errors
444 ///
445 /// Returns invalid-index or lifecycle errors, or a Base64 domain error
446 /// for invalid input. A domain error poisons the stream until reset.
447 fn transcode(
448 &mut self,
449 input: &[u8],
450 input_index: usize,
451 output: &mut [u8],
452 output_index: usize,
453 ) -> Result<TranscodeProgress, Self::Error> {
454 self.decode_segment(input, input_index, output, output_index, false)
455 }
456
457 /// Decodes input after the caller has confirmed source EOF.
458 ///
459 /// # Parameters
460 ///
461 /// - `input`: Visible encoded bytes, including any previously unconsumed
462 /// tail.
463 /// - `input_index`: Starting position in `input`; must not exceed its
464 /// length.
465 /// - `output`: Destination buffer for decoded bytes.
466 /// - `output_index`: Starting position in `output`; must not exceed its
467 /// length.
468 ///
469 /// # Returns
470 ///
471 /// Returns progress describing consumed input, written output, and whether
472 /// more output capacity is needed.
473 ///
474 /// # Errors
475 ///
476 /// Returns invalid-index or lifecycle errors, or a Base64 domain error
477 /// for invalid or incomplete EOF input. A domain error poisons the stream
478 /// until reset.
479 fn transcode_eof(
480 &mut self,
481 input: &[u8],
482 input_index: usize,
483 output: &mut [u8],
484 output_index: usize,
485 ) -> Result<TranscodeProgress, Self::Error> {
486 self.decode_segment(input, input_index, output, output_index, true)
487 }
488
489 /// Marks a successfully decoded stream as finished.
490 ///
491 /// # Parameters
492 ///
493 /// - `output`: Output buffer available to the finish phase.
494 /// - `output_index`: Starting position in `output`; must not exceed its
495 /// length.
496 ///
497 /// # Returns
498 ///
499 /// Returns the number of output elements written during finish (zero).
500 ///
501 /// # Errors
502 ///
503 /// Returns an invalid-output-index error or a lifecycle error when the
504 /// stream is not ready to finish.
505 fn finish(&mut self, output: &mut [u8], output_index: usize) -> Result<usize, Self::Error> {
506 TranscodeFailure::ensure_output_index(output.len(), output_index)?;
507 self.phase.ensure_finish()?;
508 self.phase = Base64StreamPhase::Finished;
509 Ok(0)
510 }
511}