#!/usr/bin/python3
# sg-admind -- the Control Panel's privileged half: machine administration
# for an elevated (SYSTEM) program.
#
# On Windows an administrator's elevated token may rename the computer, join
# a domain, manage local accounts and set the time zone. On Stained Glass the
# elevated token is the SYSTEM account (ADR 0012), which is unprivileged on
# the Unix side, and these are root's jobs. This is the narrow bridge: a fixed
# list of operations, each validated here, run as root for a request that only
# SYSTEM can make.
#
# Transport is a spool, because Wine gives a Windows program neither AF_UNIX
# sockets nor pipes to a native child:
#
#   /run/stained-glass-admin/requests   sgsystem 0700 -- only SYSTEM (and
#                                        root) can put a request here
#   /run/stained-glass-admin/replies    root:sgsystem 0750 -- SYSTEM reads the
#                                        answer, cannot forge one
#
# A request is <id>.req (id: 16-64 hex digits), UTF-8, one field per line:
# the verb, then its arguments; a password is always the last field. It is
# written as something else and renamed, so it is whole when it appears.
# sg-admind.path starts this (as root) when one appears; each request is
# read without following links, must be a regular file owned by SYSTEM, is
# deleted as it is read, and is answered in replies/<id>.rep:
#
#   OK                  or   FAILED <what to tell the person>
#   <detail lines>
#
# Verbs:
#   ping
#   hostname NAME
#   join-domain REALM USER DC PASSWORD        (DC may be empty)
#   user-add NAME FULLNAME standard|administrator PASSWORD
#   user-type NAME standard|administrator
#   user-password NAME PASSWORD        an administrator's reset (chpasswd)
#   user-password-own NAME CURRENT PASSWORD
#                            a person changing their own password: the
#                            current one is checked and their keyring
#                            re-encrypted (sg-password-change, sg-session)
#   user-remove NAME keep|delete
#   timezone AREA/CITY                 (and "automatically" off)
#   timezone-auto on|off                Set time zone automatically (sg-timezone-auto)
#   location on|off                     the device's location switch: GeoClue runs
#                                       only while on (sg-location)
#   ntp on|off
#   time YYYY-MM-DD HH:MM:SS                 (only with synchronisation off)
#   update-check
#   source-add NAME URI SUITES COMPONENTS   where updates come from: a deb822
#   source-remove NAME                     /etc/apt/sources.list.d/sg-user-NAME.sources
#   source-enable FILE yes|no              any .sources file there: Enabled:
#   font-install FILE        a font SYSTEM put in the machine's %WINDIR%\Fonts,
#   font-remove FILE         copied to (removed from) /usr/local/share/fonts/
#                            stained-glass for Linux programs (fontconfig)
#   autologon NAME           NAME signs in automatically when the PC starts
#                            (greetd's initial session; empty: nobody)
#   kiosk NAME APP ARGS DIR DESKTOP
#                            that account's kiosk app (all empty: none)
#   apt-install PACKAGE      SG Store's Linux apps: apt-get install PACKAGE
#   apt-remove PACKAGE       SG Store's Uninstall: apt-get remove PACKAGE, and
#                            what it alone brought in (its dependencies apt
#                            installed for it, now needed by nothing); never
#                            the system's own packages. Our own (SG Office)
#                            are unregistered from the Windows side first.
#   deb-install FILE PACKAGE VERSION
#                            a .deb SYSTEM copied into /var/cache/stained-
#                            glass-admin/debs (SG Store's "Install a Linux
#                            package"), installed with apt so its dependencies
#                            come too -- only if it is still the PACKAGE at
#                            VERSION the person was shown
#   firewall SUBCOMMAND ARGS...
#                            Stained Glass Firewall (sg-session's sg-firewall):
#                            rule-add ACTION PROFILES PROTOCOL PORTS PROGRAM NAME [SOURCE],
#                            rule-set ID ENABLED ACTION PROFILES, rule-remove ID,
#                            group NAME PROFILES, profile NAME on|off,
#                            network UUID private|public [NAME], reset,
#                            registry-push ID [TEXT] -- the firewall prompt's
#                            "Allow access", Settings' Firewall pages, and the
#                            rules netsh and INetFwPolicy2 store (wine-sg)
#   restore-point SUBCOMMAND [ID]
#                            restore points and going back (sg-session's
#                            sg-snapshot): rollback ID, delete ID, create,
#                            undo-update, undo-cancel (ext4: the previous
#                            versions of our packages), convert-schedule,
#                            convert-cancel, convert-keep, convert-undo
#                            (Control Panel > Recovery: converting the system
#                            drive to btrfs at the next start)
#
# The two installs publish their progress while apt runs, for the elevated
# program's progress bar: replies/<id>.progress (root:sgsystem 0640),
# "PROGRESS <percent>" and a line saying what apt is doing.
#
# Copyright (C) 2026 Stained Glass OS contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
import filecmp
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time

SPOOL = "/run/stained-glass-admin"
SYSTEM_USER = "sgsystem"
WINE_GROUP = "sgwine"
ADMIN_GROUPS = ("sg-admins", "sudo")   # an administrator is in both (sg-install)
ADMIN_GROUP = "sg-admins"              # ... and this one is the definition
ZONEINFO = "/usr/share/zoneinfo"
MAX_REQUEST = 16384
RESERVED = {"root", "sgsystem", "sguser", "sggreet", "nobody", "daemon", "bin",
            "sys", "admin", "administrator", "guest", "system"}
PATH = "/usr/sbin:/usr/bin:/sbin:/bin"

# "Install for all users" in the font viewer / Fonts folder: SYSTEM writes the
# file into the machine prefix's Windows\Fonts; Linux programs get a copy here.
FONTS_SRC = "/var/lib/stained-glass/prefix/drive_c/windows/Fonts"
FONTS_DST = "/usr/local/share/fonts/stained-glass"
MAX_FONT = 64 << 20

# SG Store's .deb files: SYSTEM copies one here (only it may write here;
# sg-admind.tmpfiles), sg-admind takes it into a folder of its own.
DEBS_SRC = "/var/cache/stained-glass-admin/debs"
WORK_DIR = "/var/tmp"
MAX_DEB = 4 << 30
CURRENT_ID = None      # the request being answered (its progress file)

# Apt sources some packages need from their makers, as the makers' own packages
# ship them (identical files, which the package then takes over): Steam's
# launcher and 32-bit libraries come from Valve's repository; the Linux builds
# of Chrome, Edge and Firefox from Google's, Microsoft's and Mozilla's.
VENDOR_DIR = "/usr/share/stained-glass/apt-vendors"
KEYRINGS = "/usr/share/keyrings"
PREFERENCES = "/etc/apt/preferences.d"
# sg-session's: puts a maker's source in place unless that repository is
# configured already, and keeps apt's sources readable (sg-apt-sources heal)
APT_SOURCES_TOOL = "/usr/lib/stained-glass/sg-apt-sources"
TEST = os.geteuid() != 0 and os.environ.get("SG_ADMIN_TEST") == "1"
if TEST:
    # The gate runs this unprivileged against a spool of its own, with stand-ins
    # for the system tools first in PATH. Never honoured as root.
    SPOOL = os.environ.get("SG_ADMIN_SPOOL", SPOOL)
    PATH = os.environ.get("SG_ADMIN_PATH", "") + ":" + PATH
    ZONEINFO = os.environ.get("SG_ADMIN_ZONEINFO", ZONEINFO)
    FONTS_SRC = os.environ.get("SG_ADMIN_FONTS_SRC", FONTS_SRC)
    FONTS_DST = os.environ.get("SG_ADMIN_FONTS_DST", FONTS_DST)
    DEBS_SRC = os.environ.get("SG_ADMIN_DEBS", DEBS_SRC)
    WORK_DIR = os.environ.get("SG_ADMIN_WORK", WORK_DIR)
    VENDOR_DIR = os.environ.get("SG_ADMIN_VENDORS", VENDOR_DIR)
    KEYRINGS = os.environ.get("SG_ADMIN_KEYRINGS", KEYRINGS)
    PREFERENCES = os.environ.get("SG_ADMIN_PREFERENCES", PREFERENCES)
    APT_SOURCES_TOOL = os.environ.get("SG_ADMIN_APT_SOURCES_TOOL", APT_SOURCES_TOOL)


class Refused(Exception):
    pass


def log(msg):
    sys.stderr.write("sg-admind: %s\n" % msg)
    sys.stderr.flush()


def run(argv, stdin=None, timeout=120, extra_env=None):
    env = {"PATH": PATH, "LANG": "C.UTF-8"}
    env.update(extra_env or {})
    try:
        r = subprocess.run(argv, input=stdin, capture_output=True, text=True,
                           timeout=timeout, env=env,
                           stdin=None if stdin is not None else subprocess.DEVNULL)
    except FileNotFoundError:
        raise Refused("%s is not installed." % argv[0])
    except subprocess.TimeoutExpired:
        raise Refused("%s did not finish in time." % argv[0])
    return r


def must(argv, what, stdin=None, timeout=120, extra_env=None):
    r = run(argv, stdin, timeout, extra_env)
    if r.returncode != 0:
        why = (r.stderr or r.stdout).strip().splitlines()
        log("%s failed (%d): %s" % (argv[0], r.returncode, why[-1] if why else ""))
        raise Refused("%s%s" % (what, (": " + why[-1]) if why else "."))
    return r.stdout


# ---- facts, through getent so the gate's stand-ins answer ------------------
def passwd_entry(name):
    r = run(["getent", "passwd", name])
    if r.returncode != 0:
        return None
    f = r.stdout.strip().split(":")
    return f if len(f) >= 7 else None


def group_members(group):
    r = run(["getent", "group", group])
    if r.returncode != 0:
        return []
    f = r.stdout.strip().split(":")
    return [m for m in f[3].split(",") if m] if len(f) >= 4 else []


def local_account(name):
    """A person's local account: uid 1000-59999 in /etc/passwd, not reserved."""
    e = passwd_entry(name)
    if not e:
        raise Refused("There is no account named %s." % name)
    uid = int(e[2])
    if uid < 1000 or uid >= 60000 or name in RESERVED:
        raise Refused("%s is not a person's account and cannot be changed here." % name)
    return e


