#!/bin/sh
# Hoody CLI installer (POSIX). Verifies a minisign-signed channel.json before
# downloading + installing the per-OS/arch binary.
#
# Generated for domain=hoody.icu on 2026-04-30T21:46:01Z
#
# Pinned trust anchor (must equal the domain's CDN_BIN_MINISIGN_PUBKEY).
# Substituted by Phase 6b2.5d at deploy time.
PINNED_PUBKEY="RWRpngXprNcILzU05dVs9Bkws5tS4KqPkg2FVm7vnS1sowCpg42ax3Ek"
# Comma-separated install hosts that this script trusts (from CDN_BIN_HOSTS):
BIN_HOSTS="install.hoody.icu,bin.hoody.icu,dl.hoody.icu,download.hoody.icu"
# Domain pin (informational; baked into the binary at compile time):
HOODY_DOMAIN="hoody.icu"

# Round 4 B3: minisign verifier-tool pin lines REQUIRED by hoody-cdn's
# startup invariant `verify_pins_against_tools` (src/distribution/startup.rs:1086).
# The MINISIGN_SHA256_LINUX_X86_64 (and friends) lines below are substituted by
# hoody-cdn AT STARTUP (NOT by Phase 6b2.5d) with the actual SHA-256 of each
# on-disk verifier binary in /data/storage/hoody-cdn/dist/bin/tools/. The
# regex matching these lines is install_pin.rs::sh_re(), strict format:
#   MINISIGN_SHA256_LINUX_X86_64="<64-hex>"
# (anchored, no leading whitespace, lowercase hex). Each placeholder line
# whose os/arch lacks an on-disk verifier is replaced with a comment by
# hoody-cdn so parse_pins's smuggle-rejector doesn't trip.
# NB: do NOT write the literal double-open-curly token anywhere in this
# script except as one of the recognized placeholder names (CDN_BASE or
# MINISIGN_SHA256_*). Any other occurrence survives substitute_placeholders
# and trips startup.rs:241's "any unresolved placeholder?" check, which
# refuses to serve install.sh.
MINISIGN_SHA256_LINUX_X86_64="2c74dffcc1c9a5ee55957c60971998ace2b89f22585631594ec2152c588af8db"
MINISIGN_SHA256_LINUX_AARCH64="cec9f88be8c975af76854a53b4d49c3d257feae38d916edb0d16fb55aacd3000"
MINISIGN_SHA256_DARWIN_X86_64="1c6e686763361a407b237e95cf8f6d9511d6b660b900a68e6bd9cc494bddcfc7"
MINISIGN_SHA256_DARWIN_AARCH64="1c6e686763361a407b237e95cf8f6d9511d6b660b900a68e6bd9cc494bddcfc7"
MINISIGN_SHA256_WINDOWS_X86_64="5535be9e4e123831ebe6ef324aafe9dde507015c176191f9e20c3ad60567f9e1"

# Realm-canonical bin host. https://install.hoody.icu is substituted by hoody-cdn at startup
# from the realm's CanonicalHost (e.g. https://bin.hoody.icu); used downstream
# when we wire up the actual verify-then-download flow in Phase 4.
DEFAULT_CDN_BASE="https://install.hoody.icu"

set -eu

err() { printf "install: %s\n" "$1" >&2; exit 1; }

# Verify the placeholders were substituted at deploy time. If any token of
# the form __FOO__ remains, the deploy step (Phase 6b2.5d) failed; abort.
case "$PINNED_PUBKEY" in *__*__*) err "PINNED_PUBKEY placeholder not substituted (deploy bug)";; esac
case "$BIN_HOSTS"     in *__*__*) err "BIN_HOSTS placeholder not substituted (deploy bug)";; esac
case "$HOODY_DOMAIN"  in *__*__*) err "HOODY_DOMAIN placeholder not substituted (deploy bug)";; esac

# Detect OS + arch (canonical labels: x86_64, aarch64).
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
  x86_64|amd64) arch="x86_64" ;;
  aarch64|arm64) arch="aarch64" ;;
  *) err "unsupported arch: $arch" ;;
esac
case "$os" in
  linux|darwin) ;;
  *) err "unsupported os: $os" ;;
esac

# Choose archive name. The bin matrix ships:
#   linux-x86_64 (glibc), linux-x86_64-musl, linux-aarch64 (glibc),
#   darwin-x86_64, darwin-aarch64
# Alpine on aarch64 (musl + ARM64) is NOT shipped — reject explicitly so
# users see a clear message instead of a 404.
suffix=""
if [ "$os" = "linux" ] && [ -f /etc/alpine-release ]; then
  if [ "$arch" = "aarch64" ]; then
    err "linux aarch64-musl is not yet released. Use Alpine on x86_64 or a glibc-based distro."
  fi
  suffix="-musl"
fi
archive_name="hoody-${os}-${arch}${suffix}.tar.gz"

# sha256 helper: GNU coreutils (linux) ships sha256sum; macOS / BSD ship
# shasum -a 256 (gemini G6 portability fix). Pick whichever is available.
if command -v sha256sum >/dev/null 2>&1; then
  sha256() { sha256sum "$@"; }
elif command -v shasum >/dev/null 2>&1; then
  sha256() { shasum -a 256 "$@"; }
else
  err "neither sha256sum nor shasum found — install one (apt: coreutils, brew: shasum)"
fi

# Test-only cacert override. Production users never set this; the e2e
# fixture uses HOODY_INSTALL_CACERT=/path/to/ca.pem to exercise the
# installer against a local TLS test server. Hoisted above the host probe
# so the probe AND the Phase 4 fetch helpers all use the same trust
# anchor. Never expose a scheme override (HOODY_INSTALL_SCHEME or similar)
# — that would let an env-hostile environment downgrade trust silently.
#
# Round 10 angle-05 MED: store as a `_cacert_path` (single value) instead
# of `_curl_extra="--cacert $path"`. The latter, when expanded unquoted
# (`$_curl_extra`), word-splits the path on whitespace — a CACERT path
# containing spaces becomes multiple curl args. Production paths rarely
# have spaces but Windows-style "Program Files" under WSL trips it.
# Helpers below pass it via positional `set --` so the value stays one
# arg.
_cacert_path=""
if [ -n "${HOODY_INSTALL_CACERT:-}" ]; then
  [ -r "$HOODY_INSTALL_CACERT" ] || err "HOODY_INSTALL_CACERT=$HOODY_INSTALL_CACERT not readable (test-only env var)"
  _cacert_path="$HOODY_INSTALL_CACERT"
fi
# Common curl invocation: strict TLS, redirect-on, fail-on-4xx/5xx, plus
# Round 10 angle-05 MED: bound time + redirect hops so a hostile/slow peer
# can't wedge the install. Passes `--cacert "$_cacert_path"` ONLY when set.
curl_strict() {
  # First arg is the verb (`-fsSI` for HEAD probe, `-fsSL` for fetch).
  # Remaining are appended.
  _verb="$1"; shift
  if [ -n "$_cacert_path" ]; then
    curl "$_verb" --proto '=https' --tlsv1.2 --max-time 600 --max-redirs 5 --connect-timeout 30 --cacert "$_cacert_path" "$@"
  else
    curl "$_verb" --proto '=https' --tlsv1.2 --max-time 600 --max-redirs 5 --connect-timeout 30 "$@"
  fi
}

# Pick the first reachable bin host (operator-controlled list). Convert
# the comma-CSV to whitespace-separated up front so the for-loop iterates
# under DEFAULT IFS — keeping `$_curl_extra` (and any other shell-split
# expansion below) splitting on whitespace as expected. A previous
# IFS=',' approach broke `--cacert /path/to/ca` arg splitting, since
# under IFS=',' the entire string passed to curl as a single argument.
hosts_list=$(printf "%s" "$BIN_HOSTS" | tr ',' ' ')
host=""
for h in $hosts_list; do
  h=$(printf "%s" "$h" | sed 's/^ *//;s/ *$//')
  [ -z "$h" ] && continue
  # Tighter probe timeout — a hung host shouldn't block trying the next.
  # We override --max-time to 15s for the probe specifically.
  if curl_strict -fsSI --max-time 15 "https://${h}/channel.json" >/dev/null 2>&1; then
    host="$h"
    break
  fi
done
[ -z "$host" ] && err "no install host reachable: tried $BIN_HOSTS"
printf "install: using host=%s archive=%s\n" "$host" "$archive_name"

# (Real installer would: download channel.json + .minisig, verify with PINNED_PUBKEY,
#  download the per-version archive, verify SHA256SUMS.minisig, extract, install,
#  and write ${HOME}/.config/hoody/domain symlink-safely.)
#
# This template is the BOOTSTRAP shape for the deploy-time substitution flow.
# The full verifier is implemented as part of Phase 4 (deploy script) + the
# bin role on hoody-cdn (per plans/package-distribution.md).

# Persist the active domain (symlink-safe write — refuses if target is a symlink).
# Round 4 H7: harden against pre-placed .tmp symlink. Old code wrote
#   `printf >"$domain_file.tmp"`
# without lstat-checking the .tmp path; an attacker can pre-create
# `~/.config/hoody/domain.tmp` as a symlink to e.g. `/tmp/loot` and the
# redirection silently follows the symlink. Defense:
#   1. `set -C` (noclobber) refuses `> existing-file`
#   2. unlink any pre-existing .tmp BEFORE the open, with an lstat guard
#      so we don't follow a planted symlink while unlinking
# Round 5 angle-03 #3 + #5 + angle-09 #5: tighten the dir/file write path:
#   - umask 077 in a subshell so the .tmp file is born 0600 instead of 0644
#     (closing the create→chmod race where another local UID could open the
#     file mid-flight), AND mkdir creates 0700 from the start.
#   - portable b64dec dispatch (GNU base64 -d vs BSD base64 -D vs openssl).
#   - validate written byte count (set -C only blocks pre-existing files;
#     a partial printf on ENOSPC/SIGPIPE leaves a truncated .tmp that mv -f
#     would atomically install).
b64dec() {
  # v11 R6 LOW carryover: prior dispatch grepped `base64 --help` for `-d`
  # vs `-D`, which mis-routes on stripped Alpine BusyBox builds whose
  # help text omits both. Use the OS as the dispatch key — Darwin BSD
  # base64 wants `-D`, everything else (Linux glibc + musl + BusyBox + WSL)
  # uses `-d`. openssl is the universal fallback if base64 -d errors out
  # at runtime (BusyBox without coreutils-style options).
  case "$(uname -s)" in
    Darwin) base64 -D "$@" ;;
    *)      base64 -d "$@" 2>/dev/null || openssl base64 -d "$@" ;;
  esac
}
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hoody"
# Round 6 angle-03 #6 + #7: validate parents BEFORE umask-mkdir + chmod.
#   - HOME unset would expand `${HOME:-$HOME/.config}` to `/.config/hoody`
#     (root-only) — error out clearly instead of cryptic mkdir failure.
#   - $config_dir as a pre-existing symlink turns mkdir -p into a no-op
#     and the subsequent chmod follows the link, mutating attacker space.
#     Refuse outright (POSIX `[ -L ]` lstat-checks).
[ -n "${HOME:-}" ] || err "HOME is empty; cannot resolve config dir (rerun with HOME set)"
if [ -L "$config_dir" ]; then
  err "$config_dir is a symlink; refusing to mkdir into a planted target (TOCTOU defense)"
fi
( umask 077; mkdir -p "$config_dir" )
chmod 700 "$config_dir" 2>/dev/null || true
domain_file="$config_dir/domain"
domain_tmp="$domain_file.tmp"
fingerprint_file="$domain_file.fingerprint"
fingerprint_tmp="$fingerprint_file.tmp"
if [ -L "$domain_file" ]; then
  err "$domain_file is a symlink; refusing to overwrite (TOCTOU defense)"
fi
if [ -e "$domain_tmp" ] || [ -L "$domain_tmp" ]; then
  rm -f -- "$domain_tmp" || err "cannot remove stale $domain_tmp (planted symlink? check ownership)"
fi
if [ -e "$fingerprint_tmp" ] || [ -L "$fingerprint_tmp" ]; then
  rm -f -- "$fingerprint_tmp" || err "cannot remove stale $fingerprint_tmp"
fi
if [ -e "$fingerprint_file" ] || [ -L "$fingerprint_file" ]; then
  rm -f -- "$fingerprint_file" || err "cannot remove stale $fingerprint_file"
fi
( umask 077; set -C; printf "%s" "$HOODY_DOMAIN" > "$domain_tmp" ) || err "refusing to clobber $domain_tmp"
# Round 5 angle-03 #1: validate full write (defends against partial-printf
# truncation on ENOSPC, SIGPIPE, etc.). Uses POSIX `wc -c`; expected size is
# the byte length of HOODY_DOMAIN (ASCII-only per RFC-1123).
expected_bytes=$(printf "%s" "$HOODY_DOMAIN" | wc -c | tr -d ' ')
actual_bytes=$(wc -c < "$domain_tmp" | tr -d ' ')
if [ "$expected_bytes" != "$actual_bytes" ]; then
  rm -f -- "$domain_tmp"
  err "domain write incomplete (expected $expected_bytes bytes, got $actual_bytes)"
fi
mv -f "$domain_tmp" "$domain_file"

# Round 4 H2 + Round 5 angle-03 #4 + codex Round-5 #6: write install-time
# pubkey fingerprint. Critical fix: pipe decoded bytes DIRECTLY into sha256
# (NEVER through a shell variable). POSIX shell command substitution strips
# NUL bytes; minisign's 42-byte Ed25519 pubkey contains NUL bytes in some
# positions, so $(b64dec) would silently yield a different byte stream than
# what the runtime fingerprint check (which hashes the real decoded bytes
# in domain.ts:fingerprintForPubkey) computes — every install would produce
# a fingerprint that mismatches its baked counterpart. Use a temp file +
# byte-count check to validate the decode AND keep the bytes intact.
_pk_tmp=$(mktemp) || err "internal: mktemp failed"
# Round 6 angle-03 #5: keep the trap armed across the rm — if rm fails (RO
# remount, exhausted inodes), `trap -` would otherwise leave the decoded
# pubkey bytes on disk indefinitely. The pubkey is public (printed in
# every install.sh fetch), but operator hygiene still says "leave nothing
# behind in /tmp." Trap is only cleared in the err path AFTER successful rm.
trap 'rm -f -- "$_pk_tmp" 2>/dev/null || :' EXIT INT TERM
if ! printf "%s" "$PINNED_PUBKEY" | b64dec > "$_pk_tmp" 2>/dev/null; then
  err "internal: base64 decode of PINNED_PUBKEY failed (broken installer)"
fi
_pk_bytes=$(wc -c < "$_pk_tmp" | tr -d ' ')
if [ "$_pk_bytes" != "42" ]; then
  err "internal: decoded PINNED_PUBKEY is $_pk_bytes bytes, expected 42 (broken installer)"
fi
fingerprint=$(sha256 < "$_pk_tmp" | awk '{print $1}')
rm -f -- "$_pk_tmp" || err "internal: failed to remove $_pk_tmp; check /tmp permissions"
trap - EXIT INT TERM
if [ -z "$fingerprint" ] || [ "${#fingerprint}" -ne 64 ]; then
  err "internal: failed to compute pubkey fingerprint (bad PINNED_PUBKEY?)"
fi
( umask 077; set -C; printf "%s\n" "$fingerprint" > "$fingerprint_tmp" ) || err "refusing to clobber $fingerprint_tmp"
mv -f "$fingerprint_tmp" "$fingerprint_file"

printf "install: wrote %s = %s\n" "$domain_file" "$HOODY_DOMAIN"
printf "install: wrote %s = %s…\n" "$fingerprint_file" "$(echo "$fingerprint" | cut -c1-16)"

# =============================================================================
# Phase 4: download + verify + extract + install
# =============================================================================
#
# Trust chain (each step gates the next):
#   PINNED_PUBKEY (trust anchor — signed into install.sh by Phase 6b2.5d
#                  + delivered over HTTPS from a baked-in host)
#     ↓
#   verifier tool (downloaded from /tools/, hash-pinned by
#                   MINISIGN_SHA256_<OS>_<ARCH> baked into install.sh by
#                   hoody-cdn at startup — defends against a compromised
#                   peer CDN serving a tampered minisign binary)
#     ↓
#   channel.json + .minisig (verified by verifier tool with PINNED_PUBKEY,
#                            trusted_comment must equal "hoody-cdn bin version=<latest>")
#     ↓
#   SHA256SUMS + .minisig    (per-version, same key, trusted_comment
#                             "hoody-cdn bin version=<latest>")
#     ↓
#   archive (hash matched against SHA256SUMS line)
#     ↓
#   extracted binary → atomic move to ~/.local/share/hoody/<version>/
#     ↓
#   ~/.local/bin/hoody → symlink (atomic rename swap)
#
# Failure mode: every step fail-closed via `err "…"`. Stage dir + lock
# cleaned via trap on EXIT/INT/TERM. No partial install ever lands.

fetch() {
  # $1 = output path, $2 = url. Routes through curl_strict which carries
  # the strict-TLS flags + bounded timeouts + cacert handling.
  curl_strict -fsSL -o "$1" "$2"
}

# Resolve install destinations. Per XDG, user-level bin lives in
# ~/.local/bin and shared data in ~/.local/share. Both honor XDG_*_HOME.
install_base="${XDG_DATA_HOME:-$HOME/.local/share}/hoody"
bin_link_dir="$HOME/.local/bin"
( umask 022; mkdir -p "$install_base" "$bin_link_dir" )

# Concurrency lock: one install per user at a time. mkdir is atomic
# (POSIX). Trap cleans up the lock dir + staging dir on any exit path.
lock_dir="$install_base/.install.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
  err "another install in progress (lock: $lock_dir). If stale: rmdir $lock_dir"
fi
stage_dir=""
link_tmp=""
cleanup_install() {
  [ -n "${stage_dir:-}" ] && rm -rf -- "$stage_dir" 2>/dev/null || :
  # Round 10 angle-02 HIGH: $link.new.$$ leaks in $bin_link_dir if the
  # symlink swap is interrupted mid-step. The trap removes it explicitly.
  [ -n "${link_tmp:-}" ] && rm -f -- "$link_tmp" 2>/dev/null || :
  rmdir "$lock_dir" 2>/dev/null || :
}
trap 'cleanup_install' EXIT INT TERM

# Round 10 angle-02 BLOCKER: stage UNDER $install_base so the final
# `mv stage final` is rename(2) on the same filesystem (atomic). The
# previous `mktemp -d` (default /tmp) was on a different filesystem
# than ~/.local/share on most Linux setups, degrading the move into
# copy+unlink — partial $final_dir state visible mid-move, and SIGINT
# could leave a half-populated dir that cleanup_install can't see.
stage_parent="$install_base/.staging"
( umask 077; mkdir -p "$stage_parent" )
stage_dir=$(mktemp -d "$stage_parent/install.XXXXXX" 2>/dev/null) || err "internal: mktemp -d under $stage_parent failed"
chmod 700 "$stage_dir"

# -------------------------------------------------------------------------
# Step 1: download the verifier tool, check its hash against the pin.
# -------------------------------------------------------------------------
# The verifier (minisign-<os>-<arch>) is itself the tool we use to verify
# every other signature. Its integrity is bootstrapped by: (a) install.sh
# was fetched over HTTPS from a host we already trust enough to run, and
# (b) hoody-cdn substitutes the actual on-disk SHA-256 into the
# MINISIGN_SHA256_<OS>_<ARCH> line at startup. So if the pinned hash here
# matches what we download, the tool is genuinely the operator-deployed
# binary, not an attacker swap. (Any tampering of the install.sh bytes
# would also tamper with PINNED_PUBKEY → channel.json verify would fail
# downstream.)
verifier_name="minisign-${os}-${arch}"
case "$os-$arch" in
  linux-x86_64)    pinned_tool_hash="$MINISIGN_SHA256_LINUX_X86_64" ;;
  linux-aarch64)   pinned_tool_hash="$MINISIGN_SHA256_LINUX_AARCH64" ;;
  darwin-x86_64)   pinned_tool_hash="$MINISIGN_SHA256_DARWIN_X86_64" ;;
  darwin-aarch64)  pinned_tool_hash="$MINISIGN_SHA256_DARWIN_AARCH64" ;;
  *) err "no verifier-tool hash pin baked for os=$os arch=$arch" ;;
esac
# Detect unsubstituted placeholder text by looking for any single curly
# brace in the value. We deliberately avoid writing the double-open-curly
# token in this script because hoody-cdn refuses to serve any install.sh
# whose post-substitution text still contains the double-open-curly token
# (startup.rs:241).
case "$pinned_tool_hash" in
  ""|*'{'*) err "internal: MINISIGN_SHA256_${os}_${arch} not substituted by hoody-cdn at startup (broken realm setup — operator should restart hoody-cdn)" ;;
esac
case "$pinned_tool_hash" in
  *[!0-9a-f]*) err "internal: pinned_tool_hash $pinned_tool_hash is not lowercase hex (broken installer)" ;;
esac
[ "${#pinned_tool_hash}" -eq 64 ] || err "internal: pinned_tool_hash $pinned_tool_hash is not 64 chars (broken installer)"

verifier_path="$stage_dir/$verifier_name"
fetch "$verifier_path" "https://${host}/tools/${verifier_name}" \
  || err "failed to download verifier ${verifier_name} from https://${host}/tools/"
got_tool_hash=$(sha256 < "$verifier_path" | awk '{print $1}')
[ "$got_tool_hash" = "$pinned_tool_hash" ] \
  || err "verifier hash mismatch: expected ${pinned_tool_hash}, got ${got_tool_hash} (corrupt download or compromised CDN — abort)"
chmod 0755 "$verifier_path"

# -------------------------------------------------------------------------
# Step 2: write the pinned pubkey into a file (minisign -V wants a file).
# -------------------------------------------------------------------------
pub_path="$stage_dir/PINNED_PUBKEY.pub"
( umask 077; printf "untrusted comment: pinned pubkey from install.sh\n%s\n" "$PINNED_PUBKEY" > "$pub_path" )

# -------------------------------------------------------------------------
# Step 3: fetch + verify channel.json (the version manifest).
# -------------------------------------------------------------------------
chan_path="$stage_dir/channel.json"
chan_sig="$stage_dir/channel.json.minisig"
fetch "$chan_path" "https://${host}/channel.json" \
  || err "failed to download channel.json"
fetch "$chan_sig"  "https://${host}/channel.json.minisig" \
  || err "failed to download channel.json.minisig"

if ! "$verifier_path" -V -p "$pub_path" -m "$chan_path" -x "$chan_sig" </dev/null >/dev/null 2>&1; then
  err "channel.json.minisig FAILED verification — your install URL may have been intercepted, OR the realm is misconfigured"
fi

# Parse minimal channel fields. We avoid jq (not always installed); the
# channel schema is fixed and small, so grep-based extraction is safe.
chan_schema=$(grep -oE '"schema_version"[[:space:]]*:[[:space:]]*[0-9]+' "$chan_path" | grep -oE '[0-9]+' | head -1)
chan_latest=$(grep -oE '"latest"[[:space:]]*:[[:space:]]*"[^"]+"' "$chan_path" | sed -E 's/.*"([^"]+)"$/\1/' | head -1)
chan_notafter=$(grep -oE '"not_after"[[:space:]]*:[[:space:]]*"[^"]+"' "$chan_path" | sed -E 's/.*"([^"]+)"$/\1/' | head -1)

[ "$chan_schema" = "1" ] || err "channel.json schema_version='${chan_schema}' (expected 1) — installer too old or realm misconfigured"
[ -n "$chan_latest" ] || err "channel.json missing 'latest' field"
[ -n "$chan_notafter" ] || err "channel.json missing 'not_after' field"
case "$chan_latest" in
  ..|.|*..*) err "channel.json 'latest' contains '..' / '.' (path traversal): ${chan_latest}" ;;
  *[!0-9a-zA-Z._+-]*) err "channel.json 'latest' contains invalid chars: ${chan_latest}" ;;
esac

# Round 10 angle-01 BLOCKER: tighten not_after to ZULU-only ISO 8601
# (`YYYY-MM-DDTHH:MM:SSZ`). Lexicographic compare is correct ONLY for
# this exact shape — fractional seconds (`.500Z`) or non-Z offsets
# (`+00:00`) sort wrongly because `.` (0x2e) sorts before `Z` (0x5a)
# and `+`/`-` sort before digits. Explicit format check before compare
# means a future producer change to a richer format must update this
# parser BEFORE the consumer accepts it.
case "$chan_notafter" in
  [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z) ;;
  *) err "channel.json not_after=${chan_notafter} not in expected RFC-3339 Zulu format YYYY-MM-DDTHH:MM:SSZ" ;;
esac

# Freshness: not_after must be in the future. ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`)
# is lexicographically sortable; awk handles string comparison portably
# (POSIX `[ a < b ]` is not specified for the test command).
now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
if awk -v a="$chan_notafter" -v b="$now_iso" 'BEGIN { exit (a < b ? 0 : 1) }'; then
  err "channel.json EXPIRED: not_after=${chan_notafter} now=${now_iso} (operator must redeploy with a fresh window)"
fi

# trusted_comment binding: must equal "hoody-cdn bin version=<latest>".
chan_trusted=$(grep -E '^trusted comment: ' "$chan_sig" | head -1 | sed 's/^trusted comment: //')
expected_chan_trusted="hoody-cdn bin version=${chan_latest}"
[ "$chan_trusted" = "$expected_chan_trusted" ] \
  || err "channel.json trusted_comment mismatch: expected ${expected_chan_trusted}, got ${chan_trusted} (replay attack?)"

printf "install: channel verified — version=%s\n" "$chan_latest"

# -------------------------------------------------------------------------
# Step 4: fetch + verify SHA256SUMS for that version.
# -------------------------------------------------------------------------
ver="$chan_latest"
sums_path="$stage_dir/SHA256SUMS"
sums_sig="$stage_dir/SHA256SUMS.minisig"
fetch "$sums_path" "https://${host}/${ver}/SHA256SUMS" \
  || err "failed to download SHA256SUMS for version ${ver}"
fetch "$sums_sig"  "https://${host}/${ver}/SHA256SUMS.minisig" \
  || err "failed to download SHA256SUMS.minisig for version ${ver}"
if ! "$verifier_path" -V -p "$pub_path" -m "$sums_path" -x "$sums_sig" </dev/null >/dev/null 2>&1; then
  err "${ver}/SHA256SUMS.minisig FAILED verification"
fi
sums_trusted=$(grep -E '^trusted comment: ' "$sums_sig" | head -1 | sed 's/^trusted comment: //')
expected_sums_trusted="hoody-cdn bin version=${ver}"
[ "$sums_trusted" = "$expected_sums_trusted" ] \
  || err "${ver}/SHA256SUMS trusted_comment mismatch: expected ${expected_sums_trusted}, got ${sums_trusted}"

# Find the line for our archive. SHA256SUMS format: `<hex>  <filename>`.
archive_pinned_hash=$(awk -v f="$archive_name" '$2 == f { print $1; exit }' "$sums_path")
[ -n "$archive_pinned_hash" ] || err "SHA256SUMS for ${ver} has no entry for ${archive_name}"
case "$archive_pinned_hash" in
  *[!0-9a-f]*) err "archive SHA256 line for ${archive_name} is not lowercase hex" ;;
esac
[ "${#archive_pinned_hash}" -eq 64 ] || err "archive SHA256 line for ${archive_name} is not 64 chars"

# -------------------------------------------------------------------------
# Step 5: download archive + verify hash against SHA256SUMS.
# -------------------------------------------------------------------------
archive_path="$stage_dir/$archive_name"
fetch "$archive_path" "https://${host}/${ver}/${archive_name}" \
  || err "failed to download ${archive_name}"
got_archive_hash=$(sha256 < "$archive_path" | awk '{print $1}')
[ "$got_archive_hash" = "$archive_pinned_hash" ] \
  || err "archive hash mismatch for ${archive_name}: expected ${archive_pinned_hash}, got ${got_archive_hash}"
printf "install: archive verified — %s\n" "$archive_name"

# -------------------------------------------------------------------------
# Step 6: extract into a staged tree.
# -------------------------------------------------------------------------
extract_stage="$stage_dir/extracted"
mkdir -p "$extract_stage"
case "$archive_name" in
  *.tar.gz)
    tar -xzf "$archive_path" -C "$extract_stage" \
      || err "tar extract failed for ${archive_name}"
    ;;
  *.zip)
    command -v unzip >/dev/null 2>&1 \
      || err "unzip is required to extract .zip archives (apt install unzip / brew install unzip)"
    unzip -q "$archive_path" -d "$extract_stage" \
      || err "unzip failed for ${archive_name}"
    ;;
  *) err "unsupported archive type for ${archive_name}" ;;
