Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

bb015cf


Add sget config-status to review protected config updates

Date
Commitbb015cfac4652a5171371a28b76f15e2e06b22b2
GitHubmirror

Parents

Stat

 DESIGN.md                   |  24 +++++
 README.md                   |  14 +++
 bin/pkin                    |   6 ++
 bin/sget                    | 227 ++++++++++++++++++++++++++++++++++++++++++++
 lib/common.sh               |  43 +++++++++
 man/sget.1                  |  45 +++++++++
 tests/test_config_status.sh | 153 +++++++++++++++++++++++++++++
 7 files changed, 512 insertions(+)

Diff

diff --git a/DESIGN.md b/DESIGN.md
index 3532453..f11d151 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -308,6 +308,30 @@ installed package:
 
 SPS reports the situation and leaves merging to the administrator.
 
+### Reviewing protected-file updates
+
+`sget config-status` is a read-only review of protected files. It walks the
+installed hashes for paths under `SPS_PRESERVE`, hashes the live file, and
+reports:
+
+- a file that differs from its recorded digest (locally modified), with a flag
+  when an upgrade also wrote a packaged default beside it as `.sps-new` or
+  `.sps-new.VERSION-RELEASE`; and
+- a `.sps-new*` sibling left next to a file that matches its recorded digest
+  (the packaged default changed while the admin later returned the file to the
+  packaged content, or the sibling was never cleaned up).
+
+`config-status --diff PATH` prints `diff -u` between the live local file and
+the packaged default sibling. `--raw` prints `name`, package-relative `path`,
+`clean`/`modified`, and a `0`/`1` pending-default flag as sorted,
+tab-separated records. The command never writes, merges, renames, or removes
+files; merging belongs to the administrator.
+
+`.sps-new*` file names are not package state and SPS never parses them as one.
+The review finds them on the filesystem next to recorded hash paths, which is
+why the collision-proofing in `pkin` (`.sps-new`, then a versioned spelling)
+keeps review deterministic.
+
 ## Binary package cache
 
 `sget` stores completed package artifacts under `$SPS_CACHE/packages`.
diff --git a/README.md b/README.md
index 1ac7089..b3f3f76 100644
--- a/README.md
+++ b/README.md
@@ -274,6 +274,20 @@ package also changed its copy, the new one is written beside it as `.sps-new`
 
 SPS never attempts to merge the two files automatically.
 
+To review what a round of upgrades left for you, run:
+
+```sh
+sget config-status            # list protected files that changed locally
+sget config-status openssl    # limit the list to one package
+sget config-status --raw      # tab-separated records for scripts
+sget config-status --diff /etc/ssl/openssl.cnf   # unified diff vs the new default
+```
+
+`config-status` is read-only. It reports the local file, its installed
+package, and whether an upgrade wrote a packaged default beside it as
+`.sps-new`, so you can merge by hand before the next upgrade. `pkcheck
+--modified` remains the low-level way to see every locally-changed owned file.
+
 `pkin` is still a direct installer. `sget upgrade` starts from the explicit
 `world` set, resolves that dependency closure, and upgrades it in dependency
 order. A locally newer version is kept unless `--downgrade` is requested.
diff --git a/bin/pkin b/bin/pkin
index 824914d..3de0004 100755
--- a/bin/pkin
+++ b/bin/pkin
@@ -589,6 +589,12 @@ sps_install_sps_new()
         fi
         sps_new_target=$sps_target.sps-new.$sps_version-$sps_release
     fi
