diff --git a/install.ps1 b/install.ps1 index e41f7f32d..57aca4f16 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 } diff --git a/install.py b/install.py index 05a057480..baa21de99 100644 --- a/install.py +++ b/install.py @@ -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 diff --git a/install.sh b/install.sh index 26c45e4c6..4f1d02bbf 100755 --- a/install.sh +++ b/install.sh @@ -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() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 5ee293708..6f8f8ff49 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -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 }, { diff --git a/tests/test_install.py b/tests/test_install.py index dbef10dd9..7efea9660 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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 diff --git a/wiki/installer.md b/wiki/installer.md index 73719bf16..88da4c7b0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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/.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 |