#!/usr/bin/python3 -I
"""Install one explicitly confirmed device calibration; never run USB code.

This root-owned helper deliberately has no arbitrary command/file arguments.
Installation only reloads udev rules. A separately confirmed --reboot is bound
to the successful, recent installation in the same boot and is usable once.
"""

import argparse
from contextlib import contextmanager
import errno
import fcntl
import hashlib
import json
import math
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import time


EVENT = re.compile(r"event[0-9]{1,8}\Z")
DEVICE_PATH = re.compile(r"[A-Za-z0-9_.:+-]{1,512}\Z")
PROPERTY = re.compile(r"[A-Z][A-Z0-9_]*\Z")
BOOT = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\Z")
RULES = "/etc/udev/rules.d"
STATE = "/var/lib/4web-kiosk/touch-calibration"
REBOOT_MAX_AGE = 15 * 60
REPAIR_NEEDED = "4WEB_TOUCH_REPAIRABLE_DIRECTORY"
REPAIR_PATHS = ("/etc", "/usr", "/usr/lib")


class SetupError(Exception):
    pass


def matrix_values(value):
    try:
        if len(value) != 6 or any(len(str(field)) > 64 for field in value):
            raise ValueError
        matrix = tuple(float(field) for field in value)
    except (TypeError, ValueError, OverflowError):
        raise SetupError("Die Kalibrierungsmatrix ist ungültig.") from None
    a, b, c, d, e, f = matrix
    if (not all(math.isfinite(number) for number in matrix)
            or not .25 <= a <= 4 or not .25 <= e <= 4
            or not .25 <= a * e - b * d <= 4
            or abs(b) > 1 or abs(d) > 1 or abs(c) > 1 or abs(f) > 1):
        raise SetupError("Die Kalibrierungsmatrix ist unplausibel oder gedreht.")
    return matrix


def rule_name(id_path):
    return "99-zz-4web-kiosk-touch-" + hashlib.sha256(id_path.encode("ascii")).hexdigest()[:32] + ".rules"


def rule_contents(id_path, matrix):
    numbers = " ".join(format(number, ".12g") for number in matrix)
    return ("# Installed by 4Web kiosk touch calibration; device-specific.\n"
            'SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_TOUCHSCREEN}=="1", '
            'ENV{ID_PATH}=="' + id_path + '", ENV{LIBINPUT_CALIBRATION_MATRIX}="' + numbers + '"\n').encode("ascii")


