Skip to main content

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}