From f72c4008564830ee069e4cf6923ce6df225f6ed1 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:38:59 -0500 Subject: [PATCH 1/4] test: '..' resolves physically in absolute and ~ path-tables Red ahead of the fix: a '..' after a symlinked directory is folded lexically, so the absolute and ~/ forms read a different file than the shell does. --- packages/rust/tests/path_table_cli_e2e.rs | 59 ++++++++++++++++++++++ packages/rust/tests/path_table_symlinks.rs | 47 +++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/packages/rust/tests/path_table_cli_e2e.rs b/packages/rust/tests/path_table_cli_e2e.rs index 5ef15d2e9..f3913a8e5 100644 --- a/packages/rust/tests/path_table_cli_e2e.rs +++ b/packages/rust/tests/path_table_cli_e2e.rs @@ -424,3 +424,62 @@ fn a_brace_sequence_expands_like_bash() { assert_eq!(paths(&out), vec!["br/a1.md", "br/a3.md"]); } + +#[cfg(unix)] +mod parent_through_a_symlink { + use super::*; + use std::os::unix::fs::symlink; + + /// `y/link -> real/sub` under `base`, so `y/link/..` is `real` to the + /// filesystem and `y` to a lexical fold. Each `f.md` names which. + fn link_under(base: &Path) { + fs::create_dir_all(base.join("real/sub")).unwrap(); + fs::create_dir_all(base.join("y")).unwrap(); + fs::write(base.join("real/f.md"), "PHYSICAL").unwrap(); + fs::write(base.join("y/f.md"), "LEXICAL").unwrap(); + symlink(base.join("real/sub"), base.join("y/link")).unwrap(); + } + + fn path_and_content(out: &Output) -> Vec<(String, String)> { + rows(out) + .into_iter() + .map(|r| { + ( + r["path"].as_str().unwrap().to_string(), + r["content"].as_str().unwrap().to_string(), + ) + }) + .collect() + } + + #[test] + fn an_absolute_path_reads_the_file_the_shell_reads() { + let dir = TempDir::new().unwrap(); + link_under(dir.path()); + let written = format!("{}/y/link/../f.md", dir.path().display()); + + let out = run(&dir, &format!("SELECT path, content FROM '{written}'")); + + assert_eq!( + path_and_content(&out), + vec![(written, "PHYSICAL".to_string())] + ); + } + + #[test] + fn a_home_relative_path_reads_the_file_the_shell_reads() { + let dir = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + link_under(home.path()); + + let out = run_with_home(&dir, &home, "SELECT path, content FROM '~/y/link/../f.md'"); + + assert_eq!( + path_and_content(&out), + vec![( + format!("{}/y/link/../f.md", home.path().display()), + "PHYSICAL".to_string() + )] + ); + } +} diff --git a/packages/rust/tests/path_table_symlinks.rs b/packages/rust/tests/path_table_symlinks.rs index c37588d3e..d275501b9 100644 --- a/packages/rust/tests/path_table_symlinks.rs +++ b/packages/rust/tests/path_table_symlinks.rs @@ -115,3 +115,50 @@ fn a_dot_named_symlink_is_hidden_unless_the_pattern_spells_the_dot() { assert_eq!(paths("./.dotdir/*"), vec![".dotdir/r.md"]); assert_eq!(paths("./.*/*"), vec![".dotdir/r.md"]); } + +/// `y/link -> real/sub`, so `y/link/..` is `real` to the filesystem and `y` +/// to a lexical fold. Each `f.md` says which one was read. +fn parent_through_link() -> TempDir { + let root = TempDir::new().unwrap(); + let at = |rel: &str| root.path().join(rel); + fs::create_dir_all(at("real/sub")).unwrap(); + fs::create_dir_all(at("y")).unwrap(); + fs::write(at("real/f.md"), "PHYSICAL").unwrap(); + fs::write(at("y/f.md"), "LEXICAL").unwrap(); + symlink(at("real/sub"), at("y/link")).unwrap(); + root +} + +fn path_and_content(root: &TempDir, table: &str) -> Vec<(String, String)> { + let db = DirSQL::new(root.path(), vec![]).unwrap(); + db.query(&format!("SELECT path, content FROM '{table}'")) + .unwrap() + .iter() + .map(|r| match (r.get("path"), r.get("content")) { + (Some(Value::Text(p)), Some(Value::Text(c))) => (p.clone(), c.clone()), + other => panic!("path and content were not text: {other:?}"), + }) + .collect() +} + +#[test] +fn an_absolute_parent_through_a_symlinked_directory_resolves_physically() { + let root = parent_through_link(); + let written = format!("{}/y/link/../f.md", root.path().display()); + + assert_eq!( + path_and_content(&root, &written), + vec![(written.clone(), "PHYSICAL".to_string())] + ); +} + +#[test] +fn an_absolute_glob_after_a_parent_through_a_symlinked_directory_resolves_physically() { + let root = parent_through_link(); + let dir = format!("{}/y/link/..", root.path().display()); + + assert_eq!( + path_and_content(&root, &format!("{dir}/*.md")), + vec![(format!("{dir}/f.md"), "PHYSICAL".to_string())] + ); +} From 5fe39f0e3b4c76172402aa69ecb8b992265d9994 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:41:26 -0500 Subject: [PATCH 2/4] fix: resolve '..' physically in absolute and ~ path-tables Absolute and ~/ path-table names no longer fold '..' lexically; the OS resolves it, so a '..' after a symlinked directory climbs out of the link's target as in the shell, and path reports the name as written. Verbatim Windows paths, where the OS takes '..' literally, still fold. Config table anchors keep the lexical fold. --- .../2026-11-04-absolute-path-table-dotdot.md | 1 + .../2026-11-04-absolute-path-table-dotdot.md | 31 ++++++++ packages/rust/src/path_table.rs | 75 ++++++++++++++++--- 3 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 packages/rust/changelog.d/2026-11-04-absolute-path-table-dotdot.md create mode 100644 packages/rust/migrations.d/2026-11-04-absolute-path-table-dotdot.md diff --git a/packages/rust/changelog.d/2026-11-04-absolute-path-table-dotdot.md b/packages/rust/changelog.d/2026-11-04-absolute-path-table-dotdot.md new file mode 100644 index 000000000..1acb0d6f2 --- /dev/null +++ b/packages/rust/changelog.d/2026-11-04-absolute-path-table-dotdot.md @@ -0,0 +1 @@ +**Fixed** An absolute or `~/` path-table resolves `..` through the filesystem, as the shell does, instead of folding it out of the string first. After a symlinked directory, `'/r/y/link/../f.md'` now reads the file `cat` reads (the link target's parent), and `path` reports the name as written (`/r/y/link/../f.md`), matching the `./` and `../` forms. Windows keeps its own lexical `..` handling. diff --git a/packages/rust/migrations.d/2026-11-04-absolute-path-table-dotdot.md b/packages/rust/migrations.d/2026-11-04-absolute-path-table-dotdot.md new file mode 100644 index 000000000..85887cad9 --- /dev/null +++ b/packages/rust/migrations.d/2026-11-04-absolute-path-table-dotdot.md @@ -0,0 +1,31 @@ +### `..` in absolute and `~/` path-tables resolves physically + +#### Summary + +An absolute or `~/` path-table containing `..` now lets the filesystem resolve it, so a `..` after a symlinked directory climbs out of the link's target, as in the shell. The `path` column keeps the `..` as written. No API changes; nothing breaks for paths without a symlink ahead of a `..`, except that their `path` now shows the `..` as written. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +| Before | After | +|---|---| +| `SELECT path FROM '/a/b/../*.md'` reports `/a/x.md` | reports `/a/b/../x.md` | +| `'/a/link/../f.md'` with `link -> /real/sub` reads `/a/f.md` | reads `/real/f.md`, as `cat` does | + +Windows paths keep folding `..` lexically, as Windows itself does. + +#### Verification + +```sh +mkdir -p r/real/sub r/y && printf P > r/real/f.md && printf L > r/y/f.md && ln -s "$PWD/r/real/sub" r/y/link +dirsql "SELECT content FROM '$PWD/r/y/link/../f.md'" +``` + +Expected: `content` is `P`. diff --git a/packages/rust/src/path_table.rs b/packages/rust/src/path_table.rs index 89a479fba..26ccf6b37 100644 --- a/packages/rust/src/path_table.rs +++ b/packages/rust/src/path_table.rs @@ -57,6 +57,10 @@ trait Syntax: Utf8Encoding + Sized { fn is_rooted(name: &str) -> bool; + /// Whether the OS takes a `..` in `path` literally rather than climbing + /// through it, so it must be folded before the path is used. + fn is_verbatim(path: &Utf8Path) -> bool; + fn reported(path: &Utf8Path) -> String; } @@ -68,6 +72,10 @@ impl Syntax for Utf8UnixEncoding { name.starts_with('/') } + fn is_verbatim(_: &Utf8Path) -> bool { + false + } + fn reported(path: &Utf8Path) -> String { path.as_str().to_string() } @@ -81,14 +89,17 @@ impl Syntax for Utf8WindowsEncoding { Utf8WindowsPath::new(name).has_root() } + fn is_verbatim(path: &Utf8Path) -> bool { + matches!( + path.components().next(), + Some(Utf8WindowsComponent::Prefix(prefix)) if prefix.kind().is_verbatim() + ) + } + /// Reported with `/`, matching the index-root-relative paths. A verbatim /// (`\\?\`) path is the exception: there `/` is not a separator. fn reported(path: &Utf8Path) -> String { - let verbatim = matches!( - path.components().next(), - Some(Utf8WindowsComponent::Prefix(prefix)) if prefix.kind().is_verbatim() - ); - if verbatim { + if Self::is_verbatim(path) { path.as_str().to_string() } else { path.as_str().replace('\\', "/") @@ -137,6 +148,9 @@ fn resolve_as( } match absolute_target::(&target, index_root, home) { + Some(Some(target)) if S::is_verbatim(&target) => { + Resolution::Table(split_absolute(&normalize(&target), is_dir)) + } Some(Some(target)) => Resolution::Table(split_absolute(&target, is_dir)), Some(None) => Resolution::NoHome, None if has_glob_metacharacter(name) => Resolution::Hint, @@ -193,7 +207,7 @@ fn config_anchor_as( let Some(target) = absolute_target::(glob, config_dir, home) else { return Some((config_dir.to_path_buf(), glob.to_string())); }; - let target = target?; + let target = normalize(&target?); let (literal, rest) = split_at_first_glob(&target); if !rest.is_empty() { return Some((PathBuf::from(literal.as_str()), rest)); @@ -257,7 +271,9 @@ fn typed(path: &Path) -> Utf8PathBuf { Utf8PathBuf::from(path.to_string_lossy().into_owned()) } -/// The absolute path a non-`./` path-table names, with `.` and `..` folded out. +/// The absolute path a non-`./` path-table names. A `..` is kept as written: +/// after a symlinked directory it climbs out of the link's target, as the +/// shell's does, which a lexical fold would get wrong. /// /// `None` means the name is not a path at all. `Some(None)` means it is a `~/` /// path but no home directory could be found. @@ -267,20 +283,19 @@ fn absolute_target( home: Option<&Path>, ) -> Option>> { if let Some(rest) = S::HOME_PREFIXES.iter().find_map(|p| name.strip_prefix(p)) { - return Some(home.map(|h| normalize(&typed::(h).join(rest)))); + return Some(home.map(|h| typed::(h).join(rest))); } if name.starts_with("../") { - return Some(Some(normalize(&typed::(index_root).join(name)))); + return Some(Some(typed::(index_root).join(name))); } if S::is_rooted(name) { - return Some(Some(normalize(Utf8Path::new(name)))); + return Some(Some(Utf8PathBuf::from(name))); } None } /// Fold `.` and `..` out of `path` lexically. Purely textual: a `..` is not -/// resolved through a symlink, which keeps the answer a function of the string -/// the user wrote. +/// resolved through a symlink. fn normalize(path: &Utf8Path) -> Utf8PathBuf { let mut kept: Vec<&str> = Vec::new(); let mut normal = 0; @@ -820,6 +835,29 @@ mod tests { assert_eq!(resolve_with("..", ¬hing_is_a_dir), Resolution::NotAPath); } + #[test] + fn an_absolute_path_keeps_a_parent_component_for_the_filesystem_to_resolve() { + let t = table("/a/link/../*.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new("/a/link/..")); + assert_eq!(t.glob, "*.md"); + assert_eq!(t.path_prefix, "/a/link/.."); + } + + #[test] + fn a_home_relative_path_keeps_a_parent_component_for_the_filesystem_to_resolve() { + let t = table("~/link/../f.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new("/home/u/link/..")); + assert_eq!(t.glob, "f.md"); + assert_eq!(t.path_prefix, "/home/u/link/.."); + } + + #[test] + fn an_absolute_path_drops_a_current_directory_component() { + let t = table("/a/./b/*.md", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new("/a/b")); + assert_eq!(t.path_prefix, "/a/b"); + } + #[test] fn normalize_drops_current_directory_components() { assert_eq!(unix_normalize("/a/./b"), "/a/b"); @@ -997,6 +1035,19 @@ mod tests { assert_eq!(t.path_prefix, "//server/share/docs"); } + #[test] + fn a_verbatim_path_folds_a_parent_component_the_os_would_take_literally() { + let t = table(r"\\?\C:\var\x\..\log\*.log", ¬hing_is_a_dir); + assert_eq!(t.root, Path::new(r"\\?\C:\var\log")); + assert_eq!(t.path_prefix, r"\\?\C:\var\log"); + } + + #[test] + fn a_drive_letter_path_folds_a_parent_component_as_windows_does() { + let t = table(r"C:\var\x\..\log\*.log", ¬hing_is_a_dir); + assert_eq!(t.path_prefix, "C:/var/log"); + } + #[test] fn a_verbatim_path_keeps_its_backslashes() { let t = table(r"\\?\C:\var\log\*.log", ¬hing_is_a_dir); From 3462cd1b23350d62d849b0e1695fd9764a2fe769 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:57:51 -0500 Subject: [PATCH 3/4] e2e attestation for bot/1580-abs-dotdot-1104 --- .../python/e2e-attestations/bot-1580-abs-dotdot-1104.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/bot-1580-abs-dotdot-1104.json diff --git a/packages/python/e2e-attestations/bot-1580-abs-dotdot-1104.json b/packages/python/e2e-attestations/bot-1580-abs-dotdot-1104.json new file mode 100644 index 000000000..d32deff64 --- /dev/null +++ b/packages/python/e2e-attestations/bot-1580-abs-dotdot-1104.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/query_contract_test.py tests/e2e/on_file_query_test.py tests/e2e/use_case_join_test.py tests/e2e/glob_speed_symlink_test.py tests/e2e/fanout_test.py -x -q", + "ran_at": 1793815070, + "exit_code": 0, + "commit": "5fe39f0e3b4c76172402aa69ecb8b992265d9994", + "branch": "bot/1580-abs-dotdot-1104" +} From 7169dfda93045d53911227790e34d185f3926537 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:59:27 -0500 Subject: [PATCH 4/4] e2e attestation for bot/1580-abs-dotdot-1104 --- packages/ts/e2e-attestations/bot-1580-abs-dotdot-1104.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/bot-1580-abs-dotdot-1104.json diff --git a/packages/ts/e2e-attestations/bot-1580-abs-dotdot-1104.json b/packages/ts/e2e-attestations/bot-1580-abs-dotdot-1104.json new file mode 100644 index 000000000..bcf8f4725 --- /dev/null +++ b/packages/ts/e2e-attestations/bot-1580-abs-dotdot-1104.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1793815167, + "exit_code": 0, + "commit": "3462cd1b23350d62d849b0e1695fd9764a2fe769", + "branch": "bot/1580-abs-dotdot-1104" +}