From 8ce8d1e2a5fa30787e2e84d489b7d3c4b52ac9ad Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:59:51 -0400 Subject: [PATCH 1/6] test(rust): hooks receive non-verbatim {path} and {root} Red: non_verbatim is still the identity, so verbatim Windows paths reach the hook unchanged. --- packages/rust/src/command.rs | 121 +++++++++++++++++++++++++++++++ packages/rust/src/lib.rs | 20 ++++- packages/rust/src/parsed_vtab.rs | 4 +- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/packages/rust/src/command.rs b/packages/rust/src/command.rs index ecef38e42..19788027c 100644 --- a/packages/rust/src/command.rs +++ b/packages/rust/src/command.rs @@ -49,6 +49,20 @@ 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, 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 { + path.to_string() } /// A successful command run. @@ -315,6 +329,113 @@ 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"); + } + + #[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" + ); + } + + #[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:\ab"); + 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"]); diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 097cf961a..0f325ddab 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -1945,8 +1945,8 @@ fn run_on_file( root: &Path, ) -> std::result::Result, 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 @@ -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"\\?\UNC\server\share\r"), + ) + .expect("a well-formed payload parses"); + assert_eq!( + std::fs::read_to_string(dir.path().join("seen")).unwrap(), + r"C:\r\f.txt|\\server\share\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. diff --git a/packages/rust/src/parsed_vtab.rs b/packages/rust/src/parsed_vtab.rs index 6de118226..e2aad9c6b 100644 --- a/packages/rust/src/parsed_vtab.rs +++ b/packages/rust/src/parsed_vtab.rs @@ -292,8 +292,8 @@ fn no_rows_message(pattern: &str) -> String { fn run_parser(command: &str, root: &Path, rel_path: &Path) -> std::result::Result { 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) From 64a48bb1eff07865a52fc25e1ad50722aced9e45 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:04:42 -0400 Subject: [PATCH 2/6] fix(rust): hand on-file hooks non-verbatim Windows paths {path} and {root} drop the \\?\ and \\?\UNC\ prefixes when the plain spelling names the same file. --- ...6-10-02-windows-non-verbatim-hook-paths.md | 1 + ...6-10-02-windows-non-verbatim-hook-paths.md | 42 +++++++++++++++ packages/rust/src/command.rs | 51 ++++++++++++++++++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/rust/changelog.d/2026-10-02-windows-non-verbatim-hook-paths.md create mode 100644 packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md diff --git a/packages/rust/changelog.d/2026-10-02-windows-non-verbatim-hook-paths.md b/packages/rust/changelog.d/2026-10-02-windows-non-verbatim-hook-paths.md new file mode 100644 index 000000000..7a5f8c11d --- /dev/null +++ b/packages/rust/changelog.d/2026-10-02-windows-non-verbatim-hook-paths.md @@ -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. diff --git a/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md b/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md new file mode 100644 index 000000000..db5b355b8 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md @@ -0,0 +1,42 @@ +### 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]] +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 \\?\ +``` diff --git a/packages/rust/src/command.rs b/packages/rust/src/command.rs index 19788027c..748b6fee6 100644 --- a/packages/rust/src/command.rs +++ b/packages/rust/src/command.rs @@ -62,7 +62,50 @@ impl Placeholder { /// verbatim path whose plain spelling would name a different file, is returned /// unchanged. pub(crate) fn non_verbatim(path: &str) -> String { - path.to_string() + plain_form(path).unwrap_or_else(|| path.to_string()) +} + +fn plain_form(path: &str) -> Option { + 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. @@ -340,6 +383,7 @@ mod tests { 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] @@ -353,6 +397,7 @@ mod tests { 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] @@ -413,7 +458,9 @@ mod tests { #[test] fn non_verbatim_strips_names_that_only_resemble_device_names() { - for name in ["CONSOLE", "NULL.txt", "COM0", "COM10", "LPT", "xCON", "AUXa"] { + 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}") From b44665ee97a12c56691723ae9d5c3812f110d90c Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:19:18 -0400 Subject: [PATCH 3/6] e2e attestation for claude/1208-win-nonverbatim-paths --- .../claude-1208-win-nonverbatim-paths.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1208-win-nonverbatim-paths.json diff --git a/packages/python/e2e-attestations/claude-1208-win-nonverbatim-paths.json b/packages/python/e2e-attestations/claude-1208-win-nonverbatim-paths.json new file mode 100644 index 000000000..20688b5e6 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1208-win-nonverbatim-paths.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1790939958, + "exit_code": 0, + "commit": "64a48bb1eff07865a52fc25e1ad50722aced9e45", + "branch": "claude/1208-win-nonverbatim-paths" +} From 8eeee89b430cea3d59e9201dfae2d1ac9891eead Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:21:33 -0400 Subject: [PATCH 4/6] e2e attestation for claude/1208-win-nonverbatim-paths --- .../claude-1208-win-nonverbatim-paths.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1208-win-nonverbatim-paths.json diff --git a/packages/ts/e2e-attestations/claude-1208-win-nonverbatim-paths.json b/packages/ts/e2e-attestations/claude-1208-win-nonverbatim-paths.json new file mode 100644 index 000000000..14e33cf9d --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1208-win-nonverbatim-paths.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1790940093, + "exit_code": 0, + "commit": "b44665ee97a12c56691723ae9d5c3812f110d90c", + "branch": "claude/1208-win-nonverbatim-paths" +} From 3c8e88cebc3205ea1fd765b26b6e5e75a7b56a4f Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:22:24 -0400 Subject: [PATCH 5/6] docs(rust): name the table in the migration example --- .../migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md b/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md index db5b355b8..44ccfd674 100644 --- a/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md +++ b/packages/rust/migrations.d/2026-10-02-windows-non-verbatim-hook-paths.md @@ -30,6 +30,7 @@ device name, a trailing dot or space, a path of 260 or more UTF-16 units, or a ```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}" From fe8dcbac602ff222cdb7c4e63da88db8ae483e41 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:13:28 -0400 Subject: [PATCH 6/6] test(rust): drive the sh hook test with drive-letter verbatim paths only MSYS sh collapses a leading \\ even when quoted; UNC conversion stays covered by the non_verbatim unit tests. --- packages/rust/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 0f325ddab..c11d3a6ea 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -4390,12 +4390,12 @@ mod internal_tests { r#"sh -c 'printf "%s|%s" "$1" "$2" > seen; echo "[]"' sh {path} {root}"#, r"\\?\C:\r\f.txt", dir.path(), - Path::new(r"\\?\UNC\server\share\r"), + 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|\\server\share\r" + r"C:\r\f.txt|D:\r" ); }