From 66caa0e3637a77720ab809d406d22df280b19f0d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:37:21 +0200 Subject: [PATCH] fix: read batocera entries as literals A comment after an entry swallowed the closing brace, so gba was rewritten with its emulator gate lost. The entry is now read with literal_eval, which also stops executing the platform's text. --- scripts/exporter/batocera_exporter.py | 23 +++++++++++++---------- tests/test_export_counts.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/scripts/exporter/batocera_exporter.py b/scripts/exporter/batocera_exporter.py index 0ebc6481..e5e2ad90 100644 --- a/scripts/exporter/batocera_exporter.py +++ b/scripts/exporter/batocera_exporter.py @@ -8,6 +8,7 @@ entries and the code around them survive untouched. from __future__ import annotations +import ast import re from .base_exporter import BaseExporter @@ -317,14 +318,18 @@ class Exporter(BaseExporter): @staticmethod def _parse_entry(lines: list[str]) -> dict | None: - """Evaluate one entry so two spellings of the same data compare equal.""" - text = "\n".join(lines).strip().rstrip(",") - namespace: dict[str, object] = {} + """Read one entry so two spellings of the same data compare equal. + + A literal, never executed: the text is the platform's file. The + closing brace goes on a line of its own, since an entry may end in a + comment (gba's '] }, # Optional') that swallowed it, and every such + entry was rewritten with its emulator gate lost. + """ + text = "\n".join(lines).strip() try: - exec(f"entry = {{{text}}}", {}, namespace) # noqa: S102 - except (SyntaxError, ValueError, TypeError): + entry = ast.literal_eval("{" + text + "\n}") + except (SyntaxError, ValueError, TypeError, MemoryError, RecursionError): return None - entry = namespace.get("entry") return entry if isinstance(entry, dict) else None def render( @@ -403,7 +408,6 @@ class Exporter(BaseExporter): content = produced[self.native_filename()] issues: list[str] = [] - namespace: dict[str, object] = {} block = content.split("\nsystems = {", 1) if len(block) != 2: return ["no systems dict in the output"] @@ -411,11 +415,10 @@ class Exporter(BaseExporter): if end < 0: return ["the systems dict is not closed"] try: - exec("systems = {" + block[1][:end] + "\n}", {}, namespace) # noqa: S102 - except SyntaxError as exc: + exported = ast.literal_eval("{" + block[1][:end] + "\n}") + except (SyntaxError, ValueError) as exc: return [f"the systems dict does not parse: {exc}"] - exported = namespace.get("systems", {}) if not isinstance(exported, dict): return ["the systems dict did not evaluate to a dict"] diff --git a/tests/test_export_counts.py b/tests/test_export_counts.py index 562ed668..37f95182 100644 --- a/tests/test_export_counts.py +++ b/tests/test_export_counts.py @@ -851,5 +851,23 @@ class TheSummaryCountsWhatTheFileCarries(unittest.TestCase): self.assertEqual(changes.cleared, []) +class BatoceraEntriesParseAsWritten(unittest.TestCase): + """gba ends in '] }, # Optional': the comment swallowed the closing + brace, the entry read as unparsable and was rewritten with its emulator + gate and runner keys lost. The text was also run through exec.""" + + def test_a_trailing_comment_and_no_execution(self): + from exporter.batocera_exporter import Exporter as Batocera # noqa: PLC0415 + + entry = Batocera._parse_entry([ + '"gba": { "name": "GBA", "emulator": "libretro", "biosFiles": [', + ' { "md5": "a", "file": "bios/gba_bios.bin" } ] }, # Optional', + ]) + self.assertEqual(entry["gba"]["emulator"], "libretro") + self.assertIsNone(Batocera._parse_entry(['"x": __import__("os").getcwd()'])) + source = (REPO_ROOT / "scripts" / "exporter" / "batocera_exporter.py").read_text() + self.assertNotIn("exec(", source) + + if __name__ == "__main__": unittest.main()