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