#!/bin/sh
#
# repo-binding-heal — converge this box's SHCP repo binding to the canonical
# single-writer shape (SC-493), best-effort, on shcp-keyring / shcp-release
# install and upgrade. master#787.
#
# Boxes installed before SC-493 keep an shcp.list whose signed-by still points
# at the retired /usr/share/keyrings/shcp-archive.gpg (deb), or an shcp.repo
# with a literal baseurl and a url gpgkey and no /etc/dnf/vars (rpm). The next
# key rotation shipped via `apt upgrade shcp-keyring` / `dnf upgrade
# shcp-release` lands the key only at the NEW payload path and breaks the
# package manager. This rewrites the binding to the format the bootstrap
# packages own, routing through the SAME canonical body the package defines,
# while PRESERVING the box's bound base URL + suite/series (re-deriving the
# series is the update engine's job, SC-249).
#
# It runs from the package's postinst (deb) / %post (rpm) AFTER the new signing
# key is unpacked, so the deb arm repoints trust only once the key it needs is
# present.
#
# FAIL-OPEN, ALWAYS. A bootstrap-package maintainer script that fails would
# brick package management — the exact thing this protects. Every path that
# cannot complete the heal leaves the binding untouched (or restored) and the
# script exits 0. There is deliberately no `set -e`. Idempotent: it byte-
# compares first, so a converged box is a zero-write no-op.
#
# The canonical bodies rendered below MIRROR shcp-build/tests/fixtures/repo-
# binding/ and shcp-installer/functions/packages.sh; the test suite asserts
# byte-parity against that fixture, which is this repo's single-writer
# discipline for the binding (multiple render sites kept in lockstep by the
# shared fixture).

# Never pathname-expand a token parsed out of an untrusted binding file. There
# is no glob anywhere in this script, so disabling it is pure hardening.
set -f

CANONICAL_KEYRING='/usr/share/keyrings/shcp-archive-keyring.gpg'
RPM_KEYFILE='/etc/pki/rpm-gpg/RPM-GPG-KEY-shcp'

log() { echo "shcp-repo-binding-heal: $*" >&2; }

# --- canonical bodies (render sites; fixture-asserted) ----------------------

# == tests/fixtures/repo-binding/shcp.list.expected, @BASEURL@=$1 @SUITE@=$2.
render_shcp_list() {
	printf '%s\n' \
		'# SHCP apt repository' \
		'# managed by shcp-keyring (do not edit; remove file to opt out)' \
		'# Codename and series are bound at install time.' \
		"deb [signed-by=${CANONICAL_KEYRING}] ${1}/apt ${2} main"
}

# == tests/fixtures/repo-binding/shcp.repo.expected (static; all host variance
# lives in /etc/dnf/vars, never in this body). The $shcp* tokens are dnf vars.
render_shcp_repo() {
	# shellcheck disable=SC2016  # $shcpbase/$shcpel/$shcpseries are dnf vars, literal by design
	printf '%s\n' \
		'[shcp]' \
		'name=SHCP Hosting Control Panel' \
		'baseurl=$shcpbase/rpm/el$shcpel/$shcpseries/' \
		'enabled=1' \
		'priority=10' \
		'gpgcheck=1' \
		'repo_gpgcheck=1' \
		'module_hotfixes=1' \
		'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp'
}

# --- pure parsers (ports of shcp-installer parse_apt_binding_line /
#     parse_rpm_baseurl; same accept/reject contract) --------------------------

