#!/usr/bin/env python3
"""Refuse deletion or a non-fast-forward of main, or any failing executable check.

The incumbent's checks judge the change first, then the incoming checks judge
it too. Deleting a check cannot evade it. The first push has only incoming
checks; other refs pass untouched. Every check runs through its own shebang,
even after a refusal. Git's hook environment, including GIT_DIR, is kept.
"""

import io
import os
from pathlib import Path
import stat
import subprocess
import sys
import tarfile
import tempfile


def zeros(value):
    return bool(value) and all(char == "0" for char in value)


def git(*args):
    return subprocess.run(["git", *args], capture_output=True)


def refuse(message):
    print(message, file=sys.stderr)


def run_set(revision, directory, prefix, old, new):
    directory.mkdir()
    archive = git("archive", revision, "core/immune.d")
    if archive.returncode:
        if git("cat-file", "-e", f"{revision}:core/immune.d").returncode:
            return False  # An absent directory adds no checks.
        refuse(f"immune: cannot extract core/immune.d at {revision}")
        sys.stderr.buffer.write(archive.stderr)
        return True
    try:
        with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as contents:
            # Git supplies archive paths. Extract directories and regular files
            # first, links last, so a link cannot redirect a later file write.
            members = contents.getmembers()
            for member in members:
                if member.isdir() or member.isfile():
                    contents.extract(member, directory)
            for member in members:
                if member.issym() or member.islnk():
                    contents.extract(member, directory)
    except (OSError, tarfile.TarError):
        refuse(f"immune: cannot extract core/immune.d at {revision}")
        return True
    checks = directory / "core/immune.d"
    if not checks.is_dir():
        refuse(f"immune: core/immune.d is not a directory at {revision}")
        return True
    failed = False
    for check in sorted(checks.iterdir(), key=lambda path: os.fsencode(path.name)):
        mode = check.lstat().st_mode
        if not stat.S_ISREG(mode) or not mode & 0o111:
            continue
        try:
            result = subprocess.run([str(check), old, new], stderr=subprocess.PIPE)
            error = result.stderr
            refused = result.returncode != 0
        except OSError as exc:
            error = str(exc).encode()
            refused = True
        if refused:
            failed = True
            name = prefix + check.name
            # Remove only the final newline; blank stderr lines still count.
            if error:
                lines = error.removesuffix(b"\n").split(b"\n")
            else:
                lines = [b"failed"]
            for line in lines:
                sys.stderr.buffer.write(os.fsencode(name) + b": " + line + b"\n")
    return failed


def main(args):
    if len(args) < 2:
        refuse("usage: core/immune <old> <new> [ref]")
        return 2
    old, new = args[:2]
    ref = args[2] if len(args) > 2 and args[2] else "refs/heads/main"
    if ref != "refs/heads/main":
        return 0
    if zeros(new):
        refuse("immune: refusing deletion of main")
        return 1
    if not zeros(old) and git("merge-base", "--is-ancestor", old, new).returncode:
        refuse("immune: refusing non-fast-forward to main")
        return 1
    try:
        with tempfile.TemporaryDirectory(prefix="immune.") as temp:
            failed = False
            if not zeros(old):
                failed = run_set(old, Path(temp) / "old", "old/", old, new)
            incoming_failed = run_set(new, Path(temp) / "new", "", old, new)
            return int(failed or incoming_failed)
    except OSError:
        refuse("immune: mktemp failed")
        return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
