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 00000000..d32deff6 --- /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" +} 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 00000000..1acb0d6f --- /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 00000000..85887cad --- /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 89a479fb..26ccf6b3 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); diff --git a/packages/rust/tests/path_table_cli_e2e.rs b/packages/rust/tests/path_table_cli_e2e.rs index 5ef15d2e..f3913a8e 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 c37588d3..d275501b 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())] + ); +} 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 00000000..bcf8f472 --- /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" +}