# stdin: nothing. $1: an active `deb ...` line. stdout: "<base>\t<suite>".
parse_apt_line() {
	_line="$(printf '%s' "$1" | tr -d '\r')"
	printf '%s\n' "$_line" | grep -qE '^[[:space:]]*deb[[:space:]]' || return 1
	# Drop the first [ ... ] option group whole (it can contain spaces). awk
	# does the field split, so a stray metacharacter is never expanded.
	_stripped="$(printf '%s' "$_line" | sed -E 's/\[[^]]*\]//')"
	_url="$(printf '%s' "$_stripped" | awk '{print $2}')"
	_suite="$(printf '%s' "$_stripped" | awk '{print $3}')"
	[ -n "$_url" ] && [ -n "$_suite" ] || return 1
	# Suite sanity gate: <codename>, <codename>-<X.Y>, slash suites; reject
	# glob/space/shell metacharacters rather than preserve them into a rewrite.
	printf '%s' "$_suite" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._/-]*$' || return 1
	_base="${_url%/}"
	case "$_base" in
		*/apt)    _base="${_base%/apt}" ;;
		*/debian) _base="${_base%/debian}" ;;
		*)        return 1 ;;  # unknown path shape — refuse rather than guess
	esac
	printf '%s' "$_base" | grep -qE '^https?://[^[:space:]$]+$' || return 1
	case "$_base" in */) return 1 ;; esac
	printf '%s\t%s\n' "$_base" "$_suite"
}

# $1: a baseurl value. stdout: "<base>\t<major>\t<series>".
parse_rpm_baseurl() {
	_url="$(printf '%s' "$1" | tr -d '\r')"
	_url="${_url%/}"
	case "$_url" in *"/rpm/el"*) ;; *) return 1 ;; esac
	_base="${_url%%/rpm/el*}"
	_tail="${_url##*/rpm/el}"      # "<major>/<series>"
	_major="${_tail%%/*}"
	_series="${_tail#*/}"
	printf '%s' "$_base" | grep -qE '^https?://[^[:space:]$]+$' || return 1
	printf '%s' "$_series" | grep -qE '^[0-9]+\.[0-9]+$' || return 1
	printf '%s\t%s\t%s\n' "$_base" "$_major" "$_series"
}