def administrators():
    return [m for m in group_members(ADMIN_GROUP) if passwd_entry(m)]


def role():
    try:
        with open("/etc/stained-glass/role") as f:
            for line in f:
                if line.startswith("role="):
                    return line.strip()[5:]
    except OSError:
        pass
    return ""


def signed_in(name):
    r = run(["loginctl", "show-user", name, "--property=State", "--value"])
    return r.returncode == 0 and r.stdout.strip() in ("active", "online", "lingering")


# ---- validation --------------------------------------------------------------
NAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]{0,13}[A-Za-z0-9])?$")
REALM_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")
JOINUSER_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
IPV4_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
ZONE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9][A-Za-z0-9_+-]*){0,2}$")


def account_name(name):
    if not NAME_RE.match(name):
        raise Refused("'%s' is not a valid account name: use lower-case letters, digits, - and _, starting with a letter." % name)
    if name in RESERVED:
        raise Refused("'%s' is reserved." % name)
    return name


def password(pw):
    if not pw:
        raise Refused("The password cannot be empty.")
    if len(pw) > 256 or any(c in pw for c in "\n\r\0"):
        raise Refused("That password cannot be used.")
    return pw


def kind(k):
    if k not in ("standard", "administrator"):
        raise Refused("The account type must be standard or administrator.")
    return k


# ---- the operations ------------------------------------------------------------
def op_ping(args):
    return ["pong"]


def op_hostname(args):
    (name,) = args
    if not HOST_RE.match(name):
        raise Refused("'%s' is not a valid computer name: up to 15 letters, digits and hyphens, not starting or ending with a hyphen." % name)
    if role() in ("member", "dc"):
        raise Refused("This computer is part of a domain. Renaming it is done through the domain.")
    must(["hostnamectl", "set-hostname", name], "The computer could not be renamed")
    # Debian names the machine itself on 127.0.1.1
    hosts = "/etc/hosts" if not TEST else os.environ.get("SG_ADMIN_HOSTS", "/dev/null")
    try:
        with open(hosts) as f:
            lines = f.read().splitlines()
        out, done = [], False
        for line in lines:
            if line.split()[:1] == ["127.0.1.1"]:
                line, done = "127.0.1.1\t%s" % name, True
            out.append(line)
        if not done:
            out.append("127.0.1.1\t%s" % name)
        with open(hosts + ".sg-new", "w") as f:
            f.write("\n".join(out) + "\n")
        os.chmod(hosts + ".sg-new", 0o644)
        os.replace(hosts + ".sg-new", hosts)
    except OSError as e:
        log("hosts not updated: %s" % e)
    return ["restart"]


def op_join_domain(args):
    realm, user, dc, pw = args
    if not REALM_RE.match(realm):
        raise Refused("'%s' is not a domain name (for example corp.example.com)." % realm)
    if not JOINUSER_RE.match(user):
        raise Refused("'%s' is not an account name." % user)
    if dc and not IPV4_RE.match(dc):
        raise Refused("The domain controller must be given as an IPv4 address.")
    if role():
        raise Refused("This computer is already a %s." % ("domain controller" if role() == "dc" else "domain member"))
    argv = ["sg-domain-join", "--domain", realm, "--user", user, "--password-stdin"]
    if dc:
        argv += ["--dc", dc]
    must(argv, "The computer could not join %s" % realm, stdin=password(pw) + "\n", timeout=600)
    return ["restart"]


def op_user_add(args):
    name, full, k, pw = args
    account_name(name)
    kind(k)
    password(pw)
    if len(full) > 64 or any(c in full for c in ":\n\r\0,"):
        raise Refused("The full name cannot contain ':' or ','.")
    if passwd_entry(name):
        raise Refused("There is already an account named %s." % name)
    groups = [WINE_GROUP] + (list(ADMIN_GROUPS) if k == "administrator" else [])
    groups = [g for g in groups if run(["getent", "group", g]).returncode == 0]
    must(["useradd", "-m", "-s", "/bin/bash", "-c", full or name, "-G", ",".join(groups), name],
         "The account could not be created")
    try:
        must(["chpasswd"], "The password could not be set", stdin="%s:%s\n" % (name, pw))
    except Refused:
        run(["userdel", "-r", name])
        raise
    return []


def op_user_type(args):
    name, k = args
    account_name(name)
    kind(k)
    local_account(name)
    admins = administrators()
    if k == "standard":
        if name in admins and len(admins) <= 1:
            raise Refused("%s is the only administrator. Make another account an administrator first." % name)
        for g in ADMIN_GROUPS:
            if name in group_members(g):
                must(["gpasswd", "-d", name, g], "The account type could not be changed")
    else:
        for g in ADMIN_GROUPS:
            if run(["getent", "group", g]).returncode == 0 and name not in group_members(g):
                must(["gpasswd", "-a", name, g], "The account type could not be changed")
    return ["signout"]


def op_user_password(args):
    name, pw = args
    account_name(name)
    local_account(name)
    must(["chpasswd"], "The password could not be changed", stdin="%s:%s\n" % (name, password(pw)))
    return []


PWCHANGE = "/usr/libexec/stained-glass/sg-password-change"
if TEST:
    PWCHANGE = os.environ.get("SG_ADMIN_PWCHANGE", PWCHANGE)


def op_user_password_own(args):
    """A person changes their own password, giving the current one.

    sg-password-change (sg-session) does it as passwd(1) would for them:
    PAM checks the current password and the password rules, and
    pam_gnome_keyring re-encrypts the keyring their programs keep passwords
    in, so it still opens at sign-in. A reset (user-password, chpasswd)
    cannot: there is no current password to open the keyring with."""
    name, current, new = args
    account_name(name)
    local_account(name)
    password(new)
    if not current or len(current) > 256 or any(c in current for c in "\n\r\0"):
        raise Refused("The current password is incorrect.")
    if not os.access(PWCHANGE, os.X_OK):
        # an sg-session without it: the reset, as before (the keyring stays
        # on the old password)
        log("%s missing: resetting the password instead" % PWCHANGE)
        return op_user_password([name, new])
    r = run([PWCHANGE], stdin="%s\0%s\0%s\0" % (name, current, new), timeout=90)
    out = (r.stdout or "").strip().splitlines()
    first = out[0] if out else ""
    if r.returncode == 0 and first == "OK":
        return []
    if first == "FAIL current":
        raise Refused("The current password is incorrect.")
    why = first[5:] if first.startswith("FAIL ") else "(no answer)"
    log("sg-password-change failed (%d): %s" % (r.returncode, why))
    raise Refused("The password could not be changed: %s" % why)


def op_user_remove(args):
    name, how = args
    account_name(name)
    if how not in ("keep", "delete"):
        raise Refused("Choose whether to keep or delete the account's files.")
    local_account(name)
    if name in administrators() and len(administrators()) <= 1:
        raise Refused("%s is the only administrator and cannot be removed." % name)
    if signed_in(name):
        raise Refused("%s is signed in. They must sign out before the account can be removed." % name)
    must(["userdel"] + (["-r"] if how == "delete" else []) + [name], "The account could not be removed")
    return []


def op_timezone(args):
    (zone,) = args
    if not ZONE_RE.match(zone) or ".." in zone:
        raise Refused("'%s' is not a time zone." % zone)
    path = os.path.realpath(os.path.join(ZONEINFO, zone))
    if not path.startswith(os.path.realpath(ZONEINFO) + os.sep) or not os.path.isfile(path):
        raise Refused("'%s' is not a time zone." % zone)
    must(["timedatectl", "set-timezone", zone], "The time zone could not be changed")
    # a zone chosen by hand: no longer set automatically (sg-timezone-auto)
    write_tz_auto("off")
    # Debian's record of the zone by name, which the Control Panel reads back
    tzfile = "/etc/timezone" if not TEST else os.environ.get("SG_ADMIN_TIMEZONE", "/dev/null")
    try:
        with open(tzfile + ".sg-new", "w") as f:
            f.write(zone + "\n")
        os.chmod(tzfile + ".sg-new", 0o644)
        os.replace(tzfile + ".sg-new", tzfile)
    except OSError as e:
        log("%s not updated: %s" % (tzfile, e))
    return []


TZ_AUTO = os.environ.get("SG_ADMIN_TZ_AUTO", "/etc/stained-glass/timezone-auto")


def write_tz_auto(value):
    try:
        os.makedirs(os.path.dirname(TZ_AUTO), exist_ok=True)
        with open(TZ_AUTO + ".sg-new", "w") as f:
            f.write(value + "\n")
        os.chmod(TZ_AUTO + ".sg-new", 0o644)
        os.replace(TZ_AUTO + ".sg-new", TZ_AUTO)
    except OSError as e:
        log("%s not updated: %s" % (TZ_AUTO, e))


def op_timezone_auto(args):
    """Set time zone automatically, on or off (Settings > Date & time)"""
    (v,) = args
    if v not in ("on", "off"):
        raise Refused("Setting the time zone automatically is on or off.")
    write_tz_auto(v)
    if v == "on" and not TEST:
        subprocess.run(["systemctl", "start", "--no-block", "sg-timezone-auto.service"], check=False)
    return []


LOCATION_TOOL = os.environ.get("SG_ADMIN_LOCATION_TOOL", "/usr/libexec/stained-glass/sg-location")


def op_location(args):
    """The device's location switch (Settings > Privacy > Location): GeoClue on or off"""
    (v,) = args
    if v not in ("on", "off"):
        raise Refused("Location access for this device is on or off.")
    r = run([LOCATION_TOOL, v], timeout=60)
    if r.returncode != 0:
        raise Refused("Location access could not be changed.")
    return []


LOGIND_DROPIN = os.environ.get("SG_ADMIN_LOGIND_DROPIN", "/etc/systemd/logind.conf.d/60-stained-glass-power.conf")
POWER_ACTIONS = ("ignore", "lock", "suspend", "hibernate", "poweroff")


