diff --git a/install.ps1 b/install.ps1 index 3e153d30c..e686a65f3 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 = "bb1da18416aec7c77a6d23c8bf2718cccf5c756ce0cbdbcbf54a6299a03a484f" + $defaultInstallSha256 = "8760870a1e7e1f1b72379cfec88d595ac8d7729880af905e266b424e3ee306bc" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 29aa13de2..54ce783cf 100644 --- a/install.py +++ b/install.py @@ -2429,6 +2429,12 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]: # (folder, why in words) for each folder detection could not look in. _DETECTION_REFUSED: list[tuple[Path, str]] = [] +# Those of them that failed for another cause than a permission: a read +# error from a failing card, a link that loops. +_DETECTION_UNREADABLE: set[Path] = set() +# The errors that say a place is not there, as against one that is there +# and could not be read. +_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EINVAL, errno.ENAMETOOLONG) # The platforms whose configuration names a folder with a control character. _UNNAMEABLE: list[str] = [] @@ -2437,13 +2443,18 @@ def refused_words(folder: Path, why: str, shown: str = "") -> str: """The line said for a folder detection could not look in. shown is the folder as the screen writes it (~/.config/EmuDeck), the full path by default.""" + if folder in _DETECTION_UNREADABLE: + return (f"Could not look for setups: {shown or folder} could not be read ({why}). " + "A failing card or a link that loops gives this: check it, or name the " + "BIOS folder with --dest.") return (f"Could not look for setups: this user cannot open {shown or folder} ({why}). " "Run as its owner (su - or sudo -H), or name the BIOS folder " "with --dest.") def _note_refused(path: Path, exc: OSError) -> None: - """Say once that a folder detection looks in is closed to this user. + """Say once that a folder detection looks in is closed to this user, + or could not be read. A probe refused there counts as absent and the others go on: a config folder a sudo run left at 0700 (~/.config/EmuDeck) hid a RetroArch whose @@ -2451,12 +2462,16 @@ def _note_refused(path: Path, exc: OSError) -> None: not read. """ if (not getattr(_SETUP_SEARCH, "active", False) or getattr(_SETUP_SEARCH, "quiet", False) - or not isinstance(exc, PermissionError)): + or exc.errno in _ABSENT_ERRNOS): return refused = closed_folder(path) or (path, exc.strerror or str(exc)) if refused in _DETECTION_REFUSED: return _DETECTION_REFUSED.append(refused) + if not isinstance(exc, PermissionError): + # An EIO on retroarch.cfg counted as absent: the card about to be + # written to failed, and the run said nothing was detected. + _DETECTION_UNREADABLE.add(refused[0]) words = refused_words(*refused) EVENTS.emit("unsearched", folder=str(refused[0]), reason=refused[1], message=words) print(f" {screen_text(words)}", file=sys.stderr) @@ -5038,10 +5053,8 @@ def _probed(probe: Callable[[], bool], path: Path) -> bool: # raised: a refusal is told from absence by the stat it hides. try: os.stat(path) - except PermissionError as exc: + except OSError as exc: _note_refused(path, exc) - except OSError: - pass return found diff --git a/install.sh b/install.sh index 99ebb5a32..42d48c719 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="bb1da18416aec7c77a6d23c8bf2718cccf5c756ce0cbdbcbf54a6299a03a484f" +DEFAULT_INSTALL_SHA256="8760870a1e7e1f1b72379cfec88d595ac8d7729880af905e266b424e3ee306bc" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 684a9df7a..d9e518a9b 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": 1089833, - "sha256": "bb1da18416aec7c77a6d23c8bf2718cccf5c756ce0cbdbcbf54a6299a03a484f", + "size": 1090639, + "sha256": "8760870a1e7e1f1b72379cfec88d595ac8d7729880af905e266b424e3ee306bc", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1143460, - "sha256": "8074276deabb785c034ecb1c3d5d4c2da3f7772ba17fb29f4f82241e289c7be5" + "size": 1144266, + "sha256": "1b9ae46c0bbdbb75aaa2120dcce145cea9a15a971f272f44dd556a48e6b7098c" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1089833, - "sha256": "bb1da18416aec7c77a6d23c8bf2718cccf5c756ce0cbdbcbf54a6299a03a484f", + "size": 1090639, + "sha256": "8760870a1e7e1f1b72379cfec88d595ac8d7729880af905e266b424e3ee306bc", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1089833, - "sha256": "bb1da18416aec7c77a6d23c8bf2718cccf5c756ce0cbdbcbf54a6299a03a484f", + "size": 1090639, + "sha256": "8760870a1e7e1f1b72379cfec88d595ac8d7729880af905e266b424e3ee306bc", "executable": false }, { diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index cc8e2ef58..2ea164a30 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -737,6 +737,43 @@ class ASettingsFileThisUserCannotRead(unittest.TestCase): self.assertIn(f"cannot open {path}", err.getvalue()) +class AConfigThatFailsToRead(unittest.TestCase): + """retroarch.cfg on a failing card raised EIO, and a link that loops + ELOOP: both counted as absent without a word, as only a permission + refusal was said. Absence itself stays silent.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + install._DETECTION_REFUSED.clear() + self.addCleanup(install._DETECTION_REFUSED.clear) + install._SETUP_SEARCH.active = True + self.addCleanup(setattr, install._SETUP_SEARCH, "active", False) + + def test_a_read_error_is_said_with_its_own_words(self): + cfg = self.root / "retroarch.cfg" + cfg.write_text('system_directory = "/x"\n', encoding="utf-8") + failure = OSError(errno.EIO, "Input/output error", str(cfg)) + with mock.patch.object(Path, "read_text", side_effect=failure), \ + contextlib.redirect_stderr(io.StringIO()) as err: + self.assertIsNone(install._retroarch_cfg_dir(cfg, "system_directory", + self.root, "system")) + self.assertEqual(install._DETECTION_REFUSED, [(cfg, "Input/output error")]) + said = " ".join(err.getvalue().split()) + self.assertIn(f"{cfg} could not be read (Input/output error)", said) + self.assertNotIn("Run as its owner", said) + + @unittest.skipIf(os.name == "nt", "a symbolic link needs privileges on Windows") + def test_a_link_that_loops_is_said_and_absence_is_not(self): + loop = self.root / "retroarch.cfg" + loop.symlink_to(loop.name) + with contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(install._exists(loop)) + self.assertFalse(install._exists(self.root / "absent.cfg")) + self.assertFalse(install._exists(self.root / "absent" / "x.cfg")) + self.assertEqual([folder for folder, _why in install._DETECTION_REFUSED], [loop]) + + class TheFrontendsOwnFolderClosed(unittest.TestCase): """The Flatpak RetroArch's folder at 000: its retroarch.cfg names ~/Games/bios, and the run took ~/.config/retroarch/system, a folder it diff --git a/wiki/installer.md b/wiki/installer.md index f7793f1a4..7cf21b25d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1773,7 +1773,7 @@ other schemas under `api/v1/schemas/`. | Kind | Keys | When | |------|------|------| | `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. 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 | +| `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 | | `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 |