+    # Never clobber a second existing default that differs from what we would
+    # write; leave it for the admin to review and drop instead of losing bytes.
+    if [ -e "$sps_new_target" ] || [ -L "$sps_new_target" ]; then
+        sps_warn "/$sps_path collision; keeping existing ${sps_new_target#${SPS_ROOT%/}}"
+        return 0
+    fi
     sps_new_parent=${sps_new_target%/*}
     sps_file_tmp=$(mktemp "$sps_new_parent/.sps-file.XXXXXX") ||
         sps_fail 8 "cannot stage preserved update for /$sps_path"
diff --git a/bin/sget b/bin/sget
index 78437b2..00a23e3 100755
--- a/bin/sget
+++ b/bin/sget
@@ -46,6 +46,8 @@ usage: sget [-q|--quiet] [-v|--verbose] search PATTERN [--raw]
        sget [-q|--quiet] [-v|--verbose] install [--plan] [--nodeps] [--reinstall] PACKAGE...
        sget [-q|--quiet] [-v|--verbose] upgrade [--plan] [--downgrade]
        sget [-q|--quiet] [-v|--verbose] remove [--plan] [--nodeps] PACKAGE...
+       sget [-q|--quiet] [-v|--verbose] config-status [--raw] [PACKAGE]
+       sget [-q|--quiet] [-v|--verbose] config-status --diff PATH
 EOF_USAGE
 }
 
@@ -722,6 +724,9 @@ sget_install()
         [ "$sget_upgrade_mode" -eq 1 ] && printf '%s\n' 'System packages are up to date.' ||
             printf '%s\n' 'All requested packages are already installed.'
     fi
+    if [ "${sget_plan:-0}" -eq 0 ] && sps_config_review_pending; then
+        sget_note "run 'sget config-status' to review protected configuration changes from this update"
+    fi
     sget_cleanup_install
     trap - 0 1 2 3 15
 }
@@ -860,12 +865,232 @@ sget_remove()
         pkdel "$sget_package" || exit $?
     done <"$sget_remove_work/order"
     rm -rf "$sget_remove_work"
+    if [ "$sget_remove_plan" -eq 0 ] && sps_config_review_pending; then
+        sget_note "run 'sget config-status' to review protected configuration changes"
+    fi
     sps_lock_release
     trap - 0 1 2 3 15
 }
 
 # Keep a whole sget install/upgrade/remove from interleaving with another one.
 # Low-level tools still use their shorter database lock. Queries and plans do not.
+
+# Read-only. Report protected configuration files that differ from what their
+# installed package recorded, and which of those have a packaged default that
+# an earlier upgrade wrote beside them (".sps-new*"). The admin reviews and
+# merges those by hand; nothing here writes to the filesystem.
+sget_config_status()
+{
+	sget_cs_diff=0
+	sget_cs_raw=0
+	sget_cs_scope=
+	sget_cs_nargs=0
+	while [ "$#" -gt 0 ]; do
+		case $1 in
+			--diff) sget_cs_diff=1; shift ;;
+			--raw) sget_cs_raw=1; shift ;;
+			--)
+				shift
+				while [ "$#" -gt 0 ]; do
+					sget_cs_scope=$1; sget_cs_nargs=$((sget_cs_nargs + 1)); shift
+				done
+				break
+				;;
+			-*)
+				sget_usage >&2
+				exit "$SPS_EX_USAGE"
+				;;
+			*)
+				sget_cs_scope=$1; sget_cs_nargs=$((sget_cs_nargs + 1)); shift
+				;;
+		esac
+	done
+	if [ "$sget_cs_diff" -eq 1 ]; then
+		[ "$sget_cs_nargs" -eq 1 ] ||
+			sps_die "$SPS_EX_USAGE" "config --diff takes exactly one path"
+		sps_require_cmd diff ||
+			sps_die "$SPS_EX_NOTFOUND" "diff is required for config --diff"
+	else
+		[ "$sget_cs_nargs" -le 1 ] ||
+			sps_die "$SPS_EX_USAGE" "config takes at most one package name"
+		if [ -n "$sget_cs_scope" ]; then
+			sps_validate_package_name "$sget_cs_scope" ||
+				sps_die "$SPS_EX_USAGE" "invalid package name '$sget_cs_scope'"
+		fi
+	fi
+
+	[ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
+		sps_die "$SPS_EX_DATABASE" "installed database is missing or is not a real directory"
+	[ -f "$SPS_DB/owners" ] && [ ! -L "$SPS_DB/owners" ] ||
+		sps_die "$SPS_EX_DATABASE" "ownership database is missing or is not a real file"
+
+	sget_cs_work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" config) ||
+		sps_die "$SPS_EX_PERMISSION" "cannot create config review workspace"
+	sget_cs_cleanup()
+	{
+		rm -rf "$sget_cs_work"
+	}
+	trap 'sget_cs_cleanup' 0
+
+	# --diff accepts the plain path, the ".sps-new" sibling, or the
+	# ".sps-new.VERSION-RELEASE" spelling. Normalize to the package-relative
+	# base path before matching hash records.
+	sget_cs_want=
+	if [ "$sget_cs_diff" -eq 1 ]; then
+		case "$SPS_ROOT" in
+			/) case $sget_cs_scope in /*) sget_cs_want=${sget_cs_scope#/} ;; *) sget_cs_want=$sget_cs_scope ;; esac ;;
+			*)
+				case $sget_cs_scope in
+					"$SPS_ROOT"/*) sget_cs_want=${sget_cs_scope#"$SPS_ROOT"/} ;;
+					/*) sget_cs_want=${sget_cs_scope#/} ;;
+					*) sget_cs_want=$sget_cs_scope ;;
+				esac
+				;;
+		esac
+		sget_cs_want=${sget_cs_want%/}
+		case $sget_cs_want in
+			*.sps-new) sget_cs_want=${sget_cs_want%.sps-new} ;;
+			*.sps-new.*) sget_cs_want=${sget_cs_want%.sps-new.*} ;;
+		esac
+		[ -n "$sget_cs_want" ] ||
+			sps_die "$SPS_EX_USAGE" "invalid config path '$sget_cs_scope'"
+		if ! printf '%s\n' "$sget_cs_want" | awk -v sps_action=validate-paths \
+			-v sps_program=sget -f "$SPS_LIBDIR/package.awk"; then
+			sps_die "$SPS_EX_USAGE" "invalid config path '$sget_cs_scope'"
+		fi
+		sget_cs_owner=$(awk -v sps_action=owners-query -v sps_path="$sget_cs_want" \
+			-v sps_program=sget -f "$SPS_LIBDIR/db.awk" "$SPS_DB/owners" 2>/dev/null) || :
+		[ -n "$sget_cs_owner" ] ||
+			sps_die "$SPS_EX_NOTFOUND" "no installed package owns /$sget_cs_want"
+	fi
+
+	# Walk installed hash records for preserved paths. Config files that live
+	# under SPS_PRESERVE and changed locally are the ones an upgrade refused to
+	# overwrite, so those are the interesting cases for review.
+	: >"$sget_cs_work/rows"
+	for sget_cs_record in "$SPS_DB/installed"/*; do
+		[ -d "$sget_cs_record" ] && [ ! -L "$sget_cs_record" ] || continue
+		sget_cs_name=${sget_cs_record##*/}
+		case $sget_cs_name in .|..) continue ;; esac
+		[ -f "$sget_cs_record/hashes" ] && [ ! -L "$sget_cs_record/hashes" ] || continue
+		[ -f "$sget_cs_record/meta" ] && [ ! -L "$sget_cs_record/meta" ] || continue
+		if [ "$sget_cs_diff" -eq 0 ] && [ -n "$sget_cs_scope" ] &&
+		   [ "$sget_cs_name" != "$sget_cs_scope" ]; then
+			continue
+		fi
+		while IFS="$sps_tab" read -r sget_cs_algo sget_cs_expected sget_cs_path \
+		      || [ -n "$sget_cs_path" ]; do
+			[ -n "$sget_cs_path" ] || continue
+			if [ "$sget_cs_diff" -eq 1 ]; then
+				[ "$sget_cs_path" = "$sget_cs_want" ] || continue
+			fi
+			sps_is_preserved_path "$sget_cs_path" || continue
+			sget_cs_target=$(sps_root_path "/$sget_cs_path")
+			sget_cs_state=clean
+			if [ -f "$sget_cs_target" ] && [ ! -L "$sget_cs_target" ]; then
+				sget_cs_live=$(sps_sha256 "$sget_cs_target") || sget_cs_live=
+				set -- $sget_cs_live
+				sget_cs_live=$1
+				if [ -n "$sget_cs_live" ] && [ "$sget_cs_live" != "$sget_cs_expected" ]; then
+					sget_cs_state=modified
+				fi
+			elif [ "$sget_cs_diff" -eq 1 ]; then
+				sget_cs_state=missing
+			else
+				continue
+			fi
+			sget_cs_defaults=$(sps_config_new_defaults "$sget_cs_path")
+			sget_cs_has_default=0
+			for sget_cs_def in $sget_cs_defaults; do
+				[ -n "$sget_cs_def" ] && sget_cs_has_default=1
+			done
+			printf '%s\t%s\t%s\t%s\n' \
+				"$sget_cs_name" "$sget_cs_path" "$sget_cs_state" \
+				"$sget_cs_has_default" >>"$sget_cs_work/rows"
+		done <"$sget_cs_record/hashes"
+	done
+	LC_ALL=C sort "$sget_cs_work/rows" | LC_ALL=C uniq >"$sget_cs_work/rows.sorted"
+	mv -f "$sget_cs_work/rows.sorted" "$sget_cs_work/rows"
+
+	if [ "$sget_cs_diff" -eq 1 ]; then
+		sget_cs_found=0
+		sget_cs_state=clean
+		while IFS="$sps_tab" read -r sget_cs_name sget_cs_path sget_cs_state \
+		     sget_cs_has_default; do
+			[ -n "$sget_cs_name" ] || continue
+			sget_cs_found=1
+		done <"$sget_cs_work/rows"
+		[ "$sget_cs_found" -eq 1 ] ||
+			sps_die "$SPS_EX_NOTFOUND" "/$sget_cs_want is not a managed protected config file"
+		sget_cs_live_path=$(sps_root_path "/$sget_cs_want")
+		sget_cs_defaults=$(sps_config_new_defaults "$sget_cs_want")
+		sget_cs_first=0
+		if [ "$sget_cs_state" = missing ]; then
+			sget_cs_first=1
+			printf '%s\n' "# /$sget_cs_want (owned by $sget_cs_owner)"
+			printf '%s\n' 'The local file is missing.'
+			for sget_cs_default in $sget_cs_defaults; do
+				[ -f "$sget_cs_default" ] && [ ! -L "$sget_cs_default" ] || continue
+				diff -u /dev/null "$sget_cs_default"
+			done
+			[ -n "$sget_cs_defaults" ] ||
+				printf '%s\n' 'No packaged default (.sps-new) is present for this file.'
+			exit 0
+		fi
+		for sget_cs_default in $sget_cs_defaults; do
+			[ -f "$sget_cs_default" ] && [ ! -L "$sget_cs_default" ] || continue
+			if [ "$sget_cs_first" -eq 0 ]; then
+				printf '%s\n' "# /$sget_cs_want (owned by $sget_cs_owner)"
+				printf '%s\n' 'Local file versus packaged default (written as .sps-new during an upgrade):'
+				sget_cs_first=1
+			fi
+			diff -u "$sget_cs_live_path" "$sget_cs_default"
+		done
+		if [ "$sget_cs_first" -eq 0 ]; then
+			printf '%s\n' "# /$sget_cs_want (owned by $sget_cs_owner)"
+			printf '%s\n' 'No packaged default (.sps-new) is present for this file.'
+		fi
+		exit 0
+	fi
+
+	sget_cs_any=0
+	sget_cs_pending=0
+	if [ -s "$sget_cs_work/rows" ]; then
+		while IFS="$sps_tab" read -r sget_cs_name sget_cs_path sget_cs_state \
+		     sget_cs_has_default; do
+			[ -n "$sget_cs_name" ] || continue
+			case $sget_cs_state in
+				modified)
+					sget_cs_pending=$((sget_cs_pending + 1))
+					if [ "$sget_cs_has_default" -eq 1 ]; then
+						sget_cs_label='new packaged default available'
+					else
+						sget_cs_label='modified locally'
+					fi
+					;;
+				*)
+					[ "$sget_cs_has_default" -eq 1 ] || continue
+					sget_cs_label='matches package; .sps-new left over'
+					;;
+			esac
+			sget_cs_any=1
+			if [ "$sget_cs_raw" -eq 1 ]; then
+				printf '%s\t%s\t%s\t%s\n' \
+					"$sget_cs_name" "$sget_cs_path" "$sget_cs_state" \
+					"$sget_cs_has_default"
+			else
+				printf '%-18s  /%-32s  %s\n' \
+					"$sget_cs_name" "$sget_cs_path" "$sget_cs_label"
+			fi
+		done <"$sget_cs_work/rows"
+	fi
+	if [ "$sget_cs_any" -eq 0 ] && [ "$sget_cs_raw" -eq 0 ]; then
+		printf '%s\n' 'no protected configuration changes found'
+	fi
+	printf '%s\n' "summary: $sget_cs_pending config file(s) differ from their packaged default" >&2
+	exit 0
+}
+
 sget_quiet=0
 sget_verbose=0
 while [ "$#" -gt 0 ]; do
