diff --git a/bin/omarchy-apply-pacman b/bin/omarchy-apply-pacman index 0af0fb0c..e004637d 100755 --- a/bin/omarchy-apply-pacman +++ b/bin/omarchy-apply-pacman @@ -1,18 +1,33 @@ #!/bin/bash # omarchy:summary=Restore channel and regional pacman defaults in an installed target -# omarchy:args= [target-root] +# omarchy:args= [target-root] | --render [target-root] # omarchy:requires-sudo=true # omarchy:hidden=true set -euo pipefail -if (( $# < 1 || $# > 2 )); then +usage() { echo "Usage: omarchy-apply-pacman [target-root]" >&2 + echo " omarchy-apply-pacman --render [target-root]" >&2 exit 1 +} + +# --render only builds the two files, so an unprivileged caller can hand root +# nothing but plain copies of the finished result. +render_dir="" +if [[ ${1:-} == "--render" ]]; then + (( $# == 3 || $# == 4 )) || usage + channel="$2" + render_dir="$3" + [[ -d $render_dir ]] || { echo "Render directory not found: $render_dir" >&2; exit 1; } + etc="${4:-/}/etc" +else + (( $# >= 1 && $# <= 2 )) || usage + channel="$1" + etc="${2:-/}/etc" fi -channel="$1" case "$channel" in stable|rc|edge) ;; *) echo "Invalid pacman channel: $channel" >&2; exit 1 ;; @@ -20,7 +35,6 @@ esac # The ISO writes this plain-text marker before target finalization. Missing # markers keep existing installations global; never source it as shell code. -etc="${2:-/}/etc" region=global if [[ -e $etc/omarchy/region ]]; then region=$(<"$etc/omarchy/region") @@ -32,8 +46,12 @@ esac # Build both files before replacing either. Explicit refreshes retain backups; # ordinary updates do not call this command or enforce regional defaults. -staging=$(mktemp -d) -trap 'rm -rf "$staging"' EXIT +if [[ -n $render_dir ]]; then + staging="$render_dir" +else + staging=$(mktemp -d) + trap 'rm -rf "$staging"' EXIT +fi cp "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" "$staging/pacman.conf" cp "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" "$staging/mirrorlist" if [[ $region != "global" ]]; then @@ -42,6 +60,7 @@ if [[ $region != "global" ]]; then cat "$profile/pacman.conf.append" >> "$staging/pacman.conf" cat "$profile/mirrorlist.append" >> "$staging/mirrorlist" fi +[[ -z $render_dir ]] || exit 0 mkdir -p "$etc/pacman.d" for file in pacman.conf pacman.d/mirrorlist; do diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index be2a256d..80f3c356 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -30,10 +30,17 @@ if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then exit 2 fi +# Render the channel templates plus this machine's regional additions as the +# caller, so root only copies finished files. +rendered=$(mktemp -d) +"$OMARCHY_PATH/bin/omarchy-apply-pacman" --render "$channel" "$rendered" + +sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak +sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak echo "Setting channel to $channel" -# Restores the channel templates plus this machine's regional additions, and -# backs up both files first. OMARCHY_PATH was verified as this command's root. -sudo env "OMARCHY_PATH=$OMARCHY_PATH" "$OMARCHY_PATH/bin/omarchy-apply-pacman" "$channel" +sudo cp -f "$rendered/pacman.conf" /etc/pacman.conf +sudo cp -f "$rendered/mirrorlist" /etc/pacman.d/mirrorlist +rm -rf "$rendered" # Allow user customization of /etc/pacman.conf before the upgrade runs, so # custom repositories and IgnorePkg entries shape the downgrade-capable diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh index f98934c2..7fe15ded 100755 --- a/test/shell.d/channel-sudo-boundary-test.sh +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" copy_boundary_file bin/omarchy-channel-set copy_boundary_file bin/omarchy-refresh-pacman +copy_boundary_file bin/omarchy-apply-pacman copy_boundary_file bin/omarchy-update export OMARCHY_UPDATE_LOGGED=1 diff --git a/test/shell.d/region-pacman-test.sh b/test/shell.d/region-pacman-test.sh index 2d0eb25a..c15d6d63 100644 --- a/test/shell.d/region-pacman-test.sh +++ b/test/shell.d/region-pacman-test.sh @@ -68,35 +68,22 @@ cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf" cmp "$scratch/before.mirrors" "$TARGET_ROOT/etc/pacman.d/mirrorlist" pass "missing fragment cannot leave a half-generated config" -# Execute the real refresh and apply commands against the same disposable root. -mkdir -p "$scratch/bin" -cat > "$scratch/bin/sudo" <<'SH' -#!/bin/bash -exec "$@" "$TARGET_ROOT" -SH -cat > "$scratch/bin/omarchy-hook" <<'SH' -#!/bin/bash -[[ $1 == "pre-refresh-pacman" ]] || exit 1 -printf '# user customization\n' >> "$TARGET_ROOT/etc/pacman.conf" -SH -cat > "$scratch/bin/omarchy-update-pacman" <<'SH' -#!/bin/bash -[[ $* == "-Syyuu --noconfirm" ]] || exit 1 -tail -n 1 "$TARGET_ROOT/etc/pacman.conf" > "$TARGET_ROOT/update-observed" -SH -chmod +x "$scratch/bin/"* -PATH="$scratch/bin:$PATH" "$ROOT/bin/omarchy-refresh-pacman" rc -[[ $(<"$TARGET_ROOT/update-observed") == '# user customization' ]] || fail "hook runs before update" -rg -qxF 'Server = https://pkgs.omarchy.org/rc/$arch' "$TARGET_ROOT/etc/pacman.conf" -rg -qxF '[archlinuxcn]' "$TARGET_ROOT/etc/pacman.conf" -pass "channel refresh retains region and applies user customization before updating" +# Refresh renders these files unprivileged and hands root plain copies; its +# privileged path is covered in the sudo-boundary sandbox, never on the host. +cp "$TARGET_ROOT/etc/pacman.conf" "$scratch/before.conf" +mkdir -p "$scratch/rendered" +"$ROOT/bin/omarchy-apply-pacman" --render rc "$scratch/rendered" "$TARGET_ROOT" +rg -qxF 'Server = https://pkgs.omarchy.org/rc/$arch' "$scratch/rendered/pacman.conf" +rg -qxF '[archlinuxcn]' "$scratch/rendered/pacman.conf" +[[ $(<"$scratch/rendered/mirrorlist") == $'Server = https://rc-mirror.omarchy.org/$repo/os/$arch\nServer = https://mirrors.ustc.edu.cn/archlinux/$repo/os/$arch' ]] || + fail "render keeps the channel mirror first" +cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf" +pass "render builds the regional channel files without touching the target" -printf 'not-updated\n' > "$TARGET_ROOT/update-observed" -if PATH="$scratch/bin:$PATH" "$ROOT/bin/omarchy-refresh-pacman" dev > "$scratch/error" 2>&1; then - fail "refresh accepts a nonexistent dev pacman template" +if "$ROOT/bin/omarchy-apply-pacman" --render dev "$scratch/rendered" "$TARGET_ROOT" > "$scratch/error" 2>&1; then + fail "render accepts a nonexistent dev pacman template" fi -[[ $(<"$TARGET_ROOT/update-observed") == 'not-updated' ]] || fail "failed generation still updates packages" -pass "failed refresh does not proceed to a package update" +pass "render rejects channels without pacman templates" printf 'global\n' > "$TARGET_ROOT/etc/omarchy/region" apply stable diff --git a/test/shell.d/region-refresh-boundary-test.sh b/test/shell.d/region-refresh-boundary-test.sh new file mode 100755 index 00000000..b3b2f346 --- /dev/null +++ b/test/shell.d/region-refresh-boundary-test.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-refresh-pacman +copy_boundary_file bin/omarchy-apply-pacman + +# Regional rendering must stay unprivileged: root only copies finished files, +# exactly as the channel refresh did before regions existed. +reset_boundary +"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" rc >"$boundary_tmp/output" 2>&1 || fail "refresh failed" "$(<"$boundary_tmp/output")" +assert_boundary_cold "regional refresh" +python3 - "$SUDO_TEST_LOG" "$SUDO_TEST_ROOT" <<'PY' +import re +import sys +events = open(sys.argv[1]).read().splitlines() +root = sys.argv[2] +render = [i for i, e in enumerate(events) if e.startswith('step:cp ' + root + '/default/pacman/pacman-rc.conf ')] +assert len(render) == 1, events +rendered = events[render[0]].split()[-1].rsplit('/', 1)[0] +copies = [i for i, e in enumerate(events) if e.startswith('sudo -N cp ')] +assert [events[i] for i in copies[2:]] == [ + f'sudo -N cp -f {rendered}/pacman.conf /etc/pacman.conf', + f'sudo -N cp -f {rendered}/mirrorlist /etc/pacman.d/mirrorlist', +], events +assert not any(e.startswith('sudo ') and 'omarchy-apply-pacman' in e for e in events), events +assert render[0] < copies[0], events +transaction = next(i for i, e in enumerate(events) if e.startswith('step:pacman ')) +assert max(copies) < transaction, events +PY +pass "refresh renders regional config unprivileged and gives root only plain copies" + +reset_boundary +if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" dev >"$boundary_tmp/output" 2>&1; then + fail "refresh accepts an invalid channel" +fi +if grep -Eq '^(sudo -N cp |step:pacman )' "$SUDO_TEST_LOG"; then + fail "invalid channel reached privileged work" "$(<"$SUDO_TEST_LOG")" +fi +pass "failed refresh does not proceed to privileged copies or a package update" diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh index af7ec70a..243254cf 100755 --- a/test/shell.d/update-hook-security-test.sh +++ b/test/shell.d/update-hook-security-test.sh @@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" copy_boundary_file bin/omarchy-update copy_boundary_file bin/omarchy-refresh-pacman +copy_boundary_file bin/omarchy-apply-pacman # Replace the step symlink, preserving the real fixture dispatcher. rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs" copy_boundary_file bin/omarchy-update-aur-pkgs