Files
libretro/scripts/validate_pr.py
T
Abdessamad Derraz e1b6aad51b fix: refuse abbreviated flags in every script
argparse completed a prefix into the only flag starting with it:
slots.py --emulator read profiles from a directory named after the
emulator and reported nothing, and pipeline.py --all built every pack
variant. Every parser now refuses an abbreviation.
2026-10-18 05:30:58 +02:00

425 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Validate BIOS file contributions in Pull Requests.
Usage:
python scripts/validate_pr.py [files...]
python scripts/validate_pr.py --changed # Auto-detect changed files via git
Multi-layer validation:
1. Hash verified against known databases (System.dat, batocera-systems)
2. File size matches expected value
3. File referenced in ≥1 platform config
4. Duplicate detection against database.json
5. Security checks (no executables, reasonable sizes)
Outputs a structured report suitable for PR comments.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(__file__))
from common import (
compute_hashes,
list_registered_platforms,
load_database,
load_emulator_profiles,
load_platform_config,
parse_md5_list,
)
try:
import yaml
except ImportError:
yaml = None
DEFAULT_DB = "database.json"
DEFAULT_PLATFORMS_DIR = "platforms"
DEFAULT_EMULATORS_DIR = "emulators"
BLOCKED_EXTENSIONS = {
".exe",
".bat",
".cmd",
".sh",
".ps1",
".vbs",
".js",
".msi",
".dll",
".so",
".dylib",
".py",
".rb",
".pl",
}
MAX_FILE_SIZE = 100 * 1024 * 1024
class ValidationResult:
def __init__(self, filepath: str) -> None:
self.filepath = filepath
self.filename = os.path.basename(filepath)
self.checks = [] # (status, message) tuples
self.sha1 = ""
self.md5 = ""
self.crc32 = ""
self.size = 0
def add_check(self, passed: bool, message: str) -> None:
self.checks.append(("PASS" if passed else "FAIL", message))
def add_warning(self, message: str) -> None:
self.checks.append(("WARN", message))
def add_info(self, message: str) -> None:
self.checks.append(("INFO", message))
@property
def passed(self) -> bool:
return all(s != "FAIL" for s, _ in self.checks)
def to_markdown(self) -> str:
status = "✅" if self.passed else "❌"
lines = [f"### {status} `{self.filename}`"]
lines.append("")
lines.append(f"- **Path**: `{self.filepath}`")
lines.append(f"- **Size**: {self.size:,} bytes")
lines.append(f"- **SHA1**: `{self.sha1}`")
lines.append(f"- **MD5**: `{self.md5}`")
lines.append(f"- **CRC32**: `{self.crc32}`")
lines.append("")
for status_str, message in self.checks:
if status_str == "PASS":
lines.append(f"- ✅ {message}")
elif status_str == "FAIL":
lines.append(f"- ❌ {message}")
elif status_str == "WARN":
lines.append(f"- ⚠️ {message}")
else:
lines.append(f"- ℹ️ {message}")
return "\n".join(lines)
def load_platform_hashes(platforms_dir: str) -> dict:
"""Load all known hashes from platform configs."""
known = {"sha1": set(), "md5": set(), "names": set()}
if not os.path.isdir(platforms_dir) or yaml is None:
return known
# Read the way verify reads them: inheritance and shared groups resolved,
# and a Recalbox md5 field split into the revisions it lists. The raw
# file kept "a,b,c" as one value, so a PR adding the seventh accepted
# falcon.img was told its hash differs.
for name in list_registered_platforms(platforms_dir, include_archived=True):
try:
config = load_platform_config(name, platforms_dir)
except (OSError, yaml.YAMLError) as exc:
print(f"WARNING: {name}: {exc}", file=sys.stderr)
continue
for system in config.get("systems", {}).values():
for file_entry in system.get("files", []):
sha1 = file_entry.get("sha1")
for value in sha1 if isinstance(sha1, list) else [sha1]:
if value:
known["sha1"].add(str(value).lower())
known["md5"].update(parse_md5_list(file_entry.get("md5")))
if "name" in file_entry:
known["names"].add(file_entry["name"])
return known
def load_profile_hashes(emulators_dir: str) -> dict[str, dict[str, set[str]]]:
"""Hash value to the emulators whose profile declares it, per algorithm.
A file only a core reads (a standalone emulator's firmware, a game
engine's data) is in no platform list: the review called every such
contribution "not referenced, needs manual review".
"""
known: dict[str, dict[str, set[str]]] = {"sha1": {}, "sha256": {}, "md5": {}}
for emulator, profile in load_emulator_profiles(emulators_dir).items():
for entry in profile.get("files") or []:
if not isinstance(entry, dict):
continue
for algo in known:
values = entry.get(algo)
if algo == "md5":
values = parse_md5_list(values)
for value in values if isinstance(values, (list, set)) else [values]:
if value:
known[algo].setdefault(str(value).lower(), set()).add(emulator)
return known
def validate_file(
filepath: str,
db: dict | None,
platform_hashes: dict,
profile_hashes: dict | None = None,
) -> ValidationResult:
"""Run all validation checks on a file."""
result = ValidationResult(filepath)
# Whoever opened the pull request chose this path, so its shape is
# settled before anything is read. A symlink to an endless device would
# otherwise be hashed until the job timed out, and a symlink out of the
# checkout would be reported as though its target had been contributed.
if os.path.islink(filepath):
result.add_check(False, "Symlinks are not allowed")
return result
if not os.path.exists(filepath):
result.add_check(False, f"File not found: {filepath}")
return result
if not os.path.isfile(filepath):
result.add_check(False, "Not a regular file")
return result
result.size = os.path.getsize(filepath)
hashes = compute_hashes(filepath)
result.sha1 = hashes["sha1"]
result.md5 = hashes["md5"]
result.crc32 = hashes["crc32"]
ext = os.path.splitext(filepath)[1].lower()
if ext in BLOCKED_EXTENSIONS:
result.add_check(False, f"Blocked file extension: {ext}")
if result.size > MAX_FILE_SIZE:
result.add_check(
False,
f"File too large for embedded storage ({result.size:,} > {MAX_FILE_SIZE:,} bytes). Use storage: external in platform config.",
)
elif result.size == 0:
result.add_check(False, "File is empty (0 bytes)")
else:
result.add_check(True, f"File size OK ({result.size:,} bytes)")
if db:
if result.sha1 in db.get("files", {}):
existing = db["files"][result.sha1]
result.add_warning(
f"Duplicate: identical file already exists at `{existing['path']}`"
)
else:
result.add_check(True, "Not a duplicate in database")
sha1_known = result.sha1 in platform_hashes.get("sha1", set())
md5_known = result.md5 in platform_hashes.get("md5", set())
name_known = result.filename in platform_hashes.get("names", set())
profile_hashes = profile_hashes or {}
by_profile = (
profile_hashes.get("sha1", {}).get(result.sha1)
or profile_hashes.get("sha256", {}).get(hashes.get("sha256", ""))
or profile_hashes.get("md5", {}).get(result.md5)
)
if sha1_known:
result.add_check(True, "SHA1 matches known platform requirement")
elif md5_known:
result.add_check(True, "MD5 matches known platform requirement")
elif by_profile:
names = sorted(by_profile)
shown = ", ".join(names[:5]) + (f" and {len(names) - 5} more" if len(names) > 5 else "")
result.add_check(True, f"Hash matches a file the emulator profile declares: {shown}")
elif name_known:
result.add_warning(
"Filename matches a known requirement but hash differs - may be a variant"
)
else:
result.add_warning(
"File not referenced in any platform config - needs manual review"
)
normalized = os.path.normpath(filepath)
if normalized.startswith("bios" + os.sep):
parts = normalized.split(os.sep)
if len(parts) >= 4:
result.add_check(True, f"Correct placement: bios/{parts[1]}/{parts[2]}/")
else:
result.add_warning("File should be in bios/Manufacturer/Console/ structure")
else:
result.add_warning("File is not under bios/ directory")
if name_known and not sha1_known and not md5_known:
result.add_info(
"This may be a valid variant. If accepted, it will be placed in "
f"`.variants/{result.filename}.{result.sha1[:8]}`"
)
return result
def get_changed_files() -> list[str]:
"""BIOS files the branch changes against origin's main line.
A base git cannot resolve is skipped; when none resolves the staged
changes are read and said so. A git that fails outright is an error,
never "nothing changed": a clone whose remote is not called origin
used to answer that no BIOS had changed while the branch committed one.
"""
compared = False
for base in ("main", "master", "v2"):
result = subprocess.run(
["git", "diff", "--name-only", f"origin/{base}...HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
continue
compared = True
files = [f for f in result.stdout.strip().split("\n") if f.startswith("bios/")]
if files:
return files
if compared:
return []
print(
"no origin/main, origin/master or origin/v2 to compare against; "
"reading the staged changes only",
file=sys.stderr,
)
result = subprocess.run(
["git", "diff", "--cached", "--name-only"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"git diff --cached failed: {result.stderr.strip()}")
return [f for f in result.stdout.strip().split("\n") if f.startswith("bios/") and f]
def _files_to_validate(
args: argparse.Namespace, parser: argparse.ArgumentParser
) -> list:
"""The files named on the command line, or the changed ones."""
if args.changed and args.files:
# Named files were dropped for the git diff, and the run reported
# "No changed BIOS files detected" about files it never looked at.
parser.error("--changed reads the git diff; name files or pass --changed, not both")
if not args.changed:
if not args.files:
parser.error("No files specified. Use --changed or provide file paths.")
return args.files
try:
files = get_changed_files()
except (RuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(2)
if not files and not args.json:
print("No changed BIOS files detected")
return files
def _load_database_if_any(path: str) -> dict | None:
try:
return load_database(path)
except FileNotFoundError:
return None
except json.JSONDecodeError as e:
print(f"WARNING: corrupt database.json: {e}", file=sys.stderr)
return None
def main() -> None:
parser = argparse.ArgumentParser(allow_abbrev=False, description="Validate BIOS file contributions")
parser.add_argument("files", nargs="*", help="Files to validate")
parser.add_argument(
"--changed", action="store_true", help="Auto-detect changed BIOS files"
)
parser.add_argument("--db", default=DEFAULT_DB, help="Path to database.json")
parser.add_argument("--platforms-dir", default=DEFAULT_PLATFORMS_DIR)
parser.add_argument("--emulators-dir", default=DEFAULT_EMULATORS_DIR)
# One output format: --json beside --markdown printed JSON and dropped
# the markdown without a word.
output = parser.add_mutually_exclusive_group()
output.add_argument(
"--markdown", action="store_true", help="Output as markdown (for PR comments)"
)
output.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
files = _files_to_validate(args, parser)
if not files:
if args.json:
print("[]")
return
db = _load_database_if_any(args.db)
platform_hashes = load_platform_hashes(args.platforms_dir)
profile_hashes = load_profile_hashes(args.emulators_dir)
results = []
for f in files:
result = validate_file(f, db, platform_hashes, profile_hashes)
results.append(result)
all_passed = all(r.passed for r in results)
if args.json:
output = []
for r in results:
output.append(
{
"file": r.filepath,
"passed": r.passed,
"sha1": r.sha1,
"md5": r.md5,
"size": r.size,
"checks": [{"status": s, "message": m} for s, m in r.checks],
}
)
print(json.dumps(output, indent=2))
elif args.markdown:
lines = ["## BIOS Validation Report", ""]
status = "✅ All checks passed" if all_passed else "❌ Some checks failed"
lines.append(f"**Status**: {status}")
lines.append("")
for r in results:
lines.append(r.to_markdown())
lines.append("")
print("\n".join(lines))
else:
for r in results:
status = "PASS" if r.passed else "FAIL"
print(f"\n[{status}] {r.filepath}")
print(f" SHA1: {r.sha1}")
print(f" MD5: {r.md5}")
print(f" Size: {r.size:,}")
for s, m in r.checks:
marker = (
"✓"
if s == "PASS"
else "✗"
if s == "FAIL"
else "!"
if s == "WARN"
else "i"
)
print(f" [{marker}] {m}")
if not all_passed:
sys.exit(1)
if __name__ == "__main__":
from common import run_cli
run_cli(main)