From 318113a9f7d66b3027a2179c73171c3a4e9db137 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 5 Nov 2026 19:28:41 +0000 Subject: [PATCH 1/2] fix(fs): follow final link under linked dirs; slash keys for implicit parents - FollowFs write target resolves the final component when a parent is a link, so > replaces the target, not the link, and >> works (#2829) - InMemoryFs add_file/add_dir/add_lazy_file/mkdir -p build implicit parent keys with vfs_join, not PathBuf::push (#2830) Closes #2829 Closes #2830 --- crates/bashkit/src/fs/follow.rs | 37 +++++++++++++++++-- crates/bashkit/src/fs/memory.rs | 32 ++++++++++++++-- .../tests/spec_cases/bash/symlink.test.sh | 18 +++++++++ knowledge/foundations/vfs.md | 8 +++- knowledge/log.md | 2 + 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/crates/bashkit/src/fs/follow.rs b/crates/bashkit/src/fs/follow.rs index 652995d1e..a77d6ec78 100644 --- a/crates/bashkit/src/fs/follow.rs +++ b/crates/bashkit/src/fs/follow.rs @@ -157,10 +157,9 @@ impl FollowFs { match self.inner.stat(path).await { Ok(meta) if meta.file_type.is_symlink() => self.resolve(path, true).await, Ok(_) => Ok(path.to_path_buf()), - Err(_) => Ok(self - .parent_followed(path) - .await? - .unwrap_or_else(|| path.to_path_buf())), + // The inner stat fails on a link in a parent; resolve the final + // component too so a write never replaces a link (issue #2829). + Err(_) => self.resolve(path, true).await, } } } @@ -410,6 +409,36 @@ mod tests { assert_eq!(fs.read_dir(Path::new("/d/link")).await.unwrap().len(), 1); } + // Issue #2829: with a link in a parent, the inner stat fails; the final + // component must still be followed so the write never replaces the link. + #[tokio::test] + async fn writes_through_link_in_linked_dir_follow_final_link() { + let fs = fs(); + fs.mkdir(Path::new("/sub"), true).await.unwrap(); + fs.write_file(Path::new("/file"), b"a").await.unwrap(); + fs.symlink(Path::new("../file"), Path::new("/sub/link")) + .await + .unwrap(); + fs.symlink(Path::new("sub"), Path::new("/dl")) + .await + .unwrap(); + + fs.append_file(Path::new("/dl/link"), b"b").await.unwrap(); + assert_eq!(fs.read_file(Path::new("/file")).await.unwrap(), b"ab"); + fs.write_file(Path::new("/dl/link"), b"c").await.unwrap(); + assert_eq!(fs.read_file(Path::new("/file")).await.unwrap(), b"c"); + assert!( + fs.lstat(Path::new("/sub/link")) + .await + .unwrap() + .file_type + .is_symlink() + ); + // A new file under a linked dir still lands in the real dir. + fs.write_file(Path::new("/dl/new"), b"n").await.unwrap(); + assert_eq!(fs.read_file(Path::new("/sub/new")).await.unwrap(), b"n"); + } + #[tokio::test] async fn loops_fail_with_eloop() { let fs = fs(); diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index 0ffd38e12..57d08f96a 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -932,7 +932,7 @@ impl InMemoryFs { if let Some(parent) = path.parent() { let mut current = PathBuf::from("/"); for component in parent.components().skip(1) { - current.push(component); + current = vfs_join(¤t, component); if !entries.contains_key(¤t) { entries.insert( current.clone(), @@ -1020,7 +1020,7 @@ impl InMemoryFs { // Create the directory and every ancestor that does not already exist. let mut current = PathBuf::from("/"); for component in path.components().skip(1) { - current.push(component); + current = vfs_join(¤t, component); if entries.contains_key(¤t) { continue; } @@ -1098,7 +1098,7 @@ impl InMemoryFs { if let Some(parent) = path.parent() { let mut current = PathBuf::from("/"); for component in parent.components().skip(1) { - current.push(component); + current = vfs_join(¤t, component); if !entries.contains_key(¤t) { entries.insert( current.clone(), @@ -1483,7 +1483,7 @@ impl FileSystem for InMemoryFs { if recursive { let mut current = PathBuf::from("/"); for component in path.components().skip(1) { - current.push(component); + current = vfs_join(¤t, component); match entries.get(¤t) { Some(FsEntry::Directory { .. }) => { // Directory exists, continue to next component @@ -2011,6 +2011,30 @@ mod tests { assert_eq!(entries[0].name, "main.rs"); } + // Issue #2830: implicit parent directories become entry-map keys directly, + // so they must use `/` even where the host separator is `\\`. + #[tokio::test] + async fn windows_containment_implicit_parents_use_slash_keys() { + let fs = InMemoryFs::new(); + fs.mkdir(Path::new("/m/a/b"), true).await.unwrap(); + fs.add_file("/f/x/file.txt", b"x", 0o644); + fs.add_dir("/d/y/z", 0o755); + fs.add_lazy_file("/l/w/lazy.bin", 1, 0o644, Arc::new(|| b"z".to_vec())); + + let keys: Vec = fs + .entries + .read() + .unwrap() + .keys() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let bad: Vec<&String> = keys.iter().filter(|k| k.contains('\\')).collect(); + assert!(bad.is_empty(), "host separator in VFS keys: {bad:?}"); + for dir in ["/m/a", "/m/a/b", "/f/x", "/d/y", "/d/y/z", "/l/w"] { + assert!(keys.iter().any(|k| k == dir), "missing key {dir}"); + } + } + // A regular file in the path is ENOTDIR (bash: `f/x: Not a directory`), // not a place to create entries. #[tokio::test] diff --git a/crates/bashkit/tests/spec_cases/bash/symlink.test.sh b/crates/bashkit/tests/spec_cases/bash/symlink.test.sh index 8e2281b0c..68be455e9 100644 --- a/crates/bashkit/tests/spec_cases/bash/symlink.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/symlink.test.sh @@ -146,3 +146,21 @@ lnk sub ../elsewhere ### end + +### symlink_write_through_link_in_linked_dir +# issue #2829: > and >> through a link inside a symlinked dir write the target +mkdir -p /tmp/slw/sub && cd /tmp/slw +printf a > file +ln -s ../file sub/link +ln -s sub dl +printf b >> dl/link +printf c >> dl/link +cat file; echo +printf d > dl/link +cat file; echo +readlink sub/link +### expect +abc +d +../file +### end diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index 583c3e0a9..77caf0b49 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -290,6 +290,10 @@ the read-error path in `tail`/`rev`/`tac`/`shuf`/`sort`/`uniq`/`wc` did the same `to.join(suffix)` straight into the entry map, so on Windows the renamed children were keyed `\`-style and every normalized lookup afterwards missed them. +The implicit-parent loops in `add_file`, `add_dir`, `add_lazy_file` and +recursive `mkdir` had the same shape (`current.push(component)` used as a map +key): lookups worked, but `snapshot()` exposed `/tmp\a` keys (issue #2830). +They now build keys with `vfs_join`. The same host/VFS mismatch has a second shape: `Path::is_absolute()` is false on Windows for a VFS-absolute path like `/etc/passwd`, because there is no @@ -388,7 +392,9 @@ their `stat` is an `lstat`, and they refuse to read or write through a link. `file`, `rm`, `find` (`-P` default), Python `Path.is_symlink`. - Targets are VFS paths: absolute from the VFS root, relative to the link's directory, `..` clamped at `/` (TM-ESC-002). Writes through a dangling link - create the target. + create the target. Writes resolve the final component even when a parent + is a link (`> dl/link` with `dl -> sub`), so they never replace a link + with a file (issue #2829). - Recursive `find` and `grep` share `fs::canonicalize`, resolving links through `lstat`/`read_link` with the same 40-hop cap. It uses VFS joins and root-aware symlink targets on every host. Walkers compare resolved ancestor diff --git a/knowledge/log.md b/knowledge/log.md index 150c80eca..551646141 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-11-05 +* **Writes through links under linked dirs**: `> dl/link` (with `dl -> sub`, `sub/link -> ../file`) replaced `sub/link` with a regular file and `>>` failed with NotFound, because the write path resolved only the parents once the inner `stat` failed. Writes now resolve the final component too (issue #2829). See [Virtual Filesystem](foundations/vfs.md#symlink-handling). +* **`/` keys for implicit parents on Windows**: `mkdir -p`, `add_file`, `add_dir` and `add_lazy_file` keyed implicit parent dirs with `PathBuf::push`, so `snapshot()` on Windows held `/tmp\a`-style paths (issue #2830). They use `vfs_join` now; the test runs in the Windows containment CI job. See [Virtual Filesystem](foundations/vfs.md). * **TypeScript runtime: same gaps as Python**: `ts` reserved 1M budget units per call (about 95 calls per script), reported its time limit as exit 1, and about 6 KB of nested source (`[[[...`, `1+1+...`) overflowed ZapCode's recursive parser/compiler and aborted the host process. Calls now charge 4096 units plus VM time, timeouts exit 124, and a nesting estimate plus a large-stack VM thread guard the parser (TM-TS-024). See [ZapCode Runtime](runtimes/zapcode-runtime.md). * **Python `re` gate parses code**: the embedded Monty `python3` blocked `import re` by matching lines that start with `import`/`from`, so `pass; import re` or `if True: import re` loaded the module anyway (found by a downstream integration review). The gate now walks the parsed statement tree. See [Python Builtin](runtimes/python-builtin.md). * **Python call cap and final-value hang**: each embedded `python3` call reserved `max_memory / 64` work units up front, so a default script failed on its 96th call; heap growth is now charged as it happens. The last expression is no longer echoed (CPython never does for `-c`/files/stdin): exporting it ran outside Monty's limits, and `x = [x, x]` thirty times hung past the timeout. Found reviewing a downstream integration. See [Python Builtin](runtimes/python-builtin.md). From 5f3e14a716c2cda7739b3920cc7c0d6e337c9982 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 5 Nov 2026 19:36:33 +0000 Subject: [PATCH 2/2] fix(fs): keep new-file write stat count; check final link only after a parent link --- crates/bashkit/src/fs/follow.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/src/fs/follow.rs b/crates/bashkit/src/fs/follow.rs index a77d6ec78..cf3bfe61e 100644 --- a/crates/bashkit/src/fs/follow.rs +++ b/crates/bashkit/src/fs/follow.rs @@ -157,9 +157,17 @@ impl FollowFs { match self.inner.stat(path).await { Ok(meta) if meta.file_type.is_symlink() => self.resolve(path, true).await, Ok(_) => Ok(path.to_path_buf()), - // The inner stat fails on a link in a parent; resolve the final - // component too so a write never replaces a link (issue #2829). - Err(_) => self.resolve(path, true).await, + // The inner stat fails on a missing path or a link in a parent. + // Only in the second case can the final component still be a + // link (issue #2829); check it then, so plain new-file writes + // keep their stat count. + Err(_) => match self.parent_followed(path).await? { + None => Ok(path.to_path_buf()), + Some(target) => match self.inner.stat(&target).await { + Ok(meta) if meta.file_type.is_symlink() => self.resolve(&target, true).await, + _ => Ok(target), + }, + }, } } }