fix: refuse a detected folder under a closed one

A system_directory read from retroarch.cfg under a folder this user cannot open ended the run, and the interface before its first screen, on a traceback. It now gets the refusal --dest gets. On a handheld, an idle close on an error screen shows that error for a few seconds instead of waiting for a button that cannot come.
This commit is contained in:
Abdessamad Derraz committed 2026-10-24 00:31:40 +02:00
1 parent a94050c1a3
commit f51bc069d5
7 files changed
+84 -12

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a"
$defaultInstallSha256 = "dcf0c308a5a3176b66360e196d10b2ac678bda7f9245b22bc0cf43b0459a7d4d"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+20 -9
View File
@@ -8767,6 +8767,19 @@ def _open_from_folders(folders: list[Path]) -> None:
other_drives=[str(drive) for drive in index.other_drives])
def _refuse_closed(dest: Path, named: str) -> None:
"""End the run, with its name, on a folder above dest this user cannot open."""
closed = closed_folder(dest)
if closed is None:
return
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)
def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> None:
"""Say that a --dest folder does not exist before anything goes there.
@@ -8777,14 +8790,7 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> 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)
_refuse_closed(dest, named)
if dest.exists():
return
nearest = dest.absolute()
@@ -9207,6 +9213,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
if args.dest is not None and not listing:
_missing_dest(args.dest, interactive and not (args.check or args.dry_run or args.yes))
elif not listing:
# A system_directory under /root read from retroarch.cfg, run without
# sudo: the case probe raised PermissionError before any line.
for _name, path in platforms:
_refuse_closed(path, "detected here")
total_downloaded = 0
total_up_to_date = 0
@@ -12495,7 +12506,7 @@ def _install_kind(path: Path) -> str:
("/snap/", "Snap")):
if marker in text:
return word
if (path.parent / "retroarch.cfg").is_file():
if _is_file(path.parent / "retroarch.cfg"):
return "portable"
return ""
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a"
DEFAULT_INSTALL_SHA256="dcf0c308a5a3176b66360e196d10b2ac678bda7f9245b22bc0cf43b0459a7d4d"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+6
View File
@@ -876,6 +876,12 @@ rb_here() {
# Ended by Start + Select or a signal: the player asked to leave, and no
# button may be left to close a message.
:
elif [ "$status" != "0" ] && [ "$(head -n 1 "$RB_STATE/tui.err" 2>/dev/null)" = "No button pressed, closing." ]; then
# Closed for want of a button: the mapper never opened the pad, so no A
# is coming. The error it closed is the next line, shown for
# RB_DEAD_WAIT seconds.
RB_MESSAGE_WAIT="$RB_DEAD_WAIT" rb_message \
"$(sed -n 2p "$RB_STATE/tui.err") The messages are in $RB_LOG."
elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then
# The first line of the farewell: the error screen it closes was read.
rb_message "$(head -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG."
+17
View File
@@ -484,6 +484,10 @@ if os.environ.get("FAKE_HOLD"):
note("ui.ready")
time.sleep(60)
sys.exit(0)
if os.environ.get("FAKE_IDLE_ERROR"):
# RETROBIOS_IDLE_EXIT on an error screen: the idle line, then the error.
print("No button pressed, closing.", file=sys.stderr)
print("Error: " + os.environ["FAKE_IDLE_ERROR"], file=sys.stderr)
if os.environ.get("FAKE_ERROR"):
print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr)
sys.exit(int(os.environ.get("FAKE_CODE", "0")))
@@ -1197,6 +1201,19 @@ class ThePortMasterPort(unittest.TestCase):
device.assert_mapper_gone()
device.assert_console_restored()
def test_an_idle_close_on_an_error_names_the_error_at_once(self):
"""The interface closed for want of a button: the mapper never
opened the pad, so no A can close a message. The error it closed is
named, for RB_DEAD_WAIT seconds, not the idle line for 300."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_IDLE_ERROR="could not reach the file list",
FAKE_CODE="1", RB_MESSAGE_WAIT="300", RB_DEAD_WAIT="1")
self.assertEqual(proc.returncode, 1)
self.assertLess(device.elapsed, 10)
self.assertIn("could not reach the file list", device.screen)
self.assertNotIn("No button pressed", device.screen)
device.assert_console_restored()
def test_start_select_before_the_interface_handles_signals(self):
"""A SIGTERM before run_tui installs its handlers ends Python on the
default action, status 143."""
+38
View File
@@ -595,6 +595,44 @@ class AFolderThisUserCannotEnter(unittest.TestCase):
self.assertIn(str(lock), errors[0]["message"])
class ADetectedFolderUnderAClosedOne(unittest.TestCase):
"""retroarch.cfg naming a system_directory under a folder this user
cannot open, with no --dest: the case probe raised PermissionError and
the run, and the interface before its first screen, ended on a
traceback. The refusal --dest gets is the one detection gets."""
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_it_is_refused_in_words(self):
with FakeRepository() as repo:
home = repo.root / "home"
lock = repo.root / "lock"
(home / ".config" / "retroarch").mkdir(parents=True)
lock.mkdir()
(home / ".config" / "retroarch" / "retroarch.cfg").write_text(
f'system_directory = "{lock / "system"}"\n')
env = {**repo.env(), "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"),
"RETROBIOS_OS": "linux"}
command = [sys.executable, str(REPO_ROOT / "install.py"), "--platform",
"retroarch", "--no-input"]
os.chmod(lock, 0o000)
try:
runs = {name: subprocess.run(command + extra, env=env, capture_output=True,
text=True, timeout=60, stdin=subprocess.DEVNULL)
for name, extra in (("check", ["--check"]), ("install", []))}
kinds = install.tell_apart([("retroarch", lock / "system", "RetroArch"),
("retroarch", repo.dest, "RetroArch")])
finally:
os.chmod(lock, 0o755)
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.assertEqual(len(kinds), 2)
class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file
and the run waited on an answer to a prompt nobody could see."""
+1 -1
View File
@@ -1387,7 +1387,7 @@ 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
starts: `this user cannot open /root (Permission denied), which holds
/root/.config/retroarch/system`. The full-screen interface says the same on
the folder screen and asks for another folder.