mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
--add-app, the result screen and a [y/N] question put the launcher of muOS, KNULLI or ROCKNIX on the card the BIOS folder is on. The files come from install/launchers/index.json at the same ref, each checked by size and SHA-256 before the first is written, and the app event reports it.
471 lines
21 KiB
Python
471 lines
21 KiB
Python
"""The RetroBIOS app put on a handheld's card from a computer (--add-app).
|
|
|
|
Cards are folders holding the files their systems write, the repository a
|
|
loopback copy of the launcher list and the files it names. What a console
|
|
then does with the files (the Archive Manager, the Ports list, the Tools
|
|
menu) is not exercised.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
sys.path.insert(0, str(REPO_ROOT / "tests"))
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import install # noqa: E402
|
|
from test_install_selection import FakeRepository # noqa: E402
|
|
|
|
INDEX = REPO_ROOT / "install" / "launchers" / "index.json"
|
|
|
|
|
|
def make_card(root: Path, system: str) -> Path:
|
|
"""A card as each system leaves it, and the BIOS folder on it."""
|
|
bios = _card_files(root, system)
|
|
bios.mkdir(parents=True, exist_ok=True)
|
|
return bios
|
|
|
|
|
|
def _card_files(root: Path, system: str) -> Path:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
if system == "muOS":
|
|
(root / "MUOS" / "bios").mkdir(parents=True)
|
|
return root / "MUOS" / "bios"
|
|
if system == "KNULLI":
|
|
(root / "system").mkdir()
|
|
(root / "system" / "batocera.conf").write_text("system.hostname=KNULLI\n")
|
|
return root / "bios"
|
|
if system == "ROCKNIX":
|
|
(root / ".config" / "system" / "configs").mkdir(parents=True)
|
|
(root / ".config" / "system" / "configs" / "system.cfg").write_text(
|
|
"root.password=rocknix\n")
|
|
return root / "roms" / "bios"
|
|
if system == "Onion":
|
|
(root / ".tmp_update" / "onionVersion").mkdir(parents=True)
|
|
(root / ".tmp_update" / "onionVersion" / "version.txt").write_text("4.3.1\n")
|
|
return root / "BIOS"
|
|
if system == "spruceOS":
|
|
(root / "spruce").mkdir()
|
|
return root / "BIOS"
|
|
if system == "MinUI":
|
|
(root / ".system").mkdir()
|
|
(root / ".system" / "version.txt").write_text("MinUI-20250101\n")
|
|
return root
|
|
raise ValueError(system)
|
|
|
|
|
|
# The file list each card's system gets.
|
|
CARD_PLATFORMS = {"muOS": "retroarch", "KNULLI": "knulli", "ROCKNIX": "rocknix",
|
|
"Onion": "retroarch", "spruceOS": "retroarch", "MinUI": "minui"}
|
|
|
|
|
|
class AppRepository(FakeRepository):
|
|
"""The fake repository with the launcher list and every file it names,
|
|
and the BIOS lists of the systems whose cards are tried."""
|
|
|
|
def __enter__(self) -> AppRepository:
|
|
super().__enter__()
|
|
index = json.loads(INDEX.read_text(encoding="utf-8"))
|
|
(self.root / "install" / "launchers").mkdir()
|
|
(self.root / "install" / "launchers" / "index.json").write_text(json.dumps(index))
|
|
for launcher in index["launchers"].values():
|
|
for entry in launcher.get("files", launcher.get("members", [])):
|
|
if "repo_path" in entry:
|
|
target = self.root / entry["repo_path"]
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(REPO_ROOT / entry["repo_path"], target)
|
|
base = json.loads((self.root / "install" / "retroarch.json").read_text())
|
|
for plat in ("knulli", "rocknix", "minui"):
|
|
(self.root / "install" / f"{plat}.json").write_text(
|
|
json.dumps({**base, "platform": plat}))
|
|
return self
|
|
|
|
def card(self, system: str) -> tuple[Path, Path]:
|
|
root = self.root / "cards" / system
|
|
return root, make_card(root, system)
|
|
|
|
def tamper(self, repo_path: str) -> None:
|
|
"""One byte changed, the size kept: only the hash tells."""
|
|
path = self.root / repo_path
|
|
data = bytearray(path.read_bytes())
|
|
data[-1] ^= 0x20
|
|
path.write_bytes(bytes(data))
|
|
|
|
def cli(self, *options: str, env: dict | None = None) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, str(REPO_ROOT / "install.py"), *options],
|
|
env={**self.env(), **(env or {})}, capture_output=True, text=True,
|
|
timeout=120, check=False, stdin=subprocess.DEVNULL)
|
|
|
|
|
|
def _tempdir(test: unittest.TestCase) -> Path:
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
path = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
test.addCleanup(shutil.rmtree, path, True)
|
|
return path
|
|
|
|
|
|
class Placement(unittest.TestCase):
|
|
def test_each_card_and_where_its_app_goes(self):
|
|
root = _tempdir(self)
|
|
expected = {"muOS": "ARCHIVE", "KNULLI": "roms/ports", "ROCKNIX": ".config/modules"}
|
|
for system, folder in expected.items():
|
|
with self.subTest(system=system):
|
|
card = install.card_app(make_card(root / system, system))
|
|
self.assertIsNotNone(card)
|
|
self.assertEqual(card.system, system)
|
|
self.assertEqual(card.root, root / system)
|
|
self.assertEqual(card.place.folder, folder)
|
|
self.assertEqual(card.why, "")
|
|
|
|
def test_a_bios_folder_inside_the_card_finds_its_root(self):
|
|
root = _tempdir(self)
|
|
bios = make_card(root / "sd1", "muOS")
|
|
bios.mkdir(parents=True, exist_ok=True)
|
|
self.assertEqual(install.card_app(bios).root, root / "sd1")
|
|
# The card's root itself is not its BIOS folder.
|
|
self.assertIsNone(install.card_app(root / "sd1"))
|
|
|
|
def test_cards_without_a_launcher_say_why(self):
|
|
root = _tempdir(self)
|
|
for system, words in (("Onion", "has no Python"), ("MinUI", "has no Python"),
|
|
("spruceOS", "no RetroBIOS app for spruceOS")):
|
|
with self.subTest(system=system):
|
|
card = install.card_app(make_card(root / system, system))
|
|
self.assertIsNone(card.place)
|
|
self.assertIn(words, card.why)
|
|
|
|
def test_a_folder_on_no_card(self):
|
|
self.assertIsNone(install.card_app(_tempdir(self) / "bios"))
|
|
|
|
def test_the_table_names_systems_detection_names(self):
|
|
names = {rule.name for rule in install._card_rules()}
|
|
names |= {install.display_name(name) for name, _f, _p in install._CARD_RULES}
|
|
self.assertLessEqual(set(install.APP_PLACES), names)
|
|
self.assertLessEqual(install.NO_PYTHON_CARDS, names)
|
|
self.assertFalse(set(install.APP_PLACES) & install.NO_PYTHON_CARDS)
|
|
|
|
def test_the_list_follows_the_table(self):
|
|
index = json.loads(INDEX.read_text(encoding="utf-8"))
|
|
for place in install.APP_PLACES.values():
|
|
with self.subTest(key=place.key):
|
|
targets = install.app_targets(index, place)
|
|
for target in targets:
|
|
self.assertTrue(target["dest"].startswith(place.folder + "/"))
|
|
self.assertEqual(target.get("executable", False) and not place.executable,
|
|
False, target["dest"])
|
|
|
|
|
|
class TheList(unittest.TestCase):
|
|
def setUp(self):
|
|
self.index = json.loads(INDEX.read_text(encoding="utf-8"))
|
|
self.place = install.APP_PLACES["KNULLI"]
|
|
|
|
def refused(self, change) -> str:
|
|
data = json.loads(json.dumps(self.index))
|
|
change(data["launchers"]["knulli"]["files"][0], data)
|
|
with self.assertRaises(ValueError) as caught:
|
|
install.app_targets(data, self.place)
|
|
return str(caught.exception)
|
|
|
|
def test_entries_are_held_to_their_folder(self):
|
|
self.assertIn("outside", self.refused(
|
|
lambda entry, _d: entry.update(dest="roms/RetroBIOS.sh")))
|
|
self.assertIn("unsafe", self.refused(
|
|
lambda entry, _d: entry.update(dest="roms/ports/../../bios/x")))
|
|
self.assertIn("unsafe", self.refused(
|
|
lambda entry, _d: entry.update(repo_path="/etc/passwd")))
|
|
|
|
def test_hash_size_and_source_are_checked(self):
|
|
self.assertIn("sha256", self.refused(lambda entry, _d: entry.update(sha256="ab")))
|
|
self.assertIn("size", self.refused(lambda entry, _d: entry.update(size=0)))
|
|
self.assertIn("repo_path or content", self.refused(
|
|
lambda entry, _d: entry.update(content="x")))
|
|
self.assertIn("schema", self.refused(lambda _e, data: data.update(schema=2)))
|
|
self.assertIn("no launcher", self.refused(
|
|
lambda _e, data: data["launchers"].pop("knulli")))
|
|
|
|
def test_the_archive_rebuilds_to_its_recorded_hash(self):
|
|
launcher = self.index["launchers"]["muos"]
|
|
members = [(entry, (REPO_ROOT / entry["repo_path"]).read_bytes()
|
|
if "repo_path" in entry else entry["content"].encode())
|
|
for entry in launcher["members"]]
|
|
data = install.app_archive(list(reversed(members)))
|
|
# Under every Python the installer runs on: CI runs this under 3.8 too.
|
|
self.assertEqual(hashlib.sha256(data).hexdigest(), launcher["archive"]["sha256"])
|
|
self.assertEqual(len(data), launcher["archive"]["size"])
|
|
|
|
@unittest.skipIf(sys.version_info < (3, 9), "the build scripts need Python 3.9")
|
|
def test_the_archive_holds_what_the_release_archive_holds(self):
|
|
launcher = self.index["launchers"]["muos"]
|
|
members = [(entry, (REPO_ROOT / entry["repo_path"]).read_bytes()
|
|
if "repo_path" in entry else entry["content"].encode())
|
|
for entry in launcher["members"]]
|
|
data = install.app_archive(members)
|
|
import build_port # noqa: PLC0415
|
|
built = _tempdir(self) / build_port.MUOS_APP
|
|
build_port.build_archive(build_port.MUOS_APP, built)
|
|
with zipfile.ZipFile(built) as release, zipfile.ZipFile(io.BytesIO(data)) as ours:
|
|
self.assertEqual(release.namelist(), ours.namelist())
|
|
for name in release.namelist():
|
|
self.assertEqual(release.read(name), ours.read(name), name)
|
|
self.assertEqual(release.getinfo(name).external_attr,
|
|
ours.getinfo(name).external_attr, name)
|
|
|
|
|
|
class PutTheApp(unittest.TestCase):
|
|
def setUp(self):
|
|
self.repo = AppRepository()
|
|
self.repo.__enter__()
|
|
self.addCleanup(self.repo.__exit__, None, None, None)
|
|
install.KEPT.reset()
|
|
patcher = mock.patch.multiple(
|
|
install, LAUNCHERS_URL=f"{self.repo.env()['RETROBIOS_BASE_URL']}"
|
|
"/install/launchers/index.json",
|
|
RAW_FILE_URL=f"{self.repo.env()['RETROBIOS_BASE_URL']}/{{path}}")
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.index = json.loads(INDEX.read_text(encoding="utf-8"))
|
|
self.bios: dict[str, Path] = {}
|
|
|
|
def put(self, system: str) -> tuple[install.CardApp, dict]:
|
|
if system not in self.bios:
|
|
self.bios[system] = self.repo.card(system)[1]
|
|
card = install.card_app(self.bios[system])
|
|
return card, install.put_app(card)
|
|
|
|
def assertMatchesTheList(self, card: install.CardApp) -> None:
|
|
for target in install.app_targets(self.index, card.place):
|
|
path = card.root / target["dest"]
|
|
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), target["sha256"])
|
|
|
|
def test_muos_gets_its_archive(self):
|
|
card, fields = self.put("muOS")
|
|
self.assertTrue(fields["ok"], fields)
|
|
self.assertEqual(fields["written"], ["ARCHIVE/RetroBIOS.muxapp"])
|
|
self.assertMatchesTheList(card)
|
|
with zipfile.ZipFile(card.root / "ARCHIVE" / "RetroBIOS.muxapp") as archive:
|
|
self.assertEqual(archive.read("RetroBIOS/install.py"),
|
|
(REPO_ROOT / "install.py").read_bytes())
|
|
mode = archive.getinfo("RetroBIOS/mux_launch.sh").external_attr >> 16
|
|
self.assertEqual(mode & 0o777, 0o755)
|
|
|
|
def test_knulli_and_rocknix_get_the_files(self):
|
|
for system, script, executable in (("KNULLI", "roms/ports/RetroBIOS.sh", False),
|
|
("ROCKNIX", ".config/modules/RetroBIOS.sh", True)):
|
|
with self.subTest(system=system):
|
|
card, fields = self.put(system)
|
|
self.assertTrue(fields["ok"], fields)
|
|
self.assertEqual(sorted(fields["written"]), sorted(fields["dests"]))
|
|
self.assertMatchesTheList(card)
|
|
mode = (card.root / script).stat().st_mode
|
|
self.assertEqual(bool(mode & stat.S_IXUSR), executable)
|
|
|
|
def test_a_second_run_writes_nothing(self):
|
|
card, first = self.put("KNULLI")
|
|
stamps = {path: path.stat().st_mtime_ns for path in card.root.rglob("*")}
|
|
_card, second = self.put("KNULLI")
|
|
self.assertTrue(second["ok"])
|
|
self.assertEqual(second["written"], [])
|
|
self.assertEqual(second["dests"], first["dests"])
|
|
self.assertEqual({path: path.stat().st_mtime_ns for path in card.root.rglob("*")},
|
|
stamps)
|
|
self.assertIn("already on the KNULLI card", install.app_lines(card, second)[0])
|
|
|
|
def test_a_different_file_in_place_is_kept_aside(self):
|
|
root, bios = self.repo.card("KNULLI")
|
|
self.bios["KNULLI"] = bios
|
|
mine = root / "roms" / "ports" / "RetroBIOS.sh"
|
|
mine.parent.mkdir(parents=True)
|
|
mine.write_text("my own script\n")
|
|
card, fields = self.put("KNULLI")
|
|
self.assertTrue(fields["ok"], fields)
|
|
self.assertEqual(len(fields["kept"]), 1)
|
|
self.assertEqual(fields["kept"][0]["files"], 1)
|
|
kept = list(Path(fields["kept"][0]["folder"]).rglob("RetroBIOS.sh"))
|
|
self.assertEqual([path.read_text() for path in kept], ["my own script\n"])
|
|
self.assertMatchesTheList(card)
|
|
self.assertTrue(any("kept in" in line for line in install.app_lines(card, fields)))
|
|
|
|
def test_a_tampered_file_is_refused_and_nothing_written(self):
|
|
self.repo.tamper("ports/shared/tui.txt")
|
|
root, bios = self.repo.card("ROCKNIX")
|
|
self.bios["ROCKNIX"] = bios
|
|
before = sorted(root.rglob("*"))
|
|
card, fields = self.put("ROCKNIX")
|
|
self.assertFalse(fields["ok"])
|
|
self.assertIn("SHA-256 mismatch", fields["reason"])
|
|
self.assertEqual(fields["written"], [])
|
|
self.assertEqual(sorted(root.rglob("*")), before)
|
|
self.assertIn("could not be put", install.app_lines(card, fields)[0])
|
|
|
|
def test_a_tampered_member_never_reaches_the_archive(self):
|
|
self.repo.tamper("ports/muos/mux_launch.sh")
|
|
root, bios = self.repo.card("muOS")
|
|
self.bios["muOS"] = bios
|
|
_card, fields = self.put("muOS")
|
|
self.assertFalse(fields["ok"])
|
|
self.assertFalse((root / "ARCHIVE").exists())
|
|
|
|
def test_a_card_without_a_launcher_is_not_written(self):
|
|
root, bios = self.repo.card("Onion")
|
|
self.bios["Onion"] = bios
|
|
_card, fields = self.put("Onion")
|
|
self.assertFalse(fields["ok"])
|
|
self.assertIn("has no Python", fields["reason"])
|
|
self.assertEqual(sorted(p.name for p in root.iterdir()), [".tmp_update", "BIOS"])
|
|
|
|
|
|
class TheQuestion(unittest.TestCase):
|
|
"""Line mode at a terminal asks once, and only yes puts the app."""
|
|
|
|
def run_finish(self, answer: str, system: str = "muOS", **given) -> list:
|
|
root = _tempdir(self)
|
|
bios = make_card(root / "card", system)
|
|
args = mock.Mock(add_app=False, yes=False)
|
|
for key, value in given.items():
|
|
setattr(args, key, value)
|
|
put = mock.Mock(return_value={"system": system, "folder": str(root / "card"),
|
|
"ok": True, "reason": "", "dests": ["x"],
|
|
"written": ["x"], "kept": []})
|
|
out = io.StringIO()
|
|
with mock.patch.object(install, "_ask", return_value=answer) as ask, \
|
|
mock.patch.object(install, "put_app", put), \
|
|
mock.patch("sys.stdout", out):
|
|
install.finish_app(args, [("retroarch", bios)], [], interactive=True)
|
|
self.output = out.getvalue()
|
|
return [ask.call_args_list, put.call_args_list]
|
|
|
|
def test_yes_puts_it(self):
|
|
asked, put = self.run_finish("y")
|
|
self.assertEqual(len(asked), 1)
|
|
self.assertIn("[y/N]", asked[0][0][0])
|
|
self.assertEqual(len(put), 1)
|
|
self.assertIn("Archive Manager", self.output)
|
|
|
|
def test_enter_or_no_leaves_the_card_alone(self):
|
|
for answer in ("", "n", "no", "q"):
|
|
with self.subTest(answer=answer):
|
|
_asked, put = self.run_finish(answer)
|
|
self.assertEqual(put, [])
|
|
|
|
def test_yes_flag_asks_nothing(self):
|
|
asked, put = self.run_finish("y", yes=True)
|
|
self.assertEqual((asked, put), ([], []))
|
|
|
|
def test_a_console_without_python_says_so_once(self):
|
|
asked, put = self.run_finish("y", system="Onion")
|
|
self.assertEqual((asked, put), ([], []))
|
|
self.assertIn(install.NO_PYTHON_WORDS, self.output)
|
|
|
|
|
|
class TheOption(unittest.TestCase):
|
|
"""--add-app is applied or refused, never ignored."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.repo = AppRepository()
|
|
cls.repo.__enter__()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.repo.__exit__(None, None, None)
|
|
|
|
def test_refused_where_it_cannot_apply(self):
|
|
root = _tempdir(self)
|
|
pack = root / "RetroArch_BIOS_Pack.zip"
|
|
zipfile.ZipFile(pack, "w").close()
|
|
for options, words in (
|
|
(["--check"], "--check and --dry-run do not"),
|
|
(["--dry-run"], "--check and --dry-run do not"),
|
|
(["--list-systems"], "a listing does not"),
|
|
(["--from-pack", str(pack)], "--from-pack makes no network request"),
|
|
(["--offline"], "which --offline does not"),
|
|
(["--restore", str(root)], "takes no --add-app"),
|
|
):
|
|
with self.subTest(options=options):
|
|
where = [] if "--restore" in options else ["--platform", "retroarch",
|
|
"--dest", str(root)]
|
|
proc = self.repo.cli(*where, "--add-app", *options)
|
|
self.assertEqual(proc.returncode, 2, proc.stderr)
|
|
self.assertIn("error:", proc.stderr)
|
|
self.assertIn(words, proc.stderr)
|
|
|
|
def test_refused_for_a_folder_on_no_card_or_no_launcher(self):
|
|
root = _tempdir(self)
|
|
for system, words in ((None, "is not on a card this installer recognises"),
|
|
("Onion", "Onion has no Python"),
|
|
("spruceOS", "no RetroBIOS app for spruceOS yet")):
|
|
with self.subTest(system=system):
|
|
bios = make_card(root / str(system), system) if system else root / "bios"
|
|
proc = self.repo.cli("--platform", "retroarch", "--dest", str(bios),
|
|
"--add-app", "--events")
|
|
self.assertEqual(proc.returncode, 2, proc.stderr)
|
|
self.assertIn(words, proc.stderr)
|
|
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
|
app = [event for event in events if event["event"] == "app"]
|
|
self.assertEqual(len(app), 1)
|
|
self.assertFalse(app[0]["ok"])
|
|
self.assertIn(words, app[0]["reason"])
|
|
self.assertEqual(events[-1], {"event": "end", "code": 2})
|
|
self.assertFalse([e for e in events if e["event"] == "download"])
|
|
|
|
def test_applied_after_the_install_and_reported(self):
|
|
root = _tempdir(self)
|
|
for system in ("muOS", "KNULLI"):
|
|
with self.subTest(system=system):
|
|
bios = make_card(root / system, system)
|
|
plat = CARD_PLATFORMS[system]
|
|
command = ("--platform", plat, "--dest", str(root / system), "--add-app",
|
|
"--events")
|
|
proc = self.repo.cli(*command)
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
|
kinds = [event["event"] for event in events]
|
|
self.assertEqual(kinds[-3:], ["summary", "app", "end"])
|
|
app = events[-2]
|
|
self.assertTrue(app["ok"], app)
|
|
self.assertEqual(app["system"], system)
|
|
self.assertEqual(app["folder"], str(root / system))
|
|
self.assertEqual(sorted(app["written"]), sorted(app["dests"]))
|
|
self.assertEqual(list(app), ["event", *install.EVENT_KEYS["app"]])
|
|
self.assertEqual(len(list(bios.iterdir())), 5)
|
|
self.assertIn("RetroBIOS is on the", proc.stderr)
|
|
again = self.repo.cli(*command)
|
|
app = [json.loads(line) for line in again.stdout.splitlines()][-2]
|
|
self.assertEqual((again.returncode, app["ok"], app["written"]), (0, True, []))
|
|
|
|
def test_not_added_when_the_install_does_not_finish(self):
|
|
root = _tempdir(self)
|
|
make_card(root / "card", "muOS")
|
|
# A BIOS folder that is a file: every download fails.
|
|
(root / "card" / "MUOS" / "bios").rmdir()
|
|
(root / "card" / "MUOS" / "bios").write_text("in the way")
|
|
proc = self.repo.cli("--platform", "retroarch", "--dest", str(root / "card"),
|
|
"--add-app", "--events")
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
|
app = [event for event in events if event["event"] == "app"]
|
|
self.assertEqual(len(app), 1, proc.stderr)
|
|
self.assertEqual((app[0]["ok"], app[0]["reason"]),
|
|
(False, "the install did not finish"))
|
|
self.assertFalse((root / "card" / "ARCHIVE").exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|