diff --git a/install.ps1 b/install.ps1 index 37de60804..cebb3b2b3 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 = "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546" + $defaultInstallSha256 = "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a46e1e478..4fa6d0d7a 100644 --- a/install.py +++ b/install.py @@ -43,18 +43,26 @@ import textwrap import threading import time import traceback -import unicodedata import urllib.error import urllib.parse import urllib.request import weakref import xml.etree.ElementTree as ET import zipfile -import zlib from collections.abc import Callable, Collection, Iterable, Iterator, Mapping from pathlib import Path, PurePosixPath from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO +# Built as C extensions that a Python compiled without their libraries +# lacks: the run stopped on a ModuleNotFoundError traceback. +try: + import unicodedata + import zlib +except ImportError as _missing: + print(f"Error: this Python was built without its {_missing.name} module, which " + "the installer needs. Run it with another Python 3.8 or newer.", file=sys.stderr) + sys.exit(1) + # File lists are read from main, the newest ones, whichever commit the # installer came from. RETROBIOS_REF pins them to a tag or a commit when # reproducibility matters. @@ -645,8 +653,9 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # platforms: [{"platform", "name", "bios_dir"}], empty when none is found. "detect": ("os", "os_name", "platforms"), # stage: manifest (reason: network, not_found, invalid, clock for a - # refused certificate under a clock set in the past, or no_ca for no - # trusted certificates on the machine) or detect (reason: conflict, two + # refused certificate under a clock set in the past, no_ca for no + # trusted certificates on the machine, or no_ssl for a Python built + # without ssl) or detect (reason: conflict, two # systems found on one machine, or not_found, the platform named with # --platform, or by a file list or a pack in a run without questions, # found nowhere and no folder to assume) or from (a folder named with --from, @@ -3811,7 +3820,7 @@ def network_words( ) -> tuple[str, str]: """The reason a request failed, and the same in words a beginner reads. - The reason is clock, no_ca, network, or offline for a request the run + The reason is clock, no_ca, no_ssl, network, or offline for a request the run was told not to make. Offline, the run printed "[Errno -2] Name or service not known": the raw text now follows the words only under --verbose. @@ -3823,7 +3832,14 @@ def network_words( raw = str(reason or exc) code, words = "network", "" cert_error = getattr(sys.modules.get("ssl"), "SSLCertVerificationError", None) - if cert_error is not None and isinstance(inner, cert_error): + if (isinstance(exc, urllib.error.URLError) and raw.startswith("unknown url type") + and not hasattr(urllib.request, "HTTPSHandler")): + # urllib defines no HTTPS handler in a Python built without ssl: + # three attempts and a word on the connection blamed the network. + code = "no_ssl" + words = ("This Python was built without its ssl module and cannot use HTTPS. " + "Run the installer with another Python 3.8 or newer") + elif cert_error is not None and isinstance(inner, cert_error): today = today or datetime.date.today() if not _ca_bundle_found(): code = "no_ca" @@ -22588,7 +22604,8 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: f"(--from-pack PACK), or {offline_undo()}."], 1)) return title = {"clock": "The clock is wrong", - "no_ca": "No trusted certificates"}.get(code, "No connection") + "no_ca": "No trusted certificates", + "no_ssl": "This Python cannot use HTTPS"}.get(code, "No connection") # The screen's first row is Try again: "then run again" was a second way. lines = [words[0].upper() + words[1:].replace(", then run again", ", then choose Try again") + "."] diff --git a/install.sh b/install.sh index 8555cd2c6..4c5668448 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="7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546" +DEFAULT_INSTALL_SHA256="40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 4aaafb661..40796b8ed 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": 1080006, - "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", + "size": 1080968, + "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1133183, - "sha256": "cc752f0ea8becaba5f3d96c6c77ca9c39b8b870fd9d2019985e1fd78ed912592" + "size": 1134145, + "sha256": "9a837360311366ecb4a65c4c4eff5388319ff0ade4f6acad3709cd4e63249a59" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1080006, - "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", + "size": 1080968, + "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1080006, - "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", + "size": 1080968, + "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index f6d72e39a..52353b6e7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5513,6 +5513,39 @@ class TheInstallerPageIsWrittenFromTheCode(unittest.TestCase): self.assertIn("route-keys", said) +class AStrippedPythonIsNamed(unittest.TestCase): + """A Python built without ssl blamed the network three times, and one + without zlib or unicodedata stopped on a ModuleNotFoundError traceback.""" + + def _run(self, hidden: str, *argv: str) -> subprocess.CompletedProcess: + code = (f"import runpy, sys; sys.modules[{hidden!r}] = None; " + "sys.argv = sys.argv[1:]; " + "runpy.run_path(sys.argv[0], run_name='__main__')") + env = {key: value for key, value in os.environ.items() + if key not in ("RETROBIOS_BASE_URL", install.OFFLINE_VARIABLE)} + return subprocess.run( + [sys.executable, "-c", code, str(REPO_ROOT / "install.py"), *argv], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + + def test_a_missing_c_module_is_named(self): + for hidden in ("zlib", "unicodedata"): + with self.subTest(module=hidden): + proc = self._run(hidden, "--help") + self.assertEqual(proc.returncode, 1) + self.assertNotIn("Traceback", proc.stderr) + self.assertIn(f"built without its {hidden} module", proc.stderr) + + def test_no_ssl_is_said_once_not_retried(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as dest: + proc = self._run("ssl", "--platform", "retroarch", "--dest", dest, + "--check", "--no-input") + self.assertEqual(proc.returncode, 1) + self.assertIn("built without its ssl module and cannot use HTTPS", proc.stderr) + self.assertNotIn("trying again", proc.stderr) + self.assertNotIn("HTTPS_PROXY", proc.stderr) + + class NetworkFailuresInPlainWords(unittest.TestCase): """Offline, the run printed "[Errno -2] Name or service not known". A beginner needs "no internet connection", and the raw text only under diff --git a/wiki/installer.md b/wiki/installer.md index b5c6b183e..44815a33e 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1761,7 +1761,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. 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), 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. 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`), and the run ends with code 1 before anything is written. 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 | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of |