Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1790939617,
"exit_code": 0,
"commit": "009c015ffa36453da6d92ddec17b5b655b51372f",
"branch": "claude/1207-win-argv-quote"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Fixed** On Windows, every argument of an `on-file` command is now passed double-quoted by the MS C-runtime rules, so an MSYS/Cygwin child (Git for Windows' `sh`, `printf`, `cat`) no longer glob- or brace-expands it. `on-file = "printf '[{}]'"` now yields its rows instead of none.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
### Windows on-file arguments reach MSYS children unexpanded

**Summary**

On Windows, every argument of an `on-file` command is now passed double-quoted
by the MS C-runtime rules. An MSYS/Cygwin child (Git for Windows' `sh`,
`printf`, `cat`) previously glob- and brace-expanded any argument without a
space or tab; it now receives the argument as written. MSVCRT children parse
the same values as before. No signature changed, and Unix is unchanged.

**Required changes**

_None._

**Deprecations removed**

_None._

**Behavior changes without code changes**

| Surface (Windows, MSYS child) | Before | After |
|---|---|---|
| `on-file = "printf '[{}]'"` | child prints `[]`, zero rows | child prints `[{}]`, one row |
| an argument `*.txt` | expanded to matching files | the literal `*.txt` |

A config that relied on an MSYS child expanding a glob in its argv must ask
for a shell explicitly: `sh -c 'cat *.txt'`.

**Verification**

```toml
[[table]]
name = "t"
ddl = "CREATE TABLE t (n INTEGER)"
glob = "*.txt"
on-file = "printf '[{\"n\":1}]'"
```

```bash
dirsql query "SELECT n FROM t LIMIT 1" -c .dirsql.toml
# [{"n":1}]
```
88 changes: 86 additions & 2 deletions packages/rust/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ pub fn run_command(
let argv = build_argv(command, placeholders)?;
// `build_argv` guarantees a non-empty argv.
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(cwd)
push_args(&mut cmd, &argv[1..]);
cmd.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(if stdin_payload.is_some() {
Expand Down Expand Up @@ -193,6 +193,20 @@ pub fn run_command(
}
}

// One fn with cfg blocks rather than two cfg'd fns: cargo-mutants mutates
// the uncompiled twin too, and no test on a Linux gate can kill that mutant.
fn push_args(cmd: &mut Command, args: &[String]) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
for arg in args {
cmd.raw_arg(quote_windows_arg(arg));
}
}
#[cfg(not(windows))]
cmd.args(args);
}

fn spawn_error(program: &str, source: std::io::Error, cwd: &Path) -> CommandError {
// A missing cwd fails the child's chdir with `NotFound` too.
if !searched_path(program, source.kind()) || !cwd.is_dir() {
Expand Down Expand Up @@ -244,6 +258,35 @@ pub(crate) fn build_argv(
Ok(argv)
}

/// Quote `arg` for a Windows command line by the MS C-runtime rules, always
/// wrapped in double quotes: an MSYS/Cygwin child globs, brace-expands and
/// unescapes any unquoted word, while an MSVCRT child parses the quoted form
/// back to `arg` exactly.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn quote_windows_arg(arg: &str) -> String {
let mut out = String::with_capacity(arg.len() + 2);
out.push('"');
let mut backslashes = 0;
for ch in arg.chars() {
match ch {
'\\' => backslashes += 1,
'"' => {
out.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
out.push('"');
backslashes = 0;
}
_ => {
out.extend(std::iter::repeat_n('\\', backslashes));
out.push(ch);
backslashes = 0;
}
}
}
out.extend(std::iter::repeat_n('\\', backslashes * 2));
out.push('"');
out
}

/// Replace every `{name}` in `token` with its placeholder value in a single
/// left-to-right pass. Injected values are never re-scanned, so an untrusted
/// value containing `{…}` is inert. Unknown `{…}` sequences are left literal.
Expand Down Expand Up @@ -402,6 +445,47 @@ mod tests {
assert_eq!(argv("run", &[Placeholder::new("path", "/tmp/x")]), ["run"]);
}

#[test]
fn windows_quoting_wraps_a_plain_arg_in_quotes() {
assert_eq!(quote_windows_arg("hello"), r#""hello""#);
}

#[test]
fn windows_quoting_keeps_an_empty_arg_as_an_empty_quoted_word() {
assert_eq!(quote_windows_arg(""), r#""""#);
}

#[test]
fn windows_quoting_quotes_glob_and_brace_characters() {
assert_eq!(quote_windows_arg("[{}]"), r#""[{}]""#);
assert_eq!(quote_windows_arg("a?b*c"), r#""a?b*c""#);
}

#[test]
fn windows_quoting_leaves_backslashes_not_before_a_quote_alone() {
assert_eq!(
quote_windows_arg(r"\\?\C:\Users\x.txt"),
r#""\\?\C:\Users\x.txt""#
);
}

#[test]
fn windows_quoting_doubles_trailing_backslashes_before_the_closing_quote() {
assert_eq!(quote_windows_arg(r"C:\dir\"), r#""C:\dir\\""#);
assert_eq!(quote_windows_arg(r"a\\"), r#""a\\\\""#);
}

#[test]
fn windows_quoting_escapes_embedded_quotes() {
assert_eq!(quote_windows_arg(r#"[{"n":1}]"#), r#""[{\"n\":1}]""#);
}

#[test]
fn windows_quoting_doubles_backslashes_before_an_embedded_quote() {
assert_eq!(quote_windows_arg(r#"a\"b"#), r#""a\\\"b""#);
assert_eq!(quote_windows_arg(r#"a\\"b"#), r#""a\\\\\"b""#);
}

#[test]
fn extract_payload_takes_the_last_non_empty_line() {
assert_eq!(
Expand Down
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-1207-win-argv-quote.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1790939689,
"exit_code": 0,
"commit": "d2e6cb09a48afa50f165ca6466233e0446dc7de7",
"branch": "claude/1207-win-argv-quote"
}
Loading