#!/usr/bin/env python3
"""TANINUX root-Helper — wird via pkexec mit erhöhten Rechten gestartet.

Installationspfad: /usr/lib/taninux/taninux-helper
(passend zur polkit-Action ch.gabrielevarano.taninux.maintain, deren
exec.path genau auf diesen Pfad zeigt.)

Idee: Statt `pkexec pacman …` direkt laufen zu lassen (= beliebiges root!),
reicht das GTK-Frontend sein argv durch DIESEN Helper. Der prüft das Kommando
gegen eine feste ALLOWLIST und lehnt alles andere ab. So bleibt die
argv-basierte Schnittstelle (`["pacman","-Syu"]`, `["bootctl","set-default",…]`)
nach aussen unverändert, aber root kann nur noch die erlaubten Operationen.

Aufruf:
    taninux-helper <cmd> [args…]      → validieren + ausführen (exec)
    taninux-helper --check <cmd> [..] → nur validieren, Exit 0/2 (für Tests)

Keine externen Deps — reines Stdlib, damit der Helper auch ohne installiertes
taninux-Paket aus dem Repo heraus läuft.
"""
from __future__ import annotations

import os
import re
import sys

# ALLOWLIST: erste argv-Komponente → erlaubte Operationen.
#
# Pro Kommando definieren wir, welche der nachfolgenden Tokens als Sub-/Flag
# zulässig sind. Tokens, die mit "-" beginnen, müssen in "flags" stehen
# (oder einer der erlaubten Operatoren sein). Nicht-Flag-Tokens gelten als
# Operanden (Paketnamen, Boot-Einträge) und werden NICHT eingeschränkt —
# pacman/bootctl validieren die selbst, und sie können kein anderes Programm
# starten.
#
# Wichtig: KEINE Shell, kein eval. Wir führen via os.execv() exakt das
# übergebene argv aus (Binary fest aus /usr/bin & co. aufgelöst, kein PATH),
# ohne Interpretation durch eine Shell.
ALLOWLIST: dict[str, dict[str, set[str]]] = {
    # Pakete (de)installieren + Vollupgrade. --noconfirm erlaubt, damit das
    # GUI-Frontend nicht in einem TTY-Prompt hängen bleibt.
    "pacman": {
        "ops": {"-S", "-Syu", "-Su", "-Sy", "-R", "-Rs", "-Rns"},
        "flags": {"--noconfirm", "--needed", "--noprogressbar"},
    },
    # Firewall an/aus (ufw). Nur enable/disable — keine Regel-Manipulation.
    "ufw": {
        "ops": {"enable", "disable"},
        "flags": set(),
    },
    # Benutzerverwaltung. useradd bewusst NUR mit -m/-c (KEIN -G/-u/-p/-o →
    # keine Gruppen-/UID-Injektion); Admin-Rechte laufen separat über gpasswd
    # (zu/aus wheel). passwd nur -e (Reset beim 1. Login erzwingen, kein Setzen).
    "useradd": {
        "ops": set(),
        "flags": {"-m", "-c"},
    },
    "userdel": {
        "ops": set(),
        "flags": {"-r"},
    },
    "gpasswd": {
        "ops": {"-a", "-d"},
        "flags": set(),
    },
    "passwd": {
        "ops": {"-e"},
        "flags": set(),
    },
    # Bootloader-Default / einmaliger Boot (systemd-boot).
    "bootctl": {
        "ops": {"set-default", "set-oneshot"},
        "flags": set(),
    },
    # Paket-Cache aufräumen.
    "paccache": {
        "ops": {"-r", "-rk1", "-rk0", "-ruk0"},
        "flags": {"-k", "0", "1", "2", "3", "--remove", "--uninstalled"},
    },
    # System-Locale + Tastatur-Layout (systemd). Operanden (LANG=…, Layout,
    # Variante) sind frei; localectl validiert sie selbst.
    "localectl": {
        "ops": {"set-locale", "set-keymap", "set-x11-keymap"},
        "flags": set(),
    },
    # Zeitzone + NTP (systemd-timesyncd). Operanden (Zeitzone, true/false)
    # sind freie Operanden; timedatectl validiert sie selbst. set-time o.ä.
    # ist bewusst NICHT erlaubt.
    "timedatectl": {
        "ops": {"set-timezone", "set-ntp", "set-local-rtc"},
        "flags": set(),
    },
    # Dienste verwalten (z.B. power-profiles-daemon enablen). Unit-Namen sind
    # freie Operanden; nur diese Verben sind erlaubt — kein mask/edit/o.ä.
    "systemctl": {
        "ops": {"enable", "disable", "start", "stop", "restart", "reenable"},
        "flags": {"--now"},
    },
    # CUPS-Druckerverwaltung. lpadmin: Default setzen (-d) / Drucker entfernen
    # (-x); -p/-E nur als Flags zugelassen (kein Hinzufügen mit Treiber).
    "lpadmin": {
        "ops": {"-d", "-x"},
        "flags": {"-p", "-E"},
    },
    # Drucker an-/abschalten. KEINE Op — nur ein Operand (der Druckername).
    "cupsenable": {
        "ops": set(),
        "flags": set(),
    },
    "cupsdisable": {
        "ops": set(),
        "flags": set(),
    },
    # Journal aufräumen (vacuum). Nur feste, ungefährliche Vacuum-Flags.
    "journalctl": {
        "ops": set(),
        "flags": {"--vacuum-time=2weeks", "--vacuum-size=200M"},
    },
    # Snapshots (Timeshift). Braucht für ALLES root, auch --list. Snapshot-
    # Namen/Kommentare sind freie Operanden (kein "-" am Anfang).
    "timeshift": {
        "ops": {"--list", "--create", "--delete", "--delete-all", "--restore", "--check"},
        "flags": {"--snapshot", "--comments", "--yes", "--scripted", "--tags"},
    },
}


