fix: name a --dest that is a file

A BIOS folder that is a file, or lies under one, failed at mkdir with EEXIST or ENOTDIR, and the run blamed permissions and offered sudo. It is now refused before anything is checked, with an error event whose reason is not_a_folder.
This commit is contained in:
Abdessamad Derraz committed 2026-11-06 08:22:18 +01:00
1 parent 97d0a478d2
commit f696d4974f
6 files changed
+72 -14

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
# the site's build rewrites this line there (scripts/sitebootstrap.py).
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
$defaultInstallSha256 = "f48287847612b616bcaf607bd50207353bef5648e650a732c2d9595a64726ab0"
$defaultInstallSha256 = "482a7710467d988f3878a5d8ebe5f885dfee35c3a1aac0df05077b6b062a7610"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+26 -2
View File
@@ -680,8 +680,9 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# found nowhere and no folder to assume) or from (a folder named with --from,
# reason: not_found, not_a_folder, unreadable, or system for the root
# of the system and /proc, /sys, /dev) or dest (reason: unreadable, a
# folder above it this user cannot open, or not_writable, the BIOS
# folder cannot be created, a summary following) or option (reason:
# folder above it this user cannot open, not_a_folder, the BIOS folder
# is a file or lies under one, or not_writable, the BIOS folder cannot
# be created, a summary following) or option (reason:
# unknown, a platform, target, system, core or emulator the run cannot
# use, refused before any download).
"error": ("stage", "reason", "platform", "message"),
@@ -13773,6 +13774,27 @@ def _refuse_closed(dest: Path, named: str) -> None:
sys.exit(1)
def _refuse_file(dest: Path, named: str) -> None:
"""End the run on a BIOS folder that is a file, or lies under one.
mkdir then failed on EEXIST or ENOTDIR, and the run blamed permissions
and offered sudo, which fails the same way.
"""
for folder in (dest, *dest.parents):
if _exists(folder):
break
else:
return
if _is_dir(folder):
return
where = (f"{shown_path(dest)} is a file, not a folder" if folder == dest
else f"{shown_path(dest)} cannot be a folder: {shown_path(folder)} is a file")
EVENTS.emit("error", stage="dest", reason="not_a_folder", platform="", message=where)
print(f"Error: {screen_text(where)}. Name the folder the BIOS files go in, not "
f"the file {named}.", file=sys.stderr)
sys.exit(1)
def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest",
again: bool = False) -> bool:
"""Say that a --dest folder does not exist before anything goes there.
@@ -13787,6 +13809,7 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest",
a card's folder ended the run where it meant "not that card".
"""
_refuse_closed(dest, named)
_refuse_file(dest, named)
if dest.exists():
return True
nearest = dest.absolute()
@@ -15114,6 +15137,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
else f"{found_in[0]} names" if found_in else "detected here")
# One setup by now: two found without questions stopped above.
_refuse_closed(path, f"{named} (name another with --dest)")
_refuse_file(path, f"{named} (name another with --dest)")
total_downloaded = 0
total_up_to_date = 0
+1 -1
View File
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
# curl or wget stop on a cut transfer before the hash is read: the site's
# build rewrites this line there (scripts/sitebootstrap.py).
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
DEFAULT_INSTALL_SHA256="f48287847612b616bcaf607bd50207353bef5648e650a732c2d9595a64726ab0"
DEFAULT_INSTALL_SHA256="482a7710467d988f3878a5d8ebe5f885dfee35c3a1aac0df05077b6b062a7610"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 1103894,
"sha256": "f48287847612b616bcaf607bd50207353bef5648e650a732c2d9595a64726ab0",
"size": 1104874,
"sha256": "482a7710467d988f3878a5d8ebe5f885dfee35c3a1aac0df05077b6b062a7610",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1157521,
"sha256": "0f9b070334a530a0ed5a6b6b773928d92f5a08aa0d0af18fde7b6cb4a16e731d"
"size": 1158501,
"sha256": "0ecf443039c136286322a57b33f1374dd52de0b5f2a772fbae93c1f98c19b62d"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 1103894,
"sha256": "f48287847612b616bcaf607bd50207353bef5648e650a732c2d9595a64726ab0",
"size": 1104874,
"sha256": "482a7710467d988f3878a5d8ebe5f885dfee35c3a1aac0df05077b6b062a7610",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 1103894,
"sha256": "f48287847612b616bcaf607bd50207353bef5648e650a732c2d9595a64726ab0",
"size": 1104874,
"sha256": "482a7710467d988f3878a5d8ebe5f885dfee35c3a1aac0df05077b6b062a7610",
"executable": false
},
{
+34
View File
@@ -448,6 +448,40 @@ class AMissingDestIsSaid(unittest.TestCase):
self.assertIn("does not exist", out)
class ADestThatIsAFileIsNamed(unittest.TestCase):
"""--dest naming a file, or a path under one, failed at mkdir with
EEXIST or ENOTDIR, and the run said this user cannot write there and
offered a sudo command that fails the same way."""
def _run(self, dest: Path) -> subprocess.CompletedProcess:
env = dict(os.environ, RETROBIOS_OS="linux")
return subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(dest), "--manifest", str(REPO_ROOT / "install" / "retroarch.json"),
"--system", "nintendo-gba", "--yes", "--events", "--offline"],
env=env, capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
def test_the_file_is_named_and_sudo_is_not_offered(self):
root = Path(_tempdir(self))
(root / "scph5501.bin").write_bytes(b"x")
for dest, words in (
(root / "scph5501.bin", f"{root / 'scph5501.bin'} is a file, not a folder"),
(root / "scph5501.bin" / "sub",
f"cannot be a folder: {root / 'scph5501.bin'} is a file"),
):
with self.subTest(dest=dest):
proc = self._run(dest)
self.assertEqual(proc.returncode, 1, proc.stderr)
self.assertIn(words, proc.stderr)
self.assertNotIn("sudo", proc.stderr)
errors = [json.loads(line) for line in proc.stdout.splitlines()
if line.startswith("{") and '"error"' in line]
self.assertEqual([(e["stage"], e["reason"]) for e in errors],
[("dest", "not_a_folder")])
self.assertEqual((root / "scph5501.bin").read_bytes(), b"x")
class AFolderThatRefusesWritesIsNamed(unittest.TestCase):
"""A read-only BIOS folder ended the run on a traceback (a subfolder to
create), or on three retries per file and "once that is resolved", and a
+2 -2
View File
@@ -1738,7 +1738,7 @@ fields, the installer refuses to narrow rather than install everything.
| Code | Meaning |
|------|---------|
| `0` | everything asked for is installed or already in place |
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a BIOS folder that is a file or lies under one, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/<platform>.json`), or for a platform that publishes none, which is said to have no hardware targets, `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
@@ -1785,7 +1785,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` |
| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it or a read failing there (a failing card, a link that loops). What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence |
| `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), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, 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. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart |
| `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), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, 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`), or is a file or lies under one (`reason` `not_a_folder`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart |
| `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 |
| `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on |
| `board` | `platform`, `board`, `applied`, `message` | Before `choices`, with no `--target` and no `--every-board`, when the system names its board or a Batocera or KNULLI card mounted here does (`boot/batocera.board` or `boot/knulli.board` on its boot partition, `board` then being the board its image maps to): `applied` is true when the run keeps that board's files only, false for a board the platform's list does not name, which keeps every board's files. `message` says it |