#!/bin/sh
# Copyright (c) 2026 Wireless Connect Ltd. <https://wirelessconnect.ie>
# SPDX-License-Identifier: PolyForm-Small-Business-1.0.0

# 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         the Class-B copy check (VD-E9-S2A-1): the
#                                 gates (/cfg mounted, seam link, list
#                                 readable), then every Class-B file PRESENT
#                                 in the mfs must have an identical /cfg
#                                 twin; a divergence YOUNGER than the save
#                                 cadence is "pending" (the design's own
#                                 one-minute loss bound, section 2.4), an
#                                 older one is STALE. 0 clean, 2 stale,
#                                 3/1 per the gates.
#
# 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, static and own-partition rows are never
#   touched (an own-partition row -- /mbr's boot state, the /cfg-native
#   directories -- is durable in place: nothing to copy, nothing the overlay
#   puts back); 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). A [when-used] PREFIX or SUBTREE row (10.F21 S1 D16, R19: the
# per-routing-domain resolver confs and anchors, one file per domain, gone
# when the domain is deleted) mirrors the same way, PER FILE and only while
# the LIVE DIRECTORY EXISTS: a /cfg plain file matching the stem (or under
# the subtree) whose live twin is absent is removed, each removal logged --
# and an absent live directory removes nothing (an unmounted or not-yet-made
# /var/unbound/etc must never empty /cfg, sec 13.3 I9). Untagged subtree and
# prefix rows are never mirrored (a key directory is not a state). The
# manifest's tag decides; this script never does.
#
# /etc/rc.conf.local is VALIDATED before it is copied (10.F21 S1 sec 5.3,
# 13.3 I3): rcctl(8) replaces the file through install(1) -- unlink, create
# 0600, write, fchmod 0644 last -- so a copy taken mid-rewrite is empty,
# absent or 0600. The plain `tar | tar` straight into /cfg has no bracket to
# put a check around, so that one file is copied to a /cfg dot-temp first,
# the TEMP checked (non-empty, mode 0644, a `pkg_scripts=` line that parses),
# and renamed over the target; a temp that fails the check is removed and the
# previous /cfg copy stays (`--check` reports a leftover temp as pending).
# An empty or half-written rc.conf.local reaching /cfg would boot every
# feature daemon disabled, or every resolver instance in table 0.
#
# 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/
RCCONF_TMP=.rc.conf.local.ogma-cfg-tmp	# the validate temp, beside the /cfg copy
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 five 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|own-partition)$/ || $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|tree-mirror|prefix|prefix-mirror (the prefix row's
# path is the stem, the '*' dropped; the -mirror kinds are the rows tagged
# [when-used]: a file, a subtree, a prefix).
savelist() {
	list_rows | awk -F'\t' '
		$1 == "persistB" {
			p = $2
			if (p == "/etc/fstab") next
			m = ($3 ~ /\[when-used\]/) ? "-mirror" : ""
			if (substr(p, length(p), 1) == "/") { sub(/\/$/, "", p); print p "\ttree" m; next }
			if (substr(p, length(p), 1) == "*") { sub(/\*$/, "", p); print p "\tprefix" m; next }
			print p "\tfile" m
		}
	'
}

# 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
	# --- the Class-B copy check (VD-E9-S2A-1's runtime half) -------------
	# Every Class-B file PRESENT in the mfs must have an identical /cfg
	# twin: a divergence OLDER than the save cadence means the minute cron
	# is not landing it and the next power cut loses it. A file that
	# changed within the last OGMA_CFG_CHECK_GRACE_MIN minutes (default 2,
	# twice the cron cadence) is "pending", not a defect -- the window
	# between a write and the next tick is section 2.4's own loss bound. A
	# [when-used] mirror whose live file is absent but whose /cfg copy
	# remains is likewise pending only: absence has no mtime to age.
	# find -mmin serves both CI legs; stat(1) does not (-f vs -c).
	grace=${OGMA_CFG_CHECK_GRACE_MIN:-2}
	scratch=$(mktemp -t ogma-cfg-check.XXXXXX) || exit 1
	trap 'rm -f "$scratch"' EXIT

	check_file() {
		_src=$root$1
		_dst=$cfg$1
		# a leftover rc.conf.local validate temp: the last save was cut
		# short between its copy and its rename -- pending, never stale
		if [ "$1" = /etc/rc.conf.local ] && [ -f "$cfg/etc/$RCCONF_TMP" ]; then
			echo "pending $1" >> "$scratch"
		fi
		[ -f "$_src" ] || return 0
		if [ -f "$_dst" ] && cmp -s "$_src" "$_dst"; then
			echo "current" >> "$scratch"
			return 0
		fi
		if [ -n "$(find "$_src" -mmin -"$grace" 2>/dev/null)" ]; then
			echo "pending $1" >> "$scratch"
		else
			echo "stale $1" >> "$scratch"
			log err "Class-B copy check: $1 has no current /cfg twin and is older than ${grace}m -- the minute save is not landing it"
		fi
		return 0
	}
	check_tree() {
		_dir=$root$1
		[ -d "$_dir" ] || return 0
		find "$_dir" -type f 2>/dev/null | while IFS= read -r _f; do
			check_file "${_f#$root}"
		done
	}
	check_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" ] && check_file "${_dir#$root}/$_n" ;;
			esac
		done
	}
	check_mirror() {
		_src=$root$1
		_dst=$cfg$1
		if [ ! -f "$_src" ] && [ -f "$_dst" ]; then
			echo "pending $1" >> "$scratch"
			return 0
		fi
		check_file "$1"
	}
	# a /cfg plain file matching the stem (or under the subtree) whose live
	# twin is absent: pending (the next save removes it) -- only while the
	# live directory exists
	check_prefix_mirror() {
		check_prefix "$1"
		_pat=$cfg$1
		_dir=$(dirname "$_pat")
		_pre=$(basename "$_pat")
		[ -d "$root$(dirname "$1")" ] && [ -d "$_dir" ] || return 0
		ls -A "$_dir" 2>/dev/null | while IFS= read -r _n; do
			case $_n in
			"$_pre"*) [ -f "$_dir/$_n" ] && [ ! -h "$_dir/$_n" ] && [ ! -e "$root$(dirname "$1")/$_n" ] && echo "pending $(dirname "$1")/$_n" >> "$scratch" ;;
			esac
		done
		return 0
	}
	check_tree_mirror() {
		check_tree "$1"
		[ -d "$root$1" ] && [ -d "$cfg$1" ] || return 0
		find "$cfg$1" -type f 2>/dev/null | while IFS= read -r _f; do
			[ -e "$root${_f#$cfg}" ] || echo "pending ${_f#$cfg}" >> "$scratch"
		done
		return 0
	}

	savelist | while IFS='	' read -r _p _k; do
		case $_k in
		file)          check_file "$_p" ;;
		file-mirror)   check_mirror "$_p" ;;
		tree)          check_tree "$_p" ;;
		tree-mirror)   check_tree_mirror "$_p" ;;
		prefix)        check_prefix "$_p" ;;
		prefix-mirror) check_prefix_mirror "$_p" ;;
		esac
	done

	rows=$(savelist | wc -l | tr -d ' ')
	current=$(grep -c '^current$' "$scratch" 2>/dev/null || true)
	pending=$(grep -c '^pending ' "$scratch" 2>/dev/null || true)
	stale=$(grep -c '^stale ' "$scratch" 2>/dev/null || true)
	current=${current:-0}; pending=${pending:-0}; stale=${stale:-0}
	if [ "$stale" -gt 0 ]; then
		log err "check: $stale Class-B cop$( [ "$stale" = 1 ] && echo y || echo ies ) STALE on /cfg ($current current, $pending pending, $rows rows)"
		exit 2
	fi
	echo "ok: /cfg mounted, seam link present, save-list readable ($rows Class-B rows); copies: $current current, $pending pending inside the ${grace}m window"
	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

