Skip to main content

use_escape/
lib.rs

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('<', "&lt;")
18        .replace('>', "&gt;")
19        .replace('"', "&quot;")
20        .replace('\'', "&#39;")
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('&', "&amp;")
32        .replace('<', "&lt;")
33        .replace('>', "&gt;")
34        .replace('"', "&quot;")
35        .replace('\'', "&apos;")
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}