Files
libretro/scripts/validate_pr.py
T

374 lines
12 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_platform_config,
parse_md5_list,
)
try:
import yaml
except ImportError:
yaml = None
DEFAULT_DB = "database.json"
DEFAULT_PLATFORMS_DIR = "platforms"
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):
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):
self.checks.append(("PASS" if passed else "FAIL", message))
def add_warning(self, message: str):
self.checks.append(("WARN", message))
def add_info(self, message: str):
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 validate_file(
filepath: str,
db: dict | None,
platform_hashes: dict,
) -> 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())
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 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, parser) -> list:
"""The files named on the command line, or the changed ones."""
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:
print("No changed BIOS files detected")
return files
def _load_database_if_any(path):
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():
parser = argparse.ArgumentParser(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(
"--markdown", action="store_true", help="Output as markdown (for PR comments)"
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
files = _files_to_validate(args, parser)
if not files:
return
db = _load_database_if_any(args.db)
platform_hashes = load_platform_hashes(args.platforms_dir)
results = []
for f in files:
result = validate_file(f, db, platform_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__":
main()