Skip to main content

use_percent/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum PercentEncodeSet {
6    UrlComponent,
7    PathSegment,
8    QueryComponent,
9    Fragment,
10}
11
12#[must_use]
13pub fn percent_encode(input: &str) -> String {
14    percent_encode_with_set(input, PercentEncodeSet::UrlComponent)
15}
16
17pub fn percent_decode(input: &str) -> Option<String> {
18    percent_decode_internal(input)
19}
20
21#[must_use]
22pub fn percent_encode_component(input: &str) -> String {
23    percent_encode_with_set(input, PercentEncodeSet::UrlComponent)
24}
25
26pub fn percent_decode_component(input: &str) -> Option<String> {
27    percent_decode_internal(input)
28}
29
30#[must_use]
31pub fn is_percent_encoded(input: &str) -> bool {
32    contains_percent_encoded(input) && !has_invalid_percent_encoding(input)
33}
34
35#[must_use]
36pub fn contains_percent_encoded(input: &str) -> bool {
37    let bytes = input.as_bytes();
38    let mut index = 0;
39
40    while index + 2 < bytes.len() {
41        if bytes[index] == b'%' && is_hex_byte(bytes[index + 1]) && is_hex_byte(bytes[index + 2]) {
42            return true;
43        }
44
45        index += 1;
46    }
47
48    false
49}
50
51#[must_use]
52pub fn has_invalid_percent_encoding(input: &str) -> bool {
53    let bytes = input.as_bytes();
54    let mut index = 0;
55
56    while index < bytes.len() {
57        if bytes[index] == b'%' {
58            if index + 2 >= bytes.len()
59                || !is_hex_byte(bytes[index + 1])
60                || !is_hex_byte(bytes[index + 2])
61            {
62                return true;
63            }
64
65            index += 3;
66            continue;
67        }
68
69        index += 1;
70    }
71
72    false
73}
74
75fn percent_encode_with_set(input: &str, set: PercentEncodeSet) -> String {
76    let mut output = String::with_capacity(input.len());
77
78    for byte in input.bytes() {
79        if should_encode(byte, set) {
80            output.push('%');
81            output.push(HEX[(byte >> 4) as usize] as char);
82            output.push(HEX[(byte & 0x0f) as usize] as char);
83        } else {
84            output.push(byte as char);
85        }
86    }
87
88    output
89}
90
91fn percent_decode_internal(input: &str) -> Option<String> {
92    let bytes = input.as_bytes();
93    let mut decoded = Vec::with_capacity(bytes.len());
94    let mut index = 0;
95
96    while index < bytes.len() {
97        if bytes[index] == b'%' {
98            if index + 2 >= bytes.len() {
99                return None;
100            }
101
102            let value = decode_hex_pair(bytes[index + 1], bytes[index + 2])?;
103            decoded.push(value);
104            index += 3;
105            continue;
106        }
107
108        decoded.push(bytes[index]);
109        index += 1;
110    }
111
112    String::from_utf8(decoded).ok()
113}
114
115fn should_encode(byte: u8, set: PercentEncodeSet) -> bool {
116    if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
117        return false;
118    }
119
120    match set {
121        PercentEncodeSet::Fragment => matches!(byte, b' ' | b'"' | b'<' | b'>' | b'`'),
122        PercentEncodeSet::PathSegment => true,
123        PercentEncodeSet::QueryComponent => true,
124        PercentEncodeSet::UrlComponent => true,
125    }
126}
127
128fn decode_hex_pair(high: u8, low: u8) -> Option<u8> {
129    Some((decode_hex_nibble(high)? << 4) | decode_hex_nibble(low)?)
130}
131
132fn decode_hex_nibble(byte: u8) -> Option<u8> {
133    match byte {
134        b'0'..=b'9' => Some(byte - b'0'),
135        b'a'..=b'f' => Some(byte - b'a' + 10),
136        b'A'..=b'F' => Some(byte - b'A' + 10),
137        _ => None,
138    }
139}
140
141fn is_hex_byte(byte: u8) -> bool {
142    decode_hex_nibble(byte).is_some()
143}
144
145const HEX: &[u8; 16] = b"0123456789ABCDEF";