#!/bin/sh
# ogma-cfg-save -- the appliance IMAGE edition's /cfg save-back for the
# Class-B persist paths (10.E9 S2, docs/PHASE-BOOT-FSCK-RESILIENCE.md
# section 2.4 / 6.13; the seam is PHASE10-E1-INSTALLABLE-PRODUCT.md section 3.3a).
#
#   ogma-cfg-save [-q] [-n]       save every changed Class-B path to /cfg
#   ogma-cfg-save --list          print the resolved save-list (path<TAB>kind)
#   ogma-cfg-save --check         exit 0 iff /cfg is mounted and the seam holds
#
# WHY: on the image /etc and /var are mfs (RAM). Class-A state (everything
# under /var/db/ogmaprotect -- users.db, canonical config, revisions, secrets,
# the last-good snapshots) is durable on every write, because the image maps
# that ONE prefix onto /cfg with a symlink (E1 D4) and /cfg stays mounted
# read-write (rc.image.subr). Class-B paths live OUTSIDE that prefix -- the
# rendered /etc files (interim, until the boot re-render lands), the TLS
# key+cert, host keys, dhcpd leases, rc.conf.local -- and each needs its own
# copy under /cfg/etc or /cfg/var, which resflash's rc.resflash overlays back
# into the mfs at the next boot. Upstream saves those only at shutdown
# (resflash.save), which a power cut skips; this script is run every minute
# from the image's root crontab AND at shutdown (rc.shutdown), and copies only
# what changed, so the loss window after a power cut is one minute of
# rendered /etc -- the canonical behind it is already durable.
#
# THE LIST IS NEVER HAND-WRITTEN HERE, AND THE MANIFEST IS NOT ON THE BOX.
# The ONE durable-paths manifest is scripts/ogma-savelist.conf in the tree,
# where the 10.E9 S2a guard (scripts/check_durable_paths.pl) proves every
# path the product names is classified. The image build runs that guard's
# own projection of it (`check_durable_paths.pl --list all`, which exits
# non-zero on a manifest that fails its grammar, so a broken manifest never
# becomes a list) and freezes the result into the image as
# /etc/ogmaprotect/cfg-savelist: one row per path,
# "<class><TAB><path><TAB><tags>", the manifest's markers intact. This script
# reads THAT (DATA -- parsed, never sourced):
#   persistB rows are saved; persistA rows are asserted to be under the seam
#   prefix (nothing to copy); volatile and static rows are never touched; a
#   row carrying any other class, or a path that is not absolute, is not the
#   guard's projection (a hand edit, a truncated write) and the whole list is
#   refused (exit 1) -- an unknown row must never read as "nothing to save".
#   Path forms are the manifest's: a trailing '/' is a subtree, a trailing
#   '*' is a prefix glob on the basename, anything else is one file.
#   /etc/fstab is never saved whatever the list says (it names the root
#   letter; the image apply rewrites it per root -- the resflash.save rule).
#
# Inert (exit 0) on the software edition: the image marker is the gate.
# Fail-closed on the image: /cfg not mounted or the seam link missing => exit
# 3 with a syslog line, nothing copied (a copy into an unmounted /cfg would
# land on the root filesystem's empty mount point and vanish).
#
# Nothing is REMOVED from /cfg by a plain save: a rendered file that
# disappears from the mfs stays in /cfg until the next apply re-renders it or
# the operator cleans /cfg -- deleting on absence would turn a transient
# render failure into data loss. The ONE exception is the manifest's own
# [when-used] tag on a FILE row ("present only when the feature is enabled"):
# for those, absence IS a state -- the fsck opt-out marker (10.E9: re-enabling
# auto-repair unlinks it, and a /cfg copy left behind would be overlaid back
# and re-arm the console halt), a feature daemon's rendered config once the
# feature is off -- so an absent live file removes the /cfg copy (a "mirror"
# row). Subtree and prefix rows are never mirrored, whatever their tags (a
# key directory is not a state). The manifest's tag decides; this script
# never does.
#
# Test seams (never operator knobs): OGMA_CFG_ROOT prefixes every path;
# OGMA_CFG_SAVELIST names the generated list.

