From 5c02196b2742c1e9b98390ac1201ccc337b2adad Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:24:36 +0200 Subject: [PATCH] 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. --- install.py | 73 ++++++++++++++++++++++++++++++-- tests/test_install_experience.py | 29 +++++++++++++ tests/test_install_tui.py | 54 +++++++++++++++++++++++ wiki/installer.md | 8 +++- 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/install.py b/install.py index 85eea2ff..60f49e22 100644 --- a/install.py +++ b/install.py @@ -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")) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 2a74b539..05d65bff 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -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 diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index b4fe9f42..76ad6e34 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -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. diff --git a/wiki/installer.md b/wiki/installer.md index f92d3d65..83e12994 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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