fix: refuse a --dest this user cannot reach

Path.exists() raised PermissionError from _missing_dest in line mode and from _choose in the interface. The run now names the folder and exits 1 (error event stage dest), and the interface asks for another folder.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 23:24:36 +02:00
1 parent e61d1e22c0
commit 5c02196b27
4 files changed
+160 -4

No files matched your search

+70 -3
View File
@@ -4173,6 +4173,14 @@ def _exists(path: Path) -> bool:
return False
def _is_dir(path: Path) -> bool:
"""Path.is_dir(), False where the place cannot be looked at."""
try:
return path.is_dir()
except OSError:
return False
def _is_file(path: Path) -> bool:
"""Path.is_file(), False where the place cannot be looked at."""
try:
@@ -4181,6 +4189,35 @@ def _is_file(path: Path) -> bool:
return False
def closed_folder(path: Path) -> tuple[Path, str] | None:
"""The folder above path this user cannot open, and why in words, or
None when path can be looked at, there or not.
A BIOS folder under /root run without sudo, or a card mounted at 0700
for another user: Path.exists() raised PermissionError before the
first line said where.
"""
try:
path.exists()
return None
except OSError as exc:
failure = exc
probe = path.absolute()
for folder in (*reversed(probe.parents), probe):
try:
os.stat(folder)
except OSError as exc:
if exc.errno in (errno.ENOENT, errno.ENOTDIR):
return None
return folder.parent, exc.strerror or str(exc)
return probe, failure.strerror or str(failure)
def closed_folder_words(dest: Path, closed: tuple[Path, str]) -> str:
folder, why = closed
return f"this user cannot open {folder} ({why}), which holds {dest}"
def unreachable_words(exc: OSError) -> str:
"""Why the place of a file could not be looked at, in words."""
return (f"could not be read ({exc.strerror or exc}): this user may not "
@@ -8737,8 +8774,17 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No
parent, filled, and reported done in a folder no platform reads, and
--check said only "0/7 present". One missing level is a BIOS folder not
made yet and is created. Several make a path that may be mistyped:
at a terminal the run asks first, and the answer defaults to no.
at a terminal the run asks first, and the answer defaults to no. A
folder above it this user cannot open ends the run with its name.
"""
closed = closed_folder(dest)
if closed is not None:
message = closed_folder_words(dest, closed)
EVENTS.emit("error", stage="dest", reason="unreadable", platform="", message=message)
print(f"Error: {screen_text(message)}. Give this user access to it, run as "
f"its owner, or choose another folder than the one {named}.",
file=sys.stderr)
sys.exit(1)
if dest.exists():
return
nearest = dest.absolute()
@@ -10699,7 +10745,22 @@ def _start_check(state: TuiState) -> list[tuple]:
return [("fetch", state.platform)]
def _closed_status(state: TuiState, closed: tuple[Path, str]) -> str:
return (f"This user cannot open {_short_path(closed[0], state.home)} ({closed[1]}): "
"choose another folder, or give this user access to it.")
def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]:
closed = closed_folder(dest)
if closed is not None:
# Nothing can be checked or written there: the folder is asked
# again, on the screen that chose it when it can say why.
state.platform, state.status = plat, _closed_status(state, closed)
if state.stack and state.screen in ("folder", "browse", "path"):
return []
state.folder_default = (None if plat in CARD_PLATFORMS
else _default_dest(state.os_type, plat))
return _push(state, "folder")
card = card_system(dest)
if card and card[0] == plat and card[1] != dest:
# A muOS or KNULLI card's root: that system reads a folder below it.
@@ -10801,8 +10862,9 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No
def _nearest_folder(path: Path) -> Path:
"""The deepest folder on path that exists and can be looked at."""
probe = path.absolute()
while not probe.is_dir() and probe.parent != probe:
while not _is_dir(probe) and probe.parent != probe:
probe = probe.parent
return probe
@@ -11101,6 +11163,10 @@ def _path_key(state: TuiState, key: TuiKey) -> list[tuple]:
if path is None:
state.status = "Type a folder first."
return []
closed = closed_folder(path)
if closed is not None:
state.status = _closed_status(state, closed)
return []
state.stack.pop()
return _choose(state, state.platform, path)
return []
@@ -11759,7 +11825,8 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple:
else:
title = "Which folder?"
before = [f"No usual {name} folder here: browse for it or type it."]
return (title, _compose(state, width, height, before, tui_rows(state)),
after = [state.status] if state.status else []
return (title, _compose(state, width, height, before, tui_rows(state), after),
("A/Enter Choose", "B/Esc Back", "? Help"))
+29
View File
@@ -565,6 +565,35 @@ class AFolderThisUserCannotEnter(unittest.TestCase):
os.chmod(locked, 0o755)
self.assertEqual(state, "wrong_hash")
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_a_bios_folder_under_it_is_refused_in_words(self):
"""--dest /root/.config/retroarch/system without sudo: _missing_dest
ended --check and the install on a PermissionError traceback."""
with FakeRepository() as repo:
lock = repo.root / "lock"
lock.mkdir()
repo.dest = lock / "system"
os.chmod(lock, 0o000)
try:
runs = {"check": repo.run("--check"), "install": repo.run(),
"events": repo.run("--events")}
finally:
os.chmod(lock, 0o755)
self.assertFalse(repo.dest.exists())
for name, proc in runs.items():
with self.subTest(run=name):
self.assertNotIn("Traceback", proc.stderr)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn(f"this user cannot open {lock} (Permission denied)",
proc.stderr)
self.assertIn("choose another folder", proc.stderr)
events = [json.loads(line) for line in runs["events"].stdout.splitlines()]
errors = [e for e in events if e["event"] == "error"]
self.assertEqual([(e["stage"], e["reason"]) for e in errors],
[("dest", "unreadable")])
self.assertIn(str(lock), errors[0]["message"])
class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file
+54
View File
@@ -2778,6 +2778,60 @@ class Crash(unittest.TestCase):
self.assertNotIn("Traceback", err.getvalue() + out.getvalue())
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
class AFolderThisUserCannotOpen(unittest.TestCase):
"""A BIOS folder under a folder this user cannot enter: given with
--dest the interface ended on a traceback before its first screen, and
typed on the folder screen it ended on a crash log."""
def setUp(self):
base = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, base, True)
self.lock = base / "lock"
self.lock.mkdir()
os.chmod(self.lock, 0o000)
self.addCleanup(os.chmod, self.lock, 0o755)
self.dest = self.lock / "system"
def said(self, state: install.TuiState) -> str:
return " ".join(" ".join(install.tui_view(state, 200, 40)).split())
def test_typed_it_stays_on_the_path_screen(self):
state = fresh_state()
state.platform = "retroarch"
state.stack = ["setup", "folder", "path"]
state.path_text = str(self.dest)
self.assertEqual(install.tui_update(state, Key("enter")), [])
self.assertEqual(state.screen, "path")
self.assertEqual(state.path_text, str(self.dest))
self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} "
"(Permission denied)", self.said(state))
def test_given_with_dest_opens_the_folder_screen(self):
args = _args(platform="retroarch", dest=str(self.dest))
with mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "_mounted_cards", return_value=[]):
state, effects = install.tui_start(args, "linux")
self.assertEqual(effects, [])
self.assertEqual(state.screen, "folder")
self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} "
"(Permission denied)", self.said(state))
self.assertIn("choose another folder", self.said(state))
def test_chosen_from_the_setups(self):
state = fresh_state()
state.setups = [("retroarch", self.dest, "RetroArch")]
state.stack = ["setup"]
self.assertEqual(install.tui_update(state, Key("enter")), [])
self.assertEqual(state.screen, "folder")
self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} "
"(Permission denied)", self.said(state))
def test_the_nearest_folder_stops_above_it(self):
self.assertEqual(install._nearest_folder(self.dest / "deeper"), self.lock)
# POSIX modules a Windows Python does not have: importing one raises there.
POSIX_ONLY = ("termios", "tty", "pty", "fcntl", "select", "resource", "pwd", "grp")
# The signal names a Windows Python defines.
+7 -1
View File
@@ -1289,7 +1289,7 @@ other schemas under `api/v1/schemas/`.
|------|------|------|
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found |
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). `message` says it in words |
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). `message` says it in words |
| `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered |
| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place |
| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` |
@@ -1382,6 +1382,12 @@ owns it (`ls -la`). On Batocera, Recalbox and ROCKNIX the folder belongs to
root, so the same command with `sudo` in front of it succeeds. On Windows, run
PowerShell as Administrator only for a protected folder.
When a folder above the BIOS folder cannot be opened at all (`/root/...` run
without `sudo`, a card mounted for another user), the run stops before it
starts: `this user cannot open /root/.config (Permission denied), which holds
/root/.config/retroarch/system`. The full-screen interface says the same on
the folder screen and asks for another folder.
A file system mounted read-only refuses root too, so the installer does not
suggest `sudo` for it. Linux remounts a card or drive read-only after an error,
an unclean removal for one: check it (`fsck`, or `chkdsk` on Windows) and