mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
test(shell-quote): cover cmd, and write down the one thing it cannot do
`quote_for_shell` decides how a path is typed into the user's own shell, so a name that argues with the quoting is a path that names the wrong file. Posix and PowerShell had tests; cmd had none for quoting at all — only `unquote_word` and `quoting_for` — so what it does with an awkward name was written down nowhere. The `"` case is genuinely closed and the doc already said why: a Windows path cannot hold one. `%` is the open one. It is legal in a Windows filename and cmd expands `%NAME%` inside double quotes, before it parses them, so a file actually called `%USERPROFILE%.txt` pastes as a line naming somewhere else. There is no escape for it on an interactive line — `%%` is a batch-file rule and `^` is not read inside quotes — so this is pinned and documented rather than fixed, and the doc now says what it costs: the wrong path, never a command, because `%` substitutes an environment variable and can do nothing else. The rest is the coverage cmd was missing, plus a check that `is_bare` never lets a metacharacter through unquoted — which is the actual boundary, since anything bare is typed with no quoting whatsoever.
This commit is contained in:
@@ -43,6 +43,17 @@ fn is_bare(c: char) -> bool {
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Quoting {
|
||||
/// cmd.exe: `"..."`, and a Windows path cannot hold a `"` to escape.
|
||||
///
|
||||
/// A `%` is a different matter: it is legal in a Windows filename, and cmd
|
||||
/// expands `%NAME%` *inside* double quotes, before it parses them. So a
|
||||
/// file actually called `%USERPROFILE%.txt` pastes as a command line
|
||||
/// naming some other path entirely. cmd offers no escape for this on an
|
||||
/// interactive line — `%%` is a batch-file rule, and `^` is not read
|
||||
/// inside quotes — so it is a limitation rather than an oversight, and
|
||||
/// `a_percent_in_a_name_is_the_one_thing_cmd_cannot_be_told_to_hold`
|
||||
/// records exactly how far the quoting gets. It costs the wrong path, not
|
||||
/// a command: `%` substitutes an environment variable and can do nothing
|
||||
/// else.
|
||||
Cmd,
|
||||
/// PowerShell and pwsh: `'...'`, with an embedded `'` written `''`.
|
||||
PowerShell,
|
||||
@@ -325,4 +336,69 @@ mod tests {
|
||||
assert!(!posix_escapes_for(Some("pwsh")));
|
||||
assert_eq!(posix_escapes_for(None), !cfg!(windows));
|
||||
}
|
||||
|
||||
/// What each dialect makes of a name that argues with it.
|
||||
///
|
||||
/// cmd had no quoting test at all — only `unquote_word` and `quoting_for`
|
||||
/// — so what it does with an awkward name was written down nowhere.
|
||||
#[test]
|
||||
fn every_dialect_quotes_a_name_that_argues_with_it() {
|
||||
// A space is the ordinary reason to quote, and all three manage it.
|
||||
assert_eq!(quote_as("My Documents", Quoting::Cmd), "\"My Documents\"");
|
||||
assert_eq!(
|
||||
quote_as("My Documents", Quoting::PowerShell),
|
||||
"'My Documents'"
|
||||
);
|
||||
assert_eq!(quote_as("My Documents", Quoting::Posix), "'My Documents'");
|
||||
|
||||
// The quote character itself, which is the one each dialect has its
|
||||
// own answer for. A Windows path cannot contain one, so cmd is not
|
||||
// asked the question.
|
||||
assert_eq!(quote_as("it's", Quoting::PowerShell), "'it''s'");
|
||||
assert_eq!(quote_as("it's", Quoting::Posix), r"'it'\''s'");
|
||||
|
||||
// Characters a POSIX shell would act on are inert inside single
|
||||
// quotes, which is the whole point of choosing them.
|
||||
for hostile in ["a b; rm -rf ~", "$(id)", "`id`", "a|b", "x&y", "*"] {
|
||||
let quoted = quote_as(hostile, Quoting::Posix);
|
||||
assert!(
|
||||
quoted.starts_with('\'') && quoted.ends_with('\''),
|
||||
"{hostile:?} came back unquoted as {quoted:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// And nothing hostile slips through `is_bare` unquoted.
|
||||
for bare in ["plain", "a-b_c.d", "~", "path/to/file", "1+1"] {
|
||||
assert_eq!(quote_as(bare, Quoting::Posix), bare, "{bare:?} was quoted");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `%` in a name is the one thing cmd cannot be told to hold literally.
|
||||
///
|
||||
/// Legal in a Windows filename, and expanded by cmd inside double quotes
|
||||
/// before they are parsed, with no escape available on an interactive
|
||||
/// line. Pinned rather than fixed: what it costs is the wrong path, since
|
||||
/// `%` substitutes an environment variable and can do nothing else. If a
|
||||
/// later cmd ever grows an escape, this test is where to change the
|
||||
/// answer.
|
||||
#[test]
|
||||
fn a_percent_in_a_name_is_the_one_thing_cmd_cannot_be_told_to_hold() {
|
||||
assert_eq!(
|
||||
quote_as("%USERPROFILE%.txt", Quoting::Cmd),
|
||||
"\"%USERPROFILE%.txt\"",
|
||||
"the quoting is all cmd has; the expansion happens anyway"
|
||||
);
|
||||
// The other two dialects do hold it, so this is cmd's alone.
|
||||
assert_eq!(
|
||||
quote_as("%USERPROFILE%.txt", Quoting::PowerShell),
|
||||
"'%USERPROFILE%.txt'"
|
||||
);
|
||||
assert_eq!(
|
||||
quote_as("%USERPROFILE%.txt", Quoting::Posix),
|
||||
"'%USERPROFILE%.txt'"
|
||||
);
|
||||
// And it is quoted at all only because `is_bare` excludes `%`; were it
|
||||
// bare, the name would go through with no quoting whatsoever.
|
||||
assert!(!"%".chars().all(is_bare), "`%` must never count as bare");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user