From 0bac022c2b2c95820b597dfabd40834fdfbe205a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:21:50 +0200 Subject: [PATCH] fix: replay absent core claims when narrowing --- install.ps1 | 2 +- install.py | 58 ++++++++++++++++++++++++--------- install.sh | 2 +- scripts/generate_pack.py | 23 ++++++++++++- tests/test_install_selection.py | 21 ++++++++++++ 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/install.ps1 b/install.ps1 index aaeb64fb..a5ff8b9e 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 = "61ffed8fddf816c02c1b45268dfd9bfdff9daeb1da2adedf2e13d14472f8654d" +$defaultInstallSha256 = "2e9e93d9809d9276a84209cfd421ad9b0d55853ce9c9d6761beb2ef21044ef2f" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index b7d2ce9a..cd0a8ea1 100644 --- a/install.py +++ b/install.py @@ -1151,6 +1151,27 @@ _COPY_FIELDS = ( ) +def _check_omission(entry: dict, dest: str) -> None: + """The fields of a file left out: an omitted entry, or the claim of a core + with nothing held at a destination another core's copy serves.""" + if not isinstance(entry.get("name"), str) or not entry["name"]: + raise ValueError(f"invalid omitted file name for {dest}") + if not isinstance(entry.get("system", ""), str): + raise ValueError(f"invalid omitted system for {dest}") + if not isinstance(entry.get("required"), bool): + raise ValueError(f"invalid omitted required flag for {dest}") + if entry.get("reason") not in OMISSION_REASONS: + raise ValueError(f"invalid omission reason for {dest}") + cores = entry.get("cores") + if cores is not None and ( + not isinstance(cores, list) + or not all(isinstance(core, str) for core in cores) + ): + raise ValueError(f"invalid omitted cores list for {dest}") + for field in SELECTION_FIELDS: + _check_tags(entry, field, dest) + + def _check_download(entry: dict, where: str, dest: str) -> int: """The size, hashes, source and cores of one downloadable copy; its size.""" size = entry.get("size") @@ -1271,6 +1292,13 @@ def _validate_manifest(data: object, plat: str) -> dict: # Narrowing can swap in any copy: the cap holds for the largest. size = max(size, _check_download(alternative, where, dest)) size = max(size, _check_slot(entry, f"files[{index}]", dest)) + absent = entry.get("absent_claims", []) + if not isinstance(absent, list) or len(absent) > MAX_SELECTION_TAGS: + raise ValueError(f"invalid absent claims for {dest}") + for claim in absent: + if not isinstance(claim, dict): + raise ValueError(f"invalid absent claim for {dest}") + _check_omission(claim, dest) largest_total += size if largest_total > MAX_TOTAL_DOWNLOAD_SIZE: raise ValueError("manifest total size exceeds safety limit") @@ -1299,20 +1327,7 @@ def _validate_manifest(data: object, plat: str) -> dict: if dest in seen_destinations or dest in seen_omitted: raise ValueError(f"duplicate or conflicting omitted destination: {dest}") seen_omitted.add(dest) - if not isinstance(entry.get("name"), str) or not entry["name"]: - raise ValueError(f"invalid omitted file name for {dest}") - if not isinstance(entry.get("system", ""), str): - raise ValueError(f"invalid omitted system for {dest}") - if not isinstance(entry.get("required"), bool): - raise ValueError(f"invalid omitted required flag for {dest}") - if entry.get("reason") not in OMISSION_REASONS: - raise ValueError(f"invalid omission reason for {dest}") - cores = entry.get("cores") - if cores is not None and ( - not isinstance(cores, list) - or not all(isinstance(core, str) for core in cores) - ): - raise ValueError(f"invalid omitted cores list for {dest}") + _check_omission(entry, dest) claims = entry.get("alternatives", []) if not isinstance(claims, list) or len(claims) > MAX_SELECTION_TAGS: raise ValueError(f"invalid omitted alternatives for {dest}") @@ -1566,6 +1581,8 @@ def narrow_to_cores( def kept_system(system: str) -> bool: return system not in reach or bool(cores.intersection(reach[system])) + displaced: list[dict] = [] + def kept(entries: list[dict]) -> list[dict]: result: list[dict] = [] for entry in entries: @@ -1573,6 +1590,16 @@ def narrow_to_cores( copy = _for_cores(entry, cores) if copy is not None: result.append(copy) + continue + # None of the cores served here is named: a named core that + # wants the slot and has nothing held is a file left out. + claim = next( + (c for c in entry.get("absent_claims", []) + if cores.intersection(c.get("cores") or [])), + None, + ) + if claim is not None: + displaced.append({"dest": entry["dest"], **claim}) continue # The systems declaring the file, where they are fewer than the # ones reading it. @@ -1589,7 +1616,8 @@ def narrow_to_cores( files = manifest.get("files", []) if files is None else files omitted = manifest.get("omitted_files", []) if omitted is None else omitted - return kept(files), kept(omitted) + kept_files = kept(files) + return kept_files, kept(omitted) + displaced def canonical_region(raw: str) -> str: diff --git a/install.sh b/install.sh index 1d98fd16..6470b7e6 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="61ffed8fddf816c02c1b45268dfd9bfdff9daeb1da2adedf2e13d14472f8654d" +DEFAULT_INSTALL_SHA256="2e9e93d9809d9276a84209cfd421ad9b0d55853ce9c9d6761beb2ef21044ef2f" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index f8fc665d..67f0aa5b 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -3299,6 +3299,20 @@ def _attach_claims( dest_hint=claim.get("destination", ""), offline=offline, ) if claim_status in ("not_found", "external", "user_provided") or not claim_path: + # Nothing held for this core: narrowed to it without the cores + # served above, the slot is a file left out (installer replays it). + systems = _extra_system_ids(claim) + entry.setdefault("absent_claims", []).append({ + "name": str(claim.get("name") or ""), + "system": systems[0] if systems else "", + "required": bool(claim.get("required", True)), + "reason": ( + claim_status + if claim_status in ("not_found", "external", "user_provided") + else "not_found" + ), + "cores": [claim_emu], + }) continue claim_path, _ = validated_choice( claim, claim_path, db, validation_index or {}, bios_dir, None, dest, owners, @@ -3495,7 +3509,14 @@ def _manifest_core_entries( ) manifest_files.append(entry) - omitted_by_destination.pop(full_dest, None) + displaced = omitted_by_destination.pop(full_dest, None) + if displaced and displaced.get("cores"): + # A core that wants this slot and has nothing held: a pack built + # without the cores served here leaves the slot to it, missing, + # and the installer narrowing to those cores has to say so too. + entry.setdefault("absent_claims", []).append( + {k: v for k, v in displaced.items() if k != "dest"} + ) total_size += file_size seen_destinations.add(full_dest) _register_path(full_dest, seen_destinations, seen_parents) diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index fb5646f8..a8b60d16 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -630,5 +630,26 @@ class InstallMatchesATargetManifest(unittest.TestCase): ) + +class ACoreWithNothingHeldIsAnOmission(unittest.TestCase): + """DOSBox serves SC-55/WAVEROM3.BIN, DOSBox Pure wants it and nothing is + held for it. A pack built for a target without DOSBox lists it as left + out; narrowed from the full manifest, it had vanished.""" + + MANIFEST = {"files": [{ + "dest": "SC-55/WAVEROM3.BIN", "cores": ["dosbox"], "sha1": "1" * 40, + "absent_claims": [{"name": "WAVEROM3.BIN", "system": "dos", "required": False, + "reason": "not_found", "cores": ["dosbox_pure"]}], + }], "omitted_files": []} + + def test_narrowing_away_the_holder_leaves_the_omission(self): + files, omitted = install.narrow_to_cores(self.MANIFEST, ["dosbox_pure"]) + self.assertEqual(files, []) + self.assertEqual([o["dest"] for o in omitted], ["SC-55/WAVEROM3.BIN"]) + files, omitted = install.narrow_to_cores(self.MANIFEST, ["dosbox", "dosbox_pure"]) + self.assertEqual(len(files), 1) + self.assertEqual(omitted, []) + + if __name__ == "__main__": unittest.main()