fix(file-ops): a dangling symlink is an occupied entry, never an absent path

[ -f ] and [ -e ] follow symlinks, so a dangling link probed as missing and
read_file_raw reported not_found: V4A Add followed the link and created its
target, Move replaced the link, both reporting success. The shell size
probe, the compound read probe and the native stat now classify any
symlink entry as not a regular file.
This commit is contained in:
John Paul Soliva
2026-09-25 22:35:20 +09:00
committed by Austin Pickett
parent 48a4a360a0
commit 9556b73e7f
2 changed files with 24 additions and 5 deletions

View File

@@ -547,6 +547,21 @@ class TestBomHandling:
f"*** Begin Patch\n*** Update File: {target}\n@@\n-VERSION=1\n+VERSION=2\n*** End Patch").success
assert target.read_bytes() == original
@pytest.mark.parametrize("op", ["add", "move"])
def test_a_dangling_symlink_destination_is_occupied(self, ops, tmp_path: Path, op):
# `[ -f ]` and `[ -e ]` follow the link, so a dangling one read as an absent path: Add
# followed it and created its target, Move replaced the link. The entry is there.
link = tmp_path / "link.txt"
link.symlink_to(tmp_path / "gone.txt")
(tmp_path / "src.txt").write_bytes(b"SOURCE\n")
body = (f"*** Add File: {link}\n+X\n" if op == "add"
else f"*** Move File: {tmp_path / 'src.txt'} -> {link}\n")
res = ops.patch_v4a(f"*** Begin Patch\n{body}*** End Patch")
assert not res.success
assert link.is_symlink() and os.readlink(link) == str(tmp_path / "gone.txt")
assert not (tmp_path / "gone.txt").exists()
assert (tmp_path / "src.txt").read_bytes() == b"SOURCE\n"
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="POSIX only: needs os.mkfifo and SIGALRM")
def test_native_byte_exact_read_never_opens_a_non_regular_file(self, tmp_path: Path, monkeypatch):
# The native fast path bypasses the backend timeout, so a blocking open there hangs the

View File

@@ -595,8 +595,8 @@ class ShellFileOperations(LintMixin, SearchMixin, FileOperations):
def _not_regular_error(path: str) -> ReadResult:
"""Error for a path that exists but would block if read."""
return ReadResult(error=(
f"Cannot read '{path}': not a regular file (directory, FIFO, "
"socket, or device). Reading it could block indefinitely."))
f"Cannot read '{path}': not a regular file (directory, dangling symlink, "
"FIFO, socket, or device). Reading it could block indefinitely."))
def _probe_regular_file(self, path: str) -> tuple[int, str]:
"""Byte size of a REGULAR file: ``(file_size, status)`` with status ``"ok"``,
@@ -605,14 +605,16 @@ class ShellFileOperations(LintMixin, SearchMixin, FileOperations):
wrapper itself failed (``_env_unavailable_error`` surfaces it verbatim).
``wc -c <`` on a writer-less FIFO/socket//dev/zero blocks forever and a
name-based blocklist can't cover a FIFO (a file TYPE at any path); ``[ -f ]``
is a stat (symlinks followed) so it answers without touching content."""
is a stat (symlinks followed) so it answers without touching content. A dangling
symlink is ``not_regular``, never ``missing``: the entry exists, and a writer
told the path is free would follow the link and create its target."""
arg = self._escape_shell_arg(path)
# A missing path ECHOES its sentinel: a non-zero exit with no sentinel means the shell itself did
# not run (container still starting, removed out-of-band, transport down) — not a missing file.
# Reporting that as "File not found" made the model trust a false negative for the whole session.
stat_result = self._exec(
f"if [ -f {arg} ]; then wc -c < {arg} 2>/dev/null; "
f"elif [ -e {arg} ]; then echo {NOT_REGULAR_SENTINEL}; "
f"elif [ -e {arg} ] || [ -L {arg} ]; then echo {NOT_REGULAR_SENTINEL}; "
f"else echo {MISSING_SENTINEL}; fi")
stat_output = _strip_terminal_fence_leaks(stat_result.stdout).strip()
if stat_output == MISSING_SENTINEL:
@@ -887,6 +889,8 @@ class ShellFileOperations(LintMixin, SearchMixin, FileOperations):
try:
st = os.stat(full)
except (FileNotFoundError, NotADirectoryError):
if os.path.islink(full): # dangling: an entry, not an absent path (``_probe_regular_file``)
return self._not_regular_error(path)
return self._read_file_missing(path, offset, limit)
except OSError:
return self._read_file_sequential(path, offset, limit)
@@ -988,7 +992,7 @@ class ShellFileOperations(LintMixin, SearchMixin, FileOperations):
f"wc -l < {arg} 2>/dev/null; {mark}; "
f"tail -c 1 {arg} 2>/dev/null | wc -l; {mark}; "
f'echo "$__hs $__hr"; '
f"elif [ -e {arg} ]; then echo {NOT_REGULAR_SENTINEL}; "
f"elif [ -e {arg} ] || [ -L {arg} ]; then echo {NOT_REGULAR_SENTINEL}; "
f"else echo {MISSING_SENTINEL}; fi")
def _read_file_missing(self, path: str, offset: int, limit: int) -> ReadResult: