From db47c15eeb2d17ccc05aa8719d1a3d4f3fed8231 Mon Sep 17 00:00:00 2001 From: Nino Walker Date: Wed, 2 Sep 2026 13:46:31 +0200 Subject: [PATCH] Contain backend OSErrors at the command-execution boundary A custom filesystem backend that raises anything outside the three errors the commands recognize propagates out of bash.exec() as a Python exception instead of a nonzero exit. Network-backed backends have no exception to fail through at all. Add one except OSError clause to the try/finally in _execute_simple_command, which spans builtin dispatch, command dispatch and output redirection. It reports ": : " and exit 1, and only sees errors no command handled, so existing messages are unchanged. OSError only: the interpreter's control-flow exceptions derive from InterpreterError and cannot be swallowed, and a non-OSError means the backend is broken and should crash rather than become exit 1. Document the expected exception taxonomy for backend authors in the README. --- README.md | 12 ++ src/just_bash/interpreter/interpreter.py | 19 ++ .../test_fs_error_boundary.py | 173 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 tests/test_interpreter/test_fs_error_boundary.py diff --git a/README.md b/README.md index eb2595e..82629fe 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,18 @@ await bash.exec("ls /reference") # Overlay filesystem await bash.exec("ls /tmp") # In-memory (base) ``` +#### Custom Filesystems + +You can supply your own backend by implementing the `IFileSystem` protocol (`just_bash.types`). **Signal failures with `OSError` subclasses.** `FileNotFoundError`, `IsADirectoryError` and `PermissionError` are recognized by the commands, which phrase them the way the real coreutils do (`cat: /x: No such file or directory`). Any other `OSError` — including `TimeoutError` and `ConnectionError`, useful for network-backed filesystems — is caught at the command-execution boundary and reported generically as `: : ` with exit code 1, so a backend failure ends the command instead of escaping `bash.exec()`. Exceptions that are not `OSError` are treated as bugs and propagate, so raise those only when something is genuinely broken. + +```python +class MyFs: + async def read_file(self, path: str, encoding: str = "utf-8") -> str: + if not self._reachable(): + raise TimeoutError("backend unreachable") # -> "cat: backend unreachable", exit 1 + ... +``` + #### Direct Filesystem Access You can also access the filesystem directly through the `bash.fs` property: diff --git a/src/just_bash/interpreter/interpreter.py b/src/just_bash/interpreter/interpreter.py index d705dc1..453c55f 100644 --- a/src/just_bash/interpreter/interpreter.py +++ b/src/just_bash/interpreter/interpreter.py @@ -1066,6 +1066,7 @@ async def _execute_simple_command( else: self._state.fd_table.dup(target_fd, fd) + cmd_name = "" try: # Expand command name cmd_name = await expand_word_async(self._ctx, node.name) @@ -1201,6 +1202,24 @@ async def _execute_simple_command( # Process output redirections result = await self._process_output_redirections(node.redirections, result) return result + except OSError as e: + # Backstop for filesystem backends. Commands catch the errors they + # know how to phrase (ENOENT, EISDIR, EACCES) and those never reach + # here; anything else a backend raises becomes a failed command + # instead of an exception escaping bash.exec(). + # + # OSError only, deliberately. An OSError is the environment failing, + # which a shell contains and reports as a nonzero exit -- and since + # TimeoutError and ConnectionError are OSError subclasses, a remote + # or network-backed filesystem arrives here too. The interpreter's + # own control flow (ExitError, BreakError, ReturnError, ...) derives + # from InterpreterError, not OSError, so it cannot be swallowed + # here. Anything else reaching this point is a bug in the backend + # rather than a condition the shell can report, and a bug should + # crash loudly -- which is why this is not `except Exception`. + detail = e.strerror or str(e) + location = f"{e.filename}: " if e.filename else "" + return _result("", f"{cmd_name or 'bash'}: {location}{detail}\n", 1) finally: # Restore temporary assignments (both value and export status) for name, (old_value, was_exported) in temp_assignments.items(): diff --git a/tests/test_interpreter/test_fs_error_boundary.py b/tests/test_interpreter/test_fs_error_boundary.py new file mode 100644 index 0000000..7a12c07 --- /dev/null +++ b/tests/test_interpreter/test_fs_error_boundary.py @@ -0,0 +1,173 @@ +"""Tests that a filesystem backend's errors stay inside the interpreter. + +A custom backend can fail in ways the built-in ones never do -- a network +timeout, an I/O error, a permission model of its own. Those arrive as OSError +subclasses, and the command-execution boundary turns them into a failed +command rather than an exception escaping bash.exec(). +""" + +import errno + +import pytest + +from just_bash import Bash +from just_bash.fs import InMemoryFs + + +class FlakyFs: + """InMemoryFs with chosen paths rigged to fail on read.""" + + def __init__(self, initial_files=None, failures=None): + self._inner = InMemoryFs(initial_files=initial_files or {}) + self._failures = failures or {} + + def __getattr__(self, name): + return getattr(self._inner, name) + + def _check(self, path): + error = self._failures.get(path) + if error is not None: + raise error + + async def read_file(self, path: str, encoding: str = "utf-8") -> str: + self._check(path) + return await self._inner.read_file(path, encoding) + + async def read_file_bytes(self, path: str) -> bytes: + self._check(path) + return await self._inner.read_file_bytes(path) + + def resolve_path(self, base: str, path: str) -> str: + return self._inner.resolve_path(base, path) + + +class TestBackendErrorsAreContained: + """Test that an OSError from the backend becomes a failed command.""" + + @pytest.mark.asyncio + async def test_oserror_with_filename(self): + """An errno-bearing failure should report path and strerror, exit 1.""" + fs = FlakyFs( + initial_files={"/ok.txt": "fine\n"}, + failures={"/flaky.txt": OSError(errno.EIO, "Input/output error", "/flaky.txt")}, + ) + bash = Bash(fs=fs, cwd="/") + + result = await bash.exec("cat /flaky.txt") + + assert result.exit_code == 1 + assert result.stderr == "cat: /flaky.txt: Input/output error\n" + assert result.stdout == "" + + @pytest.mark.asyncio + async def test_timeout_error_without_filename(self): + """TimeoutError is an OSError, so a slow backend is contained too.""" + fs = FlakyFs( + initial_files={"/ok.txt": "fine\n"}, + failures={"/slow.txt": TimeoutError("backend timed out")}, + ) + bash = Bash(fs=fs, cwd="/") + + result = await bash.exec("cat /slow.txt") + + assert result.exit_code == 1 + assert result.stderr == "cat: backend timed out\n" + + @pytest.mark.asyncio + async def test_shell_keeps_running_afterwards(self): + """A contained failure should behave like any other nonzero command.""" + fs = FlakyFs( + initial_files={"/ok.txt": "fine\n"}, + failures={"/flaky.txt": OSError(errno.EIO, "Input/output error", "/flaky.txt")}, + ) + bash = Bash(fs=fs, cwd="/") + + result = await bash.exec("cat /flaky.txt; echo continued; cat /ok.txt") + + assert result.exit_code == 0 + assert result.stdout == "continued\nfine\n" + + @pytest.mark.asyncio + async def test_composes_with_errexit(self): + """set -e should stop on the contained failure.""" + fs = FlakyFs( + failures={"/flaky.txt": OSError(errno.EIO, "Input/output error", "/flaky.txt")}, + ) + bash = Bash(fs=fs, cwd="/", errexit=True) + + result = await bash.exec("cat /flaky.txt\necho unreachable") + + assert result.exit_code == 1 + assert "unreachable" not in result.stdout + + +class TestExistingBehaviorUnchanged: + """Test that the errors commands already handle keep their own messages.""" + + @pytest.mark.asyncio + async def test_missing_file_message(self): + """ENOENT should still come from cat, not from the boundary.""" + bash = Bash(files={"/ok.txt": "fine\n"}, cwd="/") + + result = await bash.exec("cat /nope.txt") + + assert result.exit_code == 1 + assert result.stderr == "cat: /nope.txt: No such file or directory\n" + + @pytest.mark.asyncio + async def test_directory_message(self): + """EISDIR should still come from cat.""" + bash = Bash(files={"/dir/file.txt": "x\n"}, cwd="/") + + result = await bash.exec("cat /dir") + + assert result.exit_code == 1 + assert result.stderr == "cat: /dir: Is a directory\n" + + @pytest.mark.asyncio + async def test_permission_message(self): + """EACCES should still come from cat.""" + fs = FlakyFs( + initial_files={"/secret.txt": "x\n"}, + failures={"/secret.txt": PermissionError("EACCES: permission denied")}, + ) + bash = Bash(fs=fs, cwd="/") + + result = await bash.exec("cat /secret.txt") + + assert result.exit_code == 1 + assert result.stderr == "cat: /secret.txt: Permission denied\n" + + +class TestControlFlowStillPropagates: + """Test that the boundary does not interfere with interpreter control flow.""" + + @pytest.mark.asyncio + async def test_exit_code_propagates(self): + """exit 3 should still terminate the script with 3.""" + bash = Bash(files={"/ok.txt": "fine\n"}, cwd="/") + + result = await bash.exec("echo before\nexit 3\necho after") + + assert result.exit_code == 3 + assert result.stdout == "before\n" + + @pytest.mark.asyncio + async def test_break_propagates(self): + """break should still leave the loop rather than be caught.""" + bash = Bash(cwd="/") + + result = await bash.exec("for i in 1 2 3; do echo $i; break; done; echo done") + + assert result.exit_code == 0 + assert result.stdout == "1\ndone\n" + + @pytest.mark.asyncio + async def test_return_propagates(self): + """return should still leave the function with its code.""" + bash = Bash(cwd="/") + + result = await bash.exec("f() { return 4; }\nf\necho $?") + + assert result.exit_code == 0 + assert result.stdout == "4\n"