def op_power_buttons(args):
    """What the power button and closing the lid do (Settings > Power & sleep,
    Control Panel's Power Options; David 2026-10-03): systemd-logind's, for
    the whole machine -- the lid on battery, the lid plugged in, the power
    button -- as a drop-in of our own, which logind reads again at once."""
    lid, lid_ac, power = args
    for v in (lid, lid_ac, power):
        if v not in POWER_ACTIONS:
            raise Refused("A power action is one of: " + ", ".join(POWER_ACTIONS) + ".")
    os.makedirs(os.path.dirname(LOGIND_DROPIN), exist_ok=True)
    body = ("# Stained Glass OS: Settings > Power & sleep (sg-admind power-buttons).\n"
            "[Login]\nHandleLidSwitch=%s\nHandleLidSwitchExternalPower=%s\nHandlePowerKey=%s\n" % (lid, lid_ac, power))
    tmp = LOGIND_DROPIN + ".new"
    with open(tmp, "w") as f:
        f.write(body)
    os.chmod(tmp, 0o644)
    os.replace(tmp, LOGIND_DROPIN)
    if not TEST:
        subprocess.run(["systemctl", "kill", "-s", "HUP", "systemd-logind"], check=False)
    return []


# ---- automatic sign-in and the kiosk app (Settings > Accounts > Kiosk) ----------
# What Windows keeps as AutoAdminLogon and Assigned access. Automatic sign-in is
# greetd's initial session: run once per boot, with no password asked (greetd
# skips PAM's authentication for it, not its account and session checks), and
# never again in the same boot (greetd's runfile in /run) -- signing out, or
# Ctrl+Alt+Del's Sign out, shows the sign-in screen as always. No password is
# stored anywhere. The account and its kiosk app are in AUTOLOGON_CONF, which
# the session (sg-session's sg-kiosk) and Settings read.
AUTOLOGON_CONF = "/etc/stained-glass/autologon.conf"
GREETD_CONF = "/etc/greetd/config.toml"
if TEST:
    AUTOLOGON_CONF = os.environ.get("SG_ADMIN_AUTOLOGON_CONF", AUTOLOGON_CONF)
    GREETD_CONF = os.environ.get("SG_ADMIN_GREETD_CONF", GREETD_CONF)
SESSION_CMD = "/usr/bin/sg-session-start"
AUTOLOGON_BEGIN = "# BEGIN Stained Glass OS automatic sign-in (Settings > Accounts > Kiosk; sg-admind)"
AUTOLOGON_END = "# END Stained Glass OS automatic sign-in"


def write_root_file(path, text):
    """path, whole, root's and readable by everyone, never through a link"""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".sg-new"
    try:
        os.unlink(tmp)
    except OSError:
        pass
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
    with os.fdopen(fd, "w") as f:
        f.write(text)
    os.chmod(tmp, 0o644)
    os.replace(tmp, path)


def read_autologon():
    conf = {}
    try:
        with open(AUTOLOGON_CONF) as f:
            for line in f:
                line = line.rstrip("\n")
                if line and not line.startswith("#") and "=" in line:
                    k, v = line.split("=", 1)
                    conf[k] = v
    except OSError:
        pass
    return conf


def write_autologon(conf):
    if not conf.get("user"):
        try:
            os.unlink(AUTOLOGON_CONF)
        except FileNotFoundError:
            pass
        return
    lines = ["# Stained Glass OS: automatic sign-in and the kiosk app (Settings > Accounts > Kiosk).",
             "# Written by sg-admind; sg-kiosk starts the kiosk app in this account's session."]
    for k in ("user", "kiosk-name", "kiosk-app", "kiosk-args", "kiosk-dir", "kiosk-desktop"):
        if conf.get(k):
            lines.append("%s=%s" % (k, conf[k]))
    write_root_file(AUTOLOGON_CONF, "\n".join(lines) + "\n")


def greetd_autologon(user):
    """greetd's [initial_session] for USER, or none (USER empty). Only our own
    block is ever changed; one somebody else wrote is left alone (refused)."""
    try:
        with open(GREETD_CONF) as f:
            text = f.read()
    except OSError:
        raise Refused("The sign-in screen's settings (%s) were not found." % GREETD_CONF)
    out, inside = [], False
    for line in text.splitlines():
        if line.strip() == AUTOLOGON_BEGIN:
            inside = True
            continue
        if inside:
            if line.strip() == AUTOLOGON_END:
                inside = False
            continue
        out.append(line)
    while out and not out[-1].strip():
        out.pop()
    if user:
        if any(re.match(r"^\s*\[\s*initial_session\s*\]", line) for line in out):
            raise Refused("The sign-in screen already signs an account in automatically (%s). "
                          "Remove that first." % GREETD_CONF)
        out += ["", AUTOLOGON_BEGIN, "[initial_session]",
                'command = "%s"' % SESSION_CMD, 'user = "%s"' % user, AUTOLOGON_END]
    write_root_file(GREETD_CONF, "\n".join(out) + "\n")


def op_autologon(args):
    """autologon NAME: NAME signs in automatically when the PC starts; empty:
    nobody (the sign-in screen, and no kiosk app)."""
    (name,) = args
    conf = read_autologon()
    if not name:
        greetd_autologon("")
        write_autologon({})
        return ["restart"]
    account_name(name)
    local_account(name)
    greetd_autologon(name)
    if conf.get("user") != name:
        conf = {}      # another account: its kiosk app is not this one's
    conf["user"] = name
    write_autologon(conf)
    return ["restart"]


def kiosk_text(v, what, most):
    if len(v) > most or any(ord(c) < 32 for c in v):
        raise Refused("The kiosk app's %s cannot be used." % what)
    return v


WINPATH_RE = re.compile(r"^[A-Za-z]:\\[^<>\"|?*]*$")


def op_kiosk(args):
    """kiosk NAME APP ARGS DIR DESKTOP: the app the automatic sign-in's
    account opens full screen, and opens again when it closes. A Windows
    program (APP, a C:\\ path to its .exe or a shortcut, with ARGS and its
    folder DIR) or a Linux app (DESKTOP, its .desktop file). All empty: none."""
    name, app, app_args, folder, desktop = args
    conf = read_autologon()
    if not any(args):
        for k in [k for k in conf if k.startswith("kiosk-")]:
            del conf[k]
        write_autologon(conf)
        return []
    if not conf.get("user"):
        raise Refused("Choose an account to sign in automatically first: the kiosk app runs in that account.")
    kiosk_text(name, "name", 128)
    kiosk_text(app, "program", 520)
    kiosk_text(app_args, "arguments", 1024)
    kiosk_text(folder, "folder", 520)
    kiosk_text(desktop, "Linux app", 1024)
    if not name:
        raise Refused("The kiosk app needs a name.")
    if bool(app) == bool(desktop):
        raise Refused("A kiosk app is one program, not two.")
    if app and (not WINPATH_RE.match(app) or not app.lower().endswith((".exe", ".lnk"))):
        raise Refused("'%s' is not a program the kiosk can start." % app)
    if folder and not WINPATH_RE.match(folder):
        raise Refused("'%s' is not a folder." % folder)
    if desktop:
        if (not desktop.startswith("/") or not desktop.endswith(".desktop") or "/../" in desktop
                or not os.path.isfile(desktop)):
            raise Refused("'%s' is not a Linux app." % desktop)
    for k in [k for k in conf if k.startswith("kiosk-")]:
        del conf[k]
    conf.update({"kiosk-name": name, "kiosk-app": app, "kiosk-args": app_args, "kiosk-dir": folder,
                 "kiosk-desktop": desktop})
    write_autologon(conf)
    return []


DEFENDER_CONF = os.environ.get("SG_ADMIN_DEFENDER_CONF", "/etc/stained-glass/defender.conf")


def op_defender(args):
    """SG Defender, on or off (Settings > Virus & threat protection): its
    setting for sg-defender, and ClamAV's daemons with it -- off gives back
    the memory its definitions hold."""
    (v,) = args
    if v not in ("on", "off"):
        raise Refused("Virus & threat protection is on or off.")
    try:
        os.makedirs(os.path.dirname(DEFENDER_CONF), exist_ok=True)
        with open(DEFENDER_CONF + ".sg-new", "w") as f:
            f.write("# SG Defender: scan downloaded programs (Settings > Virus & threat protection)\n"
                    "enabled=%d\n" % (v == "on"))
        os.chmod(DEFENDER_CONF + ".sg-new", 0o644)
        os.replace(DEFENDER_CONF + ".sg-new", DEFENDER_CONF)
    except OSError as e:
        raise Refused("The setting could not be saved: %s" % e)
    if not TEST:
        units = ["clamav-daemon.service", "clamav-freshclam.service"]
        subprocess.run(["systemctl", "enable" if v == "on" else "disable", "--now", "--no-block"] + units,
                       capture_output=True, check=False)
        subprocess.run(["systemctl", "restart", "--no-block", "sg-defender.service"], capture_output=True,
                       check=False)
    return []


DEFENDER_STATE = os.environ.get("SG_ADMIN_DEFENDER_STATE", "/var/lib/stained-glass/defender")
QUARANTINE_ID = re.compile(r"\d{8}-\d{6}-[0-9a-f]{6}")


def quarantined(qid):
    """a quarantined file's record (sg-defender's quarantine/<id>.json)"""
    if not QUARANTINE_ID.fullmatch(qid):
        raise Refused("That is not a quarantined file.")
    import json
    try:
        with open(os.path.join(DEFENDER_STATE, "quarantine", qid + ".json")) as f:
            return json.load(f)
    except (OSError, ValueError):
        raise Refused("That file is no longer in quarantine.")


def forget_quarantined(qid, meta):
    for name in (qid + ".bin", qid + ".json"):
        try:
            os.unlink(os.path.join(DEFENDER_STATE, "quarantine", name))
        except FileNotFoundError:
            pass
    try:
        os.unlink(os.path.join(DEFENDER_STATE, "notices", str(int(meta.get("uid", -1))), qid + ".json"))
    except (FileNotFoundError, ValueError):
        pass


