secure_output/url.rs
1//! URL context encoder.
2
3use std::borrow::Cow;
4
5use crate::encode::OutputEncoder;
6
7/// Encodes strings for safe inclusion in URLs (percent-encoding per RFC 3986).
8///
9/// Only unreserved characters (`A-Z`, `a-z`, `0-9`, `-`, `_`, `.`, `~`) are
10/// passed through unchanged. All other bytes are percent-encoded with uppercase
11/// hex digits. Null bytes are stripped (not percent-encoded as `%00`).
12///
13/// Returns [`Cow::Borrowed`] when the input contains only unreserved characters.
14///
15/// # Examples
16///
17/// ```
18/// use secure_output::url;
19///
20/// let safe = url::encode("hello world");
21/// assert_eq!(safe, "hello%20world");
22///
23/// let safe = url::encode("safe-value_123");
24/// assert_eq!(safe, "safe-value_123");
25/// ```
26#[derive(Clone, Copy, Debug, Default)]
27pub struct UrlEncoder;
28
29fn is_unreserved(b: u8) -> bool {
30 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~')
31}
32
33impl OutputEncoder for UrlEncoder {
34 fn encode<'a>(&self, input: &'a str) -> Cow<'a, str> {
35 let needs = input.bytes().any(|b| !is_unreserved(b));
36 if !needs {
37 return Cow::Borrowed(input);
38 }
39 let mut out = String::with_capacity(input.len() * 2);
40 for b in input.bytes() {
41 if b == 0 {
42 // strip null bytes
43 continue;
44 }
45 if is_unreserved(b) {
46 out.push(b as char);
47 } else {
48 let hi = char::from_digit(u32::from(b >> 4), 16)
49 .unwrap_or('0')
50 .to_ascii_uppercase();
51 let lo = char::from_digit(u32::from(b & 0xF), 16)
52 .unwrap_or('0')
53 .to_ascii_uppercase();
54 out.push('%');
55 out.push(hi);
56 out.push(lo);
57 }
58 }
59 Cow::Owned(out)
60 }
61}
62
63/// Convenience free function for URL percent-encoding.
64///
65/// Equivalent to `UrlEncoder.encode(input)`.
66///
67/// # Examples
68///
69/// ```
70/// use secure_output::url;
71///
72/// assert_eq!(url::encode("a b"), "a%20b");
73/// ```
74#[must_use]
75pub fn encode(input: &str) -> Cow<'_, str> {
76 UrlEncoder.encode(input)
77}