Files

95 lines
3.4 KiB
Python

"""Refuse files that look like game content, keys or generated game code.
Runs in CI on every push and pull request, and locally as the pre-commit hook
(.githooks/pre-commit). With no arguments it checks every tracked file; with
--staged it checks the files staged for the next commit.
Standard library only, so it runs before any environment is set up.
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
MAX_BYTES = 1024 * 1024
# Switch containers, executables and game data formats.
BLOCKED_SUFFIXES = {
".xci", ".xcz", ".nsp", ".nsz", ".nca", ".ncz", ".tik", ".cert",
".nso", ".nro", ".nrr", ".npdm", ".keys",
".bfres", ".bntx", ".bnsh", ".sarc", ".szs", ".zs", ".pack", ".byml",
".bgyml", ".msbt", ".bars", ".bwav", ".bfwav", ".bfstm", ".bfttf",
".bfotf", ".sharcb", ".webm",
}
# ExeFS modules carry no extension.
BLOCKED_NAMES = {"main", "sdk", "rtld", "subsdk0", "subsdk1", "main.npdm"}
# Written by the code generator into every file it emits.
GENERATED_MARKER = "TOTK_RECOMP_" + "GENERATED_FROM_GAME_CODE"
# A prod.keys / title.keys line: name containing key, kek or seed, then hex.
KEY_LINE = re.compile(
r"^\s*[A-Za-z0-9_]*(key|kek|seed)[A-Za-z0-9_]*\s*=\s*[0-9A-Fa-f]{32,}\s*$",
re.MULTILINE,
)
def list_files(staged: bool) -> list[Path]:
if staged:
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"]
else:
# Tracked files plus untracked ones that are not ignored: everything a commit could pick up.
cmd = ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"]
out = subprocess.run(cmd, check=True, capture_output=True).stdout
return [Path(p) for p in out.decode("utf-8").split("\0") if p]
def problems_for(path: Path, allow_large: bool = False) -> list[str]:
"""allow_large: files over MAX_BYTES (toolchain binaries in a release folder) are checked for the generated marker
only, not refused."""
found = []
if path.suffix.lower() in BLOCKED_SUFFIXES:
found.append(f"blocked file type {path.suffix}")
if path.name.lower() in BLOCKED_NAMES:
found.append("name of a Switch executable module")
if not path.is_file():
return found
size = path.stat().st_size
if size > MAX_BYTES and allow_large:
if GENERATED_MARKER.encode() in path.read_bytes():
found.append("generated from game code")
return found
if size > MAX_BYTES:
found.append(f"{size // 1024} KiB, over the {MAX_BYTES // 1024} KiB limit")
return found
data = path.read_bytes()
if GENERATED_MARKER.encode() in data:
found.append("generated from game code")
if KEY_LINE.search(data.decode("utf-8", errors="ignore")):
found.append("contains a key in prod.keys format")
return found
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--staged", action="store_true", help="check staged files only")
args = parser.parse_args()
failed = False
for path in list_files(args.staged):
for problem in problems_for(path):
print(f"{path}: {problem}")
failed = True
if failed:
print("\nGame content, keys and generated game code never go into the repository.")
print("See CONTRIBUTING.md.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())