Three review findings on the update-hook boundary: The pre-refresh-pacman hook had been moved after the refresh transaction and, during a channel switch, deferred to the very end. That defeated the hook's purpose: custom repositories and IgnorePkg entries were not in place when the downgrade-capable -Syyuu ran. Run the hook where it used to run, after the package config is re-synced and before the transaction, but cold: revoke the timestamp, run it behind the no-update wrapper with the caller's original PATH, and revoke again before continuing. Every later privileged command authenticates with --no-update, so a detached child left by the hook has no reusable timestamp to wait for. Channel switching hands the caller's PATH to the refresh the same way the updater receives it, and no longer defers or re-runs the hook. Stay Awake was released before AUR builds, hooks and mise, so the machine could sleep during the longest part of an update. Releasing the inhibitor needs no privilege because the held command already dropped to the user, so stop it after mise and before the reboot prompt, as before. A packaged channel destination cannot be inspected before its package is installed, and a transaction can replace the running tree with a release that predates the command-scoped wrapper; from then on a bare sudo would resolve to /usr/bin/sudo and publish a timestamp, and the destination's own updater authenticates the same way. The switch used to abort only after the packages had changed, with generic rerun advice. Now it checks for the wrapper after each transaction before any further privileged step, completes what it safely can, and stops cold with instructions to run that release's update from a fresh session instead of launching it. Boundary tests pin the hook between the config copies and the transaction with a cold timestamp on both sides, the older-destination stop with its guidance and no launched updater, the new inhibitor position, and the post-update hook staying unreached on failures and signals. Docs, the manual and the sample hook describe the restored timing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
137 lines
4.7 KiB
Bash
137 lines
4.7 KiB
Bash
#!/bin/bash
|
|
|
|
# Test the real orchestration with fixed privileged paths redirected to harmless
|
|
# stand-ins. No host sudo, package transaction, namespace root, or exploit runs.
|
|
boundary_tmp=$(mktemp -d)
|
|
trap 'rm -rf "$boundary_tmp"' EXIT
|
|
export SUDO_TEST_ROOT="$boundary_tmp/omarchy"
|
|
export SUDO_TEST_LOG="$boundary_tmp/events"
|
|
export SUDO_TEST_CACHE="$boundary_tmp/cache"
|
|
export OMARCHY_PATH="$SUDO_TEST_ROOT"
|
|
export SUDO_TEST_HOME="$boundary_tmp/home"
|
|
mkdir -p "$SUDO_TEST_HOME"
|
|
mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update"
|
|
: >"$SUDO_TEST_LOG"
|
|
|
|
copy_boundary_file() {
|
|
python3 - "$ROOT" "$SUDO_TEST_ROOT" "$1" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
source, target, name = map(Path,sys.argv[1:])
|
|
p=target/name
|
|
p.parent.mkdir(parents=True,exist_ok=True)
|
|
s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME')
|
|
for command in ['sudo','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']:
|
|
s=s.replace('/usr/bin/'+command, str(target/'mock'/command))
|
|
s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"')
|
|
p.write_text(s)
|
|
p.chmod((source/name).stat().st_mode & 0o777)
|
|
PY
|
|
}
|
|
|
|
copy_boundary_file bin/omarchy-security-functions
|
|
copy_boundary_file bin/omarchy-update-pacman
|
|
copy_boundary_file default/omarchy/sudo-no-update/sudo
|
|
|
|
cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB'
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
printf 'sudo' >>"$SUDO_TEST_LOG"
|
|
printf ' %q' "$@" >>"$SUDO_TEST_LOG"
|
|
printf '\n' >>"$SUDO_TEST_LOG"
|
|
if [[ ${1:-} == "-h" ]]; then
|
|
if [[ ${SUDO_TEST_UNSUPPORTED:-0} == "1" ]]; then
|
|
echo 'usage: sudo [-ABbEHknPS] command'
|
|
else
|
|
echo 'usage: sudo [-ABbEHkNnPS] command'
|
|
fi
|
|
exit 0
|
|
fi
|
|
if [[ ${1:-} == "-k" || ${1:-} == "-K" ]]; then
|
|
[[ ${SUDO_TEST_REVOKE_FAIL:-0} != "1" ]] || exit 1
|
|
/usr/bin/rm -f "$SUDO_TEST_CACHE"
|
|
exit 0
|
|
fi
|
|
if [[ ${1:-} == "-N" ]]; then
|
|
shift
|
|
else
|
|
touch "$SUDO_TEST_CACHE"
|
|
fi
|
|
[[ ${SUDO_TEST_SUDO_FAIL:-0} != "1" ]] || exit 1
|
|
background=0
|
|
while (( $# )); do
|
|
case "$1" in
|
|
-N|-n) shift ;;
|
|
-b) background=1; shift ;;
|
|
-v) exit 0 ;;
|
|
-u|--user) shift 2 ;;
|
|
--) shift; break ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
(( $# )) || exit 0
|
|
if (( background )); then
|
|
"$@" &
|
|
else
|
|
"$@"
|
|
fi
|
|
STUB
|
|
chmod +x "$SUDO_TEST_ROOT/mock/sudo"
|
|
|
|
cat >"$SUDO_TEST_ROOT/bin/test-step" <<'STUB'
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
step=${0##*/}
|
|
printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG"
|
|
if [[ $step == "systemd-run" ]]; then
|
|
# omarchy-update-pacman registers the transaction as a PID 1 scope on booted
|
|
# hosts. Run the wrapped command in place so the pacman step still executes.
|
|
while (( $# )) && [[ $1 == -* ]]; do shift; done
|
|
exec "$@"
|
|
fi
|
|
if [[ $step == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then
|
|
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
|
|
fi
|
|
if [[ -n ${SUDO_TEST_REMOVE_WRAPPER_STEP:-} && "$step $*" == $SUDO_TEST_REMOVE_WRAPPER_STEP ]]; then
|
|
# Model a package transaction replacing the running tree with a release
|
|
# that predates the wrapper.
|
|
/usr/bin/rm -f "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"
|
|
fi
|
|
if [[ ${SUDO_TEST_FAIL_STEP:-} == "$step" ]]; then
|
|
# Model a misbehaving child leaving state behind, then failing. Cleanup must
|
|
# still revoke it. This never invokes real sudo or exercises a privilege flaw.
|
|
touch "$SUDO_TEST_CACHE"
|
|
exit 17
|
|
fi
|
|
if [[ ${SUDO_TEST_SIGNAL_STEP:-} == "$step" ]]; then
|
|
touch "$SUDO_TEST_CACHE"
|
|
kill -TERM "$PPID"
|
|
exit 0
|
|
fi
|
|
case "$step" in
|
|
omarchy-update-system-pkgs|omarchy-update-keyring|omarchy-snapshot)
|
|
sudo /usr/bin/true
|
|
;;
|
|
pacman) exit 0 ;;
|
|
yay)
|
|
[[ $* == *"--sudo $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"* ]] || exit 92
|
|
[[ $* == *"--sudoloop=false"* ]] || exit 93
|
|
;;
|
|
esac
|
|
STUB
|
|
chmod +x "$SUDO_TEST_ROOT/bin/test-step"
|
|
for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do
|
|
ln -s test-step "$SUDO_TEST_ROOT/bin/$step"
|
|
done
|
|
ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman"
|
|
|
|
reset_boundary() {
|
|
: >"$SUDO_TEST_LOG"
|
|
/usr/bin/rm -f "$SUDO_TEST_CACHE"
|
|
unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP
|
|
}
|
|
assert_boundary_cold() {
|
|
[[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization"
|
|
[[ $(tail -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "$1 did not revoke at exit" "$(<"$SUDO_TEST_LOG")"
|
|
}
|