Skip to content

Commit e444b76

Browse files
committed
unix: Relax escaping in Debug impl on Command
The Debug output of Command is very useful for showing to the user the command that was executed when something went wrong. This is done for example by rustc when invoking an external tool like the linker fails. It is also overly verbose, since everything is quoted, which makes it harder to read. Instead, we now first check if we're reasonably sure that an argument is simple enough that using it in the shell wouldn't need quoting, and then output it without quotes if possible. Before and example output could look like this: PATH="/a:/b" "cc" "foo.o" "-target" "arm64-apple-darwin11.0" Now it looks like this: PATH=/a:/b cc foo.o -target arm64-apple-darwin11.0
1 parent fbcdd72 commit e444b76

File tree

7 files changed

+101
-28
lines changed

7 files changed

+101
-28
lines changed

library/std/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@
289289
#![feature(cfi_encoding)]
290290
#![feature(concat_idents)]
291291
#![feature(const_float_methods)]
292+
#![feature(debug_closure_helpers)]
292293
#![feature(decl_macro)]
293294
#![feature(deprecated_suggestion)]
294295
#![feature(doc_cfg)]

library/std/src/process/tests.rs

+6-12
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,7 @@ fn debug_print() {
552552

553553
let mut command = Command::new("some-boring-name");
554554

555-
assert_eq!(format!("{command:?}"), format!(r#""some-boring-name""#));
555+
assert_eq!(format!("{command:?}"), format!("some-boring-name"));
556556

557557
assert_eq!(
558558
format!("{command:#?}"),
@@ -568,7 +568,7 @@ fn debug_print() {
568568

569569
command.args(&["1", "2", "3"]);
570570

571-
assert_eq!(format!("{command:?}"), format!(r#""some-boring-name" "1" "2" "3""#));
571+
assert_eq!(format!("{command:?}"), format!("some-boring-name 1 2 3"));
572572

573573
assert_eq!(
574574
format!("{command:#?}"),
@@ -587,10 +587,7 @@ fn debug_print() {
587587

588588
crate::os::unix::process::CommandExt::arg0(&mut command, "exciting-name");
589589

590-
assert_eq!(
591-
format!("{command:?}"),
592-
format!(r#"["some-boring-name"] "exciting-name" "1" "2" "3""#)
593-
);
590+
assert_eq!(format!("{command:?}"), format!("[some-boring-name] exciting-name 1 2 3"));
594591

595592
assert_eq!(
596593
format!("{command:#?}"),
@@ -609,10 +606,7 @@ fn debug_print() {
609606

610607
let mut command_with_env_and_cwd = Command::new("boring-name");
611608
command_with_env_and_cwd.current_dir("/some/path").env("FOO", "bar");
612-
assert_eq!(
613-
format!("{command_with_env_and_cwd:?}"),
614-
r#"cd "/some/path" && FOO="bar" "boring-name""#
615-
);
609+
assert_eq!(format!("{command_with_env_and_cwd:?}"), "cd /some/path && FOO=bar boring-name");
616610
assert_eq!(
617611
format!("{command_with_env_and_cwd:#?}"),
618612
format!(
@@ -638,7 +632,7 @@ fn debug_print() {
638632

639633
let mut command_with_removed_env = Command::new("boring-name");
640634
command_with_removed_env.env_remove("FOO").env_remove("BAR");
641-
assert_eq!(format!("{command_with_removed_env:?}"), r#"env -u BAR -u FOO "boring-name""#);
635+
assert_eq!(format!("{command_with_removed_env:?}"), "env -u BAR -u FOO boring-name");
642636
assert_eq!(
643637
format!("{command_with_removed_env:#?}"),
644638
format!(
@@ -660,7 +654,7 @@ fn debug_print() {
660654

661655
let mut command_with_cleared_env = Command::new("boring-name");
662656
command_with_cleared_env.env_clear().env("BAR", "val").env_remove("FOO");
663-
assert_eq!(format!("{command_with_cleared_env:?}"), r#"env -i BAR="val" "boring-name""#);
657+
assert_eq!(format!("{command_with_cleared_env:?}"), "env -i BAR=val boring-name");
664658
assert_eq!(
665659
format!("{command_with_cleared_env:#?}"),
666660
format!(

library/std/src/sys/pal/unix/process/process_common.rs

+49-6
Original file line numberDiff line numberDiff line change
@@ -580,8 +580,45 @@ impl fmt::Debug for Command {
580580

581581
debug_command.finish()
582582
} else {
583+
/// Only escape arguments when necessary, otherwise output it
584+
/// unescaped to make the output easier to read.
585+
fn relaxed_escaping(bytes: &[u8], is_arg: bool) -> impl fmt::Display + use<'_> {
586+
// Don't escape if all the characters are likely
587+
// to not be a problem when copied to the shell.
588+
let can_print_safely = move |&c| {
589+
// See: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
590+
match c {
591+
// The documentation linked above says that `=`:
592+
// > may need to be quoted under certain circumstances
593+
//
594+
// Because they may be parsed as a variable assignment.
595+
// But in argument position to a command, they
596+
// shouldn't need escaping.
597+
b'=' if is_arg => true,
598+
b'|' | b'&' | b';' | b'<' | b'>' | b'(' | b')' | b'$' | b'`' | b'\\'
599+
| b'"' | b'\'' | b' ' | b'\t' | b'\n' => false,
600+
b'*' | b'?' | b'[' | b'#' | b'~' | b'=' | b'%' => false,
601+
// `[` and `]` may be interpreted as arrays in non-POSIX shells.
602+
b'[' | b']' => false,
603+
// Assume all other printable characthers may be output.
604+
0x20..0x7e => true,
605+
// Unprintable or not ASCII.
606+
_ => false,
607+
}
608+
};
609+
fmt::from_fn(move |f| {
610+
if !bytes.is_empty() && bytes.iter().all(can_print_safely) {
611+
// Unwrap is fine, we've checked above that the bytes only contains ascii.
612+
let ascii = crate::str::from_utf8(bytes).unwrap();
613+
write!(f, "{ascii}")
614+
} else {
615+
write!(f, "\"{}\"", bytes.escape_ascii())
616+
}
617+
})
618+
}
619+
583620
if let Some(ref cwd) = self.cwd {
584-
write!(f, "cd {cwd:?} && ")?;
621+
write!(f, "cd {} && ", relaxed_escaping(cwd.to_bytes(), true))?;
585622
}
586623
if self.env.does_clear() {
587624
write!(f, "env -i ")?;
@@ -595,23 +632,29 @@ impl fmt::Debug for Command {
595632
write!(f, "env ")?;
596633
any_removed = true;
597634
}
598-
write!(f, "-u {} ", key.to_string_lossy())?;
635+
write!(f, "-u {} ", relaxed_escaping(key.as_encoded_bytes(), false))?;
599636
}
600637
}
601638
}
602639
// Altered env vars can just be added in front of the program.
603640
for (key, value_opt) in self.get_envs() {
604641
if let Some(value) = value_opt {
605-
write!(f, "{}={value:?} ", key.to_string_lossy())?;
642+
write!(
643+
f,
644+
"{}={} ",
645+
relaxed_escaping(key.as_encoded_bytes(), false),
646+
relaxed_escaping(value.as_encoded_bytes(), false)
647+
)?;
606648
}
607649
}
608650
if self.program != self.args[0] {
609-
write!(f, "[{:?}] ", self.program)?;
651+
write!(f, "[{}] ", relaxed_escaping(self.program.to_bytes(), false))?;
610652
}
611-
write!(f, "{:?}", self.args[0])?;
653+
654+
write!(f, "{}", relaxed_escaping(self.args[0].to_bytes(), false))?;
612655

613656
for arg in &self.args[1..] {
614-
write!(f, " {:?}", arg)?;
657+
write!(f, " {}", relaxed_escaping(arg.to_bytes(), true))?;
615658
}
616659
Ok(())
617660
}

library/std/src/sys/pal/unix/process/process_common/tests.rs

+33
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,36 @@ fn unix_exit_statuses() {
190190
}
191191
}
192192
}
193+
194+
#[test]
195+
fn command_debug_escaping() {
196+
#[track_caller]
197+
fn check(arg: impl AsRef<OsStr>, expected: &str) {
198+
let cmd = Command::new(arg.as_ref());
199+
assert_eq!(format!("{cmd:?}"), expected);
200+
}
201+
202+
// Escaped.
203+
check("", r#""""#);
204+
check("é", r#""\xc3\xa9""#);
205+
check("a'", r#""a\'""#);
206+
check("a\"", r#""a\"""#);
207+
check(" ", r#"" ""#);
208+
check(
209+
"/Users/The user's name/.cargo/bin/cargo",
210+
r#""/Users/The user\'s name/.cargo/bin/cargo""#,
211+
);
212+
213+
// Simple enough that we don't escape them.
214+
check("a", "a");
215+
check("/my/simple/path", "/my/simple/path");
216+
check("abc-defg_1234", "abc-defg_1234");
217+
218+
// Real-world use-case, immediately clear that two of the arguments are passed as one.
219+
let mut cmd = crate::process::Command::new("ld");
220+
cmd.arg("my/file.o");
221+
cmd.arg("-macos_version_min");
222+
cmd.arg("13.0");
223+
cmd.arg("-arch arm64");
224+
assert_eq!(format!("{cmd:?}"), r#"ld my/file.o -macos_version_min 13.0 "-arch arm64""#);
225+
}

tests/run-make/link-args-order/rmake.rs

+8-6
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@ fn main() {
1313
.linker_flavor(linker)
1414
.link_arg("a")
1515
.link_args("b c")
16-
.link_args("d e")
17-
.link_arg("f")
16+
.link_arg("d e")
17+
.link_args("f g")
18+
.link_arg("h")
1819
.run_fail()
19-
.assert_stderr_contains(r#""a" "b" "c" "d" "e" "f""#);
20+
.assert_stderr_contains(r#"a b c "d e" f g h"#);
2021
rustc()
2122
.input("empty.rs")
2223
.linker_flavor(linker)
2324
.arg("-Zpre-link-arg=a")
2425
.arg("-Zpre-link-args=b c")
25-
.arg("-Zpre-link-args=d e")
26-
.arg("-Zpre-link-arg=f")
26+
.arg("-Zpre-link-arg=d e")
27+
.arg("-Zpre-link-args=f g")
28+
.arg("-Zpre-link-arg=h")
2729
.run_fail()
28-
.assert_stderr_contains(r#""a" "b" "c" "d" "e" "f""#);
30+
.assert_stderr_contains(r#"a b c "d e" f g h"#);
2931
}

tests/run-make/link-dedup/rmake.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ fn needle_from_libs(libs: &[&str]) -> String {
3131
let mut needle = String::new();
3232
for lib in libs {
3333
if is_msvc() {
34-
let _ = needle.write_fmt(format_args!(r#""{lib}.lib" "#));
34+
let _ = needle.write_fmt(format_args!("{lib}.lib "));
3535
} else {
36-
let _ = needle.write_fmt(format_args!(r#""-l{lib}" "#));
36+
let _ = needle.write_fmt(format_args!("-l{lib} "));
3737
}
3838
}
3939
needle.pop(); // remove trailing space

tests/run-make/pass-linker-flags-flavor/rmake.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ fn main() {
7272
.stdout_utf8();
7373

7474
let no_verbatim = regex::Regex::new("l1.*-Wl,a1.*l2.*-Wl,a2.*d1.*-Wl,a3").unwrap();
75-
let one_verbatim = regex::Regex::new(r#"l1.*"a1".*l2.*-Wl,a2.*d1.*-Wl,a3"#).unwrap();
76-
let ld = regex::Regex::new(r#"l1.*"a1".*l2.*"a2".*d1.*"a3""#).unwrap();
75+
let one_verbatim = regex::Regex::new("l1.*a1.*l2.*-Wl,a2.*d1.*-Wl,a3").unwrap();
76+
let ld = regex::Regex::new("l1.*a1.*l2.*a2.*d1.*a3").unwrap();
7777

7878
assert!(no_verbatim.is_match(&out_gnu));
7979
assert!(no_verbatim.is_match(&out_att_gnu));

0 commit comments

Comments
 (0)