set -u

root=${OGMA_CFG_ROOT:-}
list=${OGMA_CFG_SAVELIST:-$root/etc/ogmaprotect/cfg-savelist}
marker=$root/etc/ogmaprotect/image-layout
cfg=$root/cfg
persist=$root/var/db/ogmaprotect
seam_prefix=/var/db/ogmaprotect/
quiet=0
dry=0
mode=save

log() {
	_lvl=$1; shift
	if [ "$_lvl" = info ] && [ "$quiet" = 1 ]; then
		logger -t ogma-cfg-save -- "$*" 2>/dev/null
		return 0
	fi
	echo "ogma-cfg-save: $*"
	logger -t ogma-cfg-save -- "$*" 2>/dev/null
	return 0
}

usage() {
	echo "usage: ogma-cfg-save [-q] [-n] | --list | --check" >&2
	exit 2
}

for a in "$@"; do
	case $a in
	-q) quiet=1 ;;
	-n) dry=1 ;;
	--list) mode=list ;;
	--check) mode=check ;;
	*) usage ;;
	esac
done

# --- the generated list ----------------------------------------------------------
# "class<TAB>path<TAB>tags" for every data row (tags as the guard prints
# them: "[when-used] [interim]", possibly empty). Fails (exit 1) on an
# unreadable list: an empty list must never read as "nothing to save".
list_rows() {
	[ -r "$list" ] || return 1
	awk -F'\t' '
		/^[ \t]*#/ { next }
		/^[ \t]*$/ { next }
		{ print $1 "\t" $2 "\t" $3 }
	' "$list"
}

# Every data row must carry one of the manifest's four classes and an
# absolute path -- anything else is not the guard's projection. Loud, exit 1.
assert_rows() {
	list_rows | awk -F'\t' '
		$1 !~ /^(persistA|persistB|volatile|static)$/ || $2 !~ /^\// { bad = bad " " $1 ":" $2 }
		END { if (bad != "") { print "malformed save-list rows:" bad; exit 1 } }
	'
}

# The resolved Class-B save-list: "path<TAB>kind", kind in
# file|file-mirror|tree|prefix (the prefix row's path is the stem, the '*'
# dropped; file-mirror = a file row tagged [when-used]).
savelist() {
	list_rows | awk -F'\t' '
		$1 == "persistB" {
			p = $2
			if (p == "/etc/fstab") next
			if (substr(p, length(p), 1) == "/") { sub(/\/$/, "", p); print p "\ttree"; next }
			if (substr(p, length(p), 1) == "*") { sub(/\*$/, "", p); print p "\tprefix"; next }
			if ($3 ~ /\[when-used\]/) { print p "\tfile-mirror"; next }
			print p "\tfile"
		}
	'
}

# Every persistA row must sit under the seam prefix -- one that does not is
# not covered by the symlink, and this script would silently not save it
# either. Loud, exit 1.
assert_persist_a() {
	list_rows | awk -F'\t' -v pre="$seam_prefix" '
		$1 == "persistA" && index($2, pre) != 1 { bad = bad " " $2 }
		END { if (bad != "") { print "persistA rows outside the seam prefix:" bad; exit 1 } }
	'
}

if [ "$mode" = list ]; then
	list_rows >/dev/null || { echo "ogma-cfg-save: save-list $list unreadable" >&2; exit 1; }
	assert_rows || exit 1
	assert_persist_a || exit 1
	savelist
	exit 0
fi

# --- the gates ----------------------------------------------------------------
if [ ! -f "$marker" ]; then
	[ "$mode" = check ] && echo "software edition (no image marker): nothing to save"
	exit 0
fi
if ! mount 2>/dev/null | grep -q " on $cfg "; then
	log err "/cfg is NOT mounted -- refusing to save (a copy would land on the root filesystem's empty mount point); the box is DEGRADED"
	exit 3
fi
if [ ! -h "$persist" ]; then
	log err "$persist is not the /cfg seam symlink -- Class-A state is RAM-only; refusing to save until the seam is repaired"
	exit 3
fi
if ! list_rows >/dev/null; then
	log err "save-list $list unreadable -- nothing saved (rebuild the image)"
	exit 1
