command_unquoted/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2024 Oxide Computer Company
6
7//! command-unquoted provides [a wrapper struct][Unquoted] for
8//! [`std::process::Command`] that provides a nicer-looking [`Debug`]
9//! implementation and is useful for logs or user-facing error messages.
10//!
11//! Instead of quoting all strings (as done in the Unix `Command`
12//! implementation), quotes are added only where necessary.
13//!
14//! As with `Command`'s `Debug` implementation, this format only approximates an
15//! appropriate shell invocation of the program with the provided environment.
16//! It may be particularly unsuitable for Windows (patches welcome). Non-UTF-8
17//! data is lossily converted using the UTF-8 replacement character. This format
18//! **is not stable** and may change between releases; only the API of this
19//! crate is stable.
20//!
21//! To keep the resulting output friendlier (and sometimes due to Rust standard
22//! library limitations), the result of these methods are not displayed in this
23//! implementation:
24//! - [`Command::current_dir`]
25//! - [`Command::env_clear`] and [`Command::env_remove`]
26//! - [`Command::stdin`], [`Command::stdout`], and [`Command::stderr`]
27//! - all methods of all `CommandExt` traits
28
29#![warn(clippy::pedantic)]
30
31use std::ffi::OsStr;
32use std::fmt::{self, Debug, Display};
33use std::process::Command;
34
35const RESERVED_COMMAND_WORDS: &[&str] = &[
36    "case", "do", "done", "elif", "else", "esac", "fi", // POSIX-1.2018
37    "for", "function", "if", "in", "select", "then", // POSIX-1.2018
38    "time", // Bash
39    "until", "while", // POSIX-1.2018
40];
41
42/// A wrapper for [`std::process::Command`] with a nicer-looking [`Debug`]
43/// implementation.
44///
45/// See [the crate-level documentation][crate] for more details.
46pub struct Unquoted<'a>(pub &'a Command);
47
48impl Debug for Unquoted<'_> {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "`")?;
51        for (name, value_opt) in self.0.get_envs() {
52            if let Some(value) = value_opt {
53                write!(f, "{}={} ", Word(name), Word(value))?;
54            }
55        }
56
57        let program = self.0.get_program();
58        if let Some(s) = program
59            .to_str()
60            .filter(|s| RESERVED_COMMAND_WORDS.binary_search(s).is_ok())
61        {
62            write!(f, "'{}'", s)?;
63        } else {
64            write!(f, "{}", Word(program))?;
65        }
66
67        for arg in self.0.get_args() {
68            write!(f, " {}", Word(arg))?;
69        }
70        write!(f, "`")
71    }
72}
73
74struct Word<'a>(&'a OsStr);
75
76impl Display for Word<'_> {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        // `OsStr::is_empty` calls `to_string_lossy`, so get this out of the way
79        // at the start.
80        let s = self.0.to_string_lossy();
81
82        if s.is_empty() {
83            return write!(f, "''");
84        }
85
86        let has_single_quote = s.contains('\'');
87        let has_special_within_double = s.contains(
88            [
89                '$', '`', '\\', '"', // POSIX-1.2018
90                '@', // Special within Bash double quotes per docs (unsure why); also extglob
91                '!', // Bash history expansion
92            ]
93            .as_slice(),
94        );
95        let has_special = has_single_quote
96            || has_special_within_double
97            || s.contains(char::is_whitespace)
98            || s.contains(char::is_control)
99            || s.contains(
100                [
101                    '|', '&', ';', '<', '>', '(', ')', ' ', '\t', '\n', // POSIX-1.2018
102                    '*', '?', '[', '#', '~', '%', // POSIX-1.2018
103                    // Technically '=' is in the above list of "may need
104                    // to be quoted under certain circumstances" but those
105                    // circumstances are generally variable assignments or are
106                    // otherwise covered by other characters here.
107                    ']', // Bash glob patterns
108                    '{', '}', // Bash brace expansion
109                ]
110                .as_slice(),
111            );
112
113        if has_single_quote && !has_special_within_double {
114            // Use double quotes
115            write!(f, r#""{}""#, s)
116        } else if has_special {
117            // Use single quotes
118            if has_single_quote {
119                write!(f, "'")?;
120                for c in s.chars() {
121                    if c == '\'' {
122                        write!(f, "'\\''")?;
123                    } else {
124                        write!(f, "{}", c)?;
125                    }
126                }
127                write!(f, "'")
128            } else {
129                write!(f, "'{}'", s)
130            }
131        } else {
132            // Use no quotes
133            write!(f, "{}", s)
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use std::{ffi::OsStr, process::Command};
141
142    use crate::{Unquoted, Word, RESERVED_COMMAND_WORDS};
143
144    #[test]
145    fn command_words_sorted() {
146        assert!(RESERVED_COMMAND_WORDS.windows(2).all(|s| s[0] < s[1]));
147    }
148
149    macro_rules! assert_q {
150        ($left:expr, $right:expr) => {
151            assert_eq!(Word(OsStr::new($left)).to_string(), $right)
152        };
153    }
154
155    #[test]
156    fn quoted() {
157        assert_q!("", r"''");
158        assert_q!("meow", r"meow");
159        assert_q!("にゃー", "にゃー");
160        assert_q!("meow", "meow");
161
162        // whitespace
163        assert_q!("meow meow", "'meow meow'");
164        assert_q!("meow\tmeow", "'meow\tmeow'");
165        assert_q!("meow\nmeow", "'meow\nmeow'");
166        assert_q!("meow meow", "'meow meow'");
167
168        // special characters, generally: use single quotes
169        assert_q!("|meow", "'|meow'");
170        assert_q!("meow&", "'meow&'");
171        assert_q!("meow;", "'meow;'");
172        assert_q!("<meow", "'<meow'");
173        assert_q!(">meow", "'>meow'");
174        assert_q!("(meow", "'(meow'");
175        assert_q!("meow)", "'meow)'");
176        assert_q!("$meow", "'$meow'");
177        assert_q!("`meow`", "'`meow`'");
178        assert_q!(r"\meow", r"'\meow'");
179        assert_q!(r#""meow""#, r#"'"meow"'"#);
180        assert_q!("meow*", "'meow*'");
181        assert_q!("meow?", "'meow?'");
182        assert_q!("[meow", "'[meow'");
183        assert_q!("meow]", "'meow]'");
184        assert_q!("{meow", "'{meow'");
185        assert_q!("meow}", "'meow}'");
186        assert_q!("#meow", "'#meow'");
187        assert_q!("~meow", "'~meow'");
188        assert_q!("%meow", "'%meow'");
189        assert_q!("@meow", "'@meow'");
190        assert_q!("!meow", "'!meow'");
191
192        // single-quote with no other special characters: use double quotes
193        assert_q!("meow's", r#""meow's""#);
194        // single-quote with special characters that don't have special meaning
195        // inside double quotes: use double quotes
196        assert_q!("|meow's", r#""|meow's""#);
197        assert_q!("meow's&", r#""meow's&""#);
198        assert_q!("meow's;", r#""meow's;""#);
199        assert_q!("<meow's", r#""<meow's""#);
200        assert_q!(">meow's", r#"">meow's""#);
201        assert_q!("(meow's", r#""(meow's""#);
202        assert_q!("meow's)", r#""meow's)""#);
203        assert_q!("meow's meow", r#""meow's meow""#);
204        assert_q!("meow's\tmeow", "\"meow's\tmeow\"");
205        assert_q!("meow's\nmeow", "\"meow's\nmeow\"");
206        assert_q!("meow's*", r#""meow's*""#);
207        assert_q!("meow's?", r#""meow's?""#);
208        assert_q!("[meow's", r#""[meow's""#);
209        assert_q!("meow's]", r#""meow's]""#);
210        assert_q!("{meow's", r#""{meow's""#);
211        assert_q!("meow's}", r#""meow's}""#);
212        assert_q!("#meow's", r##""#meow's""##);
213        assert_q!("~meow's", r#""~meow's""#);
214        assert_q!("%meow's", r#""%meow's""#);
215        // single-quote with special characters that _do_ have special meaning
216        // inside double quotes: use single quotes
217        assert_q!("$meow's", r"'$meow'\''s'");
218        assert_q!("`meow's`", r"'`meow'\''s`'");
219        assert_q!(r"\meow's", r"'\meow'\''s'");
220        assert_q!(r#""meow's""#, r#"'"meow'\''s"'"#);
221        assert_q!("@meow's", r"'@meow'\''s'");
222        assert_q!("!meow's", r"'!meow'\''s'");
223    }
224
225    macro_rules! assert_u {
226        ($value:expr, $display:expr) => {
227            assert_eq!(format!("{:?}", Unquoted(&$value)), $display)
228        };
229    }
230
231    #[test]
232    fn program_only() {
233        assert_u!(Command::new("program"), "`program`");
234        assert_u!(Command::new("programn't"), r#"`"programn't"`"#);
235        assert_u!(Command::new("case"), "`'case'`");
236    }
237
238    #[test]
239    fn args() {
240        assert_u!(
241            Command::new("program").args(["arg1", "arg b", "arg'c", r#"arg"d"#, "arg$e"]),
242            r#"`program arg1 'arg b' "arg'c" 'arg"d' 'arg$e'`"#
243        );
244    }
245
246    #[test]
247    fn env() {
248        assert_u!(
249            Command::new("program")
250                .env("BLAH1", "blah")
251                .env("BLAH2", "\"blah's blah\"")
252                .env("BLAH3", r#"\"blah's blah\""#),
253            r#"`BLAH1=blah BLAH2='"blah'\''s blah"' BLAH3='\"blah'\''s blah\"' program`"#
254        );
255    }
256}