Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<command>: <filename>: <strerror>` 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:
Expand Down
19 changes: 19 additions & 0 deletions src/just_bash/interpreter/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
173 changes: 173 additions & 0 deletions tests/test_interpreter/test_fs_error_boundary.py
Original file line number Diff line number Diff line change
@@ -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"