@@ -914,6 +1139,8 @@ case ${1-} in
         shift; sget_upgrade "$@" ;;
     remove)
         shift; sget_remove "$@" ;;
+    config-status)
+        shift; sget_config_status "$@" ;;
     -h|--help)
         sget_usage ;;
     *)
diff --git a/lib/common.sh b/lib/common.sh
index c95b985..f0620f0 100644
--- a/lib/common.sh
+++ b/lib/common.sh
@@ -379,6 +379,30 @@ sps_definition_sha256()
 	sps_definition_hash_file "$sps_definition_manifest"
 )
 
+# True when $1 is a package-relative config path whose packaged default pkin
+# wrote beside it during an upgrade (".sps-new*"). Kept in common.sh so the
+# config review command and the post-upgrade reminder share one definition.
+sps_config_review_pending()
+{
+	[ "${SPS_PRESERVE:-etc}" != none ] || return 1
+	[ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] || return 1
+	sps_cr_tab=$(printf '\tX')
+	sps_cr_tab=${sps_cr_tab%X}
+	for sps_cr_record in "$SPS_DB/installed"/*; do
+		[ -d "$sps_cr_record" ] && [ ! -L "$sps_cr_record" ] || continue
+		[ -f "$sps_cr_record/hashes" ] && [ ! -L "$sps_cr_record/hashes" ] || continue
+		while IFS="$sps_cr_tab" read -r sps_cr_algo sps_cr_expected sps_cr_path \
+		      || [ -n "$sps_cr_path" ]; do
+			[ -n "$sps_cr_path" ] || continue
+			sps_is_preserved_path "$sps_cr_path" || continue
+			if sps_config_new_defaults "$sps_cr_path" | grep -q .; then
+				return 0
+			fi
+		done <"$sps_cr_record/hashes"
+	done
+	return 1
+}
+
 sps_validate_package_name()
 {
 	case ${1-} in
@@ -387,6 +411,25 @@ sps_validate_package_name()
 	esac
 }
 
+# Print the packaged config default that pkin wrote beside a kept local file.
+# $1 is the package-relative path. The only legal derivations are the plain
+# ".sps-new" sibling and the ".sps-new.VERSION-RELEASE" collision spelling.
+# Anything else is left for the caller to report, never followed or removed.
+sps_config_new_defaults()
+{
+	sps_default_base=$1
+	sps_default_dir=${sps_default_base%/*}
+	[ "$sps_default_dir" != "$sps_default_base" ] || sps_default_dir=
+	[ -n "$sps_default_dir" ] ||
+		sps_default_dir=.
+	case $sps_default_base in
+		*"*"*|*"?"*|*"["*|*"]"*) return 0 ;;
+	esac
+	LC_ALL=C find "$(sps_root_path /$sps_default_dir)" -maxdepth 1 \
+		-name "$(basename "$sps_default_base")"'.sps-new*' \
+		-type f ! -type l 2>/dev/null | LC_ALL=C sort
+}
+
 sps_tab_safe()
 {
 	case ${1-} in
diff --git a/man/sget.1 b/man/sget.1
index b0de196..9efc957 100644
--- a/man/sget.1
+++ b/man/sget.1
@@ -11,6 +11,8 @@ sget [-q|--quiet] [-v|--verbose] why PACKAGE DEPENDENCY [--build]
 sget [-q|--quiet] [-v|--verbose] install [--plan] [--nodeps] [--reinstall] PACKAGE...
 sget [-q|--quiet] [-v|--verbose] upgrade [--plan] [--downgrade]
 sget [-q|--quiet] [-v|--verbose] remove [--plan] [--nodeps] PACKAGE...
+sget [-q|--quiet] [-v|--verbose] config-status [--raw] [PACKAGE]
+sget [-q|--quiet] [-v|--verbose] config-status --diff PATH
 .fi
 .SH DESCRIPTION
 .B sget
@@ -91,6 +93,38 @@ Remove packages through
 .BR pkdel (1).
 By default SPS refuses a removal when another installed package outside the
 requested removal set directly requires it.
+.TP
+.BI "config-status [" "package" "]"
+Review protected configuration files without changing them.  SPS protects
+regular files below the configured
+.B SPS_PRESERVE
+prefixes (default
+.BR etc ).
+When an administrator has changed such a file and an upgrade brings a new
+packaged copy, SPS keeps the local file and writes the packaged default beside
+it as
+.IR path .sps-new
+(or the versioned
+.IR path .sps-new. VERSION - RELEASE
+spelling).  Without arguments,
+.B config-status
+lists every protected file that differs from what its installed package
+recorded, plus stale
+.B .sps-new
+files, grouped by package.  Pass a package name to narrow to one package.
+The command is read-only: it never rewrites, merges, or removes files.
+.TP
+.BI "config-status --diff " path
+Print a unified diff of a protected local file against the packaged default
+that an upgrade wrote beside it.  The
+.I path
+may be the plain path, the
+.I .sps-new
+sibling, or the versioned
+.I .sps-new.VERSION-RELEASE
+spelling.
+.B diff (1)
+must be installed.  Like the list form, this only reads files.
 .SH OPTIONS
 .TP
 .BR -q ", " --quiet
@@ -121,6 +155,17 @@ override.
 With install, rebuild/reinstall an exact selected package when necessary;
 an exact cached binary artifact may be reused.
 .TP
+.B --raw
+With
+.B config-status
+(and the query commands), print stable tab-separated records instead of
+aligned human-readable output, suitable for scripts.  The
+.B config-status
+records are:
+.IR "package" , "path" , "state" ,
+and a flag for whether a packaged default is pending.  A summary line still
+goes to standard error.
+.TP
 .B --downgrade
 With upgrade, permit the configured repository selection to replace a newer
 installed version.
diff --git a/tests/test_config_status.sh b/tests/test_config_status.sh
new file mode 100644
index 0000000..1a7b2ad
--- /dev/null
+++ b/tests/test_config_status.sh
@@ -0,0 +1,153 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+fail() { printf 'test_config_status: %s\n' "$*" >&2; exit 1; }
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-confstatus.XXXXXX") || fail 'mktemp failed'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+root=$tmp/root
+db=$tmp/db
+cache=$tmp/cache
+build=$tmp/build
+mkdir -p "$root" "$cache" "$build" "$tmp/pkg"
+
+run()
+{
+    SPS_ROOT=$root SPS_DB=$db SPS_CACHE=$cache SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null SPS_PRESERVE=etc \
+    SPS_LIBDIR=$project_dir/lib "$@"
+}
+
+make_recipe_pkg()
+{
+    dir=$1 name=$2 version=$3 config_text=$4
+    mkdir -p "$dir"
+    cat >"$dir/recipe" <<EOF_RECIPE
+name $name
+version $version
+release 1
+arch any
+description config review test package
+install mkdir -p "\$PKG/etc"
+install printf '%s\\n' '$config_text' > "\$PKG/etc/$name.conf"
+EOF_RECIPE
+    rm -f "$tmp/pkg/artifact"
+    run "$project_dir/bin/mkpkg" --artifact-file "$tmp/pkg/artifact" \
+        --compression none --output "$tmp/pkg" "$dir/recipe" || return $?
+    sed -n '1p' "$tmp/pkg/artifact"
+}
+
+recipe=$tmp/recipe
+artifact1=$(make_recipe_pkg "$recipe" confpkg 1.0 'default-value-1')
+run "$project_dir/bin/pkin" "$artifact1" >/dev/null
+
+# No local changes: config-status is empty.
+status=0
+run "$project_dir/bin/sget" config-status >"$tmp/clean.out" 2>"$tmp/clean.err" || status=$?
+[ "$status" -eq 0 ] || fail 'config-status failed on a clean system'
+grep -q 'no protected configuration changes' "$tmp/clean.out" ||
+    fail 'config-status did not report a clean system'
+
+# Modify the protected file, then upgrade with a changed packaged default.
+# pkin must keep the local file and write .sps-new.
+printf '%s\n' 'local-admin-value' >"$root/etc/confpkg.conf"
+artifact2=$(make_recipe_pkg "$recipe" confpkg 2.0 'default-value-2')
+run "$project_dir/bin/pkin" "$artifact2" >"$tmp/upgrade.out" 2>"$tmp/upgrade.err"
+[ "$(cat "$root/etc/confpkg.conf")" = local-admin-value ] ||
+    fail 'upgrade overwrote protected config'
+[ "$(cat "$root/etc/confpkg.conf.sps-new")" = default-value-2 ] ||
+    fail 'new config was not emitted as .sps-new'
+
+# config-status must now list the package/path and the pending default.
+run "$project_dir/bin/sget" config-status >"$tmp/list.out" 2>"$tmp/list.err" || status=$?
+[ "$status" -eq 0 ] || fail 'config-status failed after an upgrade'
+grep -q '^confpkg' "$tmp/list.out" ||
+    fail 'config-status did not report confpkg'
+grep -q '/etc/confpkg.conf' "$tmp/list.out" ||
+    fail 'config-status did not report the modified path'
+grep -q 'new packaged default' "$tmp/list.out" ||
+    fail 'config-status did not flag the pending .sps-new'
+
+# The package-scoped spelling narrows output to that package.
+run "$project_dir/bin/sget" config-status confpkg >"$tmp/one.out" 2>"$tmp/one.err"
+grep -q '^confpkg' "$tmp/one.out" ||
+    fail 'config-status PACKAGE did not list the package'
+
+# --raw is tab-separated and scriptable.
+run "$project_dir/bin/sget" config-status --raw >"$tmp/raw.out" 2>"$tmp/raw.err"
+grep -q '^confpkg	etc/confpkg.conf	modified	1$' "$tmp/raw.out" ||
+    fail 'config-status --raw record was not as expected'
+
+# Unknown or unrelated packages produce no rows for that package.
+status=0
+run "$project_dir/bin/sget" config-status zzz-nonexistent >"$tmp/no.out" 2>"$tmp/no.err" || status=$?
+[ "$status" -eq 0 ] || fail 'config-status PACKAGE failed for a missing name'
+grep -q 'no protected configuration' "$tmp/no.out" ||
+    fail 'config-status printed rows for a package that is not installed'
+
+# --diff shows a unified diff of the local file and the .sps-new default.
+run "$project_dir/bin/sget" config-status --diff /etc/confpkg.conf \
+    >"$tmp/diff.out" 2>"$tmp/diff.err" || status=$?
+[ "$status" -eq 0 ] || fail 'config-status --diff failed'
+grep -q 'local-admin-value' "$tmp/diff.out" ||
+    fail 'config-status --diff omitted the local side'
+grep -q 'default-value-2' "$tmp/diff.out" ||
+    fail 'config-status --diff omitted the packaged default'
+grep -q '^@' "$tmp/diff.out" ||
+    fail 'config-status --diff produced no unified hunk header'
+
+# The .sps-new spelling is accepted by --diff too.
+run "$project_dir/bin/sget" config-status --diff /etc/confpkg.conf.sps-new \
+    >"$tmp/diff2.out" 2>"$tmp/diff2.err" || status=$?
+[ "$status" -eq 0 ] || fail 'config-status --diff .sps-new spelling failed'
+grep -q 'default-value-2' "$tmp/diff2.out" ||
+    fail 'config-status --diff omitted the packaged default (.sps-new spelling)'
+
+# --diff on a non-config path must fail cleanly and not modify anything.
+status=0
+run "$project_dir/bin/sget" config-status --diff /etc \
+    >"$tmp/bad.out" 2>"$tmp/bad.err" || status=$?
+[ "$status" -ne 0 ] || fail 'config-status --diff /etc should have failed'
+[ ! -s "$tmp/bad.out" ] || fail 'config-status --diff wrote to stdout on failure'
+
+# When the local file is returned to the packaged content while a .sps-new
+# remains, the review reports the stale sibling instead of silence.
+printf '%s\n' 'default-value-2' >"$root/etc/confpkg.conf"
+run "$project_dir/bin/sget" config-status >"$tmp/stale.out" 2>"$tmp/stale.err"
+grep -q 'matches package; .sps-new left over' "$tmp/stale.out" ||
+    fail 'config-status did not report the stale .sps-new sibling'
+
+# Diverge the local file again, then upgrade to 3.0 with a changed default.
+# An existing differing .sps-new sibling forces the .sps-new.VERSION-RELEASE
+# spelling, and pkin must not clobber the sibling.
+printf '%s\n' 'local-admin-value-again' >"$root/etc/confpkg.conf"
+printf '%s\n' 'admin-kept-sibling' >"$root/etc/confpkg.conf.sps-new"
+artifact3=$(make_recipe_pkg "$recipe" confpkg 3.0 'default-value-3')
+run "$project_dir/bin/pkin" "$artifact3" >"$tmp/collide.out" 2>"$tmp/collide.err"
+[ "$(cat "$root/etc/confpkg.conf.sps-new")" = admin-kept-sibling ] ||
+    fail 'pkin clobbered an existing .sps-new sibling'
+[ "$(cat "$root/etc/confpkg.conf.sps-new.3.0-1")" = default-value-3 ] ||
+    fail 'pkin did not write the versioned .sps-new.3.0-1'
+grep -q 'kept it and wrote.*confpkg.conf.sps-new.3.0-1' "$tmp/collide.err" ||
+    fail 'upgrade to 3.0 did not write the versioned .sps-new'
+
+# config-status still reports the pending default after the collision.
+run "$project_dir/bin/sget" config-status --raw >"$tmp/raw2.out" 2>"$tmp/raw2.err"
+grep -q '^confpkg	etc/confpkg.conf	modified	1$' "$tmp/raw2.out" ||
+    fail 'config-status --raw did not list the pending default'
+
+# --diff accepts the .sps-new.VERSION-RELEASE spelling and diffs that sibling.
+status=0
+run "$project_dir/bin/sget" config-status --diff /etc/confpkg.conf.sps-new.3.0-1 \
+    >"$tmp/diffv.out" 2>"$tmp/diffv.err" || status=$?
+[ "$status" -eq 0 ] || fail 'config-status --diff versioned spelling failed'
+grep -q 'local-admin-value-again' "$tmp/diffv.out" ||
+    fail 'config-status --diff versioned omitted the local side'
+grep -q 'default-value-3' "$tmp/diffv.out" ||
+    fail 'config-status --diff versioned omitted the packaged default'
+grep -q 'admin-kept-sibling' "$tmp/diffv.out" ||
+    fail 'config-status --diff versioned omitted the plain .sps-new sibling'
+
+printf '%s\n' 'config status tests passed'
\ No newline at end of file