#!/usr/bin/sh
# SPDX-License-Identifier: AGPL-3.0-only
#
# Attach scradle to the configured devices from inside the initramfs, before
# the root filesystem is mounted.
#
# WHY THIS EXISTS
#
# Persistent change tracking has an ordering obligation, stated in
# docs/technical/PERSISTENT-CBT.md and at the top of kernel/state.c: the
# module must be attached to a device before anything writes to it. Writes
# that land before the interposer is installed are invisible by construction,
# so the persisted change list under-reports, and an incremental built from it
# omits real data while reporting success. The canary set catches a *numerous*
# such modification probabilistically and pays for it with a full baseline;
# nothing catches a small one.
#
# Attaching from userspace after the root filesystem is mounted therefore
# misses the mount itself, the journal replay after an unclean stop, and
# everything the early boot writes. This script moves the attach in front of
# all of that.
#
# WHY IT CANNOT BE ALLOWED TO FAIL
#
# It runs as part of PID 1's boot path in the initramfs. Anything it does that
# blocks, loops, or exits non-zero can stop a machine from booting, and a
# machine that will not boot is a far worse outcome than a machine that takes
# a full backup. So the rule, applied without exception below:
#
#   every failure logs loudly and returns success.
#
# There is no `set -e`, no unbounded wait, no retry loop without a ceiling,
# and no command run without a timeout where one is available. The worst
# outcome this script is permitted to produce is "scradle is not tracking
# anything", which costs a full baseline and nothing else.
#
# WHAT IT DOES NOT DO
#
# It does not open any persistent record. The attach is
# SCRADLE_ATTACH_PERSIST_DEFERRED (`scradlectl attach --defer-persist`):
# tracking starts, the interposer goes in, and no state file or write-intent
# log is touched, because the filesystem holding them is not mounted yet and
# reading it here would be the very ordering problem this script exists to
# fix. The agent completes the attach later with `scradlectl persist-open`,
# which ORs what it loads into the bitmap this script has been filling since
# the initramfs. That two-step design exists for exactly this.

CONF="${SCRADLE_BOOT_CONF:-/etc/scradle/boot.conf}"
CTL="${SCRADLE_CTL:-scradlectl}"

# Bounded, and small. A device that is not there after this is reported and
# skipped. Waiting "until it appears" is how a boot hook turns a missing disk
# into an unbootable machine.
WAIT_SECS="${SCRADLE_BOOT_WAIT:-10}"

# Every external command gets one of these if the initramfs has `timeout`.
# scradlectl talks to a driver that is loading in parallel with everything
# else the early boot is doing; it should never hang, and if it ever does,
# this is what keeps that from being the operator's problem.
CMD_TIMEOUT="${SCRADLE_BOOT_CMD_TIMEOUT:-15}"

log() {
	echo "scradle-boot: $*" >&2
	[ -w /dev/kmsg ] && echo "scradle-boot: $*" > /dev/kmsg 2>/dev/null
	return 0
}

# Run a command under a timeout when one exists, without one when it does
# not. Never propagates a failure by itself; callers decide what a non-zero
# status means, and in every case below it means "log and carry on".
#
# stdin comes from /dev/null, always. The device loop below reads the
# configuration file on the loop's stdin, so a command that read stdin --
# now, or after somebody adds one -- would silently eat the rest of the
# configuration and the devices after it would never be attached, with no
# error anywhere. That is the kind of failure that gets discovered during a
# restore.
guard() {
	if command -v timeout >/dev/null 2>&1; then
		timeout "$CMD_TIMEOUT" "$@" < /dev/null
	else
		"$@" < /dev/null
	fi
}

# Resolve UUID=/LABEL=/PARTUUID=/PARTLABEL= to a node, or echo the argument
# unchanged. blkid is present in every dracut initrd and in every
# initramfs-tools initrd that has a root by UUID, which is all of them; if it
# is missing, the tag simply does not resolve and the device is skipped with
# a message that says why.
resolve() {
	_spec="$1"
	case "$_spec" in
	UUID=*|LABEL=*|PARTUUID=*|PARTLABEL=*)
		if ! command -v blkid >/dev/null 2>&1; then
			log "no blkid in this initramfs; cannot resolve $_spec"
			return 1
		fi
		_node="$(guard blkid -l -t "$_spec" -o device 2>/dev/null | head -n 1)"
		[ -n "$_node" ] || return 1
		echo "$_node"
		;;
	*)
		echo "$_spec"
		;;
	esac
	return 0
}

