1const PRETTY_SAFE_PUNCT: &[u8] = b"+,-./:=@_^";
9
10const UPPER_HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
11
12#[inline]
13fn needs_bs_quote(byte: u8) -> bool {
14 byte == b'\'' || byte == b'!'
15}
16
17pub fn sq_quote_buf(out: &mut Vec<u8>, arg: &[u8]) {
21 out.push(b'\'');
22 for &byte in arg {
23 if needs_bs_quote(byte) {
24 out.extend_from_slice(b"'\\");
25 out.push(byte);
26 out.push(b'\'');
27 } else {
28 out.push(byte);
29 }
30 }
31 out.push(b'\'');
32}
33
34pub fn sq_quote_buf_pretty(out: &mut Vec<u8>, arg: &[u8]) {
38 if !arg.is_empty()
39 && arg
40 .iter()
41 .all(|&byte| byte.is_ascii_alphanumeric() || PRETTY_SAFE_PUNCT.contains(&byte))
42 {
43 out.extend_from_slice(arg);
44 return;
45 }
46 sq_quote_buf(out, arg);
47}
48
49pub fn sq_quote(arg: &str) -> String {
51 let mut out = String::with_capacity(arg.len() + 2);
52 out.push('\'');
53 for ch in arg.chars() {
54 if ch == '\'' || ch == '!' {
55 out.push_str("'\\");
56 out.push(ch);
57 out.push('\'');
58 } else {
59 out.push(ch);
60 }
61 }
62 out.push('\'');
63 out
64}
65
66pub fn sq_quote_pretty(arg: &str) -> String {
68 if !arg.is_empty()
69 && arg.bytes().all(
70 |byte| byte.is_ascii_alphanumeric() || PRETTY_SAFE_PUNCT.contains(&byte),
71 )
72 {
73 return arg.to_string();
74 }
75 sq_quote(arg)
76}
77
78pub fn sq_quote_argv(args: &[String]) -> String {
81 let mut out = String::new();
82 for arg in args {
83 out.push(' ');
84 out.push_str(&sq_quote(arg));
85 }
86 out
87}
88
89pub fn sq_quote_argv_pretty(args: &[String]) -> String {
93 let mut out = String::new();
94 for arg in args {
95 if !out.is_empty() {
96 out.push(' ');
97 }
98 out.push_str(&sq_quote_pretty(arg));
99 }
100 out
101}
102
103pub fn hex_byte(out: &mut String, byte: u8) {
105 out.push(UPPER_HEX_DIGITS[(byte >> 4) as usize] as char);
106 out.push(UPPER_HEX_DIGITS[(byte & 0x0F) as usize] as char);
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum PercentEncodeMode {
113 Field,
115 OptionalField,
117}
118
119impl PercentEncodeMode {
120 #[inline]
121 fn allows(self, byte: u8) -> bool {
122 match self {
123 Self::Field => {
124 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'~' | b'/' | b':' | b'-')
125 }
126 Self::OptionalField => Self::Field.allows(byte) || byte == b'=',
127 }
128 }
129}
130
131pub fn percent_encode(out: &mut String, value: &[u8], mode: PercentEncodeMode) {
134 for &byte in value {
135 if mode.allows(byte) {
136 out.push(byte as char);
137 } else {
138 out.push('%');
139 hex_byte(out, byte);
140 }
141 }
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum PercentDecodeError {
147 TruncatedEscape,
149 InvalidHexDigit(u8),
151}
152
153fn hex_value(byte: u8) -> Option<u8> {
154 match byte {
155 b'0'..=b'9' => Some(byte - b'0'),
156 b'a'..=b'f' => Some(byte - b'a' + 10),
157 b'A'..=b'F' => Some(byte - b'A' + 10),
158 _ => None,
159 }
160}
161
162pub fn percent_decode(value: &[u8]) -> Result<Vec<u8>, PercentDecodeError> {
166 let mut out = Vec::with_capacity(value.len());
167 let mut index = 0;
168 while index < value.len() {
169 if value[index] != b'%' {
170 out.push(value[index]);
171 index += 1;
172 continue;
173 }
174 let Some(&high_digit) = value.get(index + 1) else {
175 return Err(PercentDecodeError::TruncatedEscape);
176 };
177 let Some(&low_digit) = value.get(index + 2) else {
178 return Err(PercentDecodeError::TruncatedEscape);
179 };
180 let high = hex_value(high_digit).ok_or(PercentDecodeError::InvalidHexDigit(high_digit))?;
181 let low = hex_value(low_digit).ok_or(PercentDecodeError::InvalidHexDigit(low_digit))?;
182 out.push((high << 4) | low);
183 index += 3;
184 }
185 Ok(out)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn empty_argument_renders_as_two_quotes() {
194 assert_eq!(sq_quote(""), "''");
195 assert_eq!(sq_quote_pretty(""), "''");
196 let mut buf = Vec::new();
197 sq_quote_buf(&mut buf, b"");
198 assert_eq!(buf, b"''");
199 sq_quote_buf_pretty(&mut buf, b"");
200 assert_eq!(buf, b"''''");
201 }
202
203 #[test]
204 fn sq_quote_always_wraps_and_escapes_quote_and_bang() {
205 assert_eq!(sq_quote("plain"), "'plain'");
206 assert_eq!(sq_quote("v!1"), "'v'\\!'1'");
207 assert_eq!(sq_quote("it's"), "'it'\\''s'");
208 assert_eq!(sq_quote("a'b!c"), "'a'\\''b'\\!'c'");
209 assert_eq!(sq_quote("back\\slash"), "'back\\slash'");
210 assert_eq!(sq_quote("café"), "'café'");
212 }
213
214 #[test]
215 fn pretty_leaves_safe_sets_bare() {
216 assert_eq!(sq_quote_pretty("plain"), "plain");
217 assert_eq!(sq_quote_pretty("aBc123"), "aBc123");
218 for punct in "+,-./:=@_^".chars() {
219 let token = punct.to_string();
220 assert_eq!(sq_quote_pretty(&token), token, "punct {punct} should stay bare");
221 }
222 }
223
224 #[test]
225 fn pretty_falls_back_to_full_quoting_for_unsafe_bytes() {
226 assert_eq!(sq_quote_pretty("a b"), "'a b'");
227 assert_eq!(sq_quote_pretty("v!1"), "'v'\\!'1'");
228 assert_eq!(sq_quote_pretty("it's"), "'it'\\''s'");
229 assert_eq!(sq_quote_pretty("tab\there"), "'tab\there'");
230 assert_eq!(sq_quote_pretty("safe!bang"), "'safe'\\!'bang'");
232 assert_eq!(sq_quote_pretty("café"), "'café'");
234 }
235
236 #[test]
237 fn buf_and_str_variants_agree() {
238 let to_string = |bytes: Vec<u8>| String::from_utf8(bytes).ok();
239 for arg in [
240 "", "plain", "v!1", "it's", "a b", "+,-./:=@_^", "café", "back\\slash",
241 ] {
242 let mut bytes = Vec::new();
243 sq_quote_buf_pretty(&mut bytes, arg.as_bytes());
244 assert_eq!(
245 to_string(bytes).as_deref(),
246 Some(sq_quote_pretty(arg).as_str()),
247 "pretty mismatch for {arg:?}"
248 );
249 let mut bytes = Vec::new();
250 sq_quote_buf(&mut bytes, arg.as_bytes());
251 assert_eq!(
252 to_string(bytes).as_deref(),
253 Some(sq_quote(arg).as_str()),
254 "buf mismatch for {arg:?}"
255 );
256 }
257 }
258
259 #[test]
260 fn argv_helpers_space_prefix_each_argument() {
261 assert_eq!(sq_quote_argv(&["git".into(), "log --oneline".into()]), " 'git' 'log --oneline'");
262 assert_eq!(
263 sq_quote_argv_pretty(&["git".into(), "log".into(), "v!1".into(), "".into()]),
264 "git log 'v'\\!'1' ''"
265 );
266 assert_eq!(sq_quote_argv(&[]), "");
267 assert_eq!(sq_quote_argv_pretty(&[]), "");
268 }
269
270 #[cfg(unix)]
271 #[test]
272 fn quoted_words_survive_sh_eval_round_trip() {
273 use std::process::Command;
274
275 for value in ["plain", "v!1", "it's", "a b", "back\\slash", "$HOME", "`id`"] {
279 let quoted = sq_quote(value);
280 let script = format!("printf %s {quoted}");
281 let output = Command::new("sh")
282 .arg("-c")
283 .arg(&script)
284 .output()
285 .expect("spawn sh");
286 assert!(output.status.success(), "sh failed for {value:?}");
287 assert_eq!(
288 String::from_utf8_lossy(&output.stdout),
289 value,
290 "round-trip failed for {value:?}"
291 );
292 }
293 }
294
295 #[test]
296 fn hex_byte_formats_uppercase_pairs() {
297 let mut out = String::new();
298 hex_byte(&mut out, 0x00);
299 hex_byte(&mut out, 0x0A);
300 hex_byte(&mut out, 0xF0);
301 hex_byte(&mut out, 0xFF);
302 assert_eq!(out, "000AF0FF");
303 }
304
305 #[test]
306 fn percent_encode_field_mode_table() {
307 let mut out = String::new();
308 percent_encode(&mut out, b"aZ0_.~/:-x", PercentEncodeMode::Field);
309 assert_eq!(out, "aZ0_.~/:-x");
310
311 out.clear();
312 percent_encode(&mut out, b" ", PercentEncodeMode::Field);
313 assert_eq!(out, "%20");
314
315 out.clear();
316 percent_encode(&mut out, b"=", PercentEncodeMode::Field);
317 assert_eq!(out, "%3D");
318
319 out.clear();
320 percent_encode(&mut out, b"=", PercentEncodeMode::OptionalField);
321 assert_eq!(out, "=");
322
323 out.clear();
324 percent_encode(&mut out, b"\xff\x01", PercentEncodeMode::Field);
325 assert_eq!(out, "%FF%01");
326
327 out.clear();
328 percent_encode(&mut out, b"a=b c/d", PercentEncodeMode::OptionalField);
329 assert_eq!(out, "a=b%20c/d");
330 }
331
332 #[test]
333 fn percent_decode_is_strict_and_case_insensitive() {
334 assert_eq!(
335 percent_decode(b"plain").as_deref(),
336 Ok(b"plain".as_slice())
337 );
338 assert_eq!(percent_decode(b"%41%62").as_deref(), Ok(b"Ab".as_slice()));
339 assert_eq!(percent_decode(b"a%20b").as_deref(), Ok(b"a b".as_slice()));
340 assert_eq!(
341 percent_decode(b"ab%"),
342 Err(PercentDecodeError::TruncatedEscape)
343 );
344 assert_eq!(
345 percent_decode(b"ab%A"),
346 Err(PercentDecodeError::TruncatedEscape)
347 );
348 assert_eq!(
349 percent_decode(b"ab%G1"),
350 Err(PercentDecodeError::InvalidHexDigit(b'G'))
351 );
352 assert_eq!(
353 percent_decode(b"ab%1G"),
354 Err(PercentDecodeError::InvalidHexDigit(b'G'))
355 );
356 for value in [b"".as_slice(), b"hello world", b"\x00\xff%25"] {
358 let mut encoded = String::new();
359 percent_encode(&mut encoded, value, PercentEncodeMode::Field);
360 assert_eq!(percent_decode(encoded.as_bytes()).as_deref(), Ok(value));
361 }
362 }
363
364 #[cfg(unix)]
365 #[test]
366 fn trace2_start_line_matches_oracle_layout() {
367 assert_eq!(
371 sq_quote_argv_pretty(&[
372 "git".to_string(),
373 "log".to_string(),
374 "--oneline".to_string(),
375 "-1".to_string(),
376 "v!1".to_string(),
377 ]),
378 "git log --oneline -1 'v'\\!'1'"
379 );
380 assert_eq!(
383 sq_quote_argv_pretty(&[
384 "git".to_string(),
385 "log".to_string(),
386 "-1".to_string(),
387 "".to_string(),
388 ]),
389 "git log -1 ''"
390 );
391 }
392}