SPS · log · files · refs · tags · releases · people · languages · GitHub · file
bin/pkconf
14697 bytes at 2909919 · GitHub
#!/bin/sh
# Reconcile protected configuration files with the packaged defaults an
# upgrade left beside them.
#
# An upgrade never overwrites a protected file the administrator changed. It
# keeps the local file and writes the packaged default next to it as
# ".sps-new" (or ".sps-new.VERSION-RELEASE"). "sget config-status" reports
# that situation and deliberately does nothing about it. pkconf is the step
# that acts on it, the way CRUX has rejmerge and Gentoo has etc-update.
#
# Accepting a packaged default is a rename: pkin already recorded the new
# package's digest for that path, so once the default is in place the file
# matches its record again and pkcheck reports it clean. Keeping the local
# file only removes the sibling; the path stays locally modified, which is
# the honest state.
sps_prog=pkconf
sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
if [ -z "${SPS_LIBDIR+x}" ]; then
if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
SPS_LIBDIR=$sps_bin_dir/../lib
else
SPS_LIBDIR=$sps_bin_dir/../lib/sps
fi
fi
if ! . "$SPS_LIBDIR/common.sh"; then
printf '%s\n' "pkconf: cannot load $SPS_LIBDIR/common.sh" >&2
exit 1
fi
sps_load_config || exit $?
sps_tab=$(printf '\tX')
sps_tab=${sps_tab%X}
usage()
{
printf '%s\n' 'usage: pkconf [--backup] [PACKAGE]'
printf '%s\n' ' pkconf --list [--raw] [PACKAGE]'
printf '%s\n' ' pkconf --diff PATH'
printf '%s\n' ' pkconf --accept [--backup] PATH...'
printf '%s\n' ' pkconf --keep PATH...'
printf '%s\n' ' pkconf --accept-all [--backup] [PACKAGE]'
printf '%s\n' ' pkconf --keep-all [PACKAGE]'
}
mode=review
raw=0
backup=0
scope=
nargs=0
paths=
while [ "$#" -gt 0 ]; do
case $1 in
--list) mode=list; shift ;;
--diff) mode=diff; shift ;;
--accept) mode=accept; shift ;;
--keep) mode=keep; shift ;;
--accept-all) mode=accept-all; shift ;;
--keep-all) mode=keep-all; shift ;;
--raw) raw=1; shift ;;
--backup) backup=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift
while [ "$#" -gt 0 ]; do
paths="$paths$1$sps_tab"; nargs=$((nargs + 1)); scope=$1; shift
done
break ;;
-*) usage >&2; exit "$SPS_EX_USAGE" ;;
*) paths="$paths$1$sps_tab"; nargs=$((nargs + 1)); scope=$1; shift ;;
esac
done
case $mode in
diff|accept|keep)
[ "$nargs" -ge 1 ] ||
sps_die "$SPS_EX_USAGE" "--${mode} needs at least one path"
[ "$mode" != diff ] || [ "$nargs" -eq 1 ] ||
sps_die "$SPS_EX_USAGE" "--diff takes exactly one path"
;;
*)
[ "$nargs" -le 1 ] ||
sps_die "$SPS_EX_USAGE" "pkconf takes at most one package name"
if [ -n "$scope" ]; then
sps_validate_package_name "$scope" ||
sps_die "$SPS_EX_USAGE" "invalid package name '$scope'"
fi
;;
esac
[ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
sps_die "$SPS_EX_DATABASE" "installed database is missing or is not a real directory"
[ "${SPS_PRESERVE:-etc}" != none ] ||
sps_die "$SPS_EX_USAGE" "SPS_PRESERVE is 'none'; no configuration files are protected"
work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" pkconf) ||
sps_die "$SPS_EX_PERMISSION" "cannot create work directory"
trap 'status=$?; rm -rf "$work"; exit "$status"' 0
trap 'exit 130' 1 2 3 15
# Turn a user-supplied path into the package-relative path SPS records. The
# ".sps-new" and ".sps-new.VERSION-RELEASE" spellings both resolve to the base
# path so "pkconf --accept /etc/foo.conf.sps-new" does what it looks like.
normalize_path()
{
np_in=$1
case "$SPS_ROOT" in
/) case $np_in in /*) np_out=${np_in#/} ;; *) np_out=$np_in ;; esac ;;
*) case $np_in in
"$SPS_ROOT"/*) np_out=${np_in#"$SPS_ROOT"/} ;;
/*) np_out=${np_in#/} ;;
*) np_out=$np_in ;;
esac ;;
esac
np_out=${np_out%/}
case $np_out in
*.sps-new) np_out=${np_out%.sps-new} ;;
*.sps-new.*) np_out=${np_out%.sps-new.*} ;;
esac
[ -n "$np_out" ] || return 1
printf '%s\n' "$np_out"
}
# Collect one row per pending packaged default:
# package <TAB> relative-path <TAB> state <TAB> default-file
# state is "modified" when the live file differs from its recorded digest and
# "clean" when it matches (a stale sibling the admin already reconciled).
: >"$work/rows"
for record in "$SPS_DB/installed"/*; do
[ -d "$record" ] && [ ! -L "$record" ] || continue
name=${record##*/}
case $name in .|..) continue ;; esac
[ -f "$record/hashes" ] && [ ! -L "$record/hashes" ] || continue
case $mode in
list|review|accept-all|keep-all)
if [ -n "$scope" ] && [ "$name" != "$scope" ]; then
continue
fi
;;
esac
while IFS="$sps_tab" read -r algo expected relpath || [ -n "$relpath" ]; do
[ -n "$relpath" ] || continue
sps_is_preserved_path "$relpath" || continue
defaults=$(sps_config_new_defaults "$relpath")
[ -n "$defaults" ] || continue
target=$(sps_root_path "/$relpath")
state=missing
if [ -f "$target" ] && [ ! -L "$target" ]; then
live=$(sps_sha256 "$target" 2>/dev/null) || live=
set -- $live
live=$1
if [ -n "$live" ] && [ "$live" = "$expected" ]; then
state=clean
else
state=modified
fi
fi
printf '%s\n' "$defaults" | while IFS= read -r default; do
[ -n "$default" ] || continue
printf '%s\t%s\t%s\t%s\n' "$name" "$relpath" "$state" "$default"
done >>"$work/rows"
done <"$record/hashes"
done
LC_ALL=C sort -u "$work/rows" -o "$work/rows"
# Restrict to explicitly named paths for --diff, --accept and --keep.
if [ "$mode" = diff ] || [ "$mode" = accept ] || [ "$mode" = keep ]; then
: >"$work/wanted"
saved_ifs=$IFS
IFS=$sps_tab
for arg in $paths; do
[ -n "$arg" ] || continue
want=$(normalize_path "$arg") ||
sps_die "$SPS_EX_USAGE" "invalid path '$arg'"
printf '%s\n' "$want" >>"$work/wanted"
done
IFS=$saved_ifs
awk -F"$sps_tab" 'NR == FNR { want[$0] = 1; next } ($2 in want)' \
"$work/wanted" "$work/rows" >"$work/rows.sel"
mv -f "$work/rows.sel" "$work/rows"
while IFS= read -r want; do
if ! awk -F"$sps_tab" -v p="$want" '$2 == p { found = 1 }
END { exit !found }' "$work/rows"; then
sps_die "$SPS_EX_NOTFOUND" \
"no pending packaged default for /$want"
fi
done <"$work/wanted"
fi
if [ ! -s "$work/rows" ]; then
if [ "$raw" -eq 0 ]; then
printf '%s\n' 'No pending packaged configuration defaults.'
fi
exit 0
fi
show_diff()
{
sd_target=$1
sd_default=$2
if ! sps_require_cmd diff 2>/dev/null; then
sps_warn "diff is not available; showing the packaged default instead"
cat "$sd_default"
return 0
fi
if [ -f "$sd_target" ] && [ ! -L "$sd_target" ]; then
# Non-zero status just means the files differ, which is the point.
diff -u "$sd_target" "$sd_default" || :
else
sps_warn "/${sd_target#${SPS_ROOT%/}} is missing; showing the packaged default"
cat "$sd_default"
fi
}
# Replace the live file with the packaged default. cp then rename keeps the
# target a complete file at every instant, and preserves the mode the package
# asked for rather than whatever the local copy drifted to.
accept_default()
{
ad_target=$1
ad_default=$2
ad_parent=${ad_target%/*}
[ -n "$ad_parent" ] || ad_parent=/
if [ "$backup" -eq 1 ] && [ -f "$ad_target" ] && [ ! -L "$ad_target" ]; then
if ! cp -p "$ad_target" "$ad_target.sps-bak"; then
sps_warn "cannot write backup for /${ad_target#${SPS_ROOT%/}}"
return 1
fi
fi
ad_tmp=$(mktemp "$ad_parent/.sps-conf.XXXXXX") || {
sps_warn "cannot stage /${ad_target#${SPS_ROOT%/}}"
return 1
}
if ! cp -p "$ad_default" "$ad_tmp"; then
rm -f "$ad_tmp"
sps_warn "cannot stage /${ad_target#${SPS_ROOT%/}}"
return 1
fi
if sps_is_root; then
chown 0:0 "$ad_tmp" 2>/dev/null || :
fi
if ! mv -f "$ad_tmp" "$ad_target"; then
rm -f "$ad_tmp"
sps_warn "cannot install packaged default for /${ad_target#${SPS_ROOT%/}}"
return 1
fi
rm -f "$ad_default"
return 0
}
keep_local()
{
kl_default=$1
rm -f "$kl_default" || {
sps_warn "cannot remove ${kl_default#${SPS_ROOT%/}}"
return 1
}
return 0
}
record_history()
{
rh_action=$1
rh_path=$2
rh_date=$(date '+%Y-%m-%d' 2>/dev/null || printf '%s' unknown)
printf '%s config %s /%s\n' "$rh_date" "$rh_action" "$rh_path" \
>>"$SPS_DB/history" 2>/dev/null || :
}
accepted=0
kept=0
skipped=0
case $mode in
list)
if [ "$raw" -eq 1 ]; then
cat "$work/rows"
else
while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
[ -n "$relpath" ] || continue
case $state in
modified) note='local file differs from the packaged default' ;;
clean) note='local file already matches its record' ;;
*) note='local file is missing' ;;
esac
printf '%-20s /%s\n' "$name" "$relpath"
printf ' %s\n' "$note"
printf ' default: %s\n' "${default#${SPS_ROOT%/}}"
done <"$work/rows"
printf '\n%s pending packaged default(s)\n' "$(wc -l <"$work/rows" | tr -d ' ')"
fi
;;
diff)
while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
[ -n "$relpath" ] || continue
show_diff "$(sps_root_path "/$relpath")" "$default"
done <"$work/rows"
;;
accept|accept-all)
while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
[ -n "$relpath" ] || continue
if accept_default "$(sps_root_path "/$relpath")" "$default"; then
accepted=$((accepted + 1))
record_history accept "$relpath"
printf '%s: installed packaged default for /%s\n' "$sps_prog" "$relpath"
fi
done <"$work/rows"
;;
keep|keep-all)
while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
[ -n "$relpath" ] || continue
if keep_local "$default"; then
kept=$((kept + 1))
record_history keep "$relpath"
printf '%s: kept local /%s\n' "$sps_prog" "$relpath"
fi
done <"$work/rows"
;;
review)
if [ ! -t 0 ]; then
sps_die "$SPS_EX_USAGE" \
"pkconf review needs a terminal; use --list, --accept-all, or --keep-all"
fi
total=$(wc -l <"$work/rows" | tr -d ' ')
index=0
# Read the rows from a dedicated descriptor so the prompt can still read
# the terminal on standard input.
exec 3<"$work/rows"
while IFS="$sps_tab" read -r name relpath state default <&3 || [ -n "$relpath" ]; do
[ -n "$relpath" ] || continue
index=$((index + 1))
target=$(sps_root_path "/$relpath")
printf '\n[%s/%s] /%s\n' "$index" "$total" "$relpath"
printf ' package: %s\n' "$name"
printf ' default: %s\n' "${default#${SPS_ROOT%/}}"
case $state in
modified) printf ' the local file differs from the packaged default\n' ;;
clean) printf ' the local file already matches its recorded digest\n' ;;
*) printf ' the local file is missing\n' ;;
esac
while :; do
printf ' [d]iff [n]ew (use packaged) [k]eep local [e]dit [s]kip [q]uit: '
if ! read -r answer; then
printf '\n'
answer=q
fi
case $answer in
d|diff)
show_diff "$target" "$default"
;;
n|new)
if accept_default "$target" "$default"; then
accepted=$((accepted + 1))
record_history accept "$relpath"
printf ' installed the packaged default\n'
fi
break
;;
k|keep)
if keep_local "$default"; then
kept=$((kept + 1))
record_history keep "$relpath"
printf ' kept the local file and removed the packaged default\n'
fi
break
;;
e|edit)
editor=${VISUAL:-${EDITOR:-vi}}
if ! command -v "${editor%% *}" >/dev/null 2>&1; then
sps_warn "editor '$editor' not found; set EDITOR"
continue
fi
# Edit a copy so an abandoned edit cannot damage either
# the live file or the packaged default.
merge=$work/merge
if [ -f "$target" ] && [ ! -L "$target" ]; then
cp -p "$target" "$merge" || continue
else
cp -p "$default" "$merge" || continue
fi
$editor "$merge" || { sps_warn 'editor exited nonzero'; continue; }
if cp -p "$merge" "$target"; then
rm -f "$default" "$merge"
accepted=$((accepted + 1))
record_history merge "$relpath"
printf ' wrote the merged file and removed the packaged default\n'
break
fi
sps_warn "cannot write /$relpath"
;;
s|skip|'')
skipped=$((skipped + 1))
break
;;
q|quit)
exec 3<&-
printf '\n%s: %s accepted, %s kept, %s skipped\n' \
"$sps_prog" "$accepted" "$kept" "$skipped"
exit 0
;;
*)
printf ' answer d, n, k, e, s, or q\n'
;;
esac
done
done
exec 3<&-
printf '\n%s: %s accepted, %s kept, %s skipped\n' \
"$sps_prog" "$accepted" "$kept" "$skipped"
;;
esac
exit 0