# --- 10.E11 S4b: a /cfg directory carries its LIVE twin's mode and owner -------
# mkdir -p creates a parent at umask (0755), and rc.resflash's boot overlay
# (`tar xpf` of /cfg/etc and /cfg/var onto the mfs twins) re-applies a /cfg
# directory's mode onto the baked one: a 0700 /etc/ssh/authorized_keys came
# back 0755 at every boot (S4a's recorded drift; /etc/ssl/private and
# /etc/iked/private the same way). So every component above a saved file is
# walked and a twin whose mode or owner differs from the live directory's is
# converged -- at every copy, so a twin created at umask before this shipped
# converges the next time a file under it changes (and rc.image.subr's
# post-overlay converge, from the build's baked list, breaks the loop for the
# live side). ls -ldn serves both CI legs and the box (stat(1) is -f on OpenBSD
# and -c on GNU); the special bits ride along. Only components under the tree
# root are touched -- never /cfg itself (10.E11 S1 owns that inode).
_dir_mode_owner() {
	ls -ldn "$1" 2>/dev/null | awk '{
		m = substr($1, 2, 9); u = 0; g = 0; o = 0; s = 0
		if (substr(m, 1, 1) == "r") u += 4
		if (substr(m, 2, 1) == "w") u += 2
		c = substr(m, 3, 1); if (c == "x" || c == "s") u += 1; if (c == "s" || c == "S") s += 4
		if (substr(m, 4, 1) == "r") g += 4
		if (substr(m, 5, 1) == "w") g += 2
		c = substr(m, 6, 1); if (c == "x" || c == "s") g += 1; if (c == "s" || c == "S") s += 2
		if (substr(m, 7, 1) == "r") o += 4
		if (substr(m, 8, 1) == "w") o += 2
		c = substr(m, 9, 1); if (c == "x" || c == "t") o += 1; if (c == "t" || c == "T") s += 1
		printf "%d%d%d%d %s:%s\n", s, u, g, o, $3, $4
	}'
}
_cfg_mirror_dirs() {
	_rel=$(dirname "$1")
	_acc=
	_saved_ifs=$IFS
	_had_f=0
	case $- in *f*) _had_f=1 ;; esac
	set -f	# a component named `*` or `?` must not glob
	IFS=/
	for _c in $_rel; do
		[ -n "$_c" ] || continue
		_acc=$_acc/$_c
		_live=$root$_acc
		_twin=$cfg$_acc
		[ -d "$_live" ] && [ ! -h "$_live" ] && [ -d "$_twin" ] && [ ! -h "$_twin" ] || continue
		_lm=$(_dir_mode_owner "$_live")
		_tm=$(_dir_mode_owner "$_twin")
		[ -n "$_lm" ] && [ "$_lm" != "$_tm" ] || continue
		chmod "${_lm%% *}" "$_twin" 2>/dev/null
		chown "${_lm#* }" "$_twin" 2>/dev/null
	done
	IFS=$_saved_ifs
	[ "$_had_f" = 1 ] || set +f
}