def write_as(uid, path, data, mode):
    """Create PATH (not an existing one) holding DATA, as UID: the folder is
    theirs, so root writing there could be led elsewhere by a link they
    planted. Returns the name used: "x (restored).exe" if x.exe is taken."""
    import pwd
    r, w = os.pipe()
    pid = os.fork()
    if pid == 0:
        try:
            os.close(r)
            if not TEST:
                pw = pwd.getpwuid(uid)
                os.setgroups([])
                os.setgid(pw.pw_gid)
                os.setuid(uid)
            base, ext = os.path.splitext(path)
            for n in range(0, 20):
                cand = path if n == 0 else "%s (restored%s)%s" % (base, "" if n == 1 else " %d" % n, ext)
                try:
                    fd = os.open(cand, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
                except FileExistsError:
                    continue
                with os.fdopen(fd, "wb") as f:
                    f.write(data)
                os.write(w, cand.encode())
                os._exit(0)
            os._exit(2)
        except BaseException:
            os._exit(1)
    os.close(w)
    with os.fdopen(r, "rb") as f:
        name = f.read().decode(errors="replace")
    _, status = os.waitpid(pid, 0)
    if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0 or not name:
        raise Refused("The file could not be put back in %s." % os.path.dirname(path))
    return name


def op_defender_restore(args):
    """Restore a quarantined file where it was, as its owner's (Settings >
    Virus & threat protection > Restore), and let that exact file be from
    now on: its SHA-256 joins sg-defender's allowed list."""
    (qid,) = args
    meta = quarantined(qid)
    with open(os.path.join(DEFENDER_STATE, "quarantine", qid + ".bin"), "rb") as f:
        data = f.read()
    import hashlib
    digest = hashlib.sha256(data).hexdigest()
    if digest != meta.get("sha256", digest):
        raise Refused("The quarantined file is not the one that was quarantined.")
    allowed = os.path.join(DEFENDER_STATE, "allowed")
    with open(allowed, "a") as f:     # first: the restored file must not be caught again
        f.write("%s %s\n" % (digest, os.path.basename(meta["path"])))
    os.chmod(allowed, 0o644)
    name = write_as(int(meta["uid"]), meta["path"], data, int(meta.get("mode", 0o644)) & 0o755)
    forget_quarantined(qid, meta)
    log("restored %s (%s) to %s" % (qid, meta.get("signature"), name))
    return [name]


def op_defender_delete(args):
    """Delete a quarantined file for good (Settings > Virus & threat protection)."""
    (qid,) = args
    meta = quarantined(qid)
    forget_quarantined(qid, meta)
    log("deleted quarantined %s (%s)" % (qid, meta.get("signature")))
    return []


def op_ntp(args):
    (v,) = args
    if v not in ("on", "off"):
        raise Refused("Time synchronisation is on or off.")
    must(["timedatectl", "set-ntp", "true" if v == "on" else "false"],
         "Time synchronisation could not be changed")
    return []


TIME_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})")


def op_time(args):
    """Set the clock by hand, as Date & time's "Change" does. Refused while
    the clock is synchronised: Windows greys the button out then too."""
    (when,) = args
    m = TIME_RE.fullmatch(when)
    if not m:
        raise Refused("'%s' is not a date and time." % when)
    y, mo, d, h, mi, s = (int(x) for x in m.groups())
    try:
        time.strptime(when, "%Y-%m-%d %H:%M:%S")
    except ValueError:
        raise Refused("'%s' is not a date and time." % when)
    if not 2000 <= y <= 2099:
        raise Refused("The year must be between 2000 and 2099.")
    out = run(["timedatectl", "show", "--property=NTP", "--value"])
    if out.returncode == 0 and out.stdout.strip() == "yes":
        raise Refused("Turn off \"Set time automatically\" first.")
    must(["timedatectl", "set-time", when], "The time could not be changed")
    return []


def op_update_check(args):
    must(["systemctl", "start", "--no-block", "sg-update-prepare.service"],
         "The update check could not be started")
    return []


# SG Store's "ours" apps: Stained Glass OS's own packages with programs on
# the Windows side (SG Office's: sg-office, with its editors). apt-install
# installs them as any package, then registers them at once.
OUR_PACKAGES = ("sg-office", "sg-office-editors")


def register_defaults(name):
    """A package's App Paths, file types and Start entries into the Windows
    side now, not at the next start: its registry defaults, imported as the
    machine account into the running shared server, with the stamps
    sg-prefix-init keeps (so the boot does not import them again). Not
    sg-prefix-init itself: it ends by waiting for the server to exit, which it
    never does while anyone is signed in."""
    files = [f for f in must(["dpkg-query", "-L", name], "%s's files could not be listed" % name).split("\n")
             if f.startswith(DEFAULTS_DIR + "/") and f.endswith(".reg")]
    if files:
        must(["runuser", "-u", SYSTEM_USER, "--", "env", "HOME=/var/lib/stained-glass",
              "XDG_RUNTIME_DIR=/run/stained-glass", "sh", "-c", REGISTER_SH, "sg-register"] + files,
             "%s was installed, but could not be registered; it will be at the next start" % name,
             timeout=600)


DEFAULTS_DIR = "/usr/share/stained-glass/defaults.d"
if TEST:
    DEFAULTS_DIR = os.environ.get("SG_ADMIN_DEFAULTS", DEFAULTS_DIR)
# as sg-prefix-init's import_defaults does, without its final wait
REGISTER_SH = (". /usr/lib/stained-glass/sg-common.sh && sg_wine_env && mkdir -p \"$SG_STATE/defaults\" && "
               "for f; do timeout 300 wine reg import \"$(winepath -w \"$f\")\" >/dev/null 2>&1 || exit 1; "
               "sha256sum < \"$f\" | cut -d' ' -f1 > \"$SG_STATE/defaults/$(basename \"$f\").sha256\"; done")


APT_SOURCES = os.environ.get("SG_APT_SOURCES", "/etc/apt/sources.list.d")
SOURCE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
SOURCE_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.sources$")
URI_RE = re.compile(r"^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+-]*)?$")
WORD_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/+-]{0,63}$")


def source_words(text, what):
    words = text.split()
    if not words or len(words) > 8 or not all(WORD_RE.fullmatch(w) and ".." not in w for w in words):
        raise Refused("'%s' is not a list of %s." % (text, what))
    return " ".join(words)


def write_file(path, text):
    """whole or not at all, and never through a link"""
    tmp = path + ".sg-new"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
    with os.fdopen(fd, "w") as f:
        f.write(text)
    os.rename(tmp, path)


def op_source_add(args):
    name, uri, suites, comps = args
    if not SOURCE_NAME_RE.fullmatch(name):
        raise Refused("A source's name uses lower-case letters, numbers and -.")
    if not URI_RE.fullmatch(uri):
        raise Refused("'%s' is not a web address of a package repository." % uri)
    path = os.path.join(APT_SOURCES, "sg-user-%s.sources" % name)
    if os.path.lexists(path):
        raise Refused("There is a source called %s already." % name)
    write_file(path, "# added in Settings > Updates > Advanced options\nTypes: deb\nURIs: %s\nSuites: %s\n"
               "Components: %s\nEnabled: yes\n" % (uri, source_words(suites, "suites"), source_words(comps, "components")))
    return []


def op_source_remove(args):
    (name,) = args
    if not SOURCE_NAME_RE.fullmatch(name):
        raise Refused("'%s' is not a source added here." % name)
    path = os.path.join(APT_SOURCES, "sg-user-%s.sources" % name)
    st = os.lstat(path) if os.path.lexists(path) else None
    if not st or not stat.S_ISREG(st.st_mode):
        raise Refused("There is no source called %s." % name)
    os.unlink(path)
    return []


def op_source_enable(args):
    fname, on = args
    if on not in ("yes", "no") or not SOURCE_FILE_RE.fullmatch(fname):
        raise Refused("Nothing to change.")
    path = os.path.join(APT_SOURCES, fname)
    st = os.lstat(path) if os.path.lexists(path) else None
    if not st or not stat.S_ISREG(st.st_mode):
        raise Refused("There is no source %s." % fname)
    with open(path) as f:
        lines = f.read().splitlines()
    out, seen = [], False
    for line in lines:
        if re.match(r"^Enabled:", line, re.IGNORECASE):
            line, seen = "Enabled: %s" % on, True
        out.append(line)
    if not seen:
        # every stanza of the file (a blank line ends one)
        out2, in_stanza = [], False
        for line in out + [""]:
            if not line.strip() and in_stanza:
                out2.append("Enabled: %s" % on)
                in_stanza = False
            elif line.strip() and not line.startswith("#"):
                in_stanza = True
            out2.append(line)
        out = out2[:-1]
    write_file(path, "\n".join(out) + "\n")
    return []


FONT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._()+-]{0,120}\.(ttf|otf|ttc|fon|fnt)$", re.IGNORECASE)
FONT_MAGIC = (b"\x00\x01\x00\x00", b"OTTO", b"true", b"ttcf", b"MZ")


def font_name(name):
    if not FONT_RE.fullmatch(name) or ".." in name:
        raise Refused("'%s' is not a font file name." % name)
    return name


def fonts_dst_dir():
    """/usr/local/share/fonts/stained-glass, made if missing, never through a link"""
    parent = os.path.dirname(FONTS_DST)
    os.makedirs(parent, 0o755, exist_ok=True)
    try:
        os.mkdir(FONTS_DST, 0o755)
    except FileExistsError:
        pass
    st = os.lstat(FONTS_DST)
    if not stat.S_ISDIR(st.st_mode):
        raise Refused("The fonts folder is not a folder.")
    return FONTS_DST


def op_font_install(args):
    name = font_name(args[0])
    try:
        dfd = os.open(FONTS_SRC, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
    except OSError:
        raise Refused("The system fonts folder is missing.")
    try:
        try:
            fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=dfd)
        except OSError:
            raise Refused("%s is not in the system fonts folder." % name)
        try:
            st = os.fstat(fd)
            # SYSTEM's own regular file, not a link to something else
            if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or st.st_uid != system_uid():
                raise Refused("%s was not installed by an administrator." % name)
            if st.st_size > MAX_FONT:
                raise Refused("%s is too large for a font." % name)
            data = os.read(fd, MAX_FONT + 1)
        finally:
            os.close(fd)
    finally:
        os.close(dfd)
    if not data.startswith(FONT_MAGIC):
        raise Refused("%s is not a font." % name)
    dst = fonts_dst_dir()
    tmp = os.path.join(dst, ".%s.tmp" % name)
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW | os.O_CLOEXEC, 0o644)
    with os.fdopen(fd, "wb") as f:
        f.write(data)
    os.chmod(tmp, 0o644)
    os.replace(tmp, os.path.join(dst, name))
    run(["fc-cache", "-f", dst])
    return [os.path.join(dst, name)]


