chore: merge audit fixes (integrity)

# Conflicts:
#	install/batocera.json
#	install/emudeck.json
#	install/lakka.json
#	install/recalbox.json
#	install/retroarch.json
#	install/retrobat.json
#	install/retrodeck.json
#	install/retropie.json
#	install/rocknix.json
#	install/romm.json
#	scripts/generate_pack.py
#	wiki/tools.md
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 06:48:42 +02:00
commit 378a9e4da5
19 files changed
+1199 -101

No files matched your search

+88 -22
View File
@@ -31,6 +31,7 @@ from common import (
get_mame_clone_map,
list_emulator_profiles,
list_registered_platforms,
load_data_dir_registry,
load_database,
load_emulator_profiles,
load_platform_config,
@@ -44,7 +45,7 @@ from common import (
select_emulator_profiles,
)
from hashing import parse_hash_list
from validation import declaration_settles, outside_gap_scope
from validation import case_folded_slots, declaration_settles, outside_gap_scope
yaml = require_yaml()
@@ -105,6 +106,25 @@ def declared_names(
)
def folded_declared_slots(
platforms_dir: str, platforms: list[str] | None, db: dict
) -> frozenset[str]:
"""The slots platforms whose filesystem folds case fill, any spelling.
RetroBat's 32X_G_BIOS.BIN is the file BlastEm opens as 32X_G_BIOS.bin,
and the exact-case name test called it undeclared.
"""
from common import platform_declarations
slots: set[str] = set()
for platform_name in platforms or list_registered_platforms(
platforms_dir, include_archived=True
):
config = load_platform_config(platform_name, platforms_dir)
slots |= case_folded_slots(config, platform_declarations(config, db))
return frozenset(slots)
def _build_supplemental_index(
data_root: str = "data", bios_root: str = "bios"
) -> set[str]:
@@ -178,21 +198,28 @@ def _resolve_source(
or ``None`` (not available anywhere).
A name hit whose size contradicts a size the emulator verifies is not the
file, so it does not count as available.
file, so it does not count as available. Nor is one whose bytes carry
none of the hashes the entry declares: a name never wins over a declared
hash, and zquestclassic's ending.mid read as held because Hydra Castle
Labyrinth has another file of that name.
"""
declared = _declared_content(file_entry or {})
def _name_hit(name: str) -> bool:
"""Whether the files indexed under *name* can be this entry."""
def _can_be(sha1s: list | str) -> bool:
if not file_entry or db_files is None:
return True
sha1s = by_name.get(name) or []
if not isinstance(sha1s, list):
sha1s = [sha1s]
return any(
name_match_size_ok(file_entry, db_files.get(sha1, {}).get("size"))
and _record_carries(sha1, db_files.get(sha1, {}), declared)
for sha1 in sha1s
)
def _name_hit(name: str) -> bool:
"""Whether the files indexed under *name* can be this entry."""
return _can_be(by_name.get(name) or [])
# bios/ via database.json by_name
if fname in by_name and _name_hit(fname):
return "bios"
@@ -209,7 +236,7 @@ def _resolve_source(
):
return "bios"
# bios/ via by_path_suffix (regional variants)
if by_path_suffix and fname in by_path_suffix:
if by_path_suffix and fname in by_path_suffix and _can_be(by_path_suffix[fname]):
return "bios"
# bios/ under the canonical MAME set name, as resolve_local_file does:
# a renamed archive is held once, under the name the dedup kept.
@@ -252,6 +279,23 @@ def _data_hit(
_HASH_FIELDS = ("sha1", "md5", "sha256", "crc32")
def _record_carries(sha1: str, record: dict, declared: dict[str, set[str]]) -> bool:
"""Whether a database record has one of the declared hashes, or none is declared."""
if not declared:
return True
actual = {
"sha1": sha1.lower(),
**{field: str(record.get(field) or "").lower() for field in ("md5", "sha256", "crc32")},
}
return any(
actual[field] and (
actual[field] in values
or (field == "md5" and any(len(v) < 32 and actual[field].startswith(v) for v in values))
)
for field, values in declared.items()
)
def _declared_content(file_entry: dict) -> dict[str, set[str]]:
"""The hashes an entry declares, lowercased, by algorithm."""
declared: dict[str, set[str]] = {}
@@ -326,9 +370,21 @@ class FileConsumers:
file by the evidence the pack uses, so a homonym is nobody's file.
"""
def __init__(self, db: dict, zip_contents: dict | None = None) -> None:
def __init__(
self, db: dict, zip_contents: dict | None = None,
data_dir_registry: dict | None = None,
) -> None:
self._db = db
self._zip_contents = zip_contents
# The builder resolves with the data trees: Dolphin's codehandler.bin
# comes from data/dolphin-sys, and without them the name step gave it
# Dolphin-MMJR's copy, which the site then said Dolphin reads.
self._data_dir_registry = (
data_dir_registry if data_dir_registry is not None
else load_data_dir_registry(
str(Path(__file__).resolve().parent.parent / DEFAULT_PLATFORMS_DIR)
)
)
self._sha1_by_path = {
path: sha1
for sha1, entry in db.get("files", {}).items()
@@ -362,7 +418,8 @@ class FileConsumers:
if owner:
entry = {**entry, "source_profile": owner}
path, status = resolve_local_file(
entry, self._db, self._zip_contents, dest_hint=dest
entry, self._db, self._zip_contents, dest_hint=dest,
data_dir_registry=self._data_dir_registry,
)
if not path or status in ("not_found", "hash_mismatch"):
return None
@@ -470,6 +527,7 @@ def entry_source(f: dict, index: dict) -> str | None:
{"indexes": {"by_path_suffix": by_path_suffix}}, path_field or fname
):
return "bios"
found = None
for candidate in [fname, *([path_field] if path_field != fname else []),
*(f.get("aliases") or [])]:
if not candidate:
@@ -478,12 +536,15 @@ def entry_source(f: dict, index: dict) -> str | None:
candidate, by_name, by_name_lower, data_names, by_path_suffix, f, db_files,
index.get("bios_dir"),
)
if source is not None:
if source == "bios":
return source
found = found or source
def values(field: str) -> list[str]:
return parse_hash_list(f.get(field))
# The declared content anywhere in bios/ comes before a data/ copy, as in
# the resolver: the hash steps run before the data directories.
held = (
any(index["by_md5"].get(v) for v in parse_md5_list(f.get("md5")))
or any(v in db_files for v in values("sha1"))
@@ -491,7 +552,7 @@ def entry_source(f: dict, index: dict) -> str | None:
or any(v in index["by_sha256"] for v in values("sha256"))
or any(index["by_crc32"].get(v) for v in values("crc32"))
)
return "bios" if held else None
return "bios" if held else found
def _resolve_archive_source(
@@ -629,7 +690,9 @@ def _cross_reference_profile(
# Check platform declaration: by name, by an alias the core answers
# to (RetroArch lists amiberry's cd32.rom as kick40060.CD32), or by
# archive.
in_platform = declaration_settles(f, fname, platform_names, effective_path)
in_platform = declaration_settles(
f, fname, platform_names, effective_path, index.get("folded_slots", frozenset())
)
if in_platform:
seen_files.add(seen_key)
@@ -736,6 +799,7 @@ def cross_reference(
data_names: set[str] | None = None,
all_declared: dict[str, list[tuple[int | None, str]]] | None = None,
standalone_cores: set[str] | None = None,
folded_slots: frozenset[str] = frozenset(),
) -> dict:
"""Compare emulator profiles against platform declarations.
@@ -772,6 +836,7 @@ def cross_reference(
"db_files": db_files,
"data_names": data_names,
"all_declared": all_declared,
"folded_slots": folded_slots,
"bios_dir": db.get("bios_dir"),
}
for emu_name, profile in profiles.items():
@@ -807,11 +872,16 @@ def print_report(
if gaps == 0 and not show_clean:
continue
parts = []
for key in ("bios", "data", "large_file", "missing"):
count = data.get(f"gap_{key}", 0)
if count:
parts.append(f"{count} {key}")
# Counted once, for the heading and the total alike: a file bundled
# with its emulator is not one to source.
emulator_bundled = sum(
1 for g in data["gap_details"]
if g.get("bundled") and g.get("source") == "missing"
)
counts = {key: data.get(f"gap_{key}", 0) for key in totals}
counts["missing"] -= emulator_bundled
counts["bundled"] = emulator_bundled
parts = [f"{count} {key}" for key, count in counts.items() if count]
status = ", ".join(parts) if parts else "OK"
systems = ", ".join(data["systems"])
@@ -840,13 +910,8 @@ def print_report(
total_gaps += gaps
for key in totals:
totals[key] += data.get(f"gap_{key}", 0)
emulator_bundled = sum(
1 for g in data["gap_details"]
if g.get("bundled") and g.get("source") == "missing"
)
totals[key] += counts[key]
bundled += emulator_bundled
totals["missing"] -= emulator_bundled
print(f"\n{'=' * 60}")
print(f"Total: {total_gaps} undeclared files across {scope}")
@@ -957,6 +1022,7 @@ def main() -> None:
profiles, declared, db, plat_data_dirs, data_names,
all_declared=declared_names(args.platforms_dir, selected, db),
standalone_cores=standalone_cores,
folded_slots=folded_declared_slots(args.platforms_dir, selected, db),
)
if args.json: