#!/bin/bash
# hal0-update — privileged seam for hal0-api's self-update ops (#1464).
#
# Background: hal0-api runs as the unprivileged `hal0` system user (P3-perms;
# installer/install.sh ships User=hal0), while /usr/lib/hal0 is pinned
# root:root 0755 and declared "never service-writable at any point"
# (src/hal0/install/perms.py). Self-update, however, has to extract a release
# into <lib>/hal0-<version>/, swap the <lib>/current symlink, and re-pip the
# tree into the root-owned venv — all EACCES for the service account. This
# script is the ENTIRE privileged surface for those ops, modeled exactly on
# hal0-systemctl / hal0-agentenv / hal0-benchctl: every argument is validated
# against a strict regex, no shell is ever evaluated, and no wildcards are
# accepted.
#
# WHY `stage` DOES THE VERIFICATION TOO: the smaller-looking grant — let the
# unprivileged API download + verify, then ask root to install the result — is
# a root-code-execution hole, because `activate` ends in `pip install`, which
# runs the tree's build backend AS ROOT. A compromised hal0-api would simply
# skip the cosign check and hand root an attacker-supplied tarball. So root
# re-fetches the manifest, re-derives the target version, re-checks the sha256
# and re-runs `cosign verify-blob` itself. The only things that cross this
# boundary are a channel name from a three-value allow-list, an optional exact
# version pin, and a `hal0-<version>` directory BASENAME — never a path, never
# a file body, never a URL.
#
# The real work lives in Python (hal0.updater.privileged), not in bash: tar-slip
# vetting, digest comparison, cosign identity binding and the atomic symlink
# swap are all reviewed, unit-tested code, and re-implementing them in shell
# would be strictly worse. This file's job is argv validation + a hardened
# re-entry into the interpreter.

set -euo pipefail

# Resolve the interpreter from OUR OWN location rather than a hardcoded path,
# so a non-default HAL0_PREFIX install works: the wrapper is installed at
# ${LIB_DIR}/bin/hal0-update, so the venv is ../venv. Both directories are
# root-owned 0755 (perms.py), so this is not an attacker-controlled lookup.
SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
LIB_ROOT="$(dirname -- "${SELF_DIR}")"
PY="${LIB_ROOT}/venv/bin/python"

die() { echo "hal0-update: $1" >&2; exit 64; }

[[ -x "${PY}" ]] || die "interpreter not found at ${PY}"

# Run from / so nothing in the caller's working directory can influence the
# child. Belt and braces with `python -I` below (isolated mode: ignores every
# PYTHON* env var, drops user site-packages, and does NOT prepend the CWD to
# sys.path) — without both, a caller-controlled cwd containing a `hal0/`
# package would be imported by a root interpreter.
cd /

# Mirrors CHANNELS in src/hal0/updater/privileged.py EXACTLY.
validate_channel() {
  local ch="$1"
  [[ -n "${ch}" ]] || die "missing channel"
  case "${ch}" in
    stable|preview|nightly) ;;
    *) die "bad channel: ${ch}" ;;
  esac
}