def op_font_remove(args):
    name = font_name(args[0])
    p = os.path.join(FONTS_DST, name)
    try:
        st = os.lstat(p)
    except FileNotFoundError:
        return []
    if not stat.S_ISREG(st.st_mode):
        raise Refused("%s is not a font file." % name)
    os.unlink(p)
    run(["fc-cache", "-f", FONTS_DST])
    return []


# ---- system packages: SG Store's Linux apps and .deb files --------------------
PKG_RE = re.compile(r"^[a-z0-9][a-z0-9+.-]{1,99}$")
VERSION_RE = re.compile(r"^[A-Za-z0-9.+~:-]{1,100}$")
STAGED_RE = re.compile(r"^[0-9a-f]{16,64}\.deb$")


def progress(percent, text):
    """replies/<id>.progress: what the elevated program's bar shows"""
    if not CURRENT_ID:
        return
    replies = os.path.join(SPOOL, "replies")
    tmp = os.path.join(replies, ".%s.progress" % CURRENT_ID)
    try:
        try:
            os.unlink(tmp)
        except FileNotFoundError:
            pass
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o640)
        with os.fdopen(fd, "w") as f:
            f.write("PROGRESS %d\n%s\n" % (max(0, min(100, int(percent))), text.replace("\n", " ")[:200]))
        if not TEST:
            os.chown(tmp, 0, system_gid())
        os.replace(tmp, os.path.join(replies, "%s.progress" % CURRENT_ID))
    except OSError as e:
        log("progress: %s" % e)


def apt_env():
    return {"PATH": PATH, "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive",
            "APT_LISTCHANGES_FRONTEND": "none", "NEEDRESTART_MODE": "a"}


def apt_get(args, what, base=0, span=100):
    """apt-get with its status on a pipe: dlstatus (downloading) and pmstatus
    (dpkg) lines become the progress file, base..base+span percent. Every use
    is the SG Store's: its restore point (the APT hook, sg-snapshot) is kept
    apart from the updates', SG_SNAP_SOURCE=store.

    An installation cut short before (a full disk, a power cut) leaves dpkg
    "interrupted", and apt then refuses every install and uninstall until
    someone runs dpkg --configure -a: the SG Store failed every Linux app
    after one (qa5 2026-10-21). Finish it -- and a package whose unpacking
    was cut off (clang-19 there) leaves the others it holds back
    unconfigured until apt-get -f install fetches it again -- then ask apt
    again, once."""
    rc, out = apt_get_once(args, what, base, span)
    # (and once dpkg's own mark is gone, apt says only "Unmet dependencies.
    # Try 'apt --fix-broken install'": the same half-done packages)
    if rc != 0 and ("dpkg was interrupted" in out or "apt --fix-broken install" in out):
        log("apt-get %s: an earlier installation was cut short; dpkg --configure -a" % " ".join(args))
        progress(base, "Finishing an installation that was cut short...")
        r = run(["dpkg", "--force-confdef", "--force-confold", "--configure", "-a"], timeout=1800, extra_env=apt_env())
        if r.returncode != 0:
            log("dpkg --configure -a failed (%d): %s" % (r.returncode, (r.stderr or r.stdout).strip()[-300:]))
            frc, fout = apt_get_once(["-f", "install"], what, base, 0)
            if frc != 0:
                log("apt-get -f install failed (%d): %s" % (frc, fout.strip()[-300:]))
        rc, out = apt_get_once(args, what, base, span)
    if rc != 0:
        errors = [l[3:].strip() for l in out.splitlines() if l.startswith("E: ")]
        log("apt-get %s failed (%d): %s" % (" ".join(args), rc, errors[-1] if errors else out.strip()[-300:]))
        raise Refused("%s%s" % (what, (": " + errors[0]) if errors else "."))
    return out


def apt_get_once(args, what, base, span):
    """(apt-get's exit code, its output). The packages it downloads go once
    they are installed: kept, they filled qa5's disk (3 GB) in one walk of
    the SG Store, and a full disk is what cut an installation short."""
    rfd, wfd = os.pipe()
    log_fd, log_path = tempfile.mkstemp(prefix="sg-apt-", dir=WORK_DIR)
    argv = ["apt-get", "-y", "-q", "-o", "APT::Status-Fd=%d" % wfd, "-o", "DPkg::Lock::Timeout=600",
            "-o", "APT::Keep-Downloaded-Packages=false",
            "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"] + args
    try:
        try:
            p = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log_fd, stderr=subprocess.STDOUT,
                                 pass_fds=(wfd,), env=dict(apt_env(), SG_SNAP_SOURCE="store"))
        except FileNotFoundError:
            raise Refused("apt-get is not installed.")
        finally:
            os.close(wfd)
        with os.fdopen(rfd, "r", errors="replace") as status:
            for line in status:
                parts = line.rstrip("\n").split(":", 3)
                if len(parts) < 4 or parts[0] not in ("dlstatus", "pmstatus"):
                    continue
                try:
                    pct = float(parts[2])
                except ValueError:
                    continue
                if parts[0] == "dlstatus":      # downloading: the first half
                    progress(base + span * pct / 200, parts[3])
                else:                           # unpacking and setting up: the second
                    progress(base + span / 2 + span * pct / 200, parts[3])
        try:
            rc = p.wait(timeout=3 * 3600)
        except subprocess.TimeoutExpired:
            p.kill()
            raise Refused("%s did not finish in time." % what)
        os.lseek(log_fd, 0, os.SEEK_SET)
        out = os.read(log_fd, 1 << 20).decode("utf-8", "replace")
    finally:
        os.close(log_fd)
        os.unlink(log_path)
    return rc, out


def refresh_lists():
    """the package lists, so apt finds today's versions (offline: carry on)"""
    progress(1, "Getting the list of packages...")
    try:
        apt_get(["update"], "The package lists could not be refreshed", 0, 0)
    except Refused as e:
        log("apt-get update: %s" % e)


def installed_version(pkg):
    r = run(["dpkg-query", "-W", "-f", "${db:Status-Status} ${Version}", pkg])
    parts = r.stdout.strip().split(" ", 1)
    return parts[1] if r.returncode == 0 and len(parts) == 2 and parts[0] == "installed" else None


VENDOR_SOURCES = {
    # the package: its maker's files, and where they go
    "steam-launcher": [("steam-stable.list", "sources"), ("steam.gpg", "keyrings")],
    # the browsers' Linux builds: each package rewrites the same file with the
    # same source on install (Chrome's postinst: google-chrome.sources; Edge's
    # accepts a signed-by in microsoft-edge.list), so apt never sees two
    # Signed-By values for one repository
    "google-chrome-stable": [("google-chrome.sources", "sources"), ("google-chrome.gpg", "keyrings")],
    "microsoft-edge-stable": [("microsoft-edge.list", "sources"), ("microsoft-edge.gpg", "keyrings")],
    # Mozilla's repository has firefox-esr too: the pin takes only firefox
    # from it, so Debian's firefox-esr stays Debian's
    "firefox": [("mozilla.list", "sources"), ("packages.mozilla.org.asc", "keyrings"), ("mozilla.pref", "preferences")],
}


def adopt_source(pkg, src):
    """the maker's source through sg-apt-sources: not added when the same
    repository is configured already (Mozilla's own instructions put
    packages.mozilla.org in mozilla.list or mozilla.sources with its key in
    /etc/apt/keyrings; a second entry naming another keyring made apt refuse
    every source, David's VM 2026-10-06); False if the tool is not there"""
    if not os.access(APT_SOURCES_TOOL, os.X_OK):
        return False
    extra = {"SG_APT_ETC": os.path.dirname(APT_SOURCES), "SG_APT_ROOT": os.environ.get("SG_APT_ROOT", "/")} if TEST else {}
    r = run([APT_SOURCES_TOOL, "adopt", src], timeout=300, extra_env=extra)
    for line in (r.stdout + r.stderr).splitlines():
        log("%s: %s" % (pkg, line))
    return True


def add_vendor_source(pkg):
    """the maker's apt source for a package that comes from it (Steam), put
    in place before apt looks for the package; True if it has one"""
    if pkg not in VENDOR_SOURCES:
        return False
    # the key (and pin) first, the source last: adopting the source heals
    # apt's sources, which turns off an entry whose keyring is not there yet
    # -- Firefox, Chrome, Edge and Steam had "no installation candidate" on a
    # machine that never had them (qa4 2026-10-25)
    for name, where in sorted(VENDOR_SOURCES[pkg], key=lambda f: f[1] == "sources"):
        dst = os.path.join({"sources": APT_SOURCES, "keyrings": KEYRINGS, "preferences": PREFERENCES}[where], name)
        if where == "sources" and adopt_source(pkg, os.path.join(VENDOR_DIR, name)):
            continue
        src = os.path.join(VENDOR_DIR, name)
        # a key the maker has signed anew since (Microsoft's: its SHA-1
        # binding is refused by Debian 13's apt, "not signed"; Edge never
        # installed) is replaced by ours, which is the maker's current one;
        # one left from an earlier try would refuse every later one
        stale = where == "keyrings" and os.path.exists(dst) and not filecmp.cmp(src, dst, shallow=False)
        if not os.path.exists(dst) or stale:
            shutil.copyfile(src, dst)
            os.chmod(dst, 0o644)
            log("%s: %s put in place for it%s" % (pkg, dst, " (the one there was out of date)" if stale else ""))
    return True


