Skip to main content

contextual_encoder/
uri.rs

1//! URI and form percent-encoders.
2//!
3//! provides percent-encoding for URI components and paths per RFC 3986, and
4//! for form values per the WHATWG URL Standard
5//! (`application/x-www-form-urlencoded`).
6//!
7//! # security notes
8//!
9//! - these encoders are for **URI components, paths, and form values**, not
10//!   entire URLs.
11//! - they **cannot** make an untrusted full URL safe. a `javascript:` URL will
12//!   be percent-encoded but still execute. always validate the URL scheme and
13//!   structure separately before embedding untrusted URLs.
14//! - the output is safe for direct embedding in HTML, CSS, and javascript
15//!   contexts because all context-significant characters are percent-encoded.
16
17use std::fmt;
18
19/// percent-encodes `input` for safe use as a URI component.
20///
21/// only unreserved characters per RFC 3986 pass through unencoded:
22/// `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`. everything else is encoded
23/// as percent-encoded UTF-8 bytes.
24///
25/// # examples
26///
27/// ```
28/// use contextual_encoder::for_uri_component;
29///
30/// assert_eq!(for_uri_component("hello world"), "hello%20world");
31/// assert_eq!(for_uri_component("a=1&b=2"), "a%3D1%26b%3D2");
32/// assert_eq!(for_uri_component("safe-text_v2.0"), "safe-text_v2.0");
33/// assert_eq!(for_uri_component("café"), "caf%C3%A9");
34/// ```
35pub fn for_uri_component(input: &str) -> String {
36    let mut out = String::with_capacity(input.len());
37    write_uri_component(&mut out, input).expect("writing to string cannot fail");
38    out
39}
40
41/// writes the percent-encoded form of `input` to `out`.
42///
43/// see [`for_uri_component`] for encoding rules.
44pub fn write_uri_component<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
45    let bytes = input.as_bytes();
46    let mut last_written = 0;
47
48    for (i, &byte) in bytes.iter().enumerate() {
49        if !is_unreserved(byte) {
50            // flush the preceding run of unreserved (ASCII) bytes
51            if last_written < i {
52                // safe: unreserved chars are all ASCII, so this slice is valid UTF-8
53                out.write_str(&input[last_written..i])?;
54            }
55            write!(out, "%{:02X}", byte)?;
56            last_written = i + 1;
57        }
58    }
59
60    // flush any trailing safe run
61    if last_written < bytes.len() {
62        out.write_str(&input[last_written..])?;
63    }
64    Ok(())
65}
66
67/// percent-encodes `input` for safe use as a URI path.
68///
69/// this encoder preserves forward-slash (`/`) separators while encoding each
70/// path segment. only unreserved characters per RFC 3986 and `/` pass through
71/// unencoded: `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`, `/`. everything else
72/// is encoded as percent-encoded UTF-8 bytes.
73///
74/// use this when you need to encode a full URI path from untrusted input.
75/// for individual path segments or query parameters, use
76/// [`for_uri_component`] instead (which also encodes `/`).
77///
78/// # security notes
79///
80/// - this encoder does **not** normalize `.` or `..` segments. callers must
81///   validate and normalize paths separately to prevent path traversal.
82/// - multiple consecutive slashes are preserved as-is.
83///
84/// # examples
85///
86/// ```
87/// use contextual_encoder::for_uri_path;
88///
89/// assert_eq!(for_uri_path("/users/café/profile"), "/users/caf%C3%A9/profile");
90/// assert_eq!(for_uri_path("/a b/c&d"), "/a%20b/c%26d");
91/// assert_eq!(for_uri_path("/safe-text_v2.0/~user"), "/safe-text_v2.0/~user");
92/// assert_eq!(for_uri_path("/path/segment"), "/path/segment");
93/// ```
94pub fn for_uri_path(input: &str) -> String {
95    let mut out = String::with_capacity(input.len());
96    write_uri_path(&mut out, input).expect("writing to string cannot fail");
97    out
98}
99
100/// writes the percent-encoded URI path form of `input` to `out`.
101///
102/// see [`for_uri_path`] for encoding rules.
103pub fn write_uri_path<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
104    let bytes = input.as_bytes();
105    let mut last_written = 0;
106
107    for (i, &byte) in bytes.iter().enumerate() {
108        if !is_unreserved(byte) && byte != b'/' {
109            // flush the preceding run of safe bytes
110            if last_written < i {
111                out.write_str(&input[last_written..i])?;
112            }
113            write!(out, "%{:02X}", byte)?;
114            last_written = i + 1;
115        }
116    }
117
118    // flush any trailing safe run
119    if last_written < bytes.len() {
120        out.write_str(&input[last_written..])?;
121    }
122    Ok(())
123}
124
125/// percent-encodes `input` for use as an
126/// `application/x-www-form-urlencoded` value.
127///
128/// follows the [WHATWG URL Standard](https://url.spec.whatwg.org/#concept-urlencoded-byte-serializer)
129/// byte serializer: spaces become `+`, the bytes `*`, `-`, `.`, `0-9`,
130/// `A-Z`, `_`, `a-z` pass through unencoded, and everything else is
131/// percent-encoded as UTF-8 bytes.
132///
133/// this encodes a single form **value** (or name). it does not insert `=`
134/// or `&` delimiters — the caller constructs the `key=value&key=value`
135/// structure using already-encoded parts.
136///
137/// # differences from [`for_uri_component`]
138///
139/// | character | `for_uri_component` (RFC 3986) | `for_form_urlencoded` (WHATWG) |
140/// |-----------|-------------------------------|-------------------------------|
141/// | space     | `%20`                         | `+`                           |
142/// | `~`       | passthrough                   | `%7E`                         |
143/// | `*`       | `%2A`                         | passthrough                   |
144///
145/// # examples
146///
147/// ```
148/// use contextual_encoder::for_form_urlencoded;
149///
150/// assert_eq!(for_form_urlencoded("hello world"), "hello+world");
151/// assert_eq!(for_form_urlencoded("a=1&b=2"), "a%3D1%26b%3D2");
152/// assert_eq!(for_form_urlencoded("safe-text_v2.0"), "safe-text_v2.0");
153/// assert_eq!(for_form_urlencoded("café"), "caf%C3%A9");
154/// assert_eq!(for_form_urlencoded("a~b"), "a%7Eb");
155/// assert_eq!(for_form_urlencoded("a*b"), "a*b");
156/// ```
157pub fn for_form_urlencoded(input: &str) -> String {
158    let mut out = String::with_capacity(input.len());
159    write_form_urlencoded(&mut out, input).expect("writing to string cannot fail");
160    out
161}
162
163/// writes the `application/x-www-form-urlencoded` encoded form of `input`
164/// to `out`.
165///
166/// see [`for_form_urlencoded`] for encoding rules.
167pub fn write_form_urlencoded<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
168    let bytes = input.as_bytes();
169    let mut last_written = 0;
170
171    for (i, &byte) in bytes.iter().enumerate() {
172        if byte == b' ' {
173            if last_written < i {
174                out.write_str(&input[last_written..i])?;
175            }
176            out.write_char('+')?;
177            last_written = i + 1;
178        } else if !is_form_safe(byte) {
179            if last_written < i {
180                out.write_str(&input[last_written..i])?;
181            }
182            write!(out, "%{:02X}", byte)?;
183            last_written = i + 1;
184        }
185    }
186
187    if last_written < bytes.len() {
188        out.write_str(&input[last_written..])?;
189    }
190    Ok(())
191}
192
193/// returns true if the byte represents an unreserved character per RFC 3986.
194fn is_unreserved(b: u8) -> bool {
195    matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
196}
197
198/// returns true if the byte passes through unencoded in
199/// `application/x-www-form-urlencoded` per the WHATWG URL Standard.
200/// space is handled separately (mapped to `+`).
201fn is_form_safe(b: u8) -> bool {
202    matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_')
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn uri_component_no_encoding_needed() {
211        assert_eq!(for_uri_component("hello"), "hello");
212        assert_eq!(for_uri_component(""), "");
213        assert_eq!(for_uri_component("ABCxyz019"), "ABCxyz019");
214        assert_eq!(for_uri_component("-._~"), "-._~");
215    }
216
217    #[test]
218    fn uri_component_encodes_space() {
219        assert_eq!(for_uri_component("a b"), "a%20b");
220    }
221
222    #[test]
223    fn uri_component_encodes_reserved_chars() {
224        assert_eq!(for_uri_component("a=b"), "a%3Db");
225        assert_eq!(for_uri_component("a&b"), "a%26b");
226        assert_eq!(for_uri_component("a+b"), "a%2Bb");
227        assert_eq!(for_uri_component("a?b"), "a%3Fb");
228        assert_eq!(for_uri_component("a#b"), "a%23b");
229        assert_eq!(for_uri_component("a/b"), "a%2Fb");
230    }
231
232    #[test]
233    fn uri_component_encodes_html_significant() {
234        assert_eq!(for_uri_component("<script>"), "%3Cscript%3E");
235        assert_eq!(for_uri_component(r#""quoted""#), "%22quoted%22");
236    }
237
238    #[test]
239    fn uri_component_encodes_two_byte_utf8() {
240        // U+00A0 (NBSP) → 0xC2 0xA0
241        assert_eq!(for_uri_component("\u{00A0}"), "%C2%A0");
242        // U+00E9 (é) → 0xC3 0xA9
243        assert_eq!(for_uri_component("é"), "%C3%A9");
244    }
245
246    #[test]
247    fn uri_component_encodes_three_byte_utf8() {
248        // U+0800 → 0xE0 0xA0 0x80
249        assert_eq!(for_uri_component("\u{0800}"), "%E0%A0%80");
250        // U+4E16 (世) → 0xE4 0xB8 0x96
251        assert_eq!(for_uri_component("世"), "%E4%B8%96");
252    }
253
254    #[test]
255    fn uri_component_encodes_four_byte_utf8() {
256        // U+10000 → 0xF0 0x90 0x80 0x80
257        assert_eq!(for_uri_component("\u{10000}"), "%F0%90%80%80");
258        // U+1F600 (😀) → 0xF0 0x9F 0x98 0x80
259        assert_eq!(for_uri_component("😀"), "%F0%9F%98%80");
260    }
261
262    #[test]
263    fn uri_component_encodes_control_chars() {
264        assert_eq!(for_uri_component("\x00"), "%00");
265        assert_eq!(for_uri_component("\x1F"), "%1F");
266        assert_eq!(for_uri_component("\x7F"), "%7F");
267    }
268
269    #[test]
270    fn uri_component_mixed() {
271        assert_eq!(
272            for_uri_component("key=hello world&foo=bar"),
273            "key%3Dhello%20world%26foo%3Dbar"
274        );
275    }
276
277    #[test]
278    fn uri_component_writer_variant() {
279        let mut out = String::new();
280        write_uri_component(&mut out, "a b").unwrap();
281        assert_eq!(out, "a%20b");
282    }
283
284    // -- uri path --
285
286    #[test]
287    fn uri_path_no_encoding_needed() {
288        assert_eq!(for_uri_path("hello"), "hello");
289        assert_eq!(for_uri_path(""), "");
290        assert_eq!(for_uri_path("-._~"), "-._~");
291    }
292
293    #[test]
294    fn uri_path_preserves_slashes() {
295        assert_eq!(for_uri_path("/a/b/c"), "/a/b/c");
296        assert_eq!(for_uri_path("/"), "/");
297        assert_eq!(for_uri_path("//"), "//");
298        assert_eq!(for_uri_path("a/b"), "a/b");
299    }
300
301    #[test]
302    fn uri_path_encodes_reserved_except_slash() {
303        assert_eq!(for_uri_path("a=b"), "a%3Db");
304        assert_eq!(for_uri_path("a&b"), "a%26b");
305        assert_eq!(for_uri_path("a?b"), "a%3Fb");
306        assert_eq!(for_uri_path("a#b"), "a%23b");
307    }
308
309    #[test]
310    fn uri_path_encodes_space() {
311        assert_eq!(for_uri_path("/a b/c d"), "/a%20b/c%20d");
312    }
313
314    #[test]
315    fn uri_path_encodes_multibyte() {
316        assert_eq!(for_uri_path("/café"), "/caf%C3%A9");
317        assert_eq!(for_uri_path("/世界"), "/%E4%B8%96%E7%95%8C");
318        assert_eq!(for_uri_path("/😀"), "/%F0%9F%98%80");
319    }
320
321    #[test]
322    fn uri_path_writer_variant() {
323        let mut out = String::new();
324        write_uri_path(&mut out, "/a b/c").unwrap();
325        assert_eq!(out, "/a%20b/c");
326    }
327
328    // -- form urlencoded --
329
330    #[test]
331    fn form_no_encoding_needed() {
332        assert_eq!(for_form_urlencoded("hello"), "hello");
333        assert_eq!(for_form_urlencoded(""), "");
334        assert_eq!(for_form_urlencoded("ABCxyz019"), "ABCxyz019");
335        assert_eq!(for_form_urlencoded("-._*"), "-._*");
336    }
337
338    #[test]
339    fn form_space_becomes_plus() {
340        assert_eq!(for_form_urlencoded("a b"), "a+b");
341        assert_eq!(for_form_urlencoded("   "), "+++");
342    }
343
344    #[test]
345    fn form_tilde_encoded() {
346        assert_eq!(for_form_urlencoded("a~b"), "a%7Eb");
347    }
348
349    #[test]
350    fn form_asterisk_safe() {
351        assert_eq!(for_form_urlencoded("a*b"), "a*b");
352    }
353
354    #[test]
355    fn form_encodes_reserved_chars() {
356        assert_eq!(for_form_urlencoded("a=b"), "a%3Db");
357        assert_eq!(for_form_urlencoded("a&b"), "a%26b");
358        assert_eq!(for_form_urlencoded("a+b"), "a%2Bb");
359        assert_eq!(for_form_urlencoded("a?b"), "a%3Fb");
360        assert_eq!(for_form_urlencoded("a#b"), "a%23b");
361        assert_eq!(for_form_urlencoded("a/b"), "a%2Fb");
362    }
363
364    #[test]
365    fn form_encodes_multibyte() {
366        assert_eq!(for_form_urlencoded("é"), "%C3%A9");
367        assert_eq!(for_form_urlencoded("世"), "%E4%B8%96");
368        assert_eq!(for_form_urlencoded("😀"), "%F0%9F%98%80");
369        assert_eq!(for_form_urlencoded("café"), "caf%C3%A9");
370    }
371
372    #[test]
373    fn form_encodes_control_chars() {
374        assert_eq!(for_form_urlencoded("\x00"), "%00");
375        assert_eq!(for_form_urlencoded("\x1F"), "%1F");
376        assert_eq!(for_form_urlencoded("\x7F"), "%7F");
377    }
378
379    #[test]
380    fn form_mixed() {
381        assert_eq!(
382            for_form_urlencoded("key=hello world&foo=bar"),
383            "key%3Dhello+world%26foo%3Dbar"
384        );
385    }
386
387    #[test]
388    fn form_writer_variant() {
389        let mut out = String::new();
390        write_form_urlencoded(&mut out, "a b").unwrap();
391        assert_eq!(out, "a+b");
392    }
393}