1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum EscapeKind {
6 Html,
7 Xml,
8 Json,
9 Shell,
10 Csv,
11}
12
13#[must_use]
14pub fn escape_html(input: &str) -> String {
15 input
16 .replace('&', "&")
17 .replace('<', "<")
18 .replace('>', ">")
19 .replace('"', """)
20 .replace('\'', "'")
21}
22
23#[must_use]
24pub fn unescape_html(input: &str) -> String {
25 unescape_markup(input)
26}
27
28#[must_use]
29pub fn escape_xml(input: &str) -> String {
30 input
31 .replace('&', "&")
32 .replace('<', "<")
33 .replace('>', ">")
34 .replace('"', """)
35 .replace('\'', "'")
36}
37
38#[must_use]
39pub fn unescape_xml(input: &str) -> String {
40 unescape_markup(input)
41}
42
43#[must_use]
44pub fn escape_json_string(input: &str) -> String {
45 let mut output = String::with_capacity(input.len());
46
47 for character in input.chars() {
48 match character {
49 '"' => output.push_str("\\\""),
50 '\\' => output.push_str("\\\\"),
51 '\n' => output.push_str("\\n"),
52 '\r' => output.push_str("\\r"),
53 '\t' => output.push_str("\\t"),
54 '\u{08}' => output.push_str("\\b"),
55 '\u{0c}' => output.push_str("\\f"),
56 control if control <= '\u{1f}' => {
57 output.push_str("\\u");
58 output.push_str(&format!("{:04X}", control as u32));
59 },
60 other => output.push(other),
61 }
62 }
63
64 output
65}
66
67pub fn unescape_json_string(input: &str) -> Option<String> {
68 let mut output = String::new();
69 let mut chars = input.chars();
70
71 while let Some(character) = chars.next() {
72 if character != '\\' {
73 output.push(character);
74 continue;
75 }
76
77 match chars.next()? {
78 '\\' => output.push('\\'),
79 '"' => output.push('"'),
80 '/' => output.push('/'),
81 'b' => output.push('\u{08}'),
82 'f' => output.push('\u{0c}'),
83 'n' => output.push('\n'),
84 'r' => output.push('\r'),
85 't' => output.push('\t'),
86 'u' => output.push(decode_json_codepoint(&mut chars)?),
87 _ => return None,
88 }
89 }
90
91 Some(output)
92}
93
94#[must_use]
95pub fn escape_csv_field(input: &str) -> String {
96 if !needs_csv_escape(input) {
97 return input.to_string();
98 }
99
100 format!("\"{}\"", input.replace('"', "\"\""))
101}
102
103#[must_use]
104pub fn escape_shell_basic(input: &str) -> String {
105 if input.is_empty() {
106 return "''".to_string();
107 }
108
109 format!("'{}'", input.replace('\'', "'\"'\"'"))
110}
111
112#[must_use]
113pub fn needs_html_escape(input: &str) -> bool {
114 input
115 .chars()
116 .any(|c| matches!(c, '&' | '<' | '>' | '"' | '\''))
117}
118
119#[must_use]
120pub fn needs_json_escape(input: &str) -> bool {
121 input
122 .chars()
123 .any(|c| matches!(c, '\\' | '"' | '\n' | '\r' | '\t'))
124}
125
126#[must_use]
127pub fn needs_csv_escape(input: &str) -> bool {
128 input.chars().any(|c| matches!(c, ',' | '"' | '\n' | '\r'))
129}
130
131fn unescape_markup(input: &str) -> String {
132 let mut output = String::with_capacity(input.len());
133 let mut index = 0;
134
135 while index < input.len() {
136 if input.as_bytes()[index] != b'&' {
137 let Some(character) = input[index..].chars().next() else {
138 break;
139 };
140 output.push(character);
141 index += character.len_utf8();
142 continue;
143 }
144
145 let rest = &input[index + 1..];
146 if let Some(entity_end) = rest.find(';') {
147 let entity = &rest[..entity_end];
148 if let Some(decoded) = decode_entity(entity) {
149 output.push(decoded);
150 index += entity_end + 2;
151 continue;
152 }
153 }
154
155 output.push('&');
156 index += 1;
157 }
158
159 output
160}
161
162fn decode_entity(entity: &str) -> Option<char> {
163 match entity {
164 "amp" => Some('&'),
165 "lt" => Some('<'),
166 "gt" => Some('>'),
167 "quot" => Some('"'),
168 "apos" => Some('\''),
169 _ => {
170 if let Some(hexadecimal) = entity
171 .strip_prefix("#x")
172 .or_else(|| entity.strip_prefix("#X"))
173 {
174 u32::from_str_radix(hexadecimal, 16)
175 .ok()
176 .and_then(char::from_u32)
177 } else if let Some(decimal) = entity.strip_prefix('#') {
178 decimal.parse::<u32>().ok().and_then(char::from_u32)
179 } else {
180 None
181 }
182 },
183 }
184}
185
186fn decode_json_codepoint(chars: &mut std::str::Chars<'_>) -> Option<char> {
187 let high = parse_json_hex_quad(chars)?;
188
189 if (0xD800..=0xDBFF).contains(&high) {
190 if chars.next()? != '\\' || chars.next()? != 'u' {
191 return None;
192 }
193
194 let low = parse_json_hex_quad(chars)?;
195 if !(0xDC00..=0xDFFF).contains(&low) {
196 return None;
197 }
198
199 let codepoint = 0x10000 + (((high - 0xD800) as u32) << 10) + u32::from(low - 0xDC00);
200 return char::from_u32(codepoint);
201 }
202
203 if (0xDC00..=0xDFFF).contains(&high) {
204 return None;
205 }
206
207 char::from_u32(u32::from(high))
208}
209
210fn parse_json_hex_quad(chars: &mut std::str::Chars<'_>) -> Option<u16> {
211 let mut value = 0_u16;
212
213 for _ in 0..4 {
214 value = (value << 4) | u16::from(chars.next()?.to_digit(16)? as u8);
215 }
216
217 Some(value)
218}