def remove_vendor_source(pkg):
    """the maker's files put in place for pkg, taken away once it is
    uninstalled, as a Windows uninstaller takes what it added: Edge's purge
    deletes its key but leaves microsoft-edge.list, and every apt-get update
    after warned "Failed to parse keyring" (qa5 2026-10-22). Only our own
    files, or a source whose key is gone; one the person changed stays"""
    files = VENDOR_SOURCES.get(pkg, [])
    dirs = {"sources": APT_SOURCES, "keyrings": KEYRINGS, "preferences": PREFERENCES}
    keys = [os.path.join(KEYRINGS, n) for n, w in files if w == "keyrings"]
    kept = False
    for name, where in files:
        dst = os.path.join(dirs[where], name)
        if where == "keyrings" or not os.path.isfile(dst):
            continue
        try:
            text = open(dst, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        ours = filecmp.cmp(os.path.join(VENDOR_DIR, name), dst, shallow=False)
        keyless = where == "sources" and any(k in text and not os.path.exists(k) for k in keys)
        if ours or keyless:
            os.remove(dst)
            log("%s: %s taken away with it" % (pkg, dst))
        elif where == "sources":
            kept = True
    for dst in keys:
        if not kept and os.path.isfile(dst) and filecmp.cmp(os.path.join(VENDOR_DIR, os.path.basename(dst)), dst, shallow=False):
            os.remove(dst)
            log("%s: %s taken away with it" % (pkg, dst))


def apt_relations(pkg):
    """what apt's candidate for pkg depends on and recommends"""
    r = run(["apt-cache", "show", "--no-all-versions", pkg])
    return parse_relations(r.stdout) if r.returncode == 0 else {}


SPACE_MARGIN = 256 << 20     # what is left free after an install, at least


def free_space(path="/"):
    """bytes free for a program on the system drive"""
    if TEST and os.environ.get("SG_ADMIN_FREE_BYTES"):
        return int(os.environ["SG_ADMIN_FREE_BYTES"])
    st = os.statvfs(path)
    return st.f_bavail * st.f_frsize


def size_text(n):
    for unit, shift in (("GB", 30), ("MB", 20)):
        if n >= 1 << shift:
            return "%.1f %s" % (n / float(1 << shift), unit)
    return "%d KB" % max(1, n >> 10)


def install_space_needed(pkg):
    """bytes installing pkg takes: what apt downloads (kept until it is
    installed) and what the packages it installs take on the drive"""
    need = 0
    r = run(["apt-get", "-qq", "--print-uris", "install", pkg], timeout=300)
    for line in r.stdout.splitlines() if r.returncode == 0 else []:
        f = line.split()
        if len(f) >= 3 and f[0].startswith("'") and f[2].isdigit():
            need += int(f[2])
    r = run(["apt-get", "-s", "-q", "install", pkg], timeout=300)
    names = [l.split()[1] for l in r.stdout.splitlines() if l.startswith("Inst ") and len(l.split()) > 1] \
        if r.returncode == 0 else []
    if names:
        r = run(["apt-cache", "show", "--no-all-versions"] + names, timeout=300)
        for line in r.stdout.splitlines() if r.returncode == 0 else []:
            if line.startswith("Installed-Size:") and line.split()[1:2] and line.split()[1].isdigit():
                need += int(line.split()[1]) << 10
    return need


def check_space(pkg, need=0):
    """refused, before apt starts, when the drive cannot hold the install:
    apt then stops half-way with "No space left on device" (qa5 2026-10-21,
    GParted and Disk Usage Analyzer on a full drive)"""
    free = free_space()
    if need + SPACE_MARGIN > free:
        log("apt-install %s: needs %d bytes (+%d), %d free" % (pkg, need, SPACE_MARGIN, free))
        raise Refused("%s needs %s of free space and the system drive has %s free. "
                      "Free some space on the drive and try again." % (pkg, size_text(need + SPACE_MARGIN), size_text(free)))


def op_apt_install(args):
    (pkg,) = args
    if not PKG_RE.fullmatch(pkg):
        raise Refused("'%s' is not a package name." % pkg)
    check_space(pkg)
    vendor = add_vendor_source(pkg)
    refresh_lists()
    rel = apt_relations(pkg)
    if wants_i386(rel) and "i386" not in foreign_archs():
        # Steam: its 32-bit libraries are i386 packages
        progress(3, "Turning on support for 32-bit programs...")
        must(["dpkg", "--add-architecture", "i386"], "Support for 32-bit programs could not be turned on")
        log("%s asks for 32-bit libraries: i386 packages turned on" % pkg)
        refresh_lists()
    progress(4, "Checking the space it needs...")
    check_space(pkg, install_space_needed(pkg))
    apt_get(["install", pkg], "%s could not be installed" % pkg, 5, 55 if vendor else 95)
    ver = installed_version(pkg)
    if not ver:
        raise Refused("%s was not installed." % pkg)
    if pkg in OUR_PACKAGES:
        progress(97, "Registering %s..." % pkg)
        register_defaults(pkg)
    failed = install_recommends(rel, 60, 38) if vendor else []
    progress(100, "Done")
    return ["%s %s" % (pkg, ver)] + (["Not installed: %s" % " ".join(failed)] if failed else [])


# What an uninstall never removes, as asked or along the way: the system
# itself (Stained Glass OS's own packages, Wine, the compositor, the image's
# base) -- apt would take sg-shell away with a library it depends on.
PROTECTED_PREFIXES = ("sg-", "stained-glass", "wine-sg", "linux-image", "systemd", "libc6", "apt", "dpkg",
                      "greetd", "xwayland", "plymouth")
REMOVABLE_OURS = ("sg-office", "sg-office-editors")


def protected(pkg):
    if pkg in REMOVABLE_OURS:
        return False
    if ":" in pkg and os.environ.get("SG_MUTANT_FOREIGN_PROTECTED") != "1":
        # another architecture's (qualified() names only those so): never
        # the system's own, which is the native one -- libc6:i386 shares
        # libc6's name and zlib1g:i386 its Priority, and Steam's 32-bit
        # libraries stayed under them. What the system does need of one, the
        # cascade check sees: apt would remove the native package with it
        return False
    if pkg.startswith(PROTECTED_PREFIXES):
        return True
    r = run(["dpkg-query", "-W", "-f", "${Essential} ${Priority}", pkg])
    words = r.stdout.split() if r.returncode == 0 else []
    return "yes" in words[:1] or any(w in ("required", "important") for w in words[1:])


def simulated_removals(args):
    """the packages apt-get -s would remove ("Remv NAME [version]")"""
    r = run(["apt-get", "-s", "-q"] + args, extra_env=apt_env())
    if r.returncode != 0:
        errors = [l[3:].strip() for l in (r.stdout + r.stderr).splitlines() if l.startswith("E: ")]
        raise Refused("It cannot be uninstalled%s" % ((": " + errors[0]) if errors else "."))
    return [qualified(*l.split()[1].partition(":")[::2]) for l in r.stdout.splitlines()
            if l.startswith("Remv ") and len(l.split()) > 1]


_native = []


def native_arch():
    if not _native:
        r = run(["dpkg", "--print-architecture"])
        _native.append(r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else "amd64")
    return _native[0]


def qualified(name, arch):
    """a package as apt-get names it: NAME, or NAME:ARCH for another
    architecture's. Steam's i386 libraries, left for autoremove, are
    libx11-6:i386 -- not Firefox's libx11-6 (qa5 2026-10-21: removing Firefox
    asked apt to remove libstdc++6, libx11-6 and the rest of the system's
    own, and only apt's refusal kept them)"""
    return name if arch in ("", "all", native_arch()) else "%s:%s" % (name, arch)


def instances(name):
    """[(arch, multi_arch)] of the installed packages called NAME"""
    r = run(["dpkg-query", "-W", "-f", "${Architecture}|${Multi-Arch}|${db:Status-Status}\n", name])
    out = []
    for line in r.stdout.splitlines() if r.returncode == 0 else []:
        f = line.split("|")
        if len(f) == 3 and f[2] == "installed":
            out.append((f[0], f[1]))
    return out


def installed_depends(pkg):
    """the installed packages pkg depends on or recommends (each
    alternative), as apt-get names them: a dependency is pkg's own architecture's (or "all"), else a
    Multi-Arch: foreign one's (steam-installer's steam-libs-i386:i386), or
    the architecture it names"""
    r = run(["dpkg-query", "-W", "-f", "${Pre-Depends}, ${Depends}, ${Recommends}", pkg])
    rel = parse_relations("Depends: " + r.stdout) if r.returncode == 0 else {}
    mine = instances(pkg)
    arch = mine[0][0] if mine else native_arch()
    out = set()
    for alts in rel.get("Depends", []):
        for name, want in alts:
            have = instances(name)
            if want == "any":
                archs = [a for a, _ in have]
            elif want == "native":
                archs = [a for a, _ in have if a in ("all", native_arch())]
            elif want:
                archs = [a for a, _ in have if a == want]
            else:
                archs = [a for a, _ in have if a in ("all", arch)] or [a for a, m in have if m == "foreign"]
            out |= {qualified(name, a) for a in archs}
    return out


OUR_PROGIDS = re.compile(r"^HKEY_LOCAL_MACHINE\\Software\\Classes\\(?:[^\\]+\\)?SGOffice\.[^\\]+", re.I)
OUR_KEYS = re.compile(r"^HKEY_LOCAL_MACHINE\\Software\\(?:Classes\\Applications\\sg-[a-z]+64\.exe|"
                      r"Microsoft\\Windows\\CurrentVersion\\App Paths\\sg-[a-z]+\.exe|"
                      r"Stained Glass\\SG Office)(?=\\|$)", re.I)


def unregistration(reg_texts):
    """A .reg that takes our own package's registrations back out of the
    Windows side: its ProgIDs (SGOffice.*, also under an extension's key, its
    ShellNew), its programs' Applications and App Paths keys, its
    Capabilities, and its values in OpenWithProgids and
    RegisteredApplications. An extension's default stays, naming a ProgID
    that is gone, as Windows leaves it when Office is uninstalled."""
    keys, values = [], []
    for text in reg_texts:
        key = None
        for line in text.splitlines():
            line = line.strip()
            if line.startswith("[") and line.endswith("]"):
                key = line[1:-1]
                m = OUR_PROGIDS.match(key) or OUR_KEYS.match(key)
                if m and m.group(0) not in keys:
                    keys.append(m.group(0))
                continue
            if not key or OUR_PROGIDS.match(key) or OUR_KEYS.match(key):
                continue
            name = re.match(r'^"([^"]+)"=', line)
            if name and (key.lower().endswith("\\openwithprogids") and name.group(1).lower().startswith("sgoffice.")
                         or key.lower().endswith("\\software\\registeredapplications") and name.group(1) == "SG Office"):
                values.append((key, name.group(1)))
    out = ["Windows Registry Editor Version 5.00", ""]
    out += ["[-%s]" % k for k in keys]
    for k, v in values:
        out += ["", "[%s]" % k, '"%s"=-' % v]
    return "\r\n".join(out) + "\r\n"


UNREGISTER_SH = (". /usr/lib/stained-glass/sg-common.sh && sg_wine_env && "
                 "timeout 300 wine reg import \"$(winepath -w \"$1\")\" >/dev/null 2>&1 || exit 1; "
                 "shift; for f; do rm -f \"$SG_STATE/defaults/$f.sha256\"; done")


def unregister_defaults(name):
    """take our own package's registrations out of the Windows side, as the
    machine account, and forget that they were imported (so a reinstall
    imports them again at the next start too)"""
    files = [f for f in must(["dpkg-query", "-L", name], "%s's files could not be listed" % name).split("\n")
             if f.startswith(DEFAULTS_DIR + "/") and f.endswith(".reg")]
    texts = []
    for f in files:
        try:
            with open(f, "rb") as fh:
                raw = fh.read()
        except OSError:
            continue
        texts.append(raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("utf-8", "replace"))
    if not texts:
        return
    fd, path = tempfile.mkstemp(prefix="sg-unreg-", suffix=".reg", dir=WORK_DIR)
    with os.fdopen(fd, "w", newline="") as f:
        f.write(unregistration(texts))
    os.chmod(path, 0o644)
    try:
        must(["runuser", "-u", SYSTEM_USER, "--", "env", "HOME=/var/lib/stained-glass",
              "XDG_RUNTIME_DIR=/run/stained-glass", "sh", "-c", UNREGISTER_SH, "sg-unregister", path]
             + [os.path.basename(f) for f in files],
             "%s's registrations could not be removed" % name, timeout=600)
    finally:
        os.unlink(path)


def op_apt_remove(args):
    (pkg,) = args
    if not PKG_RE.fullmatch(pkg):
        raise Refused("'%s' is not a package name." % pkg)
    if not installed_version(pkg):
        raise Refused("%s is not installed." % pkg)
    progress(2, "Checking what goes with it...")
    gone = simulated_removals(["remove", pkg])
    # what it brought in that nothing else needs: its dependencies apt put
    # in for it, once it is gone (apt-get autoremove's own view, narrowed to
    # this package's -- never every leftover on the system)
    deps = installed_depends(pkg)
    # what apt calls unneeded already: not this app's, never taken with it
    before = set(simulated_removals(["autoremove"]))
    for name in [pkg] + gone:
        if protected(name):
            log("apt-remove %s: refused, it would remove %s" % (pkg, name))
            raise Refused("%s cannot be uninstalled: it would remove %s, a part of the system." % (pkg, name)
                          if name != pkg else "%s is a part of the system and cannot be uninstalled." % pkg)
    if pkg in OUR_PACKAGES:
        progress(5, "Removing %s from the file types and the Start menu..." % pkg)
        unregister_defaults(pkg)
    # purged, as an uninstaller takes its machine-wide settings: kept, a
    # removed package's conffiles stay behind ("rc") -- Steam's left its apt
    # source, whose keyring went with it, and every apt-get update after
    # warned of it (qa5 2026-10-21). A person's own files are not apt's
    apt_get(["--purge", "remove", pkg], "%s could not be uninstalled" % pkg, 10, 60)
    if installed_version(pkg):
        raise Refused("%s was not uninstalled." % pkg)
    remove_vendor_source(pkg)
    # what it brought that nothing else needs, all the way down: its own
    # dependencies first, then theirs once they are gone (Steam: steam-libs-
    # i386, then the 32-bit libraries under it -- 200 packages stayed)
    removed = []
    rounds = 1 if os.environ.get("SG_MUTANT_REMOVE_ONE_ROUND") == "1" else 64
    for _ in range(rounds):
        leftover = set(simulated_removals(["autoremove"]))
        extra = sorted(d for d in deps if d in leftover and not protected(d) and d not in removed)
        if not extra and os.environ.get("SG_MUTANT_NO_SWEEP") != "1":
            # and what became unneeded on the way that the walk did not reach
            # (a library whose last user went in another round: 49 of
            # Steam's 32-bit libraries stayed): what apt calls unneeded now
            # and did not before this uninstall
            extra = sorted(d for d in leftover - before if not protected(d) and d not in removed)
        if not extra:
            break
        # and only if removing them takes nothing else: what apt-get remove of
        # the parts would take must all be leftovers, none of them the system's.
        # (qa5 2026-10-21: the parts named the system's libcairo2 and
        # libgdk-pixbuf, and apt -y took 763 packages with them -- Mesa, GTK,
        # Qt, SG Mail -- until it was stopped)
        try:
            cascade = simulated_removals(["remove"] + extra)
        except Refused:
            cascade = None
        if cascade is None or any(c not in leftover or protected(c) for c in cascade):
            log("apt-remove %s: some parts stay, removing them all would take %s" % (
                pkg, "more" if cascade is None else " ".join(sorted(c for c in cascade if c not in leftover or protected(c))[:8])))
            # each on its own: the ones whose removal takes only leftovers go
            alone = []
            for d in extra:
                try:
                    c = simulated_removals(["remove", d])
                except Refused:
                    continue
                if all(x in leftover and not protected(x) for x in c):
                    alone.append(d)
            if not alone:
                break
            extra = alone
        below = set()
        for d in extra:
            below |= installed_depends(d)
        progress(75, "Removing what it brought with it...")
        for d in extra:
            if d in OUR_PACKAGES:
                unregister_defaults(d)
        apt_get(["--purge", "remove"] + extra, "%s's parts could not be uninstalled" % pkg, 75, 20)
        removed += extra
        deps = below - set(removed)
    progress(100, "Done")
    if len(removed) > 12:
        shown = " ".join(removed[:12]) + " and %d more" % (len(removed) - 12)
    else:
        shown = " ".join(removed)
    return ["%s" % pkg] + (["Also removed: %s" % shown] if removed else [])


def deb_fields(path):
    r = run(["dpkg-deb", "--show", "--showformat=${Package}\\n${Version}\\n${Architecture}\\n", path])
    if r.returncode != 0:
        raise Refused("The file is not a Linux software package (.deb).")
    f = r.stdout.split("\n")
    if len(f) < 3:
        raise Refused("The file is not a Linux software package (.deb).")
    return f[0], f[1], f[2]


def foreign_archs():
    return run(["dpkg", "--print-foreign-architectures"]).stdout.split()


def arch_ok(arch):
    if arch == "all":
        return True
    native = run(["dpkg", "--print-architecture"]).stdout.strip()
    return arch == native or arch in foreign_archs()


def parse_relations(text):
    """Pre-Depends, Depends and Recommends of control fields, as lists of
    alternatives [(name, arch)]"""
    fields, cur = {}, None
    for line in text.splitlines():
        if line[:1] in (" ", "\t") and cur:
            fields[cur] += " " + line.strip()
        elif ":" in line:
            cur, _, v = line.partition(":")
            fields[cur.strip()] = v.strip()
    rel = {}
    for key, value in fields.items():
        if key not in ("Pre-Depends", "Depends", "Recommends"):
            continue
        groups = []
        for group in value.split(","):
            alts = []
            for alt in group.split("|"):
                name = re.sub(r"\(.*?\)|\[.*?\]|<.*?>", "", alt).strip()
                name, _, arch = name.partition(":")
                if name:
                    alts.append((name, arch))
            if alts:
                groups.append(alts)
        rel[key] = groups
    return rel


def deb_relations(path):
    """the package's Pre-Depends, Depends and Recommends as lists of
    alternatives [(name, arch)], and whether it brings apt sources of its own
    (Steam's: its libraries come from there)"""
    r = run(["dpkg-deb", "-f", path, "Pre-Depends", "Depends", "Recommends"])
    rel = parse_relations(r.stdout)
    listing = run(["dpkg-deb", "-c", path]).stdout
    sources = bool(re.search(r" \./etc/apt/sources\.list\.d/[^/\s]+\.(list|sources)$", listing, re.M))
    return rel, sources


def wants_i386(rel):
    """a package that asks for 32-bit x86 libraries (Steam: steam-libs-i386)"""
    return any(name.endswith("-i386") or arch == "i386"
               for groups in rel.values() for alts in groups for name, arch in alts)


def install_recommends(rel, base, span):
    """the Recommends apt could not see when the package went in: ones from
    the sources the package itself added (Steam's steam-libs-amd64 and
    steam-libs-i386), with the lists already refreshed. Each on its own, and
    a failure is only noted -- the package is installed. Answers the names
    that did not install."""
    missing = [alts[0][0] for alts in rel.get("Recommends", [])
               if not any(installed_version(n) for n, _ in alts) and PKG_RE.fullmatch(alts[0][0])]
    if not missing:
        return []
    i386 = "i386" in foreign_archs()
    failed = []
    for i, name in enumerate(missing):
        target = name + ":i386" if i386 and name.endswith("-i386") else name
        try:
            apt_get(["install", target], "%s could not be installed" % name,
                    base + span * i / len(missing), span / len(missing))
            # installed for the package, as apt's own Recommends are: marked
            # automatic, so its Uninstall takes them (Steam's steam-libs-*
            # and the 32-bit libraries under them stayed, manual)
            if os.environ.get("SG_MUTANT_RECOMMENDS_MANUAL") != "1":
                run(["apt-mark", "auto", target])
        except Refused as e:
            log("recommended %s: %s" % (name, e))
            failed.append(name)
    return failed


def take_staged(name):
    """the staged file's bytes, copied into a folder of ours; the staged file
    goes. Only SYSTEM's own regular file, never through a link."""
    if not STAGED_RE.fullmatch(name):
        raise Refused("'%s' is not a staged package." % name)
    try:
        dfd = os.open(DEBS_SRC, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
    except OSError:
        raise Refused("The staging folder for packages is missing.")
    work = tempfile.mkdtemp(prefix="sg-deb-", dir=WORK_DIR)
    try:
        try:
            fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=dfd)
        except OSError:
            raise Refused("The package was not staged.")
        try:
            st = os.fstat(fd)
            if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or st.st_uid != system_uid():  # SYSTEM's own
                raise Refused("The package was not staged by an administrator.")
            if st.st_size > MAX_DEB:
                raise Refused("The package is too large.")
            dst = os.path.join(work, "package.deb")
            out = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o644)
            with os.fdopen(out, "wb") as o:
                while True:
                    chunk = os.read(fd, 1 << 20)
                    if not chunk:
                        break
                    o.write(chunk)
        finally:
            os.close(fd)
            try:
                os.unlink(name, dir_fd=dfd)
            except OSError:
                pass
    except BaseException:
        shutil.rmtree(work, ignore_errors=True)
        raise
    finally:
        os.close(dfd)
    # apt's download sandbox (_apt) reads the file: the folder is ours, 0755
    os.chmod(work, 0o755)
    return work, dst


