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
45 changes: 41 additions & 4 deletions crates/bashkit/src/fs/follow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +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()),
Err(_) => Ok(self
.parent_followed(path)
.await?
.unwrap_or_else(|| path.to_path_buf())),
// 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),
},
},
}
}
}
Expand Down Expand Up @@ -410,6 +417,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();
Expand Down
32 changes: 28 additions & 4 deletions crates/bashkit/src/fs/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&current, component);
if !entries.contains_key(&current) {
entries.insert(
current.clone(),
Expand Down Expand Up @@ -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(&current, component);
if entries.contains_key(&current) {
continue;
}
Expand Down Expand Up @@ -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(&current, component);
if !entries.contains_key(&current) {
entries.insert(
current.clone(),
Expand Down Expand Up @@ -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(&current, component);
match entries.get(&current) {
Some(FsEntry::Directory { .. }) => {
// Directory exists, continue to next component
Expand Down Expand Up @@ -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() {
Comment thread
chaliy marked this conversation as resolved.
Resolved
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<String> = 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]
Expand Down
18 changes: 18 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/symlink.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion knowledge/foundations/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading