Splux Linux

source-based Linux using SPS

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

bin/pkconf


Shell

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