# Wait for one device, up to WAIT_SECS. Returns the resolved node on stdout.
wait_for() {
	_spec="$1"
	_i=0
	while :; do
		_node="$(resolve "$_spec")"
		if [ -n "$_node" ] && [ -b "$_node" ]; then
			echo "$_node"
			return 0
		fi
		[ "$_i" -ge "$WAIT_SECS" ] && return 1
		# Ask udev to catch up if it is running; harmless if it is not.
		command -v udevadm >/dev/null 2>&1 && \
			guard udevadm settle --timeout=1 >/dev/null 2>&1
		sleep 1 < /dev/null
		_i=$((_i + 1))
	done
}

load_module() {
	if [ -d /sys/module/scradle ]; then
		return 0
	fi
	if command -v modprobe >/dev/null 2>&1 && guard modprobe scradle 2>/dev/null; then
		return 0
	fi
	# dracut installs the module into the initrd's module tree, so modprobe
	# is the normal path. insmod against a staged path is the fallback for
	# an initramfs assembled by something that did not run depmod.
	for _ko in /lib/modules/"$(uname -r)"/extra/scradle.ko \
		   /lib/modules/"$(uname -r)"/updates/dkms/scradle.ko \
		   /scradle.ko; do
		[ -f "$_ko" ] || continue
		guard insmod "$_ko" 2>/dev/null && return 0
	done
	return 1
}

main() {
	if [ ! -r "$CONF" ]; then
		# Not an error and not worth a scary line: the package ships the
		# hook to every machine and only the ones with a configuration
		# want it.
		return 0
	fi

	# Strip comments and blanks first, so an empty configuration does not
	# load the module for nothing.
	if ! grep -qE '^[[:space:]]*[^#[:space:]]' "$CONF" 2>/dev/null; then
		return 0
	fi

	if ! command -v "$CTL" >/dev/null 2>&1; then
		log "scradlectl is not in this initramfs; no device will be tracked from boot (the next backup will be a full one)"
		return 0
	fi

	if ! load_module; then
		log "the scradle module would not load; nothing will be tracked from boot (the next backup will be a full one). Boot continues."
		return 0
	fi

	# The default IFS splits on spaces and tabs, which is the whole of the
	# format. `read -r` because a device path is a path, not a place for
	# backslash escapes to be interpreted, and a trailing `# comment` on a
	# device line lands in $chunk where the case below discards it.
	while read -r spec chunk _rest; do
		case "$spec" in
		''|\#*) continue ;;
		esac
		case "$chunk" in
		''|-|\#*) chunk="" ;;
		*[!0-9]*)
			log "ignoring the chunk size '$chunk' for $spec: not a number. Attaching with the driver default."
			chunk=""
			;;
		esac

		node="$(wait_for "$spec")"
		if [ -z "$node" ]; then
			log "$spec did not appear within ${WAIT_SECS}s; skipping it. Boot continues; that device will need a full backup."
			continue
		fi

		# $chunk is deliberately unquoted: it is either a validated
		# decimal string or empty, and empty must vanish rather than
		# become an empty argument scradlectl would reject.
		# shellcheck disable=SC2086
		if guard "$CTL" attach "$node" $chunk --defer-persist >/dev/null 2>&1; then
			log "tracking $node from the initramfs, before the root mount. Persistence is deferred: run 'scradlectl persist-open $node --state=...' once the filesystem holding the records is mounted."
		else
			log "could not attach $node; it will not be tracked and its next backup will be a full one. Boot continues."
		fi
	done < "$CONF"

	return 0
}

# The trailing `|| true` and the explicit exit 0 are the belt and braces on
# the rule at the top of this file: whatever main() manages to do to itself,
# this script exits successfully.
main || true
exit 0
