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": 1790939958,
"exit_code": 0,
"commit": "64a48bb1eff07865a52fc25e1ad50722aced9e45",
"branch": "claude/1208-win-nonverbatim-paths"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Fixed** On Windows, `on-file` hooks and `parsed()` parsers receive `{path}` and `{root}` in plain form (`C:\data\a.txt`, `\\server\share\a.txt`) instead of the verbatim `\\?\C:\...` / `\\?\UNC\...` form, which MSYS tools mangle and many Windows tools reject. Verbatim paths with no plain equivalent are passed through unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
### Hooks receive non-verbatim paths on Windows

**Summary**

On Windows, the `{path}` and `{root}` values substituted into `on-file` and
`parsed()` commands could carry the verbatim prefix (`\\?\C:\...`,
`\\?\UNC\server\share\...`). They are now the plain form. No signature
changed. Output on Unix is unchanged.

**Required changes**

_None._

**Deprecations removed**

_None._

**Behavior changes without code changes**

| Surface (Windows only) | Before | After |
|---|---|---|
| `{path}` / `{root}` on a drive | `\\?\C:\data\a.txt` | `C:\data\a.txt` |
| `{path}` / `{root}` on a share | `\\?\UNC\server\share\a.txt` | `\\server\share\a.txt` |

A verbatim path whose plain spelling would name a different file (a reserved
device name, a trailing dot or space, a path of 260 or more UTF-16 units, or a
`\\?\GLOBALROOT` / `\\?\Volume{...}` form) is still passed verbatim.

**Verification**

```toml
[[table]]
name = "seen"
ddl = "CREATE TABLE seen (n INTEGER)"
glob = "*.txt"
on-file = "sh -c 'printf \"%s\\n\" \"$1\" >> seen.log; echo []' sh {path}"
```

```bash
dirsql "SELECT count(*) FROM seen"
type seen.log
# each line starts with C:\, not \\?\
```
168 changes: 168 additions & 0 deletions packages/rust/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,63 @@ impl Placeholder {
value: value.into(),
}
}

/// A placeholder whose value is a filesystem path, handed to the child in
/// its plain (non-verbatim) form; see [`non_verbatim`].
pub fn path(name: impl Into<String>, path: &str) -> Self {
Self::new(name, non_verbatim(path))
}
}

/// The plain form of a Windows verbatim path: `\\?\C:\x` becomes `C:\x` and
/// `\\?\UNC\server\share\x` becomes `\\server\share\x`. Anything else, and any
/// verbatim path whose plain spelling would name a different file, is returned
/// unchanged.
pub(crate) fn non_verbatim(path: &str) -> String {
plain_form(path).unwrap_or_else(|| path.to_string())
}

fn plain_form(path: &str) -> Option<String> {
let rest = path.strip_prefix(r"\\?\")?;
let (plain, tail) = if let Some(unc) = rest.strip_prefix(r"UNC\") {
if unc.split('\\').count() < 2 {
return None;
}
(format!(r"\\{unc}"), unc)
} else {
let drive = rest.as_bytes();
let is_drive = drive.len() >= 3
&& drive[0].is_ascii_alphabetic()
&& drive[1] == b':'
&& drive[2] == b'\\';
if !is_drive {
return None;
}
(rest.to_string(), &rest[3..])
};
// Win32 rejects or rewrites these, so the plain spelling would not name
// the same file; MAX_PATH counts UTF-16 units including the terminator.
let same_file = (tail.is_empty() || tail.split('\\').all(is_plain_component))
&& plain.encode_utf16().count() < 260;
same_file.then_some(plain)
}

fn is_plain_component(name: &str) -> bool {
let invalid_char = |c: char| c < ' ' || r#"/<>:"|?*"#.contains(c);
let stem = name
.split_once('.')
.map_or(name, |(stem, _)| stem)
.trim_end_matches(' ');
let reserved = matches!(
stem.to_ascii_uppercase().as_bytes(),
b"CON"
| b"PRN"
| b"AUX"
| b"NUL"
| [b'C', b'O', b'M', b'1'..=b'9']
| [b'L', b'P', b'T', b'1'..=b'9']
);
!name.is_empty() && !name.ends_with(['.', ' ']) && !name.contains(invalid_char) && !reserved
}

/// A successful command run.
Expand Down Expand Up @@ -315,6 +372,117 @@ mod tests {
build_argv(command, placeholders).expect("valid command")
}

fn unchanged(path: &str) {
assert_eq!(non_verbatim(path), path);
}

#[test]
fn non_verbatim_strips_a_verbatim_drive_path() {
assert_eq!(
non_verbatim(r"\\?\C:\Users\runner\in_cwd.txt"),
r"C:\Users\runner\in_cwd.txt"
);
assert_eq!(non_verbatim(r"\\?\d:\x"), r"d:\x");
assert_eq!(non_verbatim(r"\\?\C:\my file.txt"), r"C:\my file.txt");
}

#[test]
fn non_verbatim_strips_a_verbatim_drive_root() {
assert_eq!(non_verbatim(r"\\?\C:\"), r"C:\");
}

#[test]
fn non_verbatim_rewrites_a_verbatim_unc_path() {
assert_eq!(
non_verbatim(r"\\?\UNC\server\share\dir\f.txt"),
r"\\server\share\dir\f.txt"
);
assert_eq!(non_verbatim(r"\\?\UNC\server\share"), r"\\server\share");
}

#[test]
fn non_verbatim_leaves_plain_paths_unchanged() {
unchanged(r"C:\Users\runner\f.txt");
unchanged(r"\\server\share\f.txt");
unchanged("/tmp/root/f.txt");
unchanged("relative/f.txt");
unchanged("");
}

#[test]
fn non_verbatim_leaves_verbatim_forms_without_a_drive_or_unc_unchanged() {
unchanged(r"\\?\GLOBALROOT\Device\HarddiskVolume1\f.txt");
unchanged(r"\\?\Volume{0b1c2d3e-0000-0000-0000-100000000000}\f.txt");
unchanged(r"\\?\C:");
unchanged(r"\\?\C:x");
unchanged(r"\\?\1:\x");
unchanged(r"\\?\");
}

#[test]
fn non_verbatim_leaves_an_incomplete_unc_unchanged() {
unchanged(r"\\?\UNC\");
unchanged(r"\\?\UNC\server");
unchanged(r"\\?\UNC\server\");
}

#[test]
fn non_verbatim_leaves_components_win32_would_reinterpret_unchanged() {
unchanged(r"\\?\C:\a\..\b");
unchanged(r"\\?\C:\a\.\b");
unchanged(r"\\?\C:\a\\b");
unchanged(r"\\?\C:\a\");
unchanged(r"\\?\C:\trailing.");
unchanged(r"\\?\C:\trailing ");
unchanged(r"\\?\C:\a/b");
unchanged(r"\\?\C:\a?b");
unchanged(r"\\?\C:\a*b");
unchanged(r"\\?\C:\a:b");
unchanged(r#"\\?\C:\a"b"#);
unchanged(r"\\?\C:\a<b");
unchanged(r"\\?\C:\a>b");
unchanged(r"\\?\C:\a|b");
unchanged("\\\\?\\C:\\a\u{1}b");
unchanged(r"\\?\UNC\server\share\..\x");
}

#[test]
fn non_verbatim_leaves_reserved_device_names_unchanged() {
for name in [
"CON", "con", "PRN", "AUX", "NUL", "nul.txt", "NUL .txt", "COM1", "COM9", "LPT1",
"lpt9.log",
] {
unchanged(&format!(r"\\?\C:\dir\{name}"));
}
}

#[test]
fn non_verbatim_strips_names_that_only_resemble_device_names() {
for name in [
"CONSOLE", "NULL.txt", "COM0", "COM10", "LPT", "xCON", "AUXa",
] {
assert_eq!(
non_verbatim(&format!(r"\\?\C:\dir\{name}")),
format!(r"C:\dir\{name}")
);
}
}

#[test]
fn non_verbatim_strips_up_to_max_path_and_no_further() {
let fits = format!(r"C:\{}", "a".repeat(256));
assert_eq!(fits.encode_utf16().count(), 259);
assert_eq!(non_verbatim(&format!(r"\\?\{fits}")), fits);
unchanged(&format!(r"\\?\C:\{}", "a".repeat(257)));
}

#[test]
fn path_placeholder_carries_the_non_verbatim_value() {
let placeholder = Placeholder::path("path", r"\\?\C:\a\f.txt");
assert_eq!(placeholder.name, "path");
assert_eq!(placeholder.value, r"C:\a\f.txt");
}

#[test]
fn splits_on_whitespace_without_placeholders() {
assert_eq!(argv("echo hello world", &[]), ["echo", "hello", "world"]);
Expand Down
20 changes: 18 additions & 2 deletions packages/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1945,8 +1945,8 @@ fn run_on_file(
root: &Path,
) -> std::result::Result<Vec<Row>, BoxError> {
let placeholders = [
Placeholder::new("path", abs_path),
Placeholder::new("root", root.to_string_lossy().into_owned()),
Placeholder::path("path", abs_path),
Placeholder::path("root", &root.to_string_lossy()),
];

// Errors travel to the caller rather than being logged and flattened to an
Expand Down Expand Up @@ -4383,6 +4383,22 @@ mod internal_tests {
);
}

#[test]
fn run_on_file_hands_the_hook_non_verbatim_path_and_root() {
let dir = TempDir::new().unwrap();
run_on_file(
r#"sh -c 'printf "%s|%s" "$1" "$2" > seen; echo "[]"' sh {path} {root}"#,
r"\\?\C:\r\f.txt",
dir.path(),
Path::new(r"\\?\D:\r"),
)
.expect("a well-formed payload parses");
assert_eq!(
std::fs::read_to_string(dir.path().join("seen")).unwrap(),
r"C:\r\f.txt|D:\r"
);
}

/// A command that cannot be spawned is an error the caller records, not an
/// empty row set: a skip and a file that legitimately produced no rows must
/// stay distinguishable.
Expand Down
4 changes: 2 additions & 2 deletions packages/rust/src/parsed_vtab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ fn no_rows_message(pattern: &str) -> String {
fn run_parser(command: &str, root: &Path, rel_path: &Path) -> std::result::Result<String, String> {
let abs_path = root.join(rel_path);
let placeholders = [
Placeholder::new("path", abs_path.to_string_lossy().into_owned()),
Placeholder::new("root", root.to_string_lossy().into_owned()),
Placeholder::path("path", &abs_path.to_string_lossy()),
Placeholder::path("root", &root.to_string_lossy()),
];

run_command(command, &placeholders, root, None)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1790940093,
"exit_code": 0,
"commit": "b44665ee97a12c56691723ae9d5c3812f110d90c",
"branch": "claude/1208-win-nonverbatim-paths"
}
Loading