fix: accept RomM files on size and any hash

RomM's verify_file_hashes compares the size, then md5, sha1 or crc. We
compared md5 alone, so a fixture entry with one mistyped hash was left out
of the pack RomM would accept it from. The rule is declared in romm.yml
and read by verify, the builder and the manifest.
This commit is contained in:
Abdessamad Derraz committed 2026-10-18 07:24:33 +02:00
1 parent 731faee6e1
commit ae3dc63bc3
10 files changed
+182 -20

No files matched your search

+6 -3
View File
@@ -805,7 +805,8 @@ def generate_pack(
# one digest accepts a file another declared hash
# contradicts, as verify judges it.
conflict_status, refusal = baseline_verdict(
file_entry, conflict_path, conflict_status, verification_mode
file_entry, conflict_path, conflict_status, verification_mode,
config.get("hash_rule", ""),
)
if conflict_status == "not_found":
file_status[dedup_key] = "missing"
@@ -937,7 +938,8 @@ def generate_pack(
# The verdict the install manifest reads too: a member missing
# from a zipped_file archive, or a hash the frontend rejects.
status, refusal = baseline_verdict(
file_entry, local_path, status, verification_mode
file_entry, local_path, status, verification_mode,
config.get("hash_rule", ""),
)
if refusal:
file_status[dedup_key] = "excluded"
@@ -3949,7 +3951,8 @@ def generate_manifest(
# missing whatever the outer resolution said. An existence
# platform never reads the bytes.
status, refusal = baseline_verdict(
file_entry, local_path, status, verification_mode
file_entry, local_path, status, verification_mode,
config.get("hash_rule", ""),
)
if refusal or status in ("not_found", "external"):
return None, "hash_mismatch" if refusal else status
+6
View File
@@ -34,6 +34,12 @@ MODES = ("existence", "md5", "sha1")
MODE_DIGEST = {"md5": "md5", "sha1": "sha1"}
#: A frontend that accepts a file on its size and any one declared hash.
#: RomM's Firmware.verify_file_hashes: size first, then md5, sha1 or crc.
SIZE_AND_ANY_HASH = "size_and_any_hash"
HASH_RULES = (SIZE_AND_ANY_HASH,)
def normalize(mode: str | None) -> str:
"""Return a declared mode, falling back to the schema default."""
return mode if mode in MODES else DEFAULT_MODE
+5 -1
View File
@@ -464,6 +464,7 @@ def _intentional_hash_exclusion(
zip_contents: dict | None = None,
data_dir_registry: dict | None = None,
verification_mode: str = "md5",
hash_rule: str = "",
) -> bool:
"""Return whether the builder must omit every declaration as unsafe.
@@ -490,7 +491,9 @@ def _intentional_hash_exclusion(
# The builder's own verdict: a zipped_file archive is refused when the
# member is missing whatever the outer resolution said, a hash
# mismatch when the frontend's digest disagrees too.
_status, refusal = baseline_verdict(entry, local_path, status, verification_mode)
_status, refusal = baseline_verdict(
entry, local_path, status, verification_mode, hash_rule
)
if refusal is None:
return False
return True
@@ -652,6 +655,7 @@ def verify_pack_against_platform(
zip_contents=exclusion_index,
data_dir_registry=data_registry,
verification_mode=verification_mode,
hash_rule=config.get("hash_rule", ""),
):
baseline_excluded += 1
continue
+2
View File
@@ -221,6 +221,8 @@ class Scraper(BaseScraper):
"base_destination": "bios",
"hash_type": "sha1",
"verification_mode": "md5",
# Firmware.verify_file_hashes: size, then md5, sha1 or crc.
"hash_rule": "size_and_any_hash",
"systems": systems,
}
+32 -7
View File
@@ -19,7 +19,7 @@ from common import (
size_fits,
)
from hashing import md5_composite, parse_md5_list
from nativemode import digest_algorithm, hash_mismatch_excludes_file
from nativemode import SIZE_AND_ANY_HASH, digest_algorithm, hash_mismatch_excludes_file
from ziptools import check_inside_zip
# Validation types that require console-specific cryptographic keys.
@@ -649,15 +649,30 @@ def agnostic_substitute(
return None
def frontend_digest_matches(file_entry: dict, local_path: str, algorithm: str) -> bool:
def frontend_digest_matches(
file_entry: dict, local_path: str, algorithm: str, rule: str = ""
) -> bool:
"""Whether the frontend's own digest accepts the file.
resolve_local_file calls a file `hash_mismatch` when any declared hash
disagrees, but a frontend compares one digest. RomM's list carries md5,
sha1 and crc32: a mistyped crc32 next to the right md5 must not withhold
a file RomM accepts. verify_entry_md5 reads it too: Batocera compares a
truncated md5 by prefix and Recalbox hashes a ZIP by its members.
disagrees, but a frontend compares one digest. verify_entry_md5 reads it
too: Batocera compares a truncated md5 by prefix and Recalbox hashes a
ZIP by its members. Under SIZE_AND_ANY_HASH (RomM) the declared size must
match and then any one of md5, sha1 and crc32 does: a mistyped hash next
to a right one does not withhold the file, a wrong size does.
"""
if rule == SIZE_AND_ANY_HASH:
if not local_path:
return False
# RomM reads an absent size as 0: such an entry never verifies.
if os.path.getsize(local_path) != int(file_entry.get("size") or 0):
return False
actual = compute_hashes(local_path)
return any(
actual[field].lower() == str(file_entry[field]).strip().lower()
for field in ("md5", "sha1", "crc32")
if file_entry.get(field)
)
if algorithm == "md5":
declared = parse_md5_list(file_entry.get("md5"))
else:
@@ -698,7 +713,11 @@ def inner_rom_check(file_entry: dict, local_path: str) -> str:
def baseline_verdict(
file_entry: dict, local_path: str | None, status: str, verification_mode: str
file_entry: dict,
local_path: str | None,
status: str,
verification_mode: str,
rule: str = "",
) -> tuple[str, str | None]:
"""The status a platform entry ships under, or why a hash platform refuses it.
@@ -721,6 +740,12 @@ def baseline_verdict(
if result == "error":
return status, "cannot read ZIP"
return status, f"{member} MD5 mismatch inside ZIP"
if rule == SIZE_AND_ANY_HASH and local_path:
# A hash found by the resolver does not settle it: the size the
# frontend compares first can still be declared otherwise.
if frontend_digest_matches(file_entry, local_path, "", rule):
return status, None
return status, "size or hash mismatch"
if status != "hash_mismatch":
return status, None
if frontend_digest_matches(file_entry, local_path, digest_algorithm(verification_mode)):
+22 -3
View File
@@ -70,6 +70,7 @@ from common import (
yaml = require_yaml()
from nativemode import (
SIZE_AND_ANY_HASH,
digest_algorithm,
hash_mismatch_excludes_file,
normalize as normalize_mode,
@@ -173,8 +174,12 @@ def verify_entry_md5(
file_entry: dict,
local_path: str | None,
resolve_status: str = "",
hash_rule: str = "",
) -> dict:
"""MD5 verification -Batocera md5sum + Recalbox multi-hash + Md5Composite."""
"""MD5 verification -Batocera md5sum + Recalbox multi-hash + Md5Composite.
Under SIZE_AND_ANY_HASH (RomM) the size and any one declared hash decide.
"""
name = file_entry.get("name", "")
expected_md5 = file_entry.get("md5", "")
zipped_file = file_entry.get("zipped_file")
@@ -189,6 +194,16 @@ def verify_entry_md5(
if not local_path:
return {**base, "status": Status.MISSING}
if hash_rule == SIZE_AND_ANY_HASH:
if frontend_digest_matches(file_entry, local_path, "md5", hash_rule):
return {**base, "status": Status.OK, "path": local_path}
return {
**base,
"status": Status.UNTESTED,
"path": local_path,
"reason": "the declared size and at least one declared hash must match",
}
if zipped_file:
found_in_zip = False
had_error = False
@@ -1034,7 +1049,9 @@ def verify_platform(
elif digest_algorithm(mode) == "sha1":
result = verify_entry_sha1(file_entry, local_path)
else:
result = verify_entry_md5(file_entry, local_path, resolve_status)
result = verify_entry_md5(
file_entry, local_path, resolve_status, config.get("hash_rule", "")
)
# Emulator-level validation: informational for platform packs.
# Platform verification (MD5) is the authority. Emulator
# mismatches are reported as discrepancies, not failures.
@@ -1058,7 +1075,9 @@ def verify_platform(
if (
local_path
and result["status"] != Status.MISSING
and baseline_verdict(file_entry, local_path, resolve_status, mode)[1] is None
and baseline_verdict(
file_entry, local_path, resolve_status, mode, config.get("hash_rule", "")
)[1] is None
):
shipped.setdefault(
f"{base_dest}/{destination}" if base_dest else destination,