#!python
"""sandbroker-grant-apply - the root installer for approved capability grants.

This is the ONLY privileged surface in the grant model, and its entire job is:
apply exactly the proposals a human has already approved with WebAuthn, and
nothing else. It writes the two artifacts a read capability needs -- the
root-owned read.d config and the verbs.json verb block -- then restarts the
daemon so the new verb loads.

WHY THIS IS A SEPARATE ROOT PROGRAM (and the daemon is not root):
  verbs.json and read.d are root:root; the daemon runs unprivileged and cannot
  write them. That is deliberate: adding a capability is a human action, so it
  must cross a privilege boundary the agent (and the unprivileged daemon) cannot.
  This installer is that boundary. It takes NO input from the agent: it reads
  only the daemon's spool, and it TRUSTS NOTHING there -- every record is
  re-validated from scratch (config re-checked by the read engine, verb
  re-checked by the daemon's validate_verb, argv[0] re-pinned to the installed
  engine, digest re-matched) before a single byte is written.

WHAT IT REFUSES:
  * a record not in state APPROVED with a WebAuthn approval marker;
  * an approval whose bound digest no longer matches the artifacts (a spool
    edited after approval);
  * a verb name that already exists in verbs.json (no silent overwrite; use
    --force only when you mean to replace);
  * anything validate_proposal rejects.

USAGE (run as root on the box):
  sudo sandbroker-grant-apply            # apply all approved grants, restart
  sudo sandbroker-grant-apply --list     # show pending/approved, change nothing
  sudo sandbroker-grant-apply --dry-run  # print what would happen, change nothing
  sudo sandbroker-grant-apply --name spotify-isrc   # just this one
  sudo sandbroker-grant-apply --no-restart          # apply, defer the restart

Paths are derived from SANDBROKER_BASE (default /opt/sandbroker); every path is
overridable via env so the test-suite drives the pure logic without root or
systemd.
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
import time
from importlib.machinery import SourceFileLoader

BASE = os.environ.get("SANDBROKER_BASE", "/opt/sandbroker")
PROPOSALS_DIR = os.environ.get("SANDBROKER_PROPOSALS_DIR",
                               os.path.join(BASE, "var", "proposals"))
READ_D = os.environ.get("SANDBROKER_READ_D", os.path.join(BASE, "etc", "read.d"))
VERBS_PATH = os.environ.get("SANDBROKER_VERBS_PATH",
                            os.path.join(BASE, "etc", "verbs.json"))


class ApplyError(Exception):
    """A proposal cannot be safely applied. The message is non-secret."""


# --------------------------------------------------------------------------
# Load the package (proposals builder + the daemon's validate_verb)
# --------------------------------------------------------------------------

def _find_pkg_dir():
    """Locate the sandbroker package directory across install layouts."""
    env = os.environ.get("SANDBROKER_PKG")
    if env and os.path.exists(os.path.join(env, "proposals.py")):
        return env
    import glob
    here = os.path.dirname(os.path.abspath(__file__))
    candidates = [
        os.path.join(os.path.dirname(here), "sandbroker"),   # repo checkout (bin/..)
        os.path.join(BASE, "sandbroker"),                     # flat cutover layout
    ]
    candidates += glob.glob(os.path.join(BASE, "venv", "lib", "python*",
                                         "site-packages", "sandbroker"))
    for c in candidates:
        if os.path.exists(os.path.join(c, "proposals.py")):
            return c
    return None


def load_pkg():
    """Return (proposals_module, validate_verb). Prefer a normal import (venv
    python / PYTHONPATH); fall back to loading by path so the installer runs
    under the system python too."""
    try:
        from sandbroker import proposals as P
        from sandbroker.sandbrokerd import validate_verb as VV
        return P, VV
    except Exception:
        pass
    pkg = _find_pkg_dir()
    if not pkg:
        raise ApplyError("cannot locate the sandbroker package (set SANDBROKER_PKG)")
    if pkg not in sys.path:
        sys.path.insert(0, pkg)  # so the modules' `import config` fallbacks resolve
    P = SourceFileLoader("sandbroker_proposals",
                         os.path.join(pkg, "proposals.py")).load_module()
    SD = SourceFileLoader("sandbrokerd",
                          os.path.join(pkg, "sandbrokerd.py")).load_module()
    return P, SD.validate_verb


# --------------------------------------------------------------------------
# Spool
# --------------------------------------------------------------------------

def read_records(names=None):
    """Yield (path, record) for every spooled proposal (optionally filtered to a
    set of names). A record that will not parse is yielded as (path, None)."""
    try:
        files = sorted(os.listdir(PROPOSALS_DIR))
    except OSError:
        return
    for fn in files:
        if not fn.endswith(".json"):
            continue
        name = fn[:-5]
        if names and name not in names:
            continue
        path = os.path.join(PROPOSALS_DIR, fn)
        try:
            with open(path) as fh:
                rec = json.load(fh)
        except (OSError, ValueError):
            rec = None
        yield path, rec


# --------------------------------------------------------------------------
# Artifact writers (atomic; root-owned perms). Pure enough to unit-test.
# --------------------------------------------------------------------------

def _atomic_write(path, text, mode):
    """Write text to path atomically with the given mode. Same-directory temp so
    os.replace is a rename, never a cross-device copy.

    On modes: the artifacts written here are root-owned and world-READABLE but
    never world-writable (0644 for a read.d config, 0444 for verbs.json), which is
    required, not incidental -- the daemon runs as an unprivileged uid and must be
    able to read root-owned config it can never modify. Both files hold only
    non-secret structure (host allowlists, emit selectors, verb definitions); no
    credential is ever written here. These are the same modes packaging/install.sh
    seeds them with, so a granted capability is indistinguishable on disk from a
    hand-installed one."""
    d = os.path.dirname(path)
    fd, tmp = tempfile.mkstemp(dir=d)
    try:
        with os.fdopen(fd, "w") as fh:
            fh.write(text)
        os.chmod(tmp, mode)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def write_config(name, config, *, dry_run):
    """Write etc/read.d/<name>.json (0644 root-owned when run as root)."""
    path = os.path.join(READ_D, name + ".json")
    if dry_run:
        return "would write config %s" % path
    _atomic_write(path, json.dumps(config, indent=2, sort_keys=True) + "\n", 0o644)
    return "wrote config %s" % path


def insert_verb(verb, inner, *, force, dry_run):
    """Insert one verb into verbs.json under `verbs`, preserving every other
    top-level key (the _comment/_not_shipped notes). Refuses to overwrite an
    existing verb unless force. Writes 0444 (matching the seeded registry)."""
    try:
        with open(VERBS_PATH) as fh:
            data = json.load(fh)
    except (OSError, ValueError) as exc:
        raise ApplyError("cannot read verbs.json: %s" % exc)
    verbs = data.get("verbs")
    if not isinstance(verbs, dict):
        raise ApplyError("verbs.json has no verbs object")
    if verb in verbs and not force:
        raise ApplyError("verb %r already exists (use --force to replace)" % verb)
    if dry_run:
        return "would %s verb %r in %s" % (
            "replace" if verb in verbs else "add", verb, VERBS_PATH)
    verbs[verb] = inner
    _atomic_write(VERBS_PATH, json.dumps(data, indent=2) + "\n", 0o444)
    return "added verb %r to %s" % (verb, VERBS_PATH)


# --------------------------------------------------------------------------
# Apply one proposal (re-validates EVERYTHING before writing)
# --------------------------------------------------------------------------

def apply_proposal(record, P, VV, *, force=False, dry_run=False):
    """Apply a single APPROVED proposal. Returns a list of action strings.
    Raises ApplyError if the record is not safe to install. This is the whole
    trust boundary: it never assumes the spool is well-formed."""
    if not isinstance(record, dict):
        raise ApplyError("unreadable record")
    name = record.get("name", "?")
    if record.get("state") != "APPROVED":
        raise ApplyError("%s: not APPROVED (state=%s)" % (name, record.get("state")))
    ap = record.get("approved") or {}
    if not ap.get("by_webauthn"):
        raise ApplyError("%s: approval is not WebAuthn-backed" % name)
    # The approval was bound to a digest; the record's current digest must still
    # match it AND the artifacts (validate_proposal checks digest==artifacts).
    if ap.get("digest") != record.get("digest"):
        raise ApplyError("%s: approved digest != record digest (edited after approval)" % name)
    P.validate_proposal(record, validate_verb=VV)  # config + verb + argv0 + digest

    verb_spec = record["verb_spec"]
    verb, inner = next(iter(verb_spec.items()))
    actions = []
    actions.append(write_config(name, record["config"], dry_run=dry_run))
    actions.append(insert_verb(verb, inner, force=force, dry_run=dry_run))
    if not dry_run:
        # The grant is live once verbs.json holds it; remove the spooled request
        # so it is not re-applied and the Approvals queue clears. The audit trail
        # already holds the capability_approved event and the verb is now visible
        # in `status`, so no state is lost.
        try:
            os.unlink(os.path.join(PROPOSALS_DIR, name + ".json"))
        except OSError:
            pass
        actions.append("cleared proposal %s" % name)
    return actions


# --------------------------------------------------------------------------
# Daemon restart (so the new verb loads)
# --------------------------------------------------------------------------

def restart_daemon(log):
    """Restart the daemon so it reloads verbs.json. Tries systemd, then launchd;
    on neither, tells the operator to restart manually (non-fatal)."""
    for cmd in (["systemctl", "restart", "sandbrokerd"],
                ["launchctl", "kickstart", "-k",
                 "system/io.github.graysoncadams.sandbroker"]):
        exe = cmd[0]
        if not _which(exe):
            continue
        try:
            subprocess.run(cmd, check=True, capture_output=True)
            log("restarted the daemon via %s" % exe)
            return True
        except (OSError, subprocess.CalledProcessError) as exc:
            log("warn: %s restart failed: %s" % (exe, exc))
    log("could not restart automatically; run: systemctl restart sandbrokerd")
    return False


def _which(exe):
    for d in os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin").split(os.pathsep):
        if os.path.exists(os.path.join(d, exe)):
            return True
    return False


# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------

def _fmt_state(rec):
    if not isinstance(rec, dict):
        return "UNREADABLE"
    return rec.get("state", "?")


def cmd_list(log):
    any_seen = False
    for path, rec in read_records():
        any_seen = True
        if not isinstance(rec, dict):
            log("  %-24s UNREADABLE (%s)" % (os.path.basename(path), path))
            continue
        approved = (rec.get("approved") or {}).get("by_webauthn")
        log("  %-24s %-9s verb=%s ref=%s%s" % (
            rec.get("name"), rec.get("state"), rec.get("verb"), rec.get("ref"),
            "  [webauthn]" if approved else ""))
    if not any_seen:
        log("  (no proposals spooled)")
    return 0


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    ap = argparse.ArgumentParser(prog="sandbroker-grant-apply")
    ap.add_argument("--list", action="store_true", help="show spooled proposals, change nothing")
    ap.add_argument("--dry-run", action="store_true", help="print actions, change nothing")
    ap.add_argument("--force", action="store_true", help="replace an existing verb of the same name")
    ap.add_argument("--name", action="append", help="apply only this proposal (repeatable)")
    ap.add_argument("--no-restart", action="store_true", help="apply but do not restart the daemon")
    args = ap.parse_args(argv)

    def log(msg):
        sys.stderr.write(msg + "\n")

    if args.list:
        return cmd_list(log)

    # The real requirement is write access to verbs.json's directory and read.d,
    # not root per se. On a normal install those are root-owned, so this asks for
    # sudo; on a user-owned base (a scratch/dev broker) the owner suffices. os.access
    # as root is always True, so this stays correct for the production case.
    if not args.dry_run:
        etc = os.path.dirname(VERBS_PATH)
        if not (os.access(etc, os.W_OK) and os.access(READ_D, os.W_OK)):
            log("cannot write %s and %s. On a normal install these are root-owned "
                "-- re-run with sudo, or use --dry-run." % (etc, READ_D))
            return 2

    try:
        P, VV = load_pkg()
    except ApplyError as exc:
        log("error: %s" % exc)
        return 1

    names = set(args.name) if args.name else None
    applied, skipped, errors = 0, 0, 0
    changed = False
    for path, rec in read_records(names):
        name = rec.get("name") if isinstance(rec, dict) else os.path.basename(path)
        if not isinstance(rec, dict) or rec.get("state") != "APPROVED":
            skipped += 1
            log("skip %s (%s)" % (name, _fmt_state(rec)))
            continue
        try:
            actions = apply_proposal(rec, P, VV, force=args.force, dry_run=args.dry_run)
        except Exception as exc:
            errors += 1
            log("FAIL %s: %s" % (name, exc))
            continue
        applied += 1
        if not args.dry_run:
            changed = True
        for a in actions:
            log("  %s" % a)

    log("applied=%d skipped=%d errors=%d%s" % (
        applied, skipped, errors, " (dry-run)" if args.dry_run else ""))

    if changed and not args.no_restart:
        restart_daemon(log)
    elif changed and args.no_restart:
        log("verbs.json changed; restart the daemon to load it: "
            "systemctl restart sandbrokerd")

    return 1 if errors else 0


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