From 91b378d6413c99afbda129c05cd599086cacac6b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:09:05 +0200 Subject: [PATCH] fix: copy standalone files from their pack path The registry names one path per standalone copy for every platform, but each manifest places the file its own way, so the installer looked where the pack had put nothing and copied nothing: EmuDeck's aes_keys.txt for Azahar, Batocera's switch/prod.keys. Each manifest copy now names its source, which the installer validates and reads. --- install.ps1 | 2 +- install.py | 9 +++++++- install.sh | 2 +- scripts/generate_pack.py | 34 ++++++++++++++++++++++++++++- tests/test_audit_regressions.py | 37 ++++++++++++++++++++++++++++++++ tests/test_install.py | 38 +++++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index d5bc3188..dc2e8869 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "e18309beb7da0a974cff3ef9ca8019292e5798a19ae78fd4711b6b2cca88ebc6" +$defaultInstallSha256 = "431f7d05350e222a3f4746dd13116bf570ec0f80117eed47d747372c61b57d77" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 23e231ce..78611275 100644 --- a/install.py +++ b/install.py @@ -1396,6 +1396,10 @@ def _validate_manifest(data: object, plat: str) -> dict: _safe_relative_path( entry["file"], f"standalone_copies[{index}].file" ) + if "source" in entry: + _safe_relative_path( + entry["source"], f"standalone_copies[{index}].source" + ) if "pattern" in entry: pattern = entry["pattern"] if ( @@ -2432,7 +2436,10 @@ def do_standalone_copies( if fnmatch(f.name, entry["pattern"]) and f.is_file() ] else: - src = bios_path / entry["file"] + # `source` is where this platform's manifest put the file; `file` + # names it as the registry does for every platform, and laid out + # that way it was missing wherever the pack places it elsewhere. + src = _destination_path(bios_path, entry.get("source") or entry["file"]) sources = [src] if src.exists() else [] if not sources: diff --git a/install.sh b/install.sh index 9c25b9ba..1715734c 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="e18309beb7da0a974cff3ef9ca8019292e5798a19ae78fd4711b6b2cca88ebc6" +DEFAULT_INSTALL_SHA256="431f7d05350e222a3f4746dd13116bf570ec0f80117eed47d747372c61b57d77" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 6eb06929..94f6304d 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -3226,6 +3226,36 @@ _MANIFEST_HASHES = frozenset({"sha1", "sha256"}) _STABLE_HASHES: dict[tuple, dict] = {} +def _placed_copies(copies: list[dict], dests: list[str]) -> list[dict]: + """Each standalone copy with the path this manifest gives its file. + + The registry names one path per file for every platform (one anchor), + while each manifest places the file its own way: EmuDeck installs + aes_keys.txt at the root and Batocera prod.keys under switch/, so the + installer looked for Citra/sysdata/aes_keys.txt, found nothing and + copied nothing. `file` stays as written, since it also lays out a custom + emulator root; `source` says where the installed file is. + """ + by_name: dict[str, list[str]] = {} + for dest in dests: + by_name.setdefault(dest.rsplit("/", 1)[-1].casefold(), []).append(dest) + placed: list[dict] = [] + for copy in copies: + wanted = copy.get("file") + if not wanted or wanted in dests: + placed.append(copy) + continue + tail = [d for d in dests if d.endswith("/" + wanted)] + if tail: + # The file whose path carries the least on top of the tail. + shortest = min(len(d) for d in tail) + tail = [d for d in tail if len(d) == shortest] + else: + tail = by_name.get(wanted.rsplit("/", 1)[-1].casefold(), []) + placed.append({**copy, "source": tail[0]} if len(tail) == 1 else copy) + return placed + + def _stable_hashes(local_path: str, db: dict) -> dict: """The file's hashes, held against the scan that built the database. @@ -4084,7 +4114,9 @@ def generate_manifest( "generated": now, "base_destination": base_dest, "detect": detect, - "standalone_copies": standalone_copies, + "standalone_copies": _placed_copies( + standalone_copies, [entry["dest"] for entry in manifest_files] + ), "total_files": len(manifest_files), "total_size": total_size, "pack_files": ( diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index ac50a73d..8e695eda 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -497,6 +497,43 @@ class PatchesTargetWhatRuns(unittest.TestCase): self.assertEqual(offenders, []) +class StandaloneCopiesNameTheirSource(unittest.TestCase): + """The registry's anchor names one path per file for every platform; + each manifest places the file its own way, and the installer copied + nothing where the two differed (EmuDeck's aes_keys.txt, Batocera's + switch/prod.keys).""" + + def _placed(self, copies, dests): + from generate_pack import _placed_copies + + return _placed_copies(copies, dests) + + def test_a_path_tail_finds_the_file(self): + (copy,) = self._placed( + [{"file": "GC/USA/IPL.bin"}], + ["dolphin-emu/Sys/GC/USA/IPL.bin", "dolphin-emu/Sys/GC/EUR/IPL.bin"], + ) + self.assertEqual(copy["source"], "dolphin-emu/Sys/GC/USA/IPL.bin") + + def test_the_tail_with_least_on_top_wins(self): + (copy,) = self._placed( + [{"file": "GC/USA/IPL.bin"}], + ["bios/GC/USA/IPL.bin", "bios/dolphin-emu/Sys/GC/USA/IPL.bin"], + ) + self.assertEqual(copy["source"], "bios/GC/USA/IPL.bin") + + def test_a_unique_name_finds_the_file_elsewhere(self): + (copy,) = self._placed( + [{"file": "Citra/sysdata/aes_keys.txt"}], ["aes_keys.txt", "bios.bin"] + ) + self.assertEqual(copy["source"], "aes_keys.txt") + + def test_an_ambiguous_or_present_file_is_left_as_written(self): + copies = [{"file": "prod.keys"}, {"file": "IPL.bin"}] + placed = self._placed(copies, ["prod.keys", "a/IPL.bin", "b/IPL.bin"]) + self.assertEqual(placed, copies) + + class FaqRegressions(unittest.TestCase): """The FAQ states facts the code owns; these tie it back to the source. diff --git a/tests/test_install.py b/tests/test_install.py index 283c91b6..1d7f354f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -882,6 +882,44 @@ class TestStandaloneCopiesExtraDirs(unittest.TestCase): ) +class StandaloneCopiesReadWhereThePackPutTheFile(unittest.TestCase): + """The registry names Citra/sysdata/aes_keys.txt for every platform; + EmuDeck's manifest installs it at the root. The installer looked under + the registry's path, found nothing and copied nothing to Azahar.""" + + def _copy(self, entry: dict) -> tuple[Path, tuple[int, int, int]]: + bios = Path(_tempdir(self)) + (bios / "aes_keys.txt").write_text("keys") + sysdata = Path(_tempdir(self)) / "sysdata" + sysdata.mkdir() + manifest = {"standalone_copies": [ + {**entry, "emulator": "azahar", "targets": {"linux": [str(sysdata)]}}, + ]} + return sysdata, install.do_standalone_copies(manifest, bios, "linux") + + def test_the_source_the_manifest_names_is_copied(self): + sysdata, (copied, _, _) = self._copy( + {"file": "Citra/sysdata/aes_keys.txt", "source": "aes_keys.txt"} + ) + self.assertEqual(copied, 1) + self.assertEqual((sysdata / "aes_keys.txt").read_text(), "keys") + + def test_without_a_source_the_file_path_is_read(self): + _, (copied, _, _) = self._copy({"file": "Citra/sysdata/aes_keys.txt"}) + self.assertEqual(copied, 0) + + def test_an_unsafe_source_is_refused_at_load(self): + manifest = json.loads( + (Path(__file__).resolve().parents[1] / "install" / "emudeck.json").read_text() + ) + manifest["standalone_copies"] = [ + {"file": "aes_keys.txt", "source": "../../etc/passwd", + "targets": {"linux": ["/tmp"]}}, + ] + with self.assertRaisesRegex(ValueError, r"standalone_copies\[0\]\.source"): + install._validate_manifest(manifest, "emudeck") + + class InstallerStaysOnPython38AndTheStdlib(unittest.TestCase): """install.py runs where the bootstraps let it run: Python 3.8, stdlib only.