diff --git a/install.ps1 b/install.ps1
index 0d73153a0..0f06a799e 100644
--- a/install.ps1
+++ b/install.ps1
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
- $defaultInstallSha256 = "50074085810dde029db4cbbc7b520e34d4e70d58fe524570ac2821501772e7d8"
+ $defaultInstallSha256 = "db262525b78f1a627d711bdfdaecd7774d9589db9b0f0aa62dd4d64f60948f70"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
diff --git a/install.py b/install.py
index 74d94fe6e..635517bdd 100644
--- a/install.py
+++ b/install.py
@@ -6922,7 +6922,7 @@ def _pick(
shown = {name: (labels or {}).get(name, name) for name, _files in rows}
name_width = min(max(len(label) for label in shown.values()), 40)
for index, (name, files) in enumerate(rows, 1):
- print(f" {index:>{width}}) {shown[name]:<{name_width}} {sizer(files)}")
+ print(f" {index:>{width}}) {screen_text(shown[name]):<{name_width}} {sizer(files)}")
hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5"
while True:
text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ")
@@ -7106,7 +7106,7 @@ def _prompt_custom_selection(
_print_menu_head(plat, files, omitted, installed, name, trees, bios_path, current,
given, manifest, narrowed)
if current is not given and command is not None:
- print(f" Same choice without questions: {command(current)}")
+ print(f" Same choice without questions: {screen_text(command(current))}")
offline = _offline_only()
if offline:
# Said before the menu: "install everything" was offered, then
@@ -7655,6 +7655,10 @@ def split_pack_lines(root: Path) -> list[str]:
return lines
+# A system id as the file lists write them (sony-playstation, other).
+_SYSTEM_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,63}")
+
+
class PackSource:
"""A pack already downloaded, whole or in parts, read in place of the network.
@@ -7754,13 +7758,19 @@ class PackSource:
# What a pack built since records of its systems (generate_pack.py
# pack_systems): --system and Choose systems then work with the pack
# alone. An older pack records none, and is read as before.
+ # A pack is a file anyone can hand over: an id or a name holding a
+ # control character would reach the terminal as an escape, and is
+ # dropped.
for key in ("system_titles", "system_names"):
held = data.get(key)
if isinstance(held, dict):
self.names[key] = {
- str(system): value for system, value in held.items()
- if isinstance(value, str) or (isinstance(value, list) and all(
- isinstance(name, str) for name in value))}
+ system: value for system, value in held.items()
+ if _SYSTEM_ID.fullmatch(str(system)) and (
+ isinstance(value, str) and not _CONTROLS.search(value)
+ or isinstance(value, list) and all(
+ isinstance(name, str) and not _CONTROLS.search(name)
+ for name in value))}
listed = []
for item in files:
if (
@@ -7773,8 +7783,9 @@ class PackSource:
entry = {"path": item["path"], "sha1": item["sha1"].lower(),
"size": item["size"]}
systems = item.get("systems")
- if isinstance(systems, list) and all(isinstance(name, str)
- for name in systems):
+ if isinstance(systems, list) and all(
+ isinstance(name, str) and _SYSTEM_ID.fullmatch(name)
+ for name in systems):
entry["systems"] = list(systems)
listed.append(entry)
return listed
diff --git a/install.sh b/install.sh
index 627f2a8c4..4fa8c7734 100755
--- a/install.sh
+++ b/install.sh
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
-DEFAULT_INSTALL_SHA256="50074085810dde029db4cbbc7b520e34d4e70d58fe524570ac2821501772e7d8"
+DEFAULT_INSTALL_SHA256="db262525b78f1a627d711bdfdaecd7774d9589db9b0f0aa62dd4d64f60948f70"
MAX_INSTALLER_BYTES=2097152
cleanup() {
diff --git a/install/launchers/index.json b/install/launchers/index.json
index a9ddae10f..b76ad310e 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": 774265,
- "sha256": "50074085810dde029db4cbbc7b520e34d4e70d58fe524570ac2821501772e7d8",
+ "size": 774796,
+ "sha256": "db262525b78f1a627d711bdfdaecd7774d9589db9b0f0aa62dd4d64f60948f70",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
- "size": 826814,
- "sha256": "2f31cccaa60c3d75dcaf7b82b1db21003c8a26965982f224790aceec6708b3da"
+ "size": 827345,
+ "sha256": "1c4e3b27bbd9159fb529820b73530cb72166d92d0098bf224266e40b29e04bce"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
- "size": 774265,
- "sha256": "50074085810dde029db4cbbc7b520e34d4e70d58fe524570ac2821501772e7d8",
+ "size": 774796,
+ "sha256": "db262525b78f1a627d711bdfdaecd7774d9589db9b0f0aa62dd4d64f60948f70",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
- "size": 774265,
- "sha256": "50074085810dde029db4cbbc7b520e34d4e70d58fe524570ac2821501772e7d8",
+ "size": 774796,
+ "sha256": "db262525b78f1a627d711bdfdaecd7774d9589db9b0f0aa62dd4d64f60948f70",
"executable": false
},
{
diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py
index cc946f44a..cddd3d7d8 100644
--- a/tests/test_install_tui.py
+++ b/tests/test_install_tui.py
@@ -7273,6 +7273,44 @@ class AuditSeventeenAndTen(unittest.TestCase):
self.assertIn("add --manifest FILE, a copy of install/retroarch.json", err.getvalue())
self.assertNotIn("older release", err.getvalue())
+ def test_a_pack_cannot_write_escapes_through_its_system_names(self):
+ """Security review of tui_audit_10 P2-4: the titles and ids a pack
+ records reached print() raw in the systems list."""
+ from test_install_offline import GBA, US # noqa: PLC0415
+
+ members = {"scph5501.bin": US, "gba_bios.bin": GBA}
+ listed = [{"path": path, "sha1": hashlib.sha1(data).hexdigest(), "size": len(data),
+ "systems": ["bad\nid" if "scph" in path else "nintendo-gba"]}
+ for path, data in members.items()]
+ path = self.root / "RetroArch_v1_BIOS_Pack.zip"
+ with zipfile.ZipFile(path, "w") as archive:
+ for member, data in members.items():
+ archive.writestr(member, data)
+ archive.writestr("manifest.json", json.dumps({
+ "files": listed,
+ "system_titles": {"nintendo-gba": "\x1b]0;x\x07GBA", "bad\nid": "Bad"},
+ "system_names": {"nintendo-gba": ["\x1b[2J"]}}))
+ pack = install.PackSource([path])
+ self.addCleanup(pack.close)
+ manifest = pack.manifest("retroarch")
+ self.assertEqual(manifest.get("system_titles", {}), {})
+ self.assertEqual(manifest.get("system_names", {}), {})
+ self.assertNotIn("systems", next(f for f in manifest["files"]
+ if f["dest"] == "scph5501.bin"))
+ # What _pick prints and the command line go through screen_text.
+ with contextlib.redirect_stdout(io.StringIO()) as out, \
+ mock.patch.object(install, "_ask", side_effect=["1"]):
+ install._pick("Systems:", [("x", [])], labels={"x": "\x1b]0;x\x07GBA"})
+ self.assertNotIn("\x1b", out.getvalue())
+ self.assertIn("?]0;x?GBA", out.getvalue())
+ with contextlib.redirect_stdout(io.StringIO()) as out, \
+ mock.patch.object(install, "_ask", side_effect=["c", "1", "q"]), \
+ contextlib.suppress(SystemExit):
+ install._prompt_custom_selection(
+ "retroarch", [{"dest": "a", "size": 1, "systems": ["x"], "cores": None}], [],
+ command=lambda _choice: "python3 install.py --dest '\x1b[2J'")
+ self.assertNotIn("\x1b", out.getvalue())
+
def test_the_home_folder_is_said_on_the_plan(self):
"""tui_audit_10 P2-5: the plan named ~ and said nothing more."""
state = checked_state()
diff --git a/wiki/installer.md b/wiki/installer.md
index 0778d34f8..69929a48a 100644
--- a/wiki/installer.md
+++ b/wiki/installer.md
@@ -749,7 +749,7 @@ what the options name, everything by default.
| `--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr |
| `--check` | Report and exit without writing |
| `--dry-run` | Show what would be downloaded, the totals first, then by system and with sizes, without writing anything. A plan taller than the screen names the ten largest systems, and `--verbose` lists every system and file |
-| `--yes`, `-y` | Do not ask before a download of 1 GB or more |
+| `--yes`, `-y` | Do not ask before a download of 1 GB or more, nor anything else. Implies `--no-input`, so a run that needs an answer (nothing found here, two setups found) stops on what to name instead |
| `--quiet`, `-q` | Print only failures and the final summary. Implies `--no-input` |
| `--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) |
| `--from FOLDER` | Copy the BIOS files a folder holds (an old SD card, a backup, a ZIP) instead of downloading them, each one recognised by its hash and copied, never moved. Repeat for several folders. See [Copy from an old card](#copy-from-an-old-card) |