qubit_codec_misc/form_urlencoded_codec.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//! `application/x-www-form-urlencoded` text codec.
9
10use core::num::NonZeroUsize;
11
12use qubit_codec::Codec;
13use qubit_codec::DecodeFailure;
14use qubit_codec::ValueDecoder;
15use qubit_codec::ValueEncoder;
16use qubit_utils::nonzero;
17
18use crate::MiscCodecError;
19use crate::MiscCodecResult;
20use crate::internal::ParseError;
21use crate::internal::PercentEncodingMode;
22use crate::misc_error::map_misc_decode_failure_with_consumed;
23use crate::percent::percent_decode_byte;
24use crate::percent::percent_decode_bytes;
25use crate::percent::percent_encode_byte;
26use crate::percent::percent_encode_bytes;
27use crate::percent::percent_encode_len;
28use crate::percent::percent_invalid_consumed;
29
30/// Encodes and decodes `application/x-www-form-urlencoded` text fragments.
31///
32/// Its low-level [`Codec<Value = u8, Unit = u8>`] implementation converts one
33/// byte at a time, including the form-specific space and `+` mapping. UTF-8
34/// validation remains part of the owned [`decode`](Self::decode) helper.
35/// Streaming skip/replace hooks may consume the malformed span visible in the
36/// current input chunk, so recovery output for malformed input can depend on
37/// chunk boundaries. This does not affect valid input or strict decoding.
38///
39/// # Examples
40///
41/// ```
42/// use qubit_codec_misc::FormUrlencodedCodec;
43///
44/// let codec = FormUrlencodedCodec::new();
45/// let encoded = codec.encode("hello world");
46/// assert_eq!(encoded, "hello+world");
47/// assert_eq!(codec.decode(&encoded).unwrap(), "hello world");
48/// ```
49#[must_use]
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub struct FormUrlencodedCodec;
52
53impl FormUrlencodedCodec {
54 /// Creates a form-url-encoded codec.
55 ///
56 /// # Returns
57 /// Form URL encoded codec.
58 #[inline]
59 pub fn new() -> Self {
60 Self
61 }
62
63 /// Encodes text, using `+` for spaces.
64 ///
65 /// # Parameters
66 /// - `text`: Text to encode.
67 ///
68 /// # Returns
69 /// Form-url-encoded text.
70 #[must_use]
71 #[inline]
72 pub fn encode(&self, text: &str) -> String {
73 percent_encode_bytes(text.as_bytes(), PercentEncodingMode::FormUrlencoded)
74 }
75
76 /// Decodes text, treating `+` as space.
77 ///
78 /// # Parameters
79 /// - `text`: Text to decode.
80 ///
81 /// # Returns
82 /// Decoded UTF-8 text.
83 ///
84 /// # Errors
85 /// Returns [`MiscCodecError`] when an escape is malformed or decoded bytes
86 /// are not valid UTF-8.
87 #[inline]
88 pub fn decode(&self, text: &str) -> MiscCodecResult<String> {
89 String::from_utf8(percent_decode_bytes(text, true)?).map_err(MiscCodecError::from)
90 }
91}
92
93impl ValueEncoder<str> for FormUrlencodedCodec {
94 type Error = MiscCodecError;
95 type Output = String;
96
97 /// Encodes text, using `+` for spaces.
98 ///
99 /// # Parameters
100 /// - `input`: Text to encode.
101 ///
102 /// # Returns
103 /// The form-url-encoded text.
104 ///
105 /// # Errors
106 /// This implementation does not fail for UTF-8 text.
107 #[inline]
108 fn encode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
109 Ok(FormUrlencodedCodec::encode(self, input))
110 }
111}
112
113impl ValueDecoder<str> for FormUrlencodedCodec {
114 type Error = MiscCodecError;
115 type Output = String;
116
117 /// Decodes form-url-encoded text.
118 ///
119 /// # Parameters
120 /// - `input`: Form-url-encoded text to decode.
121 ///
122 /// # Returns
123 /// The decoded UTF-8 text.
124 ///
125 /// # Errors
126 /// Returns an error when an escape is malformed or decoded bytes are not
127 /// valid UTF-8.
128 #[inline]
129 fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
130 FormUrlencodedCodec::decode(self, input)
131 }
132}
133
134impl Codec for FormUrlencodedCodec {
135 type Value = u8;
136 type Unit = u8;
137 type DecodeError = MiscCodecError;
138 type EncodeError = MiscCodecError;
139
140 const MIN_UNITS_PER_VALUE: usize = 1;
141 const MAX_ENCODE_UNITS_PER_VALUE: usize = 3;
142
143 const MAX_DECODE_UNITS_PER_VALUE: usize = 3;
144
145 /// Returns the exact form-url-encoded width for one byte.
146 ///
147 /// # Parameters
148 /// - `value`: Byte whose encoded width is queried.
149 ///
150 /// # Returns
151 /// The number of output bytes required to encode `value`.
152 #[inline]
153 fn encode_len(&self, value: &u8) -> usize {
154 percent_encode_len(*value, PercentEncodingMode::FormUrlencoded)
155 }
156
157 /// Decodes one raw byte, `+`, or `%XX` escape.
158 ///
159 /// # Parameters
160 /// - `input`: Encoded input bytes.
161 /// - `input_index`: Index of the value to decode.
162 ///
163 /// # Returns
164 /// The decoded byte and the non-zero number of consumed input units.
165 ///
166 /// # Errors
167 /// Returns a decode failure when the input is malformed or incomplete.
168 ///
169 /// # Safety
170 /// The caller must provide an input index with at least one readable unit
171 /// and a slice satisfying the codec trait's decode preconditions.
172 #[inline]
173 unsafe fn decode(
174 &mut self,
175 input: &[u8],
176 input_index: usize,
177 ) -> Result<(u8, NonZeroUsize), DecodeFailure<Self::DecodeError>> {
178 debug_assert!(input_index < input.len());
179
180 let (value, consumed) = percent_decode_byte(input, input_index, true).map_err(|error| {
181 ParseError::into_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
182 })?;
183 debug_assert!(consumed > 0);
184 // SAFETY: `percent_decode_byte` returns a non-zero width for every
185 // successful raw byte, `+`, or escape.
186 let consumed = nonzero(consumed);
187 Ok((value, consumed))
188 }
189
190 /// Encodes one byte using form URL encoding.
191 ///
192 /// # Parameters
193 /// - `value`: Byte to encode.
194 /// - `output`: Destination buffer.
195 /// - `output_index`: Index at which encoding begins.
196 ///
197 /// # Returns
198 /// The number of output bytes written.
199 ///
200 /// # Errors
201 /// This implementation does not fail when the caller satisfies the safety
202 /// preconditions.
203 ///
204 /// # Safety
205 /// The caller must provide enough writable output units for
206 /// [`Self::encode_len`].
207 #[inline]
208 unsafe fn encode(
209 &mut self,
210 value: &u8,
211 output: &mut [u8],
212 output_index: usize,
213 ) -> Result<usize, Self::EncodeError> {
214 let required = percent_encode_len(*value, PercentEncodingMode::FormUrlencoded);
215 debug_assert!(output_index + required <= output.len());
216
217 let written = percent_encode_byte(*value, output, output_index, PercentEncodingMode::FormUrlencoded);
218 debug_assert_eq!(written, required);
219 Ok(required)
220 }
221
222 /// Decodes one value after end of input has been confirmed.
223 ///
224 /// # Parameters
225 /// - `input`: Encoded input bytes.
226 /// - `input_index`: Index of the value to decode.
227 ///
228 /// # Returns
229 /// The decoded byte and the non-zero number of consumed input units.
230 ///
231 /// # Errors
232 /// Returns a decode failure when the input is malformed or incomplete at
233 /// end of input.
234 ///
235 /// # Safety
236 /// The caller must provide an input index with at least one readable unit
237 /// and a slice satisfying the codec trait's EOF decode preconditions.
238 #[inline]
239 unsafe fn decode_eof(
240 &mut self,
241 input: &[u8],
242 input_index: usize,
243 ) -> Result<(u8, NonZeroUsize), DecodeFailure<Self::DecodeError>> {
244 debug_assert!(input_index < input.len());
245
246 let (value, consumed) = percent_decode_byte(input, input_index, true).map_err(|error| match error {
247 ParseError::Incomplete { .. } => map_misc_decode_failure_with_consumed(
248 MiscCodecError::InvalidEscape {
249 index: input_index,
250 escape: String::from_utf8_lossy(&input[input_index..]).into_owned(),
251 reason: "expected two hexadecimal digits".to_owned(),
252 },
253 percent_invalid_consumed(input, input_index),
254 ),
255 ParseError::Invalid(error) => {
256 map_misc_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
257 }
258 })?;
259 debug_assert!(consumed > 0);
260 let consumed = nonzero(consumed);
261 Ok((value, consumed))
262 }
263}