# baseurl read ONLY from inside the [shcp] stanza — a hand-added
# [shcp-source]/[shcp-debuginfo] stanza with its own baseurl must not rebind us.
shcp_stanza_baseurl() {
	awk '
		/^[[:space:]]*\[/ { in_shcp = ($0 ~ /^[[:space:]]*\[shcp\][[:space:]]*$/) }
		in_shcp && tolower($0) ~ /^[[:space:]]*baseurl[[:space:]]*=/ {
			sub(/^[^=]*=/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit
		}' "$1"
}

# Is the on-disk shcp.repo cleanly ours to heal? Returns 0 ONLY when it holds
# exactly one stanza, [shcp]; every key in it is a managed SHCP key (no
# operator additions like proxy=/sslverify=); and its gpgkey references the
# SHCP signing key. This mirrors the deb arm's "only heal a cleanly-ours file":
# an operator-customised .repo (an extra key, or any extra stanza such as
# [shcp-source]) is the operator's to reconcile, and a genuinely-foreign .repo
# is never touched. We do NOT gate on a new marker — a pre-SC-493 box predates
# it, so that would skip the exact boxes we must heal; the [shcp]-stanza + shcp
# gpgkey + managed-key-allowlist signal reaches them while sparing operator edits.
rpm_repo_is_clean_shcp() {
	[ -f "$1" ] && [ -r "$1" ] || return 1
	# AWK implementations disagree on embedded NULs (mawk can truncate a
	# record before the byte), so reject them from a byte-level preflight.
	# Check each tool status: a failed read or detector is dirty, never clean.
	_hex="$(LC_ALL=C od -An -v -t x1 "$1" 2>/dev/null)" || return 1
	if printf '%s\n' "$_hex" | LC_ALL=C grep -Eq '(^|[[:space:]])00([[:space:]]|$)'; then
		return 1
	else
		_rc=$?
		[ "$_rc" -eq 1 ] || return 1
	fi
	LC_ALL=C awk '
		BEGIN { nstanza = 0; stanza = ""; ok = 1;
		        allowed = " name baseurl enabled priority gpgcheck repo_gpgcheck module_hotfixes gpgkey "
		        fixed["enabled"] = "1"; fixed["priority"] = "10"; fixed["gpgcheck"] = "1"
		        fixed["repo_gpgcheck"] = "1"; fixed["module_hotfixes"] = "1" }
		/^[[:space:]]*(#|;)/ { next }        # comment
		/^[[:space:]]*$/     { next }        # blank
		/^[[:space:]]*\[/ {
			nstanza++
			s = $0; sub(/^[[:space:]]*\[/, "", s); sub(/\][[:space:]]*$/, "", s)
			stanza = s
			if (s != "shcp") ok = 0          # any non-[shcp] stanza -> not clean
			next
		}
		{
			if (stanza != "shcp") { ok = 0; next }   # key outside [shcp]
			eq = index($0, "=")
			if (eq == 0) { ok = 0; next }
			key = substr($0, 1, eq - 1); value = substr($0, eq + 1)
			gsub(/^[ \t\r]+|[ \t\r]+$/, "", key)
			gsub(/^[ \t\r]+|[ \t\r]+$/, "", value)
			key = tolower(key)
			if (key == "" || index(allowed, " " key " ") == 0) { ok = 0; next }
			seen[key]++
			if (seen[key] != 1) ok = 0
			if (key in fixed && value != fixed[key]) ok = 0
			if (key == "gpgkey") {
				if (value == "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp") next
				if (value !~ /^https?:\/\/[^\/?#[:space:]]+(\/[^?#[:space:]]*)*\/shcp-repo[.]gpg[.]key$/) ok = 0
			}
		}
		END {
			if (nstanza != 1 || seen["gpgkey"] != 1) ok = 0
			if (!ok) exit 1
		}
	' "$1"
}

# Box EL major from /etc/os-release VERSION_ID. Sourced in a subshell (os-release
# is arbitrary KEY=VALUE shell — never into our shell).
rpm_box_major() {
	_osr="${SHCP_RB_OS_RELEASE:-/etc/os-release}"
	[ -r "$_osr" ] || return 1
	# shellcheck source=/dev/null  # os-release is host data, sourced in a subshell
	_vid="$( . "$_osr" 2>/dev/null; printf '%s' "${VERSION_ID:-}" )"
	_maj="${_vid%%.*}"
	[ -n "$_maj" ] || return 1
	printf '%s' "$_maj"
}

# Atomic single-value write: tmp + rename in the target's own directory.
write_var() {
	_f="$1"; _v="$2"
	_d="$(dirname "$_f")"
	_t="$(mktemp "$_d/.shcp-var.XXXXXX" 2>/dev/null)" || { log "mktemp var failed"; return 1; }
	printf '%s\n' "$_v" > "$_t" 2>/dev/null || { rm -f "$_t"; return 1; }
	chmod 0644 "$_t" 2>/dev/null
	mv -f "$_t" "$_f" 2>/dev/null || { rm -f "$_t"; return 1; }
	return 0
}

# --- deb arm ----------------------------------------------------------------
heal_deb() {
	LIST="${SHCP_RB_APT_LIST:-/etc/apt/sources.list.d/shcp.list}"
	OLDKEY="${SHCP_RB_APT_OLDKEY:-/usr/share/keyrings/shcp-archive.gpg}"
	NEWKEY="${SHCP_RB_APT_NEWKEY:-$CANONICAL_KEYRING}"

	[ -f "$LIST" ] || return 0
	# The new keyring must be present before we repoint signed-by at it.
	[ -s "$NEWKEY" ] || { log "new keyring ${NEWKEY} absent — skipping deb heal"; return 0; }

	_active="$(grep -E '^[[:space:]]*deb[[:space:]]' "$LIST" 2>/dev/null | head -n1)"
	[ -n "$_active" ] || return 0

	# Ownership gate (stricter than the interactive tool, for an unattended
	# context): only heal a file we recognise as ours — it carries the managed
	# marker, or its signed-by points at an SHCP keyring path (old or new). Never
	# rewrite an operator's unrelated file that merely sits at shcp.list.
	if ! grep -q '^# managed by shcp-keyring' "$LIST" 2>/dev/null \
	   && ! printf '%s' "$_active" | grep -qF "signed-by=${OLDKEY}" \
	   && ! printf '%s' "$_active" | grep -qF "signed-by=${NEWKEY}"; then
		log "${LIST} not recognisably SHCP-owned (no marker, no SHCP signed-by) — skipping"
		return 0
	fi

	_parsed="$(parse_apt_line "$_active")" || { log "unparseable deb line — skipping"; return 0; }
	_base="$(printf '%s' "$_parsed" | cut -f1)"
	_suite="$(printf '%s' "$_parsed" | cut -f2)"

	_canonical="$(render_shcp_list "$_base" "$_suite")"

	# Converged: body already canonical AND the retired key is gone.
	if [ ! -e "$OLDKEY" ] && [ "$(cat "$LIST")" = "$_canonical" ]; then
		return 0
	fi

	_dir="$(dirname "$LIST")"
	_tmp="$(mktemp "$_dir/.shcp-rb.XXXXXX" 2>/dev/null)" || { log "mktemp failed — skipping"; return 0; }
	printf '%s\n' "$_canonical" > "$_tmp" 2>/dev/null || { rm -f "$_tmp"; return 0; }
	chmod 0644 "$_tmp" 2>/dev/null
	_bak="${LIST}.pre-heal.$$"
	cp -p "$LIST" "$_bak" 2>/dev/null || { rm -f "$_tmp"; log "backup failed — leaving untouched"; return 0; }
	if ! mv -f "$_tmp" "$LIST" 2>/dev/null; then
		rm -f "$_tmp"; mv -f "$_bak" "$LIST" 2>/dev/null; log "install failed — restored"; return 0
	fi
	if ! grep -q '^# managed by shcp-keyring' "$LIST" 2>/dev/null \
	   || ! grep -qF "signed-by=${CANONICAL_KEYRING}" "$LIST" 2>/dev/null; then
		mv -f "$_bak" "$LIST" 2>/dev/null; log "post-write validation failed — restored"; return 0
	fi
	rm -f "$_bak"
	rm -f "$OLDKEY" 2>/dev/null
	log "healed ${LIST} (base ${_base}, suite ${_suite})"
	return 0
}

# --- rpm arm ----------------------------------------------------------------
heal_rpm() {
	REPO="${SHCP_RB_RPM_REPO:-/etc/yum.repos.d/shcp.repo}"
	VARS="${SHCP_RB_RPM_VARS:-/etc/dnf/vars}"
	KEYFILE="${SHCP_RB_RPM_KEY:-$RPM_KEYFILE}"

	[ -f "$REPO" ] || return 0
	[ -s "$KEYFILE" ] || { log "rpm key ${KEYFILE} absent — skipping rpm heal"; return 0; }

	# Non-destructive of operator config: heal only a cleanly-ours .repo. A file
	# carrying operator additions (proxy=, an extra stanza) is left byte-untouched
	# — clobbering it could sever a proxy-dependent box from the repo, worse than
	# not healing. That box is the operator's to reconcile.
	if ! rpm_repo_is_clean_shcp "$REPO"; then
		log "${REPO} carries operator additions or is not cleanly SHCP-owned — skipping"
		return 0
	fi

	_box_major="$(rpm_box_major)" || { log "cannot determine EL major — skipping"; return 0; }
	case "$_box_major" in
		10) ;;
		*) log "unsupported EL major ${_box_major} — skipping"; return 0 ;;
	esac

	# base + series: prefer existing dnf vars, else parse the literal baseurl in
	# the [shcp] stanza. Series is preserved; the major is always the box's, with
	# an old-vs-box mismatch refused (an in-place EL upgrade is beyond a heal).
	_base=''; _series=''
	[ -s "$VARS/shcpbase" ]   && _base="$(head -n1 "$VARS/shcpbase"   | tr -d '\r')"
	[ -s "$VARS/shcpseries" ] && _series="$(head -n1 "$VARS/shcpseries" | tr -d '\r')"
	_cur="$(shcp_stanza_baseurl "$REPO")"
	if [ -n "$_cur" ] && _p="$(parse_rpm_baseurl "$_cur")"; then
		_pbase="$(printf '%s' "$_p" | cut -f1)"
		_pmajor="$(printf '%s' "$_p" | cut -f2)"
		_pseries="$(printf '%s' "$_p" | cut -f3)"
		[ -z "$_base" ]   && _base="$_pbase"
		[ -z "$_series" ] && _series="$_pseries"
		case "$_pmajor" in
			''|*[!0-9]*) ;;
			*) [ "$_pmajor" != "$_box_major" ] && { log "repo says el${_pmajor} but box is el${_box_major} — skipping"; return 0; } ;;
		esac
	fi
	printf '%s' "$_base"   | grep -qE '^https?://[^[:space:]$]+$' || { log "cannot determine repo base URL — skipping"; return 0; }
	printf '%s' "$_series" | grep -qE '^[0-9]+\.[0-9]+$'          || { log "cannot determine bound series — skipping"; return 0; }

	_canonical="$(render_shcp_repo)"

	# Litter on the binding surface — removable independent of the body state.
	_litter=''
	for _f in "$REPO.rpmnew" "$REPO.rpmsave" "$REPO.rpmorig"; do
		[ -e "$_f" ] && _litter="$_litter $_f"
	done

	# Converged: .repo == canonical AND the three vars match AND no litter.
	if [ -z "$_litter" ] \
	   && [ "$(cat "$REPO")" = "$_canonical" ] \
	   && [ "$(cat "$VARS/shcpbase" 2>/dev/null)"   = "$_base" ] \
	   && [ "$(cat "$VARS/shcpel" 2>/dev/null)"     = "$_box_major" ] \
	   && [ "$(cat "$VARS/shcpseries" 2>/dev/null)" = "$_series" ]; then
		return 0
	fi

	# Vars durable FIRST (the .repo references them; a missing var aborts every
	# dnf transaction), then the .repo atomically, then the litter.
	mkdir -p "$VARS" 2>/dev/null || { log "cannot create ${VARS} — skipping"; return 0; }
	write_var "$VARS/shcpbase"   "$_base"      || { log "shcpbase write failed — skipping"; return 0; }
	write_var "$VARS/shcpel"     "$_box_major" || { log "shcpel write failed — skipping"; return 0; }
	write_var "$VARS/shcpseries" "$_series"    || { log "shcpseries write failed — skipping"; return 0; }

	_dir="$(dirname "$REPO")"
	_tmp="$(mktemp "$_dir/.shcp-rb.XXXXXX" 2>/dev/null)" || { log "mktemp failed — skipping"; return 0; }
	printf '%s\n' "$_canonical" > "$_tmp" 2>/dev/null || { rm -f "$_tmp"; return 0; }
	chmod 0644 "$_tmp" 2>/dev/null
	_bak="${REPO}.pre-heal.$$"
	cp -p "$REPO" "$_bak" 2>/dev/null || { rm -f "$_tmp"; log "backup failed — leaving untouched"; return 0; }
	if ! mv -f "$_tmp" "$REPO" 2>/dev/null; then
		rm -f "$_tmp"; mv -f "$_bak" "$REPO" 2>/dev/null; log "install failed — restored"; return 0
	fi
	if ! grep -q '^gpgkey=file://' "$REPO" 2>/dev/null; then
		mv -f "$_bak" "$REPO" 2>/dev/null; log "post-write validation failed — restored"; return 0
	fi
	rm -f "$_bak"
	for _f in $_litter; do rm -f "$_f" 2>/dev/null; done
	log "healed ${REPO} + dnf vars (el${_box_major}/${_series})"
	return 0
}

# Both arms self-guard on their binding file, so a box only ever runs the one
# that applies. Exit 0 unconditionally — fail-open is the contract.
heal_deb
heal_rpm
exit 0
