Skip to content

realpath is declared on IFileSystem but implemented only on InMemoryFs #1

Description

@ninowalker

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions