Skip to main content

contextual_encoder/
uri.rs

1//! URI component and path encoders.
2//!
3//! provides percent-encoding for URI components and paths per RFC 3986.
4//!
5//! # security notes
6//!
7//! - these encoders are for **URI components and paths**, not entire URLs.
8//! - they **cannot** make an untrusted full URL safe. a `javascript:` URL will
9//!   be percent-encoded but still execute. always validate the URL scheme and
10//!   structure separately before embedding untrusted URLs.
11//! - the output is safe for direct embedding in HTML, CSS, and javascript
12//!   contexts because all context-significant characters are percent-encoded.
13
14use std::fmt;
15
16// ---------------------------------------------------------------------------
17// for_uri_component
18// ---------------------------------------------------------------------------
19
20/// percent-encodes `input` for safe use as a URI component.
21///
22/// only unreserved characters per RFC 3986 pass through unencoded:
23/// `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`. everything else is encoded
24/// as percent-encoded UTF-8 bytes.
25///
26/// # examples
27///
28/// ```
29/// use contextual_encoder::for_uri_component;
30///
31/// assert_eq!(for_uri_component("hello world"), "hello%20world");
32/// assert_eq!(for_uri_component("a=1&b=2"), "a%3D1%26b%3D2");
33/// assert_eq!(for_uri_component("safe-text_v2.0"), "safe-text_v2.0");
34/// assert_eq!(for_uri_component("café"), "caf%C3%A9");
35/// ```
36pub fn for_uri_component(input: &str) -> String {
37    let bytes = input.as_bytes();
38    let unreserved = bytes.iter().filter(|b| is_unreserved(**b)).count();
39    let capacity = unreserved + 3 * (bytes.len() - unreserved);
40    let mut out = String::with_capacity(capacity);
41    write_uri_component(&mut out, input).expect("writing to string cannot fail");
42    out
43}
44
45/// writes the percent-encoded form of `input` to `out`.
46///
47/// see [`for_uri_component`] for encoding rules.
48pub fn write_uri_component<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
49    let bytes = input.as_bytes();
50    let mut last_written = 0;
51
52    for (i, &byte) in bytes.iter().enumerate() {
53        if !is_unreserved(byte) {
54            // flush the preceding run of unreserved (ASCII) bytes
55            if last_written < i {
56                // safe: unreserved chars are all ASCII, so this slice is valid UTF-8
57                out.write_str(&input[last_written..i])?;
58            }
59            write!(out, "%{:02X}", byte)?;
60            last_written = i + 1;
61        }
62    }
63
64    // flush any trailing safe run
65    if last_written < bytes.len() {
66        out.write_str(&input[last_written..])?;
67    }
68    Ok(())
69}
70
71// ---------------------------------------------------------------------------
72// for_uri_path
73// ---------------------------------------------------------------------------
74
75/// percent-encodes `input` for safe use as a URI path.
76///
77/// this encoder preserves forward-slash (`/`) separators while encoding each
78/// path segment. only unreserved characters per RFC 3986 and `/` pass through
79/// unencoded: `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`, `/`. everything else
80/// is encoded as percent-encoded UTF-8 bytes.
81///
82/// use this when you need to encode a full URI path from untrusted input.
83/// for individual path segments or query parameters, use
84/// [`for_uri_component`] instead (which also encodes `/`).
85///
86/// # security notes
87///
88/// - this encoder does **not** normalize `.` or `..` segments. callers must
89///   validate and normalize paths separately to prevent path traversal.
90/// - multiple consecutive slashes are preserved as-is.
91///
92/// # examples
93///
94/// ```
95/// use contextual_encoder::for_uri_path;
96///
97/// assert_eq!(for_uri_path("/users/café/profile"), "/users/caf%C3%A9/profile");
98/// assert_eq!(for_uri_path("/a b/c&d"), "/a%20b/c%26d");
99/// assert_eq!(for_uri_path("/safe-text_v2.0/~user"), "/safe-text_v2.0/~user");
100/// assert_eq!(for_uri_path("/path/segment"), "/path/segment");
101/// ```
102pub fn for_uri_path(input: &str) -> String {
103    let bytes = input.as_bytes();
104    let safe = bytes
105        .iter()
106        .filter(|b| is_unreserved(**b) || **b == b'/')
107        .count();
108    let capacity = safe + 3 * (bytes.len() - safe);
109    let mut out = String::with_capacity(capacity);
110    write_uri_path(&mut out, input).expect("writing to string cannot fail");
111    out
112}
113
114/// writes the percent-encoded URI path form of `input` to `out`.
115///
116/// see [`for_uri_path`] for encoding rules.
117pub fn write_uri_path<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
118    let bytes = input.as_bytes();
119    let mut last_written = 0;
120
121    for (i, &byte) in bytes.iter().enumerate() {
122        if !is_unreserved(byte) && byte != b'/' {
123            // flush the preceding run of safe bytes
124            if last_written < i {
125                out.write_str(&input[last_written..i])?;
126            }
127            write!(out, "%{:02X}", byte)?;
128            last_written = i + 1;
129        }
130    }
131
132    // flush any trailing safe run
133    if last_written < bytes.len() {
134        out.write_str(&input[last_written..])?;
135    }
136    Ok(())
137}
138
139/// returns true if the byte represents an unreserved character per RFC 3986.
140fn is_unreserved(b: u8) -> bool {
141    matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn uri_component_no_encoding_needed() {
150        assert_eq!(for_uri_component("hello"), "hello");
151        assert_eq!(for_uri_component(""), "");
152        assert_eq!(for_uri_component("ABCxyz019"), "ABCxyz019");
153        assert_eq!(for_uri_component("-._~"), "-._~");
154    }
155
156    #[test]
157    fn uri_component_encodes_space() {
158        assert_eq!(for_uri_component("a b"), "a%20b");
159    }
160
161    #[test]
162    fn uri_component_encodes_reserved_chars() {
163        assert_eq!(for_uri_component("a=b"), "a%3Db");
164        assert_eq!(for_uri_component("a&b"), "a%26b");
165        assert_eq!(for_uri_component("a+b"), "a%2Bb");
166        assert_eq!(for_uri_component("a?b"), "a%3Fb");
167        assert_eq!(for_uri_component("a#b"), "a%23b");
168        assert_eq!(for_uri_component("a/b"), "a%2Fb");
169    }
170
171    #[test]
172    fn uri_component_encodes_html_significant() {
173        assert_eq!(for_uri_component("<script>"), "%3Cscript%3E");
174        assert_eq!(for_uri_component(r#""quoted""#), "%22quoted%22");
175    }
176
177    #[test]
178    fn uri_component_encodes_two_byte_utf8() {
179        // U+00A0 (NBSP) → 0xC2 0xA0
180        assert_eq!(for_uri_component("\u{00A0}"), "%C2%A0");
181        // U+00E9 (é) → 0xC3 0xA9
182        assert_eq!(for_uri_component("é"), "%C3%A9");
183    }
184
185    #[test]
186    fn uri_component_encodes_three_byte_utf8() {
187        // U+0800 → 0xE0 0xA0 0x80
188        assert_eq!(for_uri_component("\u{0800}"), "%E0%A0%80");
189        // U+4E16 (世) → 0xE4 0xB8 0x96
190        assert_eq!(for_uri_component("世"), "%E4%B8%96");
191    }
192
193    #[test]
194    fn uri_component_encodes_four_byte_utf8() {
195        // U+10000 → 0xF0 0x90 0x80 0x80
196        assert_eq!(for_uri_component("\u{10000}"), "%F0%90%80%80");
197        // U+1F600 (😀) → 0xF0 0x9F 0x98 0x80
198        assert_eq!(for_uri_component("😀"), "%F0%9F%98%80");
199    }
200
201    #[test]
202    fn uri_component_encodes_control_chars() {
203        assert_eq!(for_uri_component("\x00"), "%00");
204        assert_eq!(for_uri_component("\x1F"), "%1F");
205        assert_eq!(for_uri_component("\x7F"), "%7F");
206    }
207
208    #[test]
209    fn uri_component_mixed() {
210        assert_eq!(
211            for_uri_component("key=hello world&foo=bar"),
212            "key%3Dhello%20world%26foo%3Dbar"
213        );
214    }
215
216    #[test]
217    fn uri_component_writer_variant() {
218        let mut out = String::new();
219        write_uri_component(&mut out, "a b").unwrap();
220        assert_eq!(out, "a%20b");
221    }
222
223    // -- uri path --
224
225    #[test]
226    fn uri_path_no_encoding_needed() {
227        assert_eq!(for_uri_path("hello"), "hello");
228        assert_eq!(for_uri_path(""), "");
229        assert_eq!(for_uri_path("-._~"), "-._~");
230    }
231
232    #[test]
233    fn uri_path_preserves_slashes() {
234        assert_eq!(for_uri_path("/a/b/c"), "/a/b/c");
235        assert_eq!(for_uri_path("/"), "/");
236        assert_eq!(for_uri_path("//"), "//");
237        assert_eq!(for_uri_path("a/b"), "a/b");
238    }
239
240    #[test]
241    fn uri_path_encodes_reserved_except_slash() {
242        assert_eq!(for_uri_path("a=b"), "a%3Db");
243        assert_eq!(for_uri_path("a&b"), "a%26b");
244        assert_eq!(for_uri_path("a?b"), "a%3Fb");
245        assert_eq!(for_uri_path("a#b"), "a%23b");
246    }
247
248    #[test]
249    fn uri_path_encodes_space() {
250        assert_eq!(for_uri_path("/a b/c d"), "/a%20b/c%20d");
251    }
252
253    #[test]
254    fn uri_path_encodes_multibyte() {
255        assert_eq!(for_uri_path("/café"), "/caf%C3%A9");
256        assert_eq!(for_uri_path("/世界"), "/%E4%B8%96%E7%95%8C");
257        assert_eq!(for_uri_path("/😀"), "/%F0%9F%98%80");
258    }
259
260    #[test]
261    fn uri_path_writer_variant() {
262        let mut out = String::new();
263        write_uri_path(&mut out, "/a b/c").unwrap();
264        assert_eq!(out, "/a%20b/c");
265    }
266}