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.