#!/bin/sh
# felhom-selfupdate-guarded — the ROOT half of the agent's A/B self-update (TASK D1).
#
# Install as /usr/local/sbin/felhom-selfupdate-guarded (0755 root:root). The non-root agent invokes
# `apply`/`commit` via `sudo -n` (FELHOM_SELFUPDATE alias); `rollback` is ALSO the ExecStart of
# felhom-agent-rollback.service — the OnFailure= target that auto-reverts a crash-looping update.
#
# Design provenance: SPIKE-agent-selfupdate-2026-07-05 (its SF-findings are cited inline). The core
# principle: the thing that performs rollback is never the thing being updated — this wrapper +
# systemd change almost never; the agent binary is what flips.
#
# Trust model: the agent verifies the download against the OPERATOR-SIGNED sha before staging; this
# wrapper RE-verifies the same sha as root (defense in depth — the sudoers arg glob is coarse, the
# sha check here is the real gate). Path confinement: apply only ever reads from the agent's own
# staging dir and only ever writes the fixed live path + its siblings. NO env-overridable paths —
# path-fixedness IS the security property (a test-mode override would be a root escalation hole).
#
# Verbs:
#   apply <staged> <sha256>  stage-verify → .prev → atomic flip → pending marker → detached restart
#   commit                   clear the pending marker (idempotent; .prev retained as a manual net)
#   rollback                 pending-guarded revert to .prev + restart (no pending → exit 0 no-op)
set -u

BIN=/usr/local/bin/felhom-agent
PREV=$BIN.prev
STAGING=/var/lib/felhom-agent/selfupdate
PENDING=$STAGING/pending.json
UNIT=felhom-agent.service

# Every refusal/decision goes to stderr AND the journal (strict rule 10).
log() { echo "felhom-selfupdate-guarded: $*" >&2; logger -t felhom-selfupdate-guarded -- "$*" 2>/dev/null || true; }

case "${1:-}" in

