Skip to main content

oxirs_core/
encoding.rs

1//! RFC 3986 percent-encoding utilities.
2//!
3//! Pure-std, in-house replacement for the external `urlencoding` crate with
4//! 1:1 API semantics so call sites translate directly:
5//!
6//! - [`percent_encode`] mirrors `urlencoding::encode`: every byte outside the
7//!   RFC 3986 *unreserved* set (`A-Z a-z 0-9 - _ . ~`) is encoded as `%XX`
8//!   with uppercase hexadecimal digits. Spaces become `%20` (never `+`).
9//! - [`percent_decode`] mirrors `urlencoding::decode`: `%XX` sequences are
10//!   decoded case-insensitively, malformed or truncated `%` runs are passed
11//!   through unchanged, and an error is returned only when the decoded bytes
12//!   are not valid UTF-8.
13//! - [`percent_encode_strict`] mirrors
14//!   `percent_encoding::NON_ALPHANUMERIC`: every byte that is not an ASCII
15//!   alphanumeric is encoded, including the unreserved punctuation
16//!   `- _ . ~` (so `_` becomes `%5F`). Used where form-style strict
17//!   encoding is the established wire contract (e.g. OAuth2 query
18//!   parameters).
19//!
20//! All functions return [`Cow::Borrowed`] when the input needs no changes,
21//! avoiding allocation on the fast path.
22//!
23//! # Examples
24//!
25//! ```
26//! use oxirs_core::encoding::{percent_encode, percent_decode};
27//!
28//! # fn main() -> Result<(), std::string::FromUtf8Error> {
29//! let encoded = percent_encode("SELECT * WHERE { ?s ?p ?o }");
30//! assert_eq!(
31//!     encoded,
32//!     "SELECT%20%2A%20WHERE%20%7B%20%3Fs%20%3Fp%20%3Fo%20%7D"
33//! );
34//! assert_eq!(percent_decode(&encoded)?, "SELECT * WHERE { ?s ?p ?o }");
35//! # Ok(())
36//! # }
37//! ```
38
39use std::borrow::Cow;
40use std::string::FromUtf8Error;
41
42/// Uppercase hexadecimal digits used by [`percent_encode`].
43const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
44
45/// Returns `true` if the byte belongs to the RFC 3986 *unreserved* set:
46/// `ALPHA / DIGIT / "-" / "." / "_" / "~"`.
47#[inline]
48const fn is_unreserved(byte: u8) -> bool {
49    matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
50}
51
52/// Returns `true` if the byte is an ASCII alphanumeric character
53/// (`ALPHA / DIGIT`), the set kept literal by [`percent_encode_strict`].
54#[inline]
55const fn is_ascii_alphanumeric_byte(byte: u8) -> bool {
56    matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9')
57}
58
59/// Converts an ASCII hexadecimal digit (case-insensitive) to its value.
60#[inline]
61const fn from_hex_digit(byte: u8) -> Option<u8> {
62    match byte {
63        b'0'..=b'9' => Some(byte - b'0'),
64        b'A'..=b'F' => Some(byte - b'A' + 10),
65        b'a'..=b'f' => Some(byte - b'a' + 10),
66        _ => None,
67    }
68}
69
70/// Shared percent-encoding loop: bytes for which `keep` returns `true` are
71/// copied verbatim, every other byte becomes `%XX` with uppercase hex.
72///
73/// `keep` must only accept ASCII bytes so that the unchanged prefix is a
74/// valid char boundary. Returns [`Cow::Borrowed`] when no byte requires
75/// encoding.
76fn encode_with(input: &str, keep: fn(u8) -> bool) -> Cow<'_, str> {
77    let bytes = input.as_bytes();
78
79    // Fast path: count the leading run of kept bytes.
80    let unchanged_prefix = bytes.iter().take_while(|&&b| keep(b)).count();
81    if unchanged_prefix == bytes.len() {
82        return Cow::Borrowed(input);
83    }
84
85    let mut encoded = String::with_capacity(bytes.len() + 2 * (bytes.len() - unchanged_prefix));
86    // All bytes in the prefix are ASCII, so this index is a char boundary.
87    encoded.push_str(&input[..unchanged_prefix]);
88
89    for &byte in &bytes[unchanged_prefix..] {
90        if keep(byte) {
91            encoded.push(byte as char);
92        } else {
93            encoded.push('%');
94            encoded.push(HEX_UPPER[(byte >> 4) as usize] as char);
95            encoded.push(HEX_UPPER[(byte & 0x0F) as usize] as char);
96        }
97    }
98
99    Cow::Owned(encoded)
100}
101
102/// Percent-encodes a string per RFC 3986.
103///
104/// Every byte that is **not** in the unreserved set
105/// (`A-Z a-z 0-9 - _ . ~`) is replaced by `%XX` where `XX` is the byte value
106/// in uppercase hexadecimal. Multi-byte UTF-8 characters are encoded byte by
107/// byte. Returns [`Cow::Borrowed`] when no byte requires encoding.
108///
109/// This is a drop-in replacement for `urlencoding::encode`.
110///
111/// # Examples
112///
113/// ```
114/// use std::borrow::Cow;
115/// use oxirs_core::encoding::percent_encode;
116///
117/// // Spaces become %20 (never `+`), reserved characters are escaped.
118/// assert_eq!(percent_encode("a b/c?d#e"), "a%20b%2Fc%3Fd%23e");
119///
120/// // Multi-byte UTF-8 is encoded byte by byte with uppercase hex.
121/// assert_eq!(percent_encode("日本"), "%E6%97%A5%E6%9C%AC");
122///
123/// // Unreserved input is returned without allocation.
124/// assert!(matches!(percent_encode("AZaz09-_.~"), Cow::Borrowed(_)));
125/// ```
126pub fn percent_encode(input: &str) -> Cow<'_, str> {
127    encode_with(input, is_unreserved)
128}
129
130/// Percent-encodes a string, escaping every byte that is not an ASCII
131/// alphanumeric character (`A-Z a-z 0-9`).
132///
133/// This is stricter than [`percent_encode`]: the RFC 3986 unreserved
134/// punctuation `- _ . ~` is escaped as well (`_` becomes `%5F`), matching
135/// the byte set of `percent_encoding::NON_ALPHANUMERIC`. Use it where the
136/// established wire contract expects form-style strict encoding, such as
137/// OAuth2/OIDC authorization-URL query parameters. Returns
138/// [`Cow::Borrowed`] when no byte requires encoding.
139///
140/// # Examples
141///
142/// ```
143/// use std::borrow::Cow;
144/// use oxirs_core::encoding::percent_encode_strict;
145///
146/// // Unreserved punctuation is escaped, unlike `percent_encode`.
147/// assert_eq!(percent_encode_strict("test_client_id"), "test%5Fclient%5Fid");
148/// assert_eq!(percent_encode_strict("a-b.c~d"), "a%2Db%2Ec%7Ed");
149///
150/// // Spaces become %20 (never `+`).
151/// assert_eq!(percent_encode_strict("openid profile"), "openid%20profile");
152///
153/// // Purely alphanumeric input is returned without allocation.
154/// assert!(matches!(percent_encode_strict("AZaz09"), Cow::Borrowed(_)));
155/// ```
156pub fn percent_encode_strict(input: &str) -> Cow<'_, str> {
157    encode_with(input, is_ascii_alphanumeric_byte)
158}
159
160/// Decodes percent-encoded bytes, passing malformed `%` runs through
161/// unchanged.
162///
163/// Returns [`Cow::Borrowed`] when the input contains no `%` byte at all
164/// (mirroring `urlencoding::decode_binary`).
165fn percent_decode_bytes(data: &[u8]) -> Cow<'_, [u8]> {
166    // Fast path: no '%' means nothing can change.
167    let unchanged_prefix = data.iter().take_while(|&&b| b != b'%').count();
168    if unchanged_prefix == data.len() {
169        return Cow::Borrowed(data);
170    }
171
172    let mut decoded = Vec::with_capacity(data.len());
173    decoded.extend_from_slice(&data[..unchanged_prefix]);
174
175    let mut index = unchanged_prefix;
176    while index < data.len() {
177        let byte = data[index];
178        if byte != b'%' {
179            decoded.push(byte);
180            index += 1;
181            continue;
182        }
183
184        match (data.get(index + 1).copied(), data.get(index + 2).copied()) {
185            (Some(first), Some(second)) => match from_hex_digit(first) {
186                Some(high) => match from_hex_digit(second) {
187                    Some(low) => {
188                        decoded.push((high << 4) | low);
189                        index += 3;
190                    }
191                    None => {
192                        // "%X?" where ? is not hex: emit '%' and the first
193                        // digit; rescan from the second byte (it may be '%').
194                        decoded.push(b'%');
195                        decoded.push(first);
196                        index += 2;
197                    }
198                },
199                None => {
200                    // "%?" where ? is not hex: emit '%' alone and rescan
201                    // from the next byte (it may itself start a sequence).
202                    decoded.push(b'%');
203                    index += 1;
204                }
205            },
206            _ => {
207                // Truncated "%" or "%X" at the end: pass through verbatim.
208                decoded.extend_from_slice(&data[index..]);
209                break;
210            }
211        }
212    }
213
214    Cow::Owned(decoded)
215}
216
217/// Decodes a percent-encoded string per RFC 3986.
218///
219/// `%XX` sequences are decoded with case-insensitive hexadecimal digits.
220/// Malformed or truncated sequences (`%`, `%2`, `%ZZ`, ...) are passed
221/// through unchanged instead of producing an error; an error is returned
222/// only when the decoded byte sequence is not valid UTF-8.
223///
224/// Returns [`Cow::Borrowed`] when the input contains no `%` character.
225///
226/// This is a drop-in replacement for `urlencoding::decode`.
227///
228/// # Errors
229///
230/// Returns [`FromUtf8Error`] when the decoded bytes are not valid UTF-8
231/// (for example `"%FF"`).
232///
233/// # Examples
234///
235/// ```
236/// use oxirs_core::encoding::percent_decode;
237///
238/// # fn main() -> Result<(), std::string::FromUtf8Error> {
239/// assert_eq!(percent_decode("hello%20world")?, "hello world");
240/// // Hex digits are case-insensitive.
241/// assert_eq!(percent_decode("%e6%97%A5")?, "日");
242/// // Malformed sequences pass through unchanged.
243/// assert_eq!(percent_decode("100%")?, "100%");
244/// // Invalid UTF-8 in the decoded output is the only error case.
245/// assert!(percent_decode("%FF").is_err());
246/// # Ok(())
247/// # }
248/// ```
249pub fn percent_decode(input: &str) -> Result<Cow<'_, str>, FromUtf8Error> {
250    match percent_decode_bytes(input.as_bytes()) {
251        Cow::Borrowed(_) => Ok(Cow::Borrowed(input)),
252        Cow::Owned(bytes) => Ok(Cow::Owned(String::from_utf8(bytes)?)),
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn encode_empty_string_is_borrowed() {
262        let encoded = percent_encode("");
263        assert_eq!(encoded, "");
264        assert!(matches!(encoded, Cow::Borrowed(_)));
265    }
266
267    #[test]
268    fn encode_unreserved_is_borrowed_fast_path() {
269        let input = "ABCXYZabcxyz0189-_.~";
270        let encoded = percent_encode(input);
271        assert_eq!(encoded, input);
272        assert!(matches!(encoded, Cow::Borrowed(_)));
273    }
274
275    #[test]
276    fn encode_space_is_percent20_not_plus() {
277        assert_eq!(percent_encode("hello world"), "hello%20world");
278        assert!(!percent_encode("hello world").contains('+'));
279    }
280
281    #[test]
282    fn encode_reserved_characters() {
283        assert_eq!(percent_encode("/"), "%2F");
284        assert_eq!(percent_encode("?"), "%3F");
285        assert_eq!(percent_encode("#"), "%23");
286        assert_eq!(percent_encode("&"), "%26");
287        assert_eq!(percent_encode("="), "%3D");
288        assert_eq!(
289            percent_encode("http://example.org/a?b=c&d=e#f"),
290            "http%3A%2F%2Fexample.org%2Fa%3Fb%3Dc%26d%3De%23f"
291        );
292    }
293
294    #[test]
295    fn encode_percent_literal() {
296        assert_eq!(percent_encode("100%"), "100%25");
297        assert_eq!(percent_encode("%"), "%25");
298    }
299
300    #[test]
301    fn encode_multibyte_utf8_uppercase_hex() {
302        // "日本語" is 9 UTF-8 bytes; every one must be encoded uppercase.
303        assert_eq!(percent_encode("日本語"), "%E6%97%A5%E6%9C%AC%E8%AA%9E");
304        assert_eq!(percent_encode("ä"), "%C3%A4");
305        assert_eq!(percent_encode("🦀"), "%F0%9F%A6%80");
306    }
307
308    #[test]
309    fn encode_mixed_prefix_keeps_unreserved_run() {
310        let encoded = percent_encode("abc def");
311        assert_eq!(encoded, "abc%20def");
312        assert!(matches!(encoded, Cow::Owned(_)));
313    }
314
315    #[test]
316    fn strict_encode_alphanumeric_is_borrowed_fast_path() {
317        let input = "ABCXYZabcxyz0189";
318        let encoded = percent_encode_strict(input);
319        assert_eq!(encoded, input);
320        assert!(matches!(encoded, Cow::Borrowed(_)));
321    }
322
323    #[test]
324    fn strict_encode_escapes_unreserved_punctuation() {
325        // The four RFC 3986 unreserved punctuation bytes are escaped too.
326        assert_eq!(percent_encode_strict("-"), "%2D");
327        assert_eq!(percent_encode_strict("_"), "%5F");
328        assert_eq!(percent_encode_strict("."), "%2E");
329        assert_eq!(percent_encode_strict("~"), "%7E");
330        assert_eq!(
331            percent_encode_strict("test_client_id"),
332            "test%5Fclient%5Fid"
333        );
334    }
335
336    #[test]
337    fn strict_encode_space_and_reserved_match_rfc_variant() {
338        assert_eq!(percent_encode_strict("hello world"), "hello%20world");
339        assert_eq!(
340            percent_encode_strict("a/b?c#d&e=f"),
341            "a%2Fb%3Fc%23d%26e%3Df"
342        );
343        assert_eq!(percent_encode_strict("100%"), "100%25");
344    }
345
346    #[test]
347    fn strict_encode_multibyte_utf8_uppercase_hex() {
348        assert_eq!(percent_encode_strict("日本"), "%E6%97%A5%E6%9C%AC");
349        assert_eq!(percent_encode_strict("ä"), "%C3%A4");
350    }
351
352    #[test]
353    fn strict_encode_round_trips_through_percent_decode() {
354        let samples = [
355            "",
356            "plain",
357            "test_client_id",
358            "openid profile email",
359            "code-challenge_~.value",
360            "日本語のテキスト",
361        ];
362        for sample in samples {
363            let encoded = percent_encode_strict(sample);
364            let decoded = percent_decode(&encoded).expect("strict round trip decodes");
365            assert_eq!(decoded, sample, "strict round trip failed for {sample:?}");
366        }
367    }
368
369    #[test]
370    fn strict_encoding_is_superset_of_rfc_encoding() {
371        // Every byte escaped by `percent_encode` is escaped by the strict
372        // variant; the strict variant only adds the unreserved punctuation.
373        let input = "AZaz09-_.~ /?#&=%";
374        let rfc = percent_encode(input);
375        let strict = percent_encode_strict(input);
376        assert_eq!(rfc, "AZaz09-_.~%20%2F%3F%23%26%3D%25");
377        assert_eq!(strict, "AZaz09%2D%5F%2E%7E%20%2F%3F%23%26%3D%25");
378    }
379
380    #[test]
381    fn decode_empty_string_is_borrowed() {
382        let decoded = percent_decode("").expect("empty input decodes");
383        assert_eq!(decoded, "");
384        assert!(matches!(decoded, Cow::Borrowed(_)));
385    }
386
387    #[test]
388    fn decode_without_percent_is_borrowed_fast_path() {
389        let decoded = percent_decode("plain text!").expect("plain input decodes");
390        assert_eq!(decoded, "plain text!");
391        assert!(matches!(decoded, Cow::Borrowed(_)));
392    }
393
394    #[test]
395    fn decode_basic_sequences() {
396        assert_eq!(
397            percent_decode("hello%20world").expect("valid input"),
398            "hello world"
399        );
400        assert_eq!(
401            percent_decode("%2F%3F%23%26%3D").expect("valid input"),
402            "/?#&="
403        );
404        assert_eq!(percent_decode("%25").expect("valid input"), "%");
405    }
406
407    #[test]
408    fn decode_hex_is_case_insensitive() {
409        assert_eq!(percent_decode("%2f").expect("valid input"), "/");
410        assert_eq!(percent_decode("%c3%a4").expect("valid input"), "ä");
411        assert_eq!(percent_decode("%C3%A4").expect("valid input"), "ä");
412        assert_eq!(percent_decode("%e6%97%A5").expect("valid input"), "日");
413    }
414
415    #[test]
416    fn decode_malformed_sequences_pass_through() {
417        // Lone '%' at the end.
418        assert_eq!(percent_decode("%").expect("malformed passes through"), "%");
419        assert_eq!(
420            percent_decode("100%").expect("malformed passes through"),
421            "100%"
422        );
423        // Truncated "%X" at the end.
424        assert_eq!(
425            percent_decode("%2").expect("malformed passes through"),
426            "%2"
427        );
428        // Non-hex digits.
429        assert_eq!(
430            percent_decode("%ZZ").expect("malformed passes through"),
431            "%ZZ"
432        );
433        // First digit valid, second invalid, then a valid sequence resumes.
434        assert_eq!(
435            percent_decode("%2%41").expect("malformed passes through"),
436            "%2A"
437        );
438        // '%' immediately followed by a valid sequence.
439        assert_eq!(
440            percent_decode("%%41").expect("malformed passes through"),
441            "%A"
442        );
443    }
444
445    #[test]
446    fn decode_invalid_utf8_errors() {
447        assert!(percent_decode("%FF").is_err());
448        // Lone continuation byte.
449        assert!(percent_decode("%80").is_err());
450        // Truncated multi-byte sequence (first byte of "ä" only).
451        assert!(percent_decode("%C3").is_err());
452    }
453
454    #[test]
455    fn round_trip_ascii_and_unicode() {
456        let samples = [
457            "",
458            "plain",
459            "hello world",
460            "a/b?c#d&e=f",
461            "100% sure",
462            "日本語のテキスト",
463            "emoji 🦀 crab",
464            "tab\tnewline\nquote\"",
465            "AZaz09-_.~",
466        ];
467        for sample in samples {
468            let encoded = percent_encode(sample);
469            let decoded = percent_decode(&encoded).expect("round trip decodes");
470            assert_eq!(decoded, sample, "round trip failed for {sample:?}");
471        }
472    }
473
474    #[test]
475    fn encode_matches_sparql_encode_for_uri_semantics() {
476        // SPARQL ENCODE_FOR_URI("Los Angeles") = "Los%20Angeles"
477        assert_eq!(percent_encode("Los Angeles"), "Los%20Angeles");
478    }
479}