SPS · log · files · refs · tags · releases · people · languages · GitHub · commit
bb015cf
Add sget config-status to review protected config updates
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