apply)
    staged=${2:-}; want=${3:-}
    # [SF-7] entry sweep: a kill between staging-copy and mv leaves an orphaned temp — harmless,
    # but sweep it so temps can never accumulate.
    rm -f "$BIN".new.*

    if [ -z "$staged" ] || [ -z "$want" ]; then
        log "refusing apply: usage: apply <staged> <sha256>"
        exit 2
    fi
    # Root-side path confinement: the staged binary MUST live in the agent's staging dir.
    case "$staged" in
        "$STAGING"/*) ;;
        *) log "refusing apply: staged path outside $STAGING: $staged"; exit 1 ;;
    esac
    case "$staged" in
        *..*) log "refusing apply: staged path contains '..'"; exit 1 ;;
    esac
    [ -f "$staged" ] || { log "refusing apply: staged file missing: $staged"; exit 1; }
    # The sha must be 64 lowercase hex chars — anything else is refused before any hashing.
    case "$want" in
        *[!0-9a-f]*) log "refusing apply: sha256 is not lowercase hex"; exit 1 ;;
    esac
    [ "${#want}" -eq 64 ] || { log "refusing apply: sha256 must be 64 hex chars (got ${#want})"; exit 1; }

    # [SF-7] sha-verify FIRST — before .prev, before any mutation (spike S3a companion ordering).
    got=$(sha256sum "$staged" | awk '{print $1}')
    if [ "$got" != "$want" ]; then
        log "refusing apply: sha mismatch (got=$got want=$want)"
        exit 1
    fi

    # Same-fs assert (§8): the atomic-rename guarantee only holds within one filesystem.
    if [ "$(stat -c %d "$staged")" != "$(stat -c %d /usr/local/bin)" ]; then
        log "refusing apply: staging and /usr/local/bin are on different filesystems — atomic rename impossible"
        exit 1
    fi

    old_ver=$("$BIN" --version 2>/dev/null | awk '{print $2}')
    [ -n "$old_ver" ] || old_ver=unknown
    # The staged filename is felhom-agent-<version> (executor contract) — version without executing.
    new_ver=$(basename "$staged"); new_ver=${new_ver#felhom-agent-}

    cp -p "$BIN" "$PREV" || { log "apply failed: cannot snapshot current binary to .prev"; exit 1; }
    # Stage a root-owned 0755 copy next to the live path, then ATOMIC same-fs rename.
    if ! cp "$staged" "$BIN.new.$$" || ! chmod 0755 "$BIN.new.$$" || ! chown root:root "$BIN.new.$$"; then
        rm -f "$BIN.new.$$"; log "apply failed: staging copy"; exit 1
    fi
    mv "$BIN.new.$$" "$BIN" || { rm -f "$BIN.new.$$"; log "apply failed: atomic rename"; exit 1; }

    # Pending marker: written AFTER the flip — its existence means "an update is awaiting commit",
    # which is exactly the rollback unit's trigger condition.
    printf '{"old_version":"%s","new_version":"%s","sha256":"%s","applied_at":"%s"}\n' \
        "$old_ver" "$new_ver" "$want" "$(date -Is)" > "$PENDING" \
        || { log "apply failed: cannot write pending marker"; exit 1; }

    # [SF-4/5] deliberate restarts consume start-limit budget — clear the counter first.
    systemctl reset-failed "$UNIT" 2>/dev/null || true
    # [SF-6] the spike's S2b winner, verbatim: detached transient timer OUTSIDE the agent's cgroup,
    # so the sudo/agent caller survives to log the handoff and the restart cannot be torn down
    # by its own requester dying.
    systemd-run --on-active=2s --timer-property=AccuracySec=100ms systemctl restart "$UNIT" \
        || { log "apply: flip done but detached restart scheduling FAILED — restart $UNIT manually"; exit 1; }
    log "applied $new_ver (prev $old_ver, sha $want); detached restart scheduled"
    ;;

commit)
    if [ ! -f "$PENDING" ]; then
        log "commit: no pending — no-op"
        exit 0
    fi
    # .prev is deliberately RETAINED (spike S3d) — a manual safety net until the next apply.
    rm -f "$PENDING" || { log "commit failed: cannot remove pending marker"; exit 1; }
    log "committed (pending cleared, .prev retained)"
    ;;

rollback)
    # [SF-1] On systemd 257 OnFailure= fires on EVERY crash, so this verb runs MANY times per
    # incident — the pending-guard makes every fire after the first a harmless no-op, and makes a
    # crash with NO update in flight touch nothing at all (spike S1d/S3e).
    if [ ! -f "$PENDING" ]; then
        log "rollback: no pending update — no-op"
        exit 0
    fi
    [ -f "$PREV" ] || { log "rollback FAILED: pending exists but no .prev binary"; exit 1; }
    rm -f "$BIN".new.*
    if ! cp "$PREV" "$BIN.new.$$" || ! chmod 0755 "$BIN.new.$$" || ! chown root:root "$BIN.new.$$"; then
        rm -f "$BIN.new.$$"; log "rollback FAILED: staging copy"; exit 1
    fi
    mv "$BIN.new.$$" "$BIN" || { rm -f "$BIN.new.$$"; log "rollback FAILED: atomic rename"; exit 1; }
    # Clear pending BEFORE the restart: once the binary is reverted, later OnFailure fires must
    # no-op (the guard above) instead of re-copying .prev forever.
    rm -f "$PENDING"
    # [SF-4/5] the crash burst has been eating the start-limit budget — reset before starting.
    systemctl reset-failed "$UNIT" 2>/dev/null || true
    # Direct restart is correct HERE: this caller is the rollback oneshot, OUTSIDE the agent cgroup.
    systemctl restart "$UNIT" || { log "rollback: binary reverted but restart FAILED"; exit 1; }
    log "rolled back to previous binary and restarted $UNIT"
    ;;

*)
    log "usage: felhom-selfupdate-guarded apply <staged> <sha256> | commit | rollback"
    exit 2
    ;;
esac
