realpath() is declared on the IFileSystem protocol in just_bash/types.py:
async def realpath(self, path: str) -> str:
"""Resolve path to absolute canonical path (resolve all symlinks)."""
...
Only InMemoryFs implements it. ReadWriteFs, OverlayFs and MountableFs do not, so any command that reaches for it raises AttributeError on those three backends.
Two commands call it:
| call site |
reached by |
result on a backend without it |
commands/pwd/pwd.py:47 |
pwd -P |
AttributeError escapes bash.exec() |
commands/readlink/readlink.py:59 |
readlink -f on an existing path |
caught by the command, reported as if it were a file error |
import asyncio, os, tempfile
from just_bash import Bash
from just_bash.fs import ReadWriteFs, ReadWriteFsOptions
async def main():
with tempfile.TemporaryDirectory() as d:
open(os.path.join(d, "real.txt"), "w").write("x\n")
os.symlink("real.txt", os.path.join(d, "link.txt"))
bash = Bash(fs=ReadWriteFs(ReadWriteFsOptions(root=d)), cwd="/")
print(await bash.exec("readlink -f /link.txt"))
# exit_code=1, stderr="readlink: /link.txt: 'ReadWriteFs' object has no attribute 'realpath'\n"
await bash.exec("pwd -P")
# AttributeError: 'ReadWriteFs' object has no attribute 'realpath'
asyncio.run(main())
readlink -f is arguably the worse of the two: it does not crash, it reports a Python attribute error in the position where a user expects a filesystem message. pwd -P escapes as an exception.
This is the same class of gap as dbreunig#6 (utimes missing from ReadWriteFs, MountableFs, OverlayFs) — the protocol declares a method, one backend has it, the rest silently do not.
Why it wasn't fixed in dbreunig#10
Upstream PR dbreunig#10 widens IFileSystem with cp, mv, link and lstat, and adds a conformance test that checks every built-in backend implements everything the protocol declares. That test is what surfaced this: realpath is currently listed in its KNOWN_GAPS set so the test can guard everything else without hiding it.
It was left out of that PR on purpose. Widening a protocol to match what the backends already do is mechanical — every backend agreed on all four signatures, so there was nothing to decide. Writing realpath is not mechanical, because canonicalization is exactly where symlink escape lives, and OverlayFs has a dedicated security test suite (tests/test_fs/test_overlay_fs_security.py) built around not leaking real paths. That deserves a deliberate decision from whoever owns the sandbox's threat model, not a drive-by implementation folded into an unrelated PR.
What a fix needs, per backend
ReadWriteFs — resolve through the real filesystem (self._to_real_path(path).resolve()), then map back into the virtual namespace. The open question is what to do when the result lands outside self._root: readlink() currently returns the real path in that case (read_write_fs.py:442-444), which is at least consistent, but for pwd -P it means a host path leaks into shell output. Clamping to / is the other option.
OverlayFs — has to resolve across both layers: in-memory symlink entries in self._memory and real symlinks under self._root, honouring self._deleted and re-prefixing with self._mount_point. readlink() already does the "convert absolute real paths back to virtual paths to prevent leaking" dance (overlay_fs.py:904-919) and is the model to follow. This is the one that needs care.
MountableFs — route with _route_path(), delegate, then re-prefix the mount point onto the child's answer (readlink() at mountable_fs.py:464-467 delegates but does not re-prefix, which looks like a separate bug worth checking). Keeping the hasattr guard used elsewhere in that file would leave third-party backends that predate the protocol working.
Once a backend implements it, removing its entry from KNOWN_GAPS in tests/test_fs/test_ifilesystem_conformance.py keeps the conformance test passing.
realpath()is declared on theIFileSystemprotocol injust_bash/types.py:Only
InMemoryFsimplements it.ReadWriteFs,OverlayFsandMountableFsdo not, so any command that reaches for it raisesAttributeErroron those three backends.Two commands call it:
commands/pwd/pwd.py:47pwd -PAttributeErrorescapesbash.exec()commands/readlink/readlink.py:59readlink -fon an existing pathreadlink -fis arguably the worse of the two: it does not crash, it reports a Python attribute error in the position where a user expects a filesystem message.pwd -Pescapes as an exception.This is the same class of gap as dbreunig#6 (
utimesmissing fromReadWriteFs,MountableFs,OverlayFs) — the protocol declares a method, one backend has it, the rest silently do not.Why it wasn't fixed in dbreunig#10
Upstream PR dbreunig#10 widens
IFileSystemwithcp,mv,linkandlstat, and adds a conformance test that checks every built-in backend implements everything the protocol declares. That test is what surfaced this:realpathis currently listed in itsKNOWN_GAPSset so the test can guard everything else without hiding it.It was left out of that PR on purpose. Widening a protocol to match what the backends already do is mechanical — every backend agreed on all four signatures, so there was nothing to decide. Writing
realpathis not mechanical, because canonicalization is exactly where symlink escape lives, andOverlayFshas a dedicated security test suite (tests/test_fs/test_overlay_fs_security.py) built around not leaking real paths. That deserves a deliberate decision from whoever owns the sandbox's threat model, not a drive-by implementation folded into an unrelated PR.What a fix needs, per backend
ReadWriteFs— resolve through the real filesystem (self._to_real_path(path).resolve()), then map back into the virtual namespace. The open question is what to do when the result lands outsideself._root:readlink()currently returns the real path in that case (read_write_fs.py:442-444), which is at least consistent, but forpwd -Pit means a host path leaks into shell output. Clamping to/is the other option.OverlayFs— has to resolve across both layers: in-memory symlink entries inself._memoryand real symlinks underself._root, honouringself._deletedand re-prefixing withself._mount_point.readlink()already does the "convert absolute real paths back to virtual paths to prevent leaking" dance (overlay_fs.py:904-919) and is the model to follow. This is the one that needs care.MountableFs— route with_route_path(), delegate, then re-prefix the mount point onto the child's answer (readlink()atmountable_fs.py:464-467delegates but does not re-prefix, which looks like a separate bug worth checking). Keeping thehasattrguard used elsewhere in that file would leave third-party backends that predate the protocol working.Once a backend implements it, removing its entry from
KNOWN_GAPSintests/test_fs/test_ifilesystem_conformance.pykeeps the conformance test passing.