esac

# Round 10 BLOCKER: reject non-regular files BEFORE any -f / chmod. The
# old order `[ -f extracted_bin ] && chmod 0755 extracted_bin && find …`
# follows a planted `hoody → /etc/shadow` symlink: -f follows the link
# (target is a regular file → true) and chmod 0755 then changes mode of
# the LINK TARGET (Linux GNU chmod default follows symlinks). The find
# sweep ran AFTER, too late. Defense-in-depth against an operator-key
# compromise that signed a tarball with planted symlinks: do the sweep
# FIRST, then verify -f AND not-a-symlink (`! -L`) before chmod.
bad_entry=$(find "$extract_stage" -mindepth 1 \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit 2>/dev/null)
if [ -n "$bad_entry" ]; then
  err "extracted archive contains non-regular file ${bad_entry}; refusing to install"
fi
# Top-level entry MUST be a regular file (not a symlink, even one whose
# target happens to be a regular file).
extracted_bin="$extract_stage/hoody"
if [ -L "$extracted_bin" ]; then
  err "${archive_name} top-level 'hoody' is a symlink; refusing to install (corrupt build or compromised producer key?)"
fi
[ -f "$extracted_bin" ] || err "${archive_name} did not contain 'hoody' executable at top level (corrupt build?)"
chmod 0755 "$extracted_bin"

# -------------------------------------------------------------------------
# Step 7: atomic move stage → final, refuse to clobber a different version.
# -------------------------------------------------------------------------
final_dir="$install_base/$ver"
if [ -L "$final_dir" ]; then
  err "$final_dir is a symlink; refusing to install over it"
fi
if [ -d "$final_dir" ]; then
  if [ -f "$final_dir/hoody" ]; then
    existing_hash=$(sha256 < "$final_dir/hoody" | awk '{print $1}')
    fresh_hash=$(sha256 < "$extracted_bin" | awk '{print $1}')
    if [ "$existing_hash" = "$fresh_hash" ]; then
      printf "install: version %s already installed (hash matches); refreshing symlink only\n" "$ver"
    else
      err "$final_dir/hoody exists but its hash differs from the freshly-verified ${ver}; refusing to overwrite. If you intend to reinstall, remove $final_dir manually"
    fi
  else
    err "$final_dir exists but contains no 'hoody' binary (prior install corrupt); remove $final_dir and retry"
  fi
else
  mv "$extract_stage" "$final_dir" \
    || err "failed to move ${extract_stage} → ${final_dir} (cross-fs?)"
fi

# -------------------------------------------------------------------------
# Step 8: atomic symlink swap — ~/.local/bin/hoody → final_dir/hoody.
# -------------------------------------------------------------------------
link="$bin_link_dir/hoody"
link_tmp="${link}.new.$$"
# Plant the new symlink (with -f so a stale link_tmp from a prior aborted
# install is overwritten).
ln -sf "$final_dir/hoody" "$link_tmp" \
  || err "failed to create staged symlink ${link_tmp}"
# rename(2) is atomic and replaces $link in-place (concurrent readers see
# either the old or new symlink, never a half-state). We do NOT pre-check
# whether $link is a regular file vs symlink — the user owns ~/.local/bin
# and may have customized; mv -f overwrites either way.
mv -f "$link_tmp" "$link" \
  || err "failed to install symlink ${link}"

printf "install: hoody %s installed at %s\n" "$ver" "$final_dir/hoody"
printf "install: symlink %s → %s\n" "$link" "$final_dir/hoody"

# PATH advice (non-fatal). Test colon-delimited PATH membership without
# regex (case-glob is portable across shells).
case ":${PATH}:" in
  *":$bin_link_dir:"*) ;;
  *) printf "install: NOTE: %s is not in your PATH. Add it to your shell profile:\n  export PATH=\"%s:\$PATH\"\n" "$bin_link_dir" "$bin_link_dir" ;;
esac

# Cleanup runs via trap on normal exit too.
exit 0