# $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
	_cfg_mirror_dirs "$1"
	if [ "$1" = /etc/rc.conf.local ]; then
		save_rcconf "$_src" "$_dst" "$1"
		return 0
	fi
	_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
}

# rc.conf.local: copy to a /cfg dot-temp, validate the TEMP, rename over the
# target; a temp that fails validation is removed and the old /cfg copy stays
# (never a failure: rcctl was mid-rewrite, the next minute lands it).
# $1 live path, $2 /cfg path, $3 the manifest path (for the log).
save_rcconf() {
	_d=$(dirname "$2")
	_tmpd=$_d/.$RCCONF_TMP.d
	_tmp=$_d/$RCCONF_TMP
	rm -rf "$_tmpd" "$_tmp" 2>/dev/null
	mkdir -p "$_tmpd" 2>/dev/null
	if ! (cd "$(dirname "$1")" && tar cf - "$(basename "$1")" | tar xpf - -C "$_tmpd") 2>/dev/null ||
	    ! mv -f "$_tmpd/$(basename "$1")" "$_tmp" 2>/dev/null; then
		rm -rf "$_tmpd" "$_tmp" 2>/dev/null
		echo "failed $3" >> "$scratch"
		log err "could not save $3 to $2"
		return 0
	fi
	rmdir "$_tmpd" 2>/dev/null
	_why=
	if [ ! -s "$_tmp" ]; then
		_why="empty"
	elif [ "$(_dir_mode_owner "$_tmp" | cut -d' ' -f1)" != 0644 ]; then
		_why="mode $(_dir_mode_owner "$_tmp" | cut -d' ' -f1), not 0644"
	elif ! grep -q '^pkg_scripts=' "$_tmp" 2>/dev/null; then
		_why="no pkg_scripts= line"
	elif ! awk -F= '/^pkg_scripts=/ { v = $2; gsub(/"/, "", v); if (v ~ /^[A-Za-z0-9_ \t-]*$/) ok = 1 } END { exit ok ? 0 : 1 }' "$_tmp"; then
		_why="pkg_scripts= line does not parse"
	fi
	if [ -n "$_why" ]; then
		rm -f "$_tmp" 2>/dev/null
		log info "deferred $3: the live file is being rewritten ($_why); the /cfg copy is kept"
		return 0
	fi
	if mv -f "$_tmp" "$2" 2>/dev/null; then
		echo "copied" >> "$scratch"
	else
		rm -f "$_tmp" 2>/dev/null
		echo "failed $3" >> "$scratch"
		log err "could not save $3 to $2"
	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"
}

