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";