def op_deb_install(args):
    name, want_pkg, want_ver = args
    if not PKG_RE.fullmatch(want_pkg) or not VERSION_RE.fullmatch(want_ver):
        raise Refused("That is not a package and version.")
    progress(1, "Checking the package...")
    work, deb = take_staged(name)
    try:
        pkg, ver, arch = deb_fields(deb)
        if (pkg, ver) != (want_pkg, want_ver):
            raise Refused("The file changed: it is now %s %s, not the %s %s that was shown. Nothing was installed."
                          % (pkg, ver, want_pkg, want_ver))
        if not arch_ok(arch):
            raise Refused("%s is built for another kind of processor (%s)." % (pkg, arch))
        rel, sources = deb_relations(deb)
        if wants_i386(rel) and "i386" not in foreign_archs():
            # Steam and other 32-bit games: their libraries are i386 packages,
            # which apt installs only with the architecture turned on
            progress(2, "Turning on support for 32-bit programs...")
            must(["dpkg", "--add-architecture", "i386"], "Support for 32-bit programs could not be turned on")
            log("%s asks for 32-bit libraries: i386 packages turned on" % pkg)
        refresh_lists()
        apt_get(["install", deb], "%s could not be installed" % pkg, 5, 55 if sources else 95)
    finally:
        shutil.rmtree(work, ignore_errors=True)
    got = installed_version(pkg)
    if got != ver:
        raise Refused("%s %s was not installed." % (pkg, ver))
    if sources and os.environ.get("SG_MUTANT_DEB_SOURCES_STALE") != "1":
        # apt learns the sources the package added -- even when its
        # Recommends were all there already (Steam installed again after an
        # Uninstall): Valve's steamdeps found its repository missing from
        # apt-cache policy and asked for a root "apt-get update" in a terminal
        refresh_lists()
    failed = install_recommends(rel, 60, 38) if sources else []
    progress(100, "Done")
    return ["%s %s" % (pkg, ver)] + (["Not installed: %s" % " ".join(failed)] if failed else [])