# One /cfg plain file whose live twin is absent, under a [when-used] prefix or
# subtree row: removed, logged. $1 = the path without any root.
_mirror_remove() {
	if [ "$dry" = 1 ]; then
		echo "would remove $1 from /cfg (absent live; [when-used] mirror)"
		echo "copied" >> "$scratch"
		return 0
	fi
	if rm -f "$cfg$1" 2>/dev/null; then
		echo "copied" >> "$scratch"
		log info "removed $1 from /cfg (absent live; [when-used] mirror)"
	else
		echo "failed $1" >> "$scratch"
		log err "could not remove $cfg$1 ([when-used] mirror)"
	fi
	return 0
}

# A [when-used] prefix row: the plain save, then -- only while the LIVE
# directory exists -- every /cfg plain file matching the stem whose live twin
# is absent is removed (a deleted domain's conf must not be overlaid back at
# the next boot; an absent live directory removes nothing).
save_prefix_mirror() {
	save_prefix "$1"
	_ldir=$root$(dirname "$1")
	_cdir=$cfg$(dirname "$1")
	_pre=$(basename "$1")
	[ -d "$_ldir" ] && [ ! -h "$_ldir" ] && [ -d "$_cdir" ] || return 0
	ls -A "$_cdir" 2>/dev/null | while IFS= read -r _n; do
		case $_n in
		"$_pre"*)
			[ -f "$_cdir/$_n" ] && [ ! -h "$_cdir/$_n" ] || continue
			[ -e "$_ldir/$_n" ] || _mirror_remove "$(dirname "$1")/$_n"
			;;
		esac
	done
	return 0
}

# A [when-used] subtree row: the same rule over every plain file under it.
save_tree_mirror() {
	save_tree "$1"
	[ -d "$root$1" ] && [ ! -h "$root$1" ] && [ -d "$cfg$1" ] || return 0
	find "$cfg$1" -type f 2>/dev/null | while IFS= read -r _f; do
		[ -e "$root${_f#$cfg}" ] || _mirror_remove "${_f#$cfg}"
	done
	return 0
}

savelist | while IFS='	' read -r _p _k; do
	case $_k in
	file)          save_file "$_p" ;;
	file-mirror)   save_mirror "$_p" ;;
	tree)          save_tree "$_p" ;;
	tree-mirror)   save_tree_mirror "$_p" ;;
	prefix)        save_prefix "$_p" ;;
	prefix-mirror) save_prefix_mirror "$_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