class Rejected(Exception):
    """Argv hat die Allowlist nicht passiert."""


# systemd-Unit-Namen enthalten nie Slashes — Pfad-Operanden (z.B. eine Unit-
# Datei ausserhalb der Unit-Suchpfade) werden abgelehnt.
_UNIT_NAME_RE = re.compile(r"^[A-Za-z0-9@._:\-]+$")

# Kommandos, deren Operanden Benutzer-/Gruppennamen (bzw. bei useradd -c ein
# Kommentar) sind — nie Pfade. Ohne den Check könnte z.B. `useradd -m ../x`
# ein Home-Verzeichnis an unerwarteter Stelle anlegen.
_NAME_OPERAND_CMDS = {"useradd", "userdel", "passwd", "gpasswd"}

# Erlaubte Verzeichnisse zum Auflösen des Kommandos — der Helper läuft als
# root, PATH darf die Wahl des Binaries nicht beeinflussen.
_SAFE_EXEC_DIRS = ("/usr/bin", "/usr/sbin", "/bin", "/sbin")


def _resolve_command(cmd: str) -> str | None:
    """Löst ein Allowlist-Kommando zu einem absoluten Pfad in den festen
    System-Verzeichnissen auf (kein PATH-Lookup)."""
    for d in _SAFE_EXEC_DIRS:
        candidate = os.path.join(d, cmd)
        if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
            return candidate
    return None


def validate(argv: list[str]) -> list[str]:
    """Prüft argv gegen die ALLOWLIST. Gibt das (unveränderte) argv zurück
    oder wirft Rejected mit einer Begründung."""
    if not argv:
        raise Rejected("leeres kommando")

    cmd = argv[0]
    rule = ALLOWLIST.get(cmd)
    if rule is None:
        raise Rejected(f"kommando nicht erlaubt: {cmd!r}")

    allowed_ops = rule["ops"]
    allowed_flags = rule["flags"]
    saw_op = False

    for tok in argv[1:]:
        if tok in allowed_ops:
            saw_op = True
            continue
        if tok in allowed_flags:
            continue
        if tok.startswith("-"):
            # ein Flag/Operator, der weder Operation noch erlaubtes Flag ist
            raise Rejected(f"option nicht erlaubt für {cmd}: {tok!r}")
        # ein Operand (Paketname, Boot-Eintrag) — keine Optionen-Injektion
        # möglich, da kein "-" am Anfang.
        if cmd == "systemctl":
            if "/" in tok or ".." in tok or not _UNIT_NAME_RE.match(tok):
                raise Rejected(f"ungültiger unit-name: {tok!r}")
        elif cmd in _NAME_OPERAND_CMDS:
            if "/" in tok or ".." in tok:
                raise Rejected(f"ungültiger operand für {cmd}: {tok!r}")
        # Alle anderen Operanden sind frei — pacman/bootctl/… validieren selbst.

    # Kommandos mit erlaubten Operationen müssen eine davon nennen. Kommandos
    # OHNE Ops (z.B. cupsenable/cupsdisable: nur Operand = Druckername) sind
    # auch ohne Op gültig.
    if allowed_ops and not saw_op:
        raise Rejected(f"keine erlaubte operation für {cmd} angegeben")

    # gpasswd darf NUR die wheel-Gruppe verwalten (Admin-Toggle) — keine
    # beliebige Gruppen-Mitgliedschaft (z.B. root).
    if cmd == "gpasswd" and (not argv or argv[-1] != "wheel"):
        raise Rejected("gpasswd nur für die wheel-Gruppe erlaubt")

    return argv


def main(raw_argv: list[str]) -> int:
    check_only = False
    args = raw_argv[1:]
    if args and args[0] == "--check":
        check_only = True
        args = args[1:]

    try:
        argv = validate(args)
    except Rejected as err:
        print(f"taninux-helper: abgelehnt: {err}", file=sys.stderr)
        return 2

    if check_only:
        print(f"taninux-helper: ok: {' '.join(argv)}")
        return 0

    # erlaubtes Kommando ausführen — exec ersetzt den Helper-Prozess,
    # damit pkexec den echten Exit-Code des Kommandos durchreicht.
    # Kein execvp/PATH-Lookup: das Binary wird ausschliesslich in festen
    # System-Verzeichnissen aufgelöst.
    exe = _resolve_command(argv[0])
    if exe is None:
        print(
            f"taninux-helper: kommando {argv[0]!r} nicht gefunden in "
            f"{':'.join(_SAFE_EXEC_DIRS)}",
            file=sys.stderr,
        )
        return 127
    try:
        os.execv(exe, argv)
    except OSError as err:
        print(f"taninux-helper: exec fehlgeschlagen: {err}", file=sys.stderr)
        return 127


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