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}