class TouchSetup:
    # Alternate roots/owners and a command runner are isolated-test seams only;
    # none are exposed by the root CLI.
    def __init__(self, root=Path("/"), runner=subprocess.run, owner=0, group=0, clock=time.monotonic):
        self.root = Path(root).resolve(strict=True)
        self.runner, self.owner, self.group, self.clock = runner, owner, group, clock

    def path(self, name):
        return self.root / name.lstrip("/")

    @staticmethod
    def reject_extended_acl(path):
        # A named ACL can grant write access even when ordinary group/other
        # mode bits appear safe. Also reject default ACLs on directories,
        # which can change permissions on files created beneath them.
        is_directory = stat.S_ISDIR(os.fstat(path).st_mode if isinstance(path, int) else path.lstat().st_mode)
        for name in (("system.posix_acl_access", "system.posix_acl_default") if is_directory
                     else ("system.posix_acl_access",)):
            TouchSetup.reject_acl_attribute(path, name)

    @staticmethod
    def reject_acl_attribute(path, name):
        try:
            if not hasattr(os, "getxattr"):
                raise SetupError("Zugriffsrechte konnten nicht sicher geprüft werden; nichts geändert.")
            if isinstance(path, int):
                os.getxattr(path, name)
            else:
                os.getxattr(path, name, follow_symlinks=False)
        except OSError as error:
            absent = {errno.ENODATA, errno.ENOTSUP, errno.EOPNOTSUPP}
            if hasattr(errno, "ENOATTR"):
                absent.add(errno.ENOATTR)
            if error.errno in absent:
                return
            raise SetupError("Zugriffsrechte konnten nicht sicher geprüft werden; nichts geändert.") from None
        raise SetupError("Erweiterte Zugriffsrechte gefunden; bitte administrativ prüfen. Nichts geändert.")

    def repairable_directory(self, path, info):
        return (path in tuple(self.path(name) for name in REPAIR_PATHS) and stat.S_ISDIR(info.st_mode)
                and info.st_uid == self.owner and info.st_gid == self.group
                and stat.S_IMODE(info.st_mode) == 0o775)

    def directory(self, path, create=False, mode=0o755):
        if path != self.root:
            self.directory(path.parent, create=create)
        try:
            info = path.lstat()
        except FileNotFoundError:
            if not create:
                raise SetupError("Ein erforderliches Systemverzeichnis fehlt.") from None
            path.mkdir(mode=mode)
            os.chown(path, self.owner, self.group, follow_symlinks=False)
            self.sync_parent(path)
            info = path.lstat()
        if not stat.S_ISDIR(info.st_mode) or info.st_uid != self.owner:
            raise SetupError("Unsicheres Systemverzeichnis; nichts geändert.")
        self.reject_extended_acl(path)
        if info.st_mode & 0o022:
            if self.repairable_directory(path, info):
                raise SetupError(REPAIR_NEEDED + ": /" + str(path.relative_to(self.root)))
            raise SetupError("Unsicheres Systemverzeichnis; nichts geändert.")

    def existing_parent_directory(self, path):
        # Inspect the deepest existing ancestor without creating anything.
        # Broken symlinks count as existing and are rejected by directory().
        while not os.path.lexists(path):
            if path == self.root:
                raise SetupError("Ein erforderliches Systemverzeichnis fehlt.")
            path = path.parent
        self.directory(path)

    def verify_udev_directories(self):
        # udevadm test can execute PROGRAM/IMPORT from all of these rule
        # directories. Reject writable parents before invoking even udevadm
        # info. Missing directories are fine, but their existing ancestors
        # must still be protected against unprivileged creation.
        rule_paths = [self.path(name) for name in ("/etc/udev/rules.d", "/usr/lib/udev/rules.d",
                                                   "/usr/local/lib/udev/rules.d", "/run/udev/rules.d")]
        for path in [rule_paths[0], self.path("/usr/bin"), *rule_paths[1:]]:
            self.existing_parent_directory(path)
        lib = self.path("/lib")
        if os.path.lexists(lib):
            info = lib.lstat()
            if stat.S_ISLNK(info.st_mode):
                if info.st_uid != self.owner or os.readlink(lib) not in ("usr/lib", "/usr/lib"):
                    raise SetupError("Unsicherer Systemverweis /lib; nichts geändert.")
            else:
                path = lib / "udev/rules.d"
                self.existing_parent_directory(path)
                rule_paths.append(path)
        # An otherwise protected directory can still contain an editable rule.
        # A symlink to /dev/null is udev's documented way to mask a rule;
        # other symlinks are rejected instead of trusting their target chain.
        for directory in rule_paths:
            if not directory.exists():
                continue
            for path in directory.glob("*.rules"):
                info = path.lstat()
                if stat.S_ISLNK(info.st_mode):
                    if info.st_uid != self.owner or os.readlink(path) != "/dev/null":
                        raise SetupError("Eine Udev-Regel verweist auf ein nicht geprüftes Ziel; nichts geändert.")
                elif (not stat.S_ISREG(info.st_mode) or info.st_uid != self.owner or info.st_mode & 0o022):
                    raise SetupError("Eine Udev-Regel ist nicht sicher geschützt; nichts geändert.")
                else:
                    self.reject_extended_acl(path)

    def repair_directory(self, name):
        """Remove only group write on an exact allowed directory, after consent."""
        if name not in REPAIR_PATHS:
            raise SetupError("Dieses Systemverzeichnis kann nicht automatisch korrigiert werden.")
        self.directory(self.root)
        rootfd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
        try:
            parts = name.lstrip("/").split("/")
            parentfd = os.dup(rootfd)
            try:
                parent = self.root
                for part in parts[:-1]:
                    parent = parent / part
                    self.directory(parent)
                    nextfd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parentfd)
                    os.close(parentfd)
                    parentfd = nextfd
                fd = os.open(parts[-1], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parentfd)
            except BaseException:
                os.close(parentfd)
                raise
            changing = False
            try:
                info = os.fstat(fd)
                if not self.repairable_directory(self.path(name), info):
                    raise SetupError("Das Verzeichnis entspricht nicht der erwarteten Konfiguration; nichts geändert.")
                self.reject_extended_acl(fd)
                current = os.stat(parts[-1], dir_fd=parentfd, follow_symlinks=False)
                if (current.st_dev, current.st_ino, current.st_mode, current.st_uid, current.st_gid) != (
                        info.st_dev, info.st_ino, info.st_mode, info.st_uid, info.st_gid):
                    raise SetupError("Das Verzeichnis wurde zwischenzeitlich verändert; nichts geändert.")
                # If chmod, fsync, or a postcondition check fails, do not
                # falsely claim that the original permissions are intact.
                changing = True
                os.fchmod(fd, stat.S_IMODE(info.st_mode) & ~0o020)
                os.fsync(fd)
                after = os.fstat(fd)
                current = os.stat(parts[-1], dir_fd=parentfd, follow_symlinks=False)
                if (stat.S_IMODE(after.st_mode) != 0o755 or after.st_uid != self.owner
                        or after.st_gid != self.group or (after.st_dev, after.st_ino) != (current.st_dev, current.st_ino)):
                    raise SetupError("Die Verzeichniskorrektur muss administrativ geprüft werden.")
                self.reject_extended_acl(fd)
            except (OSError, SetupError) as error:
                if changing:
                    raise SetupError("Die Berechtigung des Systemverzeichnisses wurde möglicherweise bereits geändert; "
                                     "bitte administrativ prüfen. Die Kalibrierung wurde nicht gespeichert.") from error
                raise
            finally:
                os.close(fd)
                os.close(parentfd)
        finally:
            os.close(rootfd)

    def sync_parent(self, path):
        descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
        try:
            os.fsync(descriptor)
        finally:
            os.close(descriptor)

    def read(self, path, limit=16384):
        self.directory(path.parent)
        descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
        with os.fdopen(descriptor, "rb") as source:
            info = os.fstat(source.fileno())
            if (not stat.S_ISREG(info.st_mode) or info.st_uid != self.owner
                    or info.st_mode & 0o022 or info.st_size > limit):
                raise SetupError("Unsichere Kalibrierungsdatei; nichts geändert.")
            self.reject_extended_acl(source.fileno())
            content = source.read(limit + 1)
            if len(content) > limit:
                raise SetupError("Die Kalibrierungsdatei ist zu groß.")
        return content

    @contextmanager
    def lock(self):
        path = self.path("/run/4web-kiosk-touch.lock")
        self.directory(path.parent)
        descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
        try:
            info = os.fstat(descriptor)
            if not stat.S_ISREG(info.st_mode) or info.st_uid != self.owner or info.st_mode & 0o077:
                raise SetupError("Unsichere Sperrdatei für die Touch-Kalibrierung.")
            try:
                fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
            except BlockingIOError:
                raise SetupError("Eine Touch-Einrichtung läuft bereits.") from None
            yield
        finally:
            os.close(descriptor)

    def command(self, arguments, *, limit=1024 * 1024):
        try:
            result = self.runner(arguments, capture_output=True, text=True, timeout=10,
                                 env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C"})
        except (OSError, subprocess.SubprocessError):
            raise SetupError("Die Systemaktion konnte nicht ausgeführt werden.") from None
        if result.returncode or len(result.stdout) + len(result.stderr) > limit:
            raise SetupError("Die Systemaktion ist fehlgeschlagen. Es erfolgt kein automatischer Neustart.")
        return result.stdout + "\n" + result.stderr

    def properties(self, event, *, test=False):
        target = self.path("/sys/class/input/" + event)
        if not target.exists():
            raise SetupError("Das ausgewählte Touchgerät ist nicht mehr angeschlossen.")
        arguments = (["/usr/bin/udevadm", "test", str(target)] if test else
                     ["/usr/bin/udevadm", "info", "--query=property", "--path", str(target)])
        properties = {}
        for line in self.command(arguments).splitlines():
            if "=" in line:
                key, value = line.split("=", 1)
                if PROPERTY.fullmatch(key):
                    properties[key] = value
        return properties

    def current_device(self, event, expected_path):
        if not EVENT.fullmatch(event) or not DEVICE_PATH.fullmatch(expected_path):
            raise SetupError("Die Touch-Gerätekennung ist ungültig.")
        properties = self.properties(event)
        current = properties.get("ID_PATH", "")
        if (properties.get("ID_INPUT_TOUCHSCREEN") != "1"
                or not DEVICE_PATH.fullmatch(current) or current != expected_path):
            raise SetupError("Das Touchgerät hat sich geändert. Bitte neu prüfen und messen.")
        return properties

    def boot_id(self):
        try:
            boot = self.read(self.path("/proc/sys/kernel/random/boot_id"), 128).decode("ascii").strip()
        except (OSError, UnicodeError):
            raise SetupError("Der aktuelle Systemstart konnte nicht sicher bestimmt werden.") from None
        if not BOOT.fullmatch(boot):
            raise SetupError("Der aktuelle Systemstart konnte nicht sicher bestimmt werden.")
        return boot

    def receipt_path(self, id_path):
        return self.path(STATE) / (hashlib.sha256(id_path.encode("ascii")).hexdigest() + ".json")

    def atomic(self, path, content, *, exclusive=False):
        self.directory(path.parent, create=True)
        temporary, published, identity = None, False, None
        try:
            descriptor, temporary = tempfile.mkstemp(prefix=".4web-touch-", dir=path.parent)
            with os.fdopen(descriptor, "wb") as destination:
                os.fchown(destination.fileno(), self.owner, self.group)
                os.fchmod(destination.fileno(), 0o644)
                destination.write(content)
                destination.flush()
                os.fsync(destination.fileno())
                identity = os.fstat(destination.fileno())
            if exclusive:
                try:
                    os.link(temporary, path, follow_symlinks=False)
                except FileExistsError:
                    raise SetupError("Für dieses Gerät ist bereits eine Regel oder Installationsquittung vorhanden. Nichts überschrieben.") from None
                published = True
            else:
                self.read(path)
                os.replace(temporary, path)
            self.sync_parent(path)
            return identity.st_dev, identity.st_ino
        except (OSError, SetupError):
            # A failure after link() but before the directory fsync must not
            # leave a rule behind that the caller never learned it created.
            if exclusive and published:
                self.remove_created(path, (identity.st_dev, identity.st_ino))
            raise
        finally:
            if temporary is not None:
                try:
                    os.unlink(temporary)
                except FileNotFoundError:
                    pass

    def remove_created(self, path, identity):
        if identity is not None:
            info = path.lstat()
            if (info.st_dev, info.st_ino) != identity:
                raise SetupError("Eine Systemdatei wurde zwischenzeitlich geändert. Bitte administrativ prüfen.")
            path.unlink()
            self.sync_parent(path)

    def install(self, event, id_path, values):
        matrix = matrix_values(values)
        self.verify_udev_directories()
        properties = self.current_device(event, id_path)
        if "LIBINPUT_CALIBRATION_MATRIX" in properties:
            raise SetupError("Für dieses Touchgerät ist bereits eine Kalibrierung vorhanden. Nichts geändert.")
        # udevadm test evaluates rules (including PROGRAM/IMPORT), so do not
        # execute it while an untrusted process can modify the /etc rules.
        # Recheck immediately before each actual write below as well.
        rule = self.path(RULES) / rule_name(id_path)
        self.verify_udev_directories()
        # Also protect a rule that has been installed but is not yet reflected
        # in the active udev database. 'test' simulates rules; no trigger occurs.
        pending = self.properties(event, test=True)
        if (pending.get("ID_INPUT_TOUCHSCREEN") != "1" or pending.get("ID_PATH") != id_path):
            raise SetupError("Das Touchgerät konnte nicht eindeutig geprüft werden.")
        if "LIBINPUT_CALIBRATION_MATRIX" in pending:
            raise SetupError("Eine vorhandene Kalibrierungsregel wird nicht überschrieben. Bitte gegebenenfalls neu starten.")
        boot = self.boot_id()
        receipt_path = self.receipt_path(id_path)
        self.directory(rule.parent, create=True)
        self.directory(receipt_path.parent, create=True)
        # Existing broken files/symlinks also count as occupied; never replace.
        if os.path.lexists(rule) or os.path.lexists(receipt_path):
            raise SetupError("Für dieses Gerät ist bereits eine 4Web-Kalibrierung vorbereitet. Bitte administrativ prüfen oder neu starten.")
        receipt = {"version": 1, "id_path": id_path, "event": event,
                   "rule_sha256": hashlib.sha256(rule_contents(id_path, matrix)).hexdigest(),
                   "boot_id": boot, "installed_monotonic": self.clock(), "reboot_requested": False}
        created_rule = created_receipt = None
        try:
            created_rule = self.atomic(rule, rule_contents(id_path, matrix), exclusive=True)
            self.command(["/usr/bin/udevadm", "control", "--reload-rules"])
            created_receipt = self.atomic(receipt_path, json.dumps(receipt, sort_keys=True).encode("ascii") + b"\n", exclusive=True)
        except (OSError, SetupError):
            # Roll back only files whose exact inode this invocation created.
            # Existing calibration files and unrelated rules are never removed.
            try:
                self.remove_created(receipt_path, created_receipt)
            finally:
                try:
                    self.remove_created(rule, created_rule)
                finally:
                    # A failed directory fsync after unlink must not suppress
                    # the attempt to reload the restored rules on disk.
                    if created_rule is not None:
                        try:
                            self.command(["/usr/bin/udevadm", "control", "--reload-rules"])
                        except SetupError:
                            pass
            raise

    def reboot(self, event, id_path):
        self.verify_udev_directories()
        self.current_device(event, id_path)
        path = self.receipt_path(id_path)
        try:
            receipt = json.loads(self.read(path))
        except (OSError, ValueError, TypeError):
            raise SetupError("Für einen Neustart fehlt eine gültige eigene Installationsquittung.") from None
        expected = {"version", "id_path", "event", "rule_sha256", "boot_id", "installed_monotonic", "reboot_requested"}
        if (not isinstance(receipt, dict) or set(receipt) != expected
                or receipt["version"] != 1 or receipt["id_path"] != id_path or receipt["event"] != event
                or type(receipt["reboot_requested"]) is not bool or receipt["reboot_requested"]
                or receipt["boot_id"] != self.boot_id()
                or type(receipt["installed_monotonic"]) not in (int, float)
                or not math.isfinite(receipt["installed_monotonic"])
                or not 0 <= self.clock() - receipt["installed_monotonic"] <= REBOOT_MAX_AGE):
            raise SetupError("Der Neustart ist nicht mehr freigegeben. Bitte bei Bedarf manuell neu starten.")
        rule = self.read(self.path(RULES) / rule_name(id_path))
        if receipt["rule_sha256"] != hashlib.sha256(rule).hexdigest():
            raise SetupError("Die Kalibrierungsregel wurde verändert. Kein automatischer Neustart.")
        receipt["reboot_requested"] = True
        self.atomic(path, json.dumps(receipt, sort_keys=True).encode("ascii") + b"\n")
        self.command(["/usr/bin/systemctl", "reboot"])


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("--event")
    parser.add_argument("--id-path")
    # Keep the six coefficients last. REMAINDER also accepts negative
    # scientific notation produced by the affine solver; argparse's default
    # optional-argument detection otherwise mistakes '-1e-16' for an option.
    parser.add_argument("--matrix", nargs=argparse.REMAINDER, metavar="COEFFICIENT")
    parser.add_argument("--reboot", action="store_true")
    parser.add_argument("--repair-directory", choices=REPAIR_PATHS)
    arguments = parser.parse_args(argv)
    if arguments.repair_directory and (arguments.event or arguments.id_path or arguments.matrix is not None or arguments.reboot):
        parser.error("--repair-directory darf nicht mit anderen Aktionen kombiniert werden.")
    if not arguments.repair_directory and (not arguments.event or not arguments.id_path):
        parser.error("--event und --id-path sind erforderlich.")
    if not arguments.repair_directory and bool(arguments.matrix) == arguments.reboot:
        parser.error("Zur Installation ist --matrix nötig; --reboot erlaubt keine Matrix.")
    if arguments.matrix is not None and len(arguments.matrix) != 6:
        parser.error("--matrix muss am Ende stehen und genau sechs Zahlen enthalten.")
    if os.geteuid() != 0:
        raise SetupError("Diese bestätigte Systemaktion benötigt Administratorrechte.")
    if sys.platform != "linux":
        raise SetupError("Die Touch-Einrichtung ist nur unter Linux verfügbar.")
    os.umask(0o022)
    setup = TouchSetup()
    with setup.lock():
        if arguments.repair_directory:
            setup.repair_directory(arguments.repair_directory)
        elif arguments.reboot:
            setup.reboot(arguments.event, arguments.id_path)
        else:
            setup.install(arguments.event, arguments.id_path, arguments.matrix)
    if arguments.repair_directory:
        print("Die Gruppenschreibberechtigung des bestätigten Verzeichnisses wurde entfernt.")
    elif arguments.reboot:
        print("Der bestätigte Neustart wurde angefordert.")
    else:
        print("Die Touch-Kalibrierung wurde vorbereitet. Ein separat bestätigter Neustart ist erforderlich.")


if __name__ == "__main__":
    try:
        main()
    except (SetupError, OSError) as error:
        print("Fehler: " + (str(error) if isinstance(error, SetupError) else
                           "Die Touch-Einrichtung konnte nicht abgeschlossen werden. Bitte administrativ prüfen."), file=sys.stderr)
        sys.exit(1)