# Loose-but-bounded: the exact grammar is ReleasePolicy's, re-validated as root
# by validate_release_version(). No '/', no whitespace, no option-looking
# leading '-', bounded length.
validate_version() {
  local v="$1"
  [[ -n "${v}" ]] || die "missing version"
  [[ "${v}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.+-][A-Za-z0-9.]{1,40})?$ ]] || die "bad version: ${v}"
}

# Mirrors RELEASE_DIR_RE in src/hal0/updater/updater.py EXACTLY. A validated
# token contains no '/', so it can only ever name a direct child of the install
# root; the leading [A-Za-z0-9] after the prefix also rules out `hal0-..`.
validate_dir_name() {
  local d="$1"
  [[ -n "${d}" ]] || die "missing release directory name"
  [[ "${d}" =~ ^hal0-[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$ ]] || die "bad release directory name: ${d}"
}

# ── operator releases-URL override (#1690) ──────────────────────────────────
#
# hal0-api (unprivileged) resolves its manifest URL via
# hal0.updater.updater.releases_url(), which honours HAL0_RELEASES_URL from
# its systemd EnvironmentFile= (/etc/hal0/api.env) — the documented interim
# mechanism while releases.hal0.dev does not exist (see
# scripts/release-prototype/RELEASE_PIPELINE_NOTES.md). `sudo` resets the
# environment for this grant (no env_keep in packaging/sudoers/hal0-update),
# so root never inherited that value: `stage` silently re-resolved the
# production default instead, so a custom-URL box passed
# /api/updates/check and then always failed stage against a host
# (releases.hal0.dev) that does not exist yet.
#
# Root does NOT accept the URL via sudo argv or the caller's environment —
# both are an injection surface into a root process. It reads the config
# itself, as root, with no `source`/`eval` of the file (these files also carry
# provider tokens and HAL0_ADMIN_KEY/HAL0_CLIENT_KEY): exactly the
# ``HAL0_RELEASES_URL=`` line is grep+cut out, and it must look like an
# accepted URL before it is exported into the Python re-entry's environment.
# A present-but-malformed value dies loudly here rather than silently falling
# back to the default — that silent fallback is the exact bug #1690 fixed.
#
# #1750 — the two config files are NOT equally trusted, and an earlier version
# of this comment wrongly called api.env a root-owned trusted config that root
# and the daemon both read. It is not: src/hal0/install/perms.py builds the
# /etc/hal0 rows with ``etc_owner = service_user if flipped else "root"`` and
# ``service_user="hal0"`` is the DEFAULT (the hardened flip), so on a shipped
# box /etc/hal0/api.env is hal0:hal0 0600 — writable by the very unprivileged
# account this sudo grant exists to contain. Hence two sources, two trust
# levels:
#
#   /etc/hal0/update.conf  root:root 0644 (its own perms.py PermRow, optional)
#       Genuinely root-owned, so it may name any accepted scheme including
#       file://. Checked first and authoritative — a value here cannot be
#       overridden from the service-owned file.
#   /etc/hal0/api.env      service-owned under the flip
#       Still honoured, because #1690's interim mechanism lives there and
#       operators rely on it while releases.hal0.dev does not exist — but
#       https:// ONLY. A file:// from here would let a process compromised as
#       hal0 point ROOT's manifest fetch at an arbitrary local path
#       (Path(...).read_bytes()/shutil.copyfile on the Python side), so it is
#       refused with a pointer at update.conf.
#
# Cosign identity+issuer pinning still backstops whatever URL wins; it is not
# asked to be the only thing standing between the service account and root.
resolve_releases_url() {
  local etc_root="/etc/hal0"
  # HAL0_HOME sandboxes the whole config root for dev installs + integration
  # tests (src/hal0/config/paths.py: etc()/api_env()) — mirrored here so a
  # sandboxed run resolves the exact files the Python side resolves,
  # never a real box's /etc/hal0.
  if [[ -n "${HAL0_HOME:-}" ]]; then
    etc_root="${HAL0_HOME}/etc/hal0"
  fi

  local raw
  # Root-owned file first: it may name file:// as well as https://.
  raw="$(read_releases_url "${etc_root}/update.conf")"
  if [[ -n "${raw}" ]]; then
    case "${raw}" in
      https://*|file://*) export HAL0_RELEASES_URL="${raw}"; return 0 ;;
      *) die "bad HAL0_RELEASES_URL in ${etc_root}/update.conf (must be https:// or file://): ${raw}" ;;
    esac
  fi

  # Service-owned fallback: https:// only.
  raw="$(read_releases_url "${etc_root}/api.env")"
  [[ -n "${raw}" ]] || return 0
  case "${raw}" in
    https://*) export HAL0_RELEASES_URL="${raw}" ;;
    file://*)
      die "refusing a file:// HAL0_RELEASES_URL from ${etc_root}/api.env (service-owned; put it in ${etc_root}/update.conf, root:root 0644): ${raw}"
      ;;
    *) die "bad HAL0_RELEASES_URL in ${etc_root}/api.env (must be https://): ${raw}" ;;
  esac
}

# Echo the last ``HAL0_RELEASES_URL=`` value in a KEY=VALUE file, or nothing.
read_releases_url() {
  local file="$1" raw
  [[ -r "${file}" ]] || return 0

  # `|| true`: grep exits 1 on no match (the common case — most boxes never
  # set this override), and with `pipefail` that would otherwise trip
  # `set -e` and kill the whole seam with an opaque rc=1 before `stage` ever
  # runs. No match is not an error here, just "no override configured".
  raw="$(grep -E '^HAL0_RELEASES_URL=' -- "${file}" 2>/dev/null | tail -n1 | cut -d'=' -f2-)" || true
  raw="${raw%$'\r'}"
  # Both files are plain KEY=VALUE; strip one layer of matching quotes if the
  # operator added them (systemd's EnvironmentFile= parser does the same).
  case "${raw}" in
    \"*\") raw="${raw#\"}"; raw="${raw%\"}" ;;
    \'*\') raw="${raw#\'}"; raw="${raw%\'}" ;;
  esac
  printf '%s' "${raw}"
}

cmd="${1:-help}"; shift || true

case "${cmd}" in
  check)                      # non-mutating liveness probe (doctor + preflight)
    exec "${PY}" -I -m hal0.updater.privileged check
    ;;

  stage)                      # stage <channel> [version]
    channel="${1:-}"; validate_channel "${channel}"
    version="${2:-}"
    resolve_releases_url      # #1690 — same-origin HAL0_RELEASES_URL as the daemon
    if [[ -n "${version}" ]]; then
      validate_version "${version}"
      exec "${PY}" -I -m hal0.updater.privileged stage "${channel}" "${version}"
    fi
    exec "${PY}" -I -m hal0.updater.privileged stage "${channel}"
    ;;

  activate)                   # activate <hal0-VERSION>
    dir_name="${1:-}"; validate_dir_name "${dir_name}"
    exec "${PY}" -I -m hal0.updater.privileged activate "${dir_name}"
    ;;

  discard)                    # discard <hal0-VERSION>   (rm -rf semantics)
    dir_name="${1:-}"; validate_dir_name "${dir_name}"
    exec "${PY}" -I -m hal0.updater.privileged discard "${dir_name}"
    ;;

  help|"")
    cat <<EOF
usage: hal0-update <command> [args]
  check                            prove the grant resolves (no side effects)
  stage <channel> [version]        download + sha256 + cosign + extract a release
                                   channel: stable|preview|nightly
  activate <hal0-VERSION>          swap ${LIB_ROOT}/current + re-pip the venv
  discard <hal0-VERSION>           remove a staged ${LIB_ROOT}/<dir> tree
EOF
    ;;

  *) die "bad cmd: ${cmd}" ;;
esac
