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": "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"
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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`.
75 changes: 63 additions & 12 deletions packages/rust/src/path_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) -> bool;

fn reported(path: &Utf8Path<Self>) -> String;
}

Expand All @@ -68,6 +72,10 @@ impl Syntax for Utf8UnixEncoding {
name.starts_with('/')
}

fn is_verbatim(_: &Utf8Path<Self>) -> bool {
false
}

fn reported(path: &Utf8Path<Self>) -> String {
path.as_str().to_string()
}
Expand All @@ -81,14 +89,17 @@ impl Syntax for Utf8WindowsEncoding {
Utf8WindowsPath::new(name).has_root()
}

fn is_verbatim(path: &Utf8Path<Self>) -> 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<Self>) -> 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('\\', "/")
Expand Down Expand Up @@ -137,6 +148,9 @@ fn resolve_as<S: Syntax>(
}

match absolute_target::<S>(&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,
Expand Down Expand Up @@ -193,7 +207,7 @@ fn config_anchor_as<S: Syntax>(
let Some(target) = absolute_target::<S>(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));
Expand Down Expand Up @@ -257,7 +271,9 @@ fn typed<S: Syntax>(path: &Path) -> Utf8PathBuf<S> {
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.
Expand All @@ -267,20 +283,19 @@ fn absolute_target<S: Syntax>(
home: Option<&Path>,
) -> Option<Option<Utf8PathBuf<S>>> {
if let Some(rest) = S::HOME_PREFIXES.iter().find_map(|p| name.strip_prefix(p)) {
return Some(home.map(|h| normalize(&typed::<S>(h).join(rest))));
return Some(home.map(|h| typed::<S>(h).join(rest)));
}
if name.starts_with("../") {
return Some(Some(normalize(&typed::<S>(index_root).join(name))));
return Some(Some(typed::<S>(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<S: Syntax>(path: &Utf8Path<S>) -> Utf8PathBuf<S> {
let mut kept: Vec<&str> = Vec::new();
let mut normal = 0;
Expand Down Expand Up @@ -820,6 +835,29 @@ mod tests {
assert_eq!(resolve_with("..", &nothing_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", &nothing_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", &nothing_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", &nothing_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");
Expand Down Expand Up @@ -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", &nothing_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", &nothing_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", &nothing_is_a_dir);
Expand Down
59 changes: 59 additions & 0 deletions packages/rust/tests/path_table_cli_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)]
);
}
}
47 changes: 47 additions & 0 deletions packages/rust/tests/path_table_symlinks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())]
);
}
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/bot-1580-abs-dotdot-1104.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1793815167,
"exit_code": 0,
"commit": "3462cd1b23350d62d849b0e1695fd9764a2fe769",
"branch": "bot/1580-abs-dotdot-1104"
}
Loading