fi
if ! assert_rows; then
	log err "save-list $list is not the build's projection (malformed rows) -- refusing; nothing saved"
	exit 1
fi
if ! assert_persist_a; then
	log err "save-list has a persistA row outside the seam prefix -- refusing (fix scripts/ogma-savelist.conf and rebuild)"
	exit 1
fi
if [ "$mode" = check ]; then
	echo "ok: /cfg mounted, seam link present, save-list readable ($(savelist | wc -l | tr -d ' ') Class-B rows)"
	exit 0
fi

# --- the save ---------------------------------------------------------------------
# The per-file workers run inside `while read` subshells, so the counters are
# marks in a scratch file rather than shell variables.
scratch=$(mktemp -t ogma-cfg-save.XXXXXX) || exit 1
trap 'rm -f "$scratch"' EXIT

# $1 = the path WITHOUT the test root. Copy to $cfg$1 when absent or different,
# preserving mode/owner/mtime (tar, as resflash does; cp -p would follow a
# symlink). Parent directories are created on demand.
save_file() {
	_src=$root$1
	_dst=$cfg$1
	[ -f "$_src" ] || return 0
	if [ -f "$_dst" ] && cmp -s "$_src" "$_dst"; then
		return 0
	fi
	if [ "$dry" = 1 ]; then
		echo "would save $1"
		echo "copied" >> "$scratch"
		return 0
	fi
	_d=$(dirname "$_dst")
	mkdir -p "$_d" 2>/dev/null
	_srcdir=$(dirname "$_src")
	_base=$(basename "$_src")
	if (cd "$_srcdir" && tar cf - "$_base" | tar xpf - -C "$_d") 2>/dev/null; then
		echo "copied" >> "$scratch"
	else
		echo "failed $1" >> "$scratch"
		log err "could not save $1 to $_dst"
	fi
	return 0
}

save_tree() {
	_dir=$root$1
	[ -d "$_dir" ] || return 0
	find "$_dir" -type f 2>/dev/null | while IFS= read -r _f; do
		save_file "${_f#$root}"
	done
}

save_prefix() {
	_pat=$root$1
	_dir=$(dirname "$_pat")
	_pre=$(basename "$_pat")
	[ -d "$_dir" ] || return 0
	ls -A "$_dir" 2>/dev/null | while IFS= read -r _n; do
		case $_n in
		"$_pre"*) [ -f "$_dir/$_n" ] && save_file "${_dir#$root}/$_n" ;;
		esac
	done
}

# A mirror row ([when-used] file): the live file absent + a /cfg copy present
# => remove the copy (the manifest said absence is state); present => a plain
# save.
save_mirror() {
	_src=$root$1
	_dst=$cfg$1
	if [ ! -f "$_src" ] && [ -f "$_dst" ]; then
		if [ "$dry" = 1 ]; then
			echo "would remove $1 from /cfg (absent live; [when-used] mirror)"
			echo "copied" >> "$scratch"
			return 0
		fi
		if rm -f "$_dst" 2>/dev/null; then
			echo "copied" >> "$scratch"
		else
			echo "failed $1" >> "$scratch"
			log err "could not remove $_dst ([when-used] mirror)"
		fi
		return 0
	fi
	save_file "$1"
}

savelist | while IFS='	' read -r _p _k; do
	case $_k in
	file)        save_file "$_p" ;;
	file-mirror) save_mirror "$_p" ;;
	tree)        save_tree "$_p" ;;
	prefix)      save_prefix "$_p" ;;
	esac
done

copied=$(grep -c '^copied$' "$scratch" 2>/dev/null || true)
failed=$(grep -c '^failed ' "$scratch" 2>/dev/null || true)
copied=${copied:-0}; failed=${failed:-0}
if [ "$dry" = 1 ]; then
	exit 0		# a dry run prints only its "would ..." lines
fi
sync
if [ "$failed" -gt 0 ]; then
	log err "saved $copied path(s) to /cfg, $failed FAILED"
	exit 1
fi
[ "$copied" -gt 0 ] && log info "saved $copied path(s) to /cfg"
exit 0
