fix: check the install record before using it

A record's choice holds only the options of the choice and their
values, or the whole record is said once and ignored: it no longer reaches
the commands printed for the user. A recorded games folder is kept only
under the folder that holds the BIOS folder. A recorded option the list
can no longer apply is left out in one line, never refused. The record is
read only when it is a regular file of at most 16 MiB, its day is unknown
when no clock can show it, one a newer installer wrote is left as it is,
and a setup that cannot be used is dropped alone. The plan opened on the
one setup found keeps that screen's notes under Details.
This commit is contained in:
Abdessamad Derraz committed 2026-11-10 12:17:08 +01:00
1 parent 8f2018e7c2
commit 5f3f4cff38
7 files changed
+489 -64

No files matched your search

+1 -1
View File
@@ -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 = "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
$defaultInstallSha256 = "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147"
# The pin above refuses any changed byte: this cap only stops a body that
# cannot be the installer (a sign-in portal's page) before it is hashed.
$maximumInstallerBytes = 8MB
+292 -49
View File
@@ -6136,63 +6136,165 @@ def record_paths(folder: Path) -> list[Path]:
return [holder / name for holder, _prefix in KeptFiles._holders(folder)]
def _valid_setup(setup: object) -> bool:
# Read whole, never past this: a record of every file of the longest list
# holds 1.6 MB per setup, and a few setups share one folder.
MAX_RECORD_BYTES = 16 * 1024 * 1024
# The choice options that take a value. --for-games takes one or none.
_RECORD_VALUED = ("--system", "--core", "--emulator", "--region", "--target")
def games_folder_kept(value: str, folder: Path) -> bool:
"""Whether a recorded --for-games folder may be read again: an absolute
path under the folder that holds the BIOS folder (a card's root, the
setup's own folder), never another drive or a network share."""
if not value or value.startswith(("\\\\", "//")) or not os.path.isabs(value):
return False
parent = os.path.normcase(os.path.normpath(os.path.dirname(os.path.abspath(folder))))
games = os.path.normcase(os.path.normpath(value))
try:
return os.path.commonpath([parent, games]) == parent
except ValueError:
# Two drives on Windows: no common path.
return False
def valid_choice(choice: list[str], folder: Path) -> bool:
"""Whether every word of a recorded choice is an option of the choice or
the value right after one: a record naming --dest, --from or --manifest
would put them in the commands printed for the user to copy."""
words = list(choice)
while words:
flag = words.pop(0)
if flag not in RECORD_CHOICE:
return False
if flag in _RECORD_VALUED:
if not words or words[0].startswith("-") or not words[0].strip():
return False
value = words.pop(0)
if any(ord(char) < 32 or ord(char) == 127 for char in value):
return False
elif flag == "--for-games" and words and not words[0].startswith("--"):
if not games_folder_kept(words.pop(0), folder):
return False
return True
def _valid_setup(setup: object, folder: Path) -> bool:
if not isinstance(setup, dict):
return False
choice, files = setup.get("choice"), setup.get("files")
return (isinstance(setup.get("written"), str) and isinstance(choice, list)
and all(isinstance(word, str) for word in choice) and isinstance(files, list)
and all(isinstance(word, str) for word in choice) and valid_choice(choice, folder)
and isinstance(files, list)
and all(isinstance(item, dict) and isinstance(item.get("dest"), str)
and isinstance(item.get("sha1"), str) and isinstance(item.get("size"), int)
for item in files))
def read_record(folder: Path) -> tuple[dict | None, Path | None, str]:
"""(the record of folder, where it is, why it cannot be used). Neither a
record nor a reason where there is none."""
class RecordRead(NamedTuple):
"""What reading a record found: the record with its usable setups, where
it is, why it cannot be used, whether a newer installer wrote it, and
the setups it holds that cannot be used."""
data: dict | None
path: Path | None
problem: str
newer: bool = False
dropped: tuple = ()
def _read_record_bytes(path: Path) -> bytes:
"""The bytes of a record that is a regular file of a record's size. A
FIFO, a device or a folder is refused before it is read: reading one
waited forever."""
info = os.stat(path)
if not stat.S_ISREG(info.st_mode):
raise OSError(errno.EINVAL, "it is not a file")
if info.st_size > MAX_RECORD_BYTES:
raise OSError(errno.EFBIG, "it is larger than any record")
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
handle = os.open(path, flags)
try:
# Opened without waiting, then checked again: what is open now is
# what is read, whatever took the name since the stat.
if not stat.S_ISREG(os.fstat(handle).st_mode):
raise OSError(errno.EINVAL, "it is not a file")
chunks, left = [], MAX_RECORD_BYTES + 1
while left > 0:
chunk = os.read(handle, min(left, 1 << 20))
if not chunk:
break
chunks.append(chunk)
left -= len(chunk)
finally:
os.close(handle)
data = b"".join(chunks)
if len(data) > MAX_RECORD_BYTES:
raise OSError(errno.EFBIG, "it is larger than any record")
return data
def load_record(folder: Path) -> RecordRead:
"""The record of folder, read once (RecordRead)."""
for path in record_paths(folder):
try:
text = path.read_text(encoding="utf-8")
text = _read_record_bytes(path).decode("utf-8")
except (FileNotFoundError, NotADirectoryError):
continue
except (OSError, UnicodeDecodeError) as exc:
reason = getattr(exc, "strerror", None) or str(exc)
return None, path, f"The install record {path} cannot be read ({reason})"
return RecordRead(None, path, f"The install record {path} cannot be read ({reason})")
try:
data = json.loads(text)
except ValueError:
return None, path, f"The install record {path} is damaged"
if not isinstance(data, dict) or not isinstance(data.get("setups"), dict):
return None, path, f"The install record {path} is damaged"
if data.get("schema") != RECORD_SCHEMA:
return None, path, (f"The install record {path} was written by another "
"version of the installer")
if not all(_valid_setup(setup) for setup in data["setups"].values()):
return None, path, f"The install record {path} is damaged"
return data, path, ""
return None, None, ""
return RecordRead(None, path, f"The install record {path} is damaged")
schema = data.get("schema") if isinstance(data, dict) else None
if isinstance(schema, int) and not isinstance(schema, bool) and schema > RECORD_SCHEMA:
# Its shape is the newer installer's: kept for it, never read.
return RecordRead(None, path, f"The install record {path} was written by a newer "
"installer", newer=True)
if (not isinstance(data, dict) or not isinstance(data.get("setups"), dict)
or not isinstance(data.get("folder"), str) or schema != RECORD_SCHEMA):
return RecordRead(None, path, f"The install record {path} is damaged")
setups = {name: setup for name, setup in data["setups"].items()
if _valid_setup(setup, folder)}
dropped = tuple(sorted(str(name) for name in data["setups"] if name not in setups))
return RecordRead({**data, "setups": setups}, path, "", dropped=dropped)
return RecordRead(None, None, "")
def read_record(folder: Path) -> tuple[dict | None, Path | None, str]:
"""(the record of folder, where it is, why it cannot be used). Neither a
record nor a reason where there is none."""
found = load_record(folder)
return found.data, found.path, found.problem
def setup_record(plat: str, folder: Path) -> tuple[dict | None, Path | None, str]:
"""The record of plat in folder, where it is, and why it cannot be used.
A record written for a folder of another name (copied, or renamed with
it) is not this folder's."""
data, path, problem = read_record(folder)
if data is None:
return None, path, problem
written_for = str(data.get("folder") or "")
it) is not this folder's. A setup of it that cannot be used (an option
the choice has no place for, a games folder elsewhere) is damaged, the
others still read."""
found = load_record(folder)
if found.data is None:
return None, found.path, found.problem
written_for = str(found.data.get("folder") or "")
if Path(written_for).name != Path(os.path.abspath(folder)).name:
return None, path, ""
return data["setups"].get(plat), path, ""
return None, found.path, ""
if plat in found.dropped:
return None, found.path, f"The install record {found.path} is damaged"
return found.data["setups"].get(plat), found.path, ""
def record_day(setup: dict) -> str:
"""The day a record was written, as the plan says it: 3 Nov."""
try:
when = datetime.datetime.fromisoformat(setup["written"].replace("Z", "+00:00"))
except (KeyError, ValueError):
# Year 1 or 9999 cannot be moved to local time: an unknown day.
local = when.astimezone()
except (KeyError, TypeError, AttributeError, ValueError, OverflowError, OSError):
return ""
local = when.astimezone()
day = f"{local.day} {_MONTHS[local.month - 1]}"
return day if local.year == datetime.datetime.now().year else f"{day} {local.year}"
@@ -6265,9 +6367,18 @@ def write_record(
earlier runs placed: (where, the record of plat, why it was not
written). A record that cannot be read is replaced."""
folder = Path(os.path.abspath(folder))
data, found, _problem = read_record(folder)
if data is None:
data = {"setups": {}}
read = load_record(folder)
if read.newer:
# What a newer installer wrote is kept for it: rewritten here, the
# other setups' history would go.
if str(read.path) not in _RECORD_SAID:
_RECORD_SAID.add(str(read.path))
print(f" {read.problem}: left as it is.")
return None, {"files": [], "written": ""}, f"{read.problem}: left as it is"
found = read.path
# A record that cannot be read is replaced, said when it was read. One
# with a setup that cannot be used keeps its others.
data = read.data if read.data is not None else {"setups": {}}
previous = data["setups"].get(plat) or {}
merged = {(str(item.get("root") or ""), item["dest"]): item
for item in previous.get("files") or []}
@@ -6339,6 +6450,29 @@ def say_record_problem(plat: str, folder: Path, path: Path | None, problem: str)
choice=[], date="", files=0, message=message)
def by_record(args: argparse.Namespace) -> bool:
"""Whether the run's choice came from the install record: nothing of
it was typed, so a part that cannot apply is left out, never refused."""
return bool(getattr(args, "from_record", False))
def drop_recorded(args: argparse.Namespace, plat: str, flag: str, why: str,
keep: list[str] | None = None) -> None:
"""Leave out a recorded option that cannot apply, in one line: the run
goes on, and the record written after it no longer holds it. keep, for
an option of several names, is the names that still apply."""
name = flag[2:].replace("-", "_")
chosen = _INVOCATION.chosen.get(plat, [])
if keep:
setattr(args, name, list(keep))
_INVOCATION.chosen[plat] = [option for option in chosen if option[0] != flag] + [
(flag, value) for value in keep]
else:
setattr(args, name, build_parser().get_default(name))
_INVOCATION.chosen[plat] = [option for option in chosen if option[0] != flag]
print(f" Left out of the recorded choice: {flag} ({why}).")
def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | None:
"""Take the choice recorded for plat in folder as the run's, as if its
options had been typed: the plan's first choice. Returns the record,
@@ -6365,6 +6499,12 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No
# a person expects, and said nothing new.
_RECORD_TAKEN[plat] = setup
_INVOCATION.chosen[plat] = [option for option in options if option[0] != "--all-files"]
if isinstance(args.for_games, Path) and not _is_dir(args.for_games):
# The games folder recorded is gone: the card's own games are read.
_INVOCATION.chosen[plat] = [(flag, None if flag == "--for-games" else value)
for flag, value in _INVOCATION.chosen[plat]]
args.for_games = True
print(" Left out of the recorded choice: its games folder (no longer there).")
message = record_menu_words(setup)
EVENTS.emit("record", platform=plat, folder=str(folder), path=str(path), status="applied",
choice=list(setup["choice"]), date=setup["written"],
@@ -10502,12 +10642,47 @@ def _known_names(values: list[str] | None, known: list[str], label: str,
try:
resolve_choices([value], known, label, aliases)
except ValueError:
detail(f" {value} is no longer on the list: left out of the recorded choice")
continue
kept.append(value)
return kept
def _recorded_names(args: argparse.Namespace, plat: str, flag: str, known: list[str],
aliases: dict[str, str] | None = None) -> None:
"""A recorded --system, --core or --emulator kept to the names the list
still holds, the others left out in one line."""
name = flag[2:].replace("-", "_")
values = list(getattr(args, name) or [])
kept = _known_names(values, known, flag[2:], aliases)
if kept != values:
gone = ", ".join(value for value in values if value not in kept)
drop_recorded(args, plat, flag, f"{gone} no longer on the list", kept)
def _recorded_emulators(args: argparse.Namespace, plat: str, manifest: dict,
files: list[dict], omitted: list[dict]) -> None:
"""A recorded --emulator that cannot narrow this list, left out."""
if "system_cores" not in manifest:
drop_recorded(args, plat, "--emulator", "the list does not say which emulators "
"read which systems")
return
_recorded_names(args, plat, "--emulator", sorted(emulator_names(manifest, files, omitted)))
def _recorded_regions(args: argparse.Namespace, plat: str) -> None:
"""A recorded --region kept to the regions this installer knows."""
values = list(args.region or [])
kept = []
for value in values:
try:
resolve_regions([value])
except ValueError:
continue
kept.append(value)
if kept != values:
drop_recorded(args, plat, "--region", "a region this installer does not know", kept)
def _requested_narrowing(
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict],
manifest: dict | None = None,
@@ -10524,6 +10699,22 @@ def _requested_narrowing(
asked = [(flag, axis) for flag, given, axis in (
("--system", args.system, "systems"), ("--core", args.core, "cores"),
("--region", args.region, "regions")) if given]
if getattr(args, "from_record", False):
# A recorded option that cannot narrow this list is left out: nobody
# typed it, and every run without options would stop on it.
for flag, axis in asked:
if axis not in recorded:
drop_recorded(args, plat, flag, f"the list records no {axis}")
asked = [(flag, axis) for flag, axis in asked if axis in recorded]
_recorded_regions(args, plat)
choices = available_choices(files, omitted)
if args.system:
_recorded_names(args, plat, "--system", choices["systems"],
system_aliases(manifest))
if args.core:
_recorded_names(args, plat, "--core", choices["cores"])
if not (args.system or args.core or args.region):
return None
for flag, axis in asked:
if axis in recorded:
continue
@@ -10536,14 +10727,6 @@ def _requested_narrowing(
print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr)
sys.exit(1)
choices = available_choices(files, omitted)
if getattr(args, "from_record", False):
# A name the list no longer holds is dropped from a recorded choice,
# never refused: nobody typed it.
args.system = _known_names(args.system, choices["systems"], "system",
system_aliases(manifest)) or None
args.core = _known_names(args.core, choices["cores"], "core") or None
if not (args.system or args.core or args.region):
return None
try:
systems = resolve_choices(
args.system or [], choices["systems"], "system", system_aliases(manifest),
@@ -14577,6 +14760,8 @@ def main() -> None:
# the menu. Yes to every question is no question.
args.no_input = True
_INVOCATION.record(args)
if os.environ.get(TUI_RECORD_ENV):
args.from_record = True
if os.environ.get(TUI_LIST_ENV):
# The full screen's own copy of the list, deleted when it closes: a
# command naming it fails once the screen is gone.
@@ -16424,7 +16609,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
if not written and setup_record(plat, folder)[0] is None:
# Nothing of ours is there to record, and no choice to keep.
return
options = _INVOCATION.flags(plat)
# A games folder is kept only where the next run reads it from a
# record (games_folder_kept): elsewhere the card's own games are.
options = [(flag, value if flag != "--for-games" or not value
else os.path.abspath(value)
if games_folder_kept(os.path.abspath(value), folder) else None)
for flag, value in _INVOCATION.flags(plat)]
if games:
options += [("--for-games", None),
*([("--required-only", None)] if args.required_only else [])]
@@ -16441,16 +16631,23 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
f"Recorded {_plural(count, 'file')} and this choice in {path}.")
if len(platforms) == 1 and not listing and not choice_given(args):
if apply_record(args, *platforms[0]) is not None and args.target:
known = fetch_targets(platforms[0][0])
if known is not None and args.target not in known:
# A board the list no longer names: every board's files.
args.target = None
apply_record(args, *platforms[0])
# --target is checked for every platform before anything is written, as
# --installed-cores is below: EmuDeck installed 3.2 GB and only then was
# steamos refused for RetroArch, with no summary of what had landed.
target_lists: dict[str, dict] = {}
if args.target and by_record(args) and len(platforms) == 1:
# The screen's runs carry the record's options as typed ones.
plat = platforms[0][0]
if LOCAL.offline and local_targets(plat) is None and plat in TARGET_PLATFORMS:
drop_recorded(args, plat, "--target", "the board list needs the internet")
else:
with contextlib.redirect_stderr(io.StringIO()):
known = fetch_targets(plat)
if known is None or args.target not in known:
drop_recorded(args, plat, "--target", "the board list does not name it"
if known is not None else "the board list could not be read")
if args.target:
for plat_name, _bios_path in platforms:
targets = fetch_targets(plat_name)
@@ -16485,6 +16682,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path)
cores_found[(plat_name, bios_path)] = installed_cores(searched)
cores_searched[(plat_name, bios_path)] = searched
elif args.installed_cores and by_record(args):
drop_recorded(args, plat_name, "--installed-cores",
f"{plat_name} ships its cores with the system")
elif args.installed_cores:
print(
f"Error: {plat_name} ships its cores with the system. "
@@ -16493,7 +16693,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
sys.exit(1)
if args.installed_cores and not cores_found[(plat_name, bios_path)]:
_no_core_found(plat_name, bios_path, searched)
if by_record(args):
drop_recorded(args, plat_name, "--installed-cores", "no core is installed")
else:
_no_core_found(plat_name, bios_path, searched)
for plat_name, bios_path in platforms:
if bios_path not in installed_paths:
@@ -16536,6 +16739,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
game_data_cut = 0
if args.no_game_data and not manifest.get("categorized") and by_record(args):
drop_recorded(args, plat_name, "--no-game-data", "the list marks no game data")
if args.no_game_data and not manifest.get("categorized"):
# Without the field nothing is marked, and the run would
# install the game data it was asked to leave out.
@@ -16619,6 +16824,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
found_cores = []
elif found_cores:
detail(f" {_plural(len(found_cores), 'core')} installed")
if args.installed_cores and "system_cores" not in manifest and by_record(args):
drop_recorded(args, plat_name, "--installed-cores",
"the list does not say which cores read which systems")
if args.installed_cores:
if "system_cores" not in manifest:
print(
@@ -16644,6 +16852,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
else (files, omitted_files)))
continue
if args.emulator and by_record(args):
_recorded_emulators(args, plat_name, manifest, files, omitted_files)
if args.emulator:
files, omitted_files = _narrow_to_emulators(
args.emulator, manifest, plat_name, files, omitted_files
@@ -16756,7 +16966,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
return _INVOCATION.command(plat, folder)
# The record's options, which the menu's commands overwrite.
recorded = _INVOCATION.chosen.get(plat_name)
record_options_kept = _INVOCATION.chosen.get(plat_name)
selection = _prompt_custom_selection(
plat_name, bare, bare_omitted + unplaced, installed,
detected_os(plat_name, bios_path) or display,
@@ -16808,9 +17018,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
_INVOCATION.chosen[plat_name] = []
else:
_INVOCATION.chosen.pop(plat_name, None)
if kept_record and recorded is not None:
if kept_record and record_options_kept is not None:
# Enter on the recorded choice: still the record's options.
_INVOCATION.chosen[plat_name] = recorded
_INVOCATION.chosen[plat_name] = record_options_kept
elif selection is None and plat_name in _RECORD_TAKEN and not games_applied:
# e: every file, the recorded narrowing given up.
args.system = args.core = args.region = None
@@ -17465,6 +17675,10 @@ TUI_ESC_WAIT = 0.04
TUI_FRAME = 0.1
# Set for a child run handed the interface's own copy of the file list.
TUI_LIST_ENV = "RETROBIOS_TUI_LIST"
# Set for a child run whose choice options are the install record's, kept
# on the screen: a part of them that cannot apply is left out, as line mode
# leaves it out of a record it reads, never refused.
TUI_RECORD_ENV = "RETROBIOS_TUI_RECORDED"
_PASTE_START = "\x1b[200~"
_PASTE_END = "\x1b[201~"
# Turned on by the interface, so a pasted path arrives as one piece and its
@@ -18900,6 +19114,19 @@ def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]:
return argv + _TUI_MODES[run]
def tui_run_env(state: TuiState, own_list: bool) -> dict | None:
"""The environment of a child run: None for this one's own, else with
the markers the run reads (TUI_LIST_ENV, TUI_RECORD_ENV)."""
marks = {TUI_LIST_ENV: "1"} if own_list else {}
if tui_record_kept(state):
marks[TUI_RECORD_ENV] = "1"
if not marks and TUI_RECORD_ENV not in os.environ:
return None
env = {key: value for key, value in os.environ.items() if key != TUI_RECORD_ENV}
env.update(marks)
return env
def tui_hint(state: TuiState) -> Invocation:
"""The command the screen's choices make, every option of the run in
it: what "Same choice without questions" and the sudo advice give."""
@@ -22675,6 +22902,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
if check is state.check:
lines += _unplaced_lines(_choice_summary(state), state,
"Choose it in Options, Save folder (--save-dir).")
if check is state.check:
lines += _found_lines(state)
if check is state.check and state.from_folders:
# A folder copied from that holds none of them: the plan came back
# the same, still saying to copy from a folder.
@@ -23722,6 +23951,20 @@ def _unavailable(state: TuiState) -> tuple[list[dict], int]:
return needed, others
def _found_lines(state: TuiState) -> list[TuiLine]:
"""What the setups screen said of this machine, on a plan that opened
in its place: the setup found by its label, and the notes (ES-DE found,
what detection printed) on Details, one line on the plan saying so."""
if not state.opened_on_plan or not state.setups:
return []
lines = _details([f"Found here: {state.setups[0][2]}.", *state.notes])
if state.notes:
brief = _styled(f"{_plural(len(state.notes), 'note')} on this machine: Details.")
brief.brief = True
lines.insert(0, brief)
return lines
def _unavailable_brief(state: TuiState) -> list[TuiLine]:
"""The count of the files an emulator needs that cannot be supplied, on
the screen itself: their names are on Details, and in place with
@@ -25303,7 +25546,7 @@ def run_tui(args: argparse.Namespace) -> int:
try:
current.append(_EngineRun(
tui_argv(state, effect[1], sys.executable, script), log, inbox,
state.run_id, {**os.environ, TUI_LIST_ENV: "1"} if own_list else None))
state.run_id, tui_run_env(state, own_list)))
except OSError as exc:
inbox.put(("ended", state.run_id, 1,
[f"The installer could not be started: {exc}"]))
+1 -1
View File
@@ -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="70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
DEFAULT_INSTALL_SHA256="37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147"
# The pin above refuses any changed byte: this cap only stops a body that
# cannot be the installer (a sign-in portal's page) before it is hashed.
MAX_INSTALLER_BYTES=8388608
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 1189759,
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
"size": 1201450,
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1243384,
"sha256": "ae0d36d5f2dda70c39bfe8be474108b92f0a5a80457bc8c07d36736b09b0310d"
"size": 1255075,
"sha256": "23393eb6b88f7a7875bd034e79c02d81e80409c9403290bb96a75166bf7a60b6"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 1189759,
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
"size": 1201450,
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 1189759,
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
"size": 1201450,
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
"executable": false
},
{
+140
View File
@@ -25,6 +25,7 @@ import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
@@ -619,5 +620,144 @@ class InstallRecord(unittest.TestCase):
events(screen, "check")[0]["bytes_needed"])
def record_text(folder: Path, choice: list[str], **setups: object) -> str:
"""A record of folder whose retroarch setup chose choice."""
setup = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": choice,
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 1,
"from": "download"}]}
return json.dumps({"schema": install.RECORD_SCHEMA, "installer": "x", "folder": str(folder),
"setups": {"retroarch": setup, **setups}})
class RecordReview(unittest.TestCase):
"""A record is data another program or a person may have written: what
it holds is checked before any of it is used or printed."""
setUp = InstallRecord.setUp
run_line = InstallRecord.run_line
dests = InstallRecord.dests
everything = InstallRecord.everything
def write(self, choice: list[str], **setups: object) -> None:
self.dest.mkdir(parents=True, exist_ok=True)
self.record.parent.mkdir(exist_ok=True)
self.record.write_text(record_text(self.dest, choice, **setups), encoding="utf-8")
def test_a_word_outside_the_choice_rejects_the_whole_record(self):
for choice in (["--system", "nintendo-gba", "--from", "/mnt/x"],
["--manifest", "http://example.invalid/list.json"],
["--system", "nintendo-gba", "--dest", "/etc"],
["--system", "--yes"], ["--system", "a\nb"], ["nintendo-gba"]):
with self.subTest(choice=choice):
self.write(choice)
proc = self.run_line("--check", "--events")
said = events(proc, "record")
self.assertEqual([event["status"] for event in said], ["unreadable"], said)
self.assertEqual(self.dests(proc), self.everything())
text = proc.stdout + proc.stderr
for word in ("/mnt/x", "example.invalid", "/etc"):
self.assertNotIn(word, text)
# The screen reads it through the same function.
state = install.TuiState()
state.platform, state.dest = "retroarch", self.dest
install._tui_record_load(state)
self.assertIsNone(state.record)
self.assertEqual(install.tui_flags(state), [])
def test_a_recorded_games_folder_stays_beside_the_bios_folder(self):
parent = self.dest.parent
self.assertTrue(install.games_folder_kept(str(parent / "roms"), self.dest))
self.assertTrue(install.games_folder_kept(str(parent), self.dest))
for value in ("/etc", str(parent.parent / "other"), "roms", "\\\\server\\share",
"//server/share", ""):
with self.subTest(value=value):
self.assertFalse(install.games_folder_kept(value, self.dest))
self.write(["--for-games", "/etc"])
proc = self.run_line("--check", "--events")
self.assertEqual(events(proc, "record")[0]["status"], "unreadable")
self.assertEqual(self.dests(proc), self.everything())
def test_a_recorded_option_that_cannot_apply_is_left_out(self):
"""A run nobody typed options for went on failing: the list has no
cores, no categories and no board list, and a region is unknown."""
for choice, typed in ((["--system", "nintendo-gba", "--region", "atlantis"],
["--region", "atlantis"]),
(["--system", "nintendo-gba", "--emulator", "nosuch"],
["--emulator", "nosuch"]),
(["--system", "nintendo-gba", "--installed-cores"],
["--installed-cores"]),
(["--system", "nintendo-gba", "--target", "nosuch"],
["--target", "nosuch"]),
(["--system", "nintendo-gba", "--no-game-data"],
["--no-game-data"])):
with self.subTest(choice=choice):
self.write(choice)
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(proc.stderr.count("Left out of the recorded choice"), 1,
proc.stderr)
self.assertEqual(self.dests(proc), ["gba_bios.bin"])
# Written again without it: the next run says nothing.
kept = json.loads(self.record.read_text())["setups"]["retroarch"]["choice"]
self.assertEqual(kept, ["--system", "nintendo-gba"])
# Typed, it is refused as before.
self.assertNotEqual(self.run_line(*typed, "--check").returncode, 0)
def test_a_date_no_clock_can_show_is_an_unknown_day(self):
if not hasattr(time, "tzset"):
self.skipTest("no tzset")
zone = os.environ.get("TZ")
def back() -> None:
if zone is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = zone
time.tzset()
self.addCleanup(back)
for tz, written in (("UTC+5", "0001-01-01T00:00:00Z"),
("UTC-5", "9999-12-31T23:59:59Z")):
with self.subTest(written=written):
os.environ["TZ"] = tz
time.tzset()
self.assertEqual(install.record_day({"written": written}), "")
def test_a_record_that_is_no_file_is_never_read(self):
self.dest.mkdir(parents=True)
self.record.parent.mkdir()
os.mkfifo(self.record)
proc = self.run_line("--check", "--events")
self.assertIn("it is not a file", events(proc, "record")[0]["message"])
self.assertEqual(self.dests(proc), self.everything())
self.record.unlink()
with open(self.record, "wb") as handle:
handle.truncate(install.MAX_RECORD_BYTES + 1)
self.assertIn("larger than any record", install.load_record(self.dest).problem)
def test_a_newer_record_is_left_as_it_is(self):
self.dest.mkdir(parents=True)
self.record.parent.mkdir()
newer = json.dumps({"schema": install.RECORD_SCHEMA + 1, "setups": {"x": {}}})
self.record.write_text(newer, encoding="utf-8")
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual([event["status"] for event in events(proc, "record")],
["unreadable", "not_written"])
self.assertEqual(self.record.read_text(encoding="utf-8"), newer)
self.assertEqual(proc.stderr.count("written by a newer installer"), 1)
def test_a_bad_setup_is_dropped_and_the_others_kept(self):
good = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": ["--all-files"],
"files": []}
self.write(["--system", "nintendo-gba"], lakka=good, batocera={"choice": 3})
path, _setup, problem = install.write_record(self.dest, "retropie", {}, ["--all-files"],
[])
self.assertEqual((path, problem), (self.record, ""))
kept = json.loads(self.record.read_text())["setups"]
self.assertEqual(sorted(kept), ["lakka", "retroarch", "retropie"])
self.assertEqual(kept["retroarch"]["choice"], ["--system", "nintendo-gba"])
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -1148,6 +1148,36 @@ class RecordedPlan(unittest.TestCase):
press(state, "enter")
self.assertEqual(state.screen, "setup")
def test_the_notes_of_the_setups_screen_are_on_the_plan(self):
"""Opened on the plan, the ES-DE note and the setup's label, which
only the setups screen showed, were never seen."""
setups = fresh_state().setups[:1]
with mock.patch.object(install, "found_setups", return_value=(setups, set())), \
mock.patch.object(install, "detect_frontends", return_value=["esde"]), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_DETECTION_REFUSED", []), \
mock.patch.object(install.Path, "exists", return_value=True):
state, _effects = install.tui_start(_args(), "linux")
self.assertTrue(state.opened_on_plan)
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
for event in [*FILES, *SYSTEMS, CHECK]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
state.pad = "both"
plan = screen_text(install.tui_view(state, 80, 24))
self.assertIn("1 note on this machine: Details.", plan)
self.assertNotIn(install.TUI_ESDE_NOTE, plan)
# The plan's budget, plus the note line (6 words) and Change where (2).
self.assertLessEqual(screen_words(install.tui_view(state, 80, 24)),
SCREEN_WORDS["plan"][2] + 8)
state.focus["plan"] = [row.action for row in install.tui_rows(state)].index("details")
press(state, "enter")
details = screen_text(install.tui_view(state, 120, 40))
self.assertIn(screen_text([install.TUI_ESDE_NOTE]), details)
self.assertIn("Found here: RetroArch.", details)
def test_several_setups_open_the_list(self):
with mock.patch.object(install, "found_setups", return_value=(
fresh_state().setups, set())), \
+17 -5
View File
@@ -172,7 +172,8 @@ Run at a terminal, the installer opens a full screen that a keyboard or a
controller drives. With one setup found here, it opens on that setup's plan,
and two presses of A or Enter install everything it reads: the plan, then the
result. **Change where** (or B) goes back to the setups, which open first when
none or several are found. After an install, the plan of the same folder opens
none or several are found. What that screen would have noted (ES-DE found, the
setup's name) is under **Details**, one plan line saying so. After an install, the plan of the same folder opens
on the same choice (**Same choice as on 10 Nov: yes**, the
[install record](#the-install-record)), and **no** takes the whole list, or the
games of the card when it holds some. The plan screen says
@@ -489,10 +490,21 @@ the choice (`--system`, `--core`, `--region`, `--for-games`, `--no-game-data`,
`--target`, `--every-board`, `--emulator`, `--installed-cores`, `--declared-only`)
replaces it, `--all-files` installs the whole list, and a run over several
setups or from a file list (`--manifest`) never reads it. `--check` and
`--dry-run` read it too, and say so. To forget it, delete the file. A record
that cannot be read, or that another version wrote, is said once and ignored,
and the next install writes a new one. A record kept for a folder of another
name (copied or renamed with it) is not applied. With `--events` the record
`--dry-run` read it too, and say so. To forget it, delete the file.
A record is read only when it is a regular file of at most 16 MiB whose
choice holds nothing but the options above and their values. A games folder
(`--for-games FOLDER`) is kept only under the folder that holds the BIOS
folder, so a record never sends the games scan to another drive or a network
share: any other folder is recorded as `--for-games` alone, the card's own
games. A record that breaks any of this, or cannot be read, is said once and
ignored, and the next install writes a new one, keeping the other setups'
entries that read. One written by a newer installer is left as it is. A
recorded option the list can no longer apply (a region, an emulator, a board,
the installed cores, game data on a list that marks none) is left out in one
line, `Left out of the recorded choice: --target (...)`, and the run goes on:
typed on the command line, the same option is refused as before. A record kept
for a folder of another name (copied or renamed with it) is not applied. With `--events` the record
gives a `record` event, see [Machine-readable events](#machine-readable-events).
## Reading a run { #reading-a-run }