# What an administrator may change in the firewall; sg-firewall checks every
# value itself (programs are full paths, ports numbers, no tabs or breaks).
FIREWALL_COMMANDS = {"rule-add": (6, 7), "rule-set": (4, 4), "rule-remove": (1, 1), "group": (2, 2),
                     "profile": (2, 2), "network": (2, 3), "reset": (0, 0), "registry-push": (1, 2)}


def op_firewall(args):
    sub, rest = args[0], args[1:]
    if sub not in FIREWALL_COMMANDS:
        raise Refused("That is not something the firewall can change.")
    lo, hi = FIREWALL_COMMANDS[sub]
    if not lo <= len(rest) <= hi:
        raise Refused("Malformed request.")
    r = run(["sg-firewall", sub] + rest, timeout=60)
    lines = [l for l in r.stdout.splitlines() if l.strip()]
    last = lines[-1] if lines else ""
    if r.returncode == 0 and last == "OK":
        return lines[:-1]
    if last.startswith("ERROR "):
        raise Refused(last.split(" ", 2)[2] if len(last.split(" ", 2)) > 2 else "The firewall refused.")
    raise Refused("The firewall could not be changed.")


# Restore points (sg-snapshot): what an administrator may ask, and how long
# each may take (making the conversion's initrd takes a minute or more).
RESTORE_COMMANDS = {"rollback": (1, 300), "delete": (1, 300), "create": (0, 300), "undo-update": (0, 60),
                    "undo-cancel": (0, 60), "convert-schedule": (0, 1800), "convert-cancel": (0, 120),
                    "convert-keep": (0, 600), "convert-undo": (0, 1800)}
RESTORE_ID_RE = re.compile(r"^[0-9]{8}-[0-9]{6}$")


def op_restore_point(args):
    sub, rest = args[0], args[1:]
    if sub not in RESTORE_COMMANDS:
        raise Refused("That is not something restore points can do.")
    n, timeout = RESTORE_COMMANDS[sub]
    if len(rest) != n or not all(RESTORE_ID_RE.match(a) for a in rest):
        raise Refused("Malformed request.")
    argv = ["sg-snapshot", sub] + rest
    if sub == "create":
        argv += ["--label", "Restore point made in Settings", "--kind", "manual"]
    r = run(argv, timeout=timeout)
    lines = [l for l in r.stdout.splitlines() if l.strip()]
    last = lines[-1] if lines else ""
    if r.returncode == 0 and last == "OK":
        return lines[:-1]
    if last.startswith("ERROR "):
        raise Refused(last[6:])
    raise Refused("The restore point could not be used.")


OPS = {
    "ping": (op_ping, 0),
    "hostname": (op_hostname, 1),
    "join-domain": (op_join_domain, 4),
    "user-add": (op_user_add, 4),
    "user-type": (op_user_type, 2),
    "user-password": (op_user_password, 2),
    "user-password-own": (op_user_password_own, 3),
    "user-remove": (op_user_remove, 2),
    "timezone": (op_timezone, 1),
    "timezone-auto": (op_timezone_auto, 1),
    "location": (op_location, 1),
    "ntp": (op_ntp, 1),
    "defender": (op_defender, 1),
    "power-buttons": (op_power_buttons, 3),
    "autologon": (op_autologon, 1),
    "kiosk": (op_kiosk, 5),
    "defender-restore": (op_defender_restore, 1),
    "defender-delete": (op_defender_delete, 1),
    "time": (op_time, 1),
    "update-check": (op_update_check, 0),
    "source-add": (op_source_add, 4),
    "source-remove": (op_source_remove, 1),
    "source-enable": (op_source_enable, 2),
    "font-install": (op_font_install, 1),
    "font-remove": (op_font_remove, 1),
    "apt-install": (op_apt_install, 1),
    "apt-remove": (op_apt_remove, 1),
    "deb-install": (op_deb_install, 3),
    "firewall": (op_firewall, None),        # 1 to 8 fields: a subcommand and its arguments
    "restore-point": (op_restore_point, None),
}
# how many of the last fields are passwords (never logged)
SECRET = {"join-domain": 1, "user-add": 1, "user-password": 1, "user-password-own": 2}


def system_uid():
    if TEST:
        return int(os.environ.get("SG_ADMIN_SYSTEM_UID", os.geteuid()))
    import pwd
    return pwd.getpwnam(SYSTEM_USER).pw_uid


def system_gid():
    import grp
    return grp.getgrnam(SYSTEM_USER).gr_gid


ID_RE = re.compile(r"^([0-9a-f]{16,64})\.req$")


def read_request(dirfd, name, uid):
    """The request's fields, or None if it is not one we may act on."""
    try:
        fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=dirfd)
    except OSError as e:
        log("%s: not opened: %s" % (name, e))
        return None
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode) or st.st_uid != uid or st.st_nlink != 1:
            log("%s: refused: not a request from SYSTEM" % name)
            return None
        data = os.read(fd, MAX_REQUEST + 1)
    finally:
        os.close(fd)
    if len(data) > MAX_REQUEST:
        log("%s: refused: too large" % name)
        return None
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError:
        return [""]
    return text.split("\n")[:-1] if text.endswith("\n") else text.split("\n")


def answer(req_id, lines):
    replies = os.path.join(SPOOL, "replies")
    tmp = os.path.join(replies, ".%s.tmp" % req_id)
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o640)
    with os.fdopen(fd, "w") as f:
        f.write("\n".join(lines) + "\n")
    if not TEST:
        os.chown(tmp, 0, system_gid())
    os.replace(tmp, os.path.join(replies, "%s.rep" % req_id))
    try:
        os.unlink(os.path.join(replies, "%s.progress" % req_id))
    except OSError:
        pass


def handle(fields):
    verb, args = (fields[0], fields[1:]) if fields else ("", [])
    if verb not in OPS:
        raise Refused("Unknown request.")
    fn, n = OPS[verb]
    if (len(args) != n) if n is not None else not 1 <= len(args) <= 8:
        raise Refused("Malformed request.")
    shown = args[:-SECRET[verb]] if verb in SECRET else args
    log("%s %s" % (verb, " ".join(repr(a) for a in shown)))
    return fn(args)


def process():
    global CURRENT_ID
    uid = system_uid()
    reqdir = os.path.join(SPOOL, "requests")
    dirfd = os.open(reqdir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    try:
        for name in sorted(os.listdir(dirfd)):
            m = ID_RE.match(name)
            if not m:
                # a half-written request (renamed into place when complete) is
                # left alone; anything else that is stale is cleared
                try:
                    st = os.stat(name, dir_fd=dirfd, follow_symlinks=False)
                    if time.time() - st.st_mtime > 600:
                        os.unlink(name, dir_fd=dirfd)
                except OSError:
                    pass
                continue
            fields = read_request(dirfd, name, uid)
            try:
                os.unlink(name, dir_fd=dirfd)
            except OSError:
                pass
            if fields is None:
                continue
            CURRENT_ID = m.group(1)
            try:
                detail = handle(fields)
                reply = ["OK"] + detail
                log("%s: done" % fields[0])
            except Refused as e:
                reply = ["FAILED %s" % e]
                log("%s: refused: %s" % (fields[0] if fields else "?", e))
            except Exception as e:   # never leave the elevated program waiting
                reply = ["FAILED Something went wrong."]
                log("%s: error: %r" % (fields[0] if fields else "?", e))
            fields = None
            answer(m.group(1), reply)
            CURRENT_ID = None
    finally:
        os.close(dirfd)
    # old answers nobody collected
    replies = os.path.join(SPOOL, "replies")
    for name in os.listdir(replies):
        p = os.path.join(replies, name)
        try:
            if time.time() - os.lstat(p).st_mtime > 600:
                os.unlink(p)
        except OSError:
            pass


if __name__ == "__main__":
    process()
