From 2b91fdc0d3d6e8e5c9c659abda685e48f1e1f167 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 25 Aug 2026 18:42:15 +0100 Subject: [PATCH 01/13] [Security] Pin Plymouth asset reads before elevation --- bin/omarchy-plymouth-set | 19 ++++- test/shell.d/plymouth-set-test.sh | 130 ++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 30 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 1fd70d50..56426f1f 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -51,6 +51,15 @@ theme_dir="/usr/share/plymouth/themes/omarchy" staging_dir=$(mktemp -d) trap 'rm -rf "$staging_dir"' EXIT +# Publish a staged asset without ever asking a privileged process to resolve +# its user-writable source path. The shell opens source before sudo starts, so +# the inherited descriptor pins the bytes and a replacement symlink to a +# root-only file either loses the race or fails under the caller's permissions. +publish_asset() { + local source="$1" destination="$2" + sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" +} + find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + cp "$logo_path" "$staging_dir/logo.png" @@ -63,7 +72,9 @@ for asset in bullet.png entry.png lock.png progress_bar.png; do magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" done -sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/" +for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do + publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -81,12 +92,12 @@ sed \ -e "s/#ffffff/#$text_hex/g" \ "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null -sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" +publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 for asset in bullet.png entry.png lock.png; do - sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" + publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 done for asset in entry lock; do magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" + publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 done sudo rm -f "$sddm_dir/logo.svg" diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index eb8b120b..56b6447e 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -3,30 +3,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -source_dir="$test_tmp/source" -theme_dir="$test_tmp/theme" - -mkdir -m 0700 "$source_dir" -mkdir -m 0755 "$theme_dir" -touch "$source_dir/logo.png" - -cp -a --no-preserve=mode,ownership "$source_dir/." "$theme_dir/" - -[[ $(stat -c %a "$theme_dir") == "755" ]] || - fail "Plymouth asset copy preserves the theme directory permissions" - -grep -Fq \ - 'cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"' \ - "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" - -pass "Plymouth asset copy preserves the package-owned directory metadata" +trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from -# ~/.config/omarchy/themes, and both copies below land in world-readable -# /usr/share, so a symlink there would republish whatever it points at. +# ~/.config/omarchy/themes. Both installed copies are world-readable, so a +# symlink there must not republish whatever it points at. secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" @@ -34,12 +15,9 @@ ln -s "$secret" "$test_tmp/logo-link.png" output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" +((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" -grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" - pass "a themed logo cannot republish a file it merely points at" # Style > Unlock picks a theme by name and hands the answer to @@ -141,3 +119,103 @@ run_unlock_action "default" [[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")" pass "the unlock picker still applies a theme and still resets on default" + +# Exercise the full publisher with sudo and ImageMagick shims. Immediately +# after the unprivileged shell opens each staged source, the sudo shim renames +# that source away and replaces its pathname with a symlink to a simulated +# root-only secret. Reading via the inherited stdin descriptor must still +# publish the original bytes. The shim restores the source after each read so +# every Plymouth and SDDM asset gets attacked independently. +fake_bin="$test_tmp/bin" +fake_root="$test_tmp/root" +stages="$test_tmp/stages" +attack_log="$test_tmp/attacked" +sudo_log="$test_tmp/sudo.log" +mkdir -p "$fake_bin" "$fake_root" "$stages" + +cat >"$fake_bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_SUDO_LOG" + +case "$1" in +tee) + destination="$2" + mapped="$TEST_FAKE_ROOT$destination" + mkdir -p "$(dirname -- "$mapped")" + + stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) + asset=$(basename -- "$destination") + source="$stage/$asset" + pinned="$stage/.pinned-$asset" + + if [[ -n $stage && -f $source && ! -L $source ]]; then + mv -T -- "$source" "$pinned" + ln -s "$TEST_SECRET" "$source" + printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" + /usr/bin/tee "$mapped" + result=$? + rm -f -- "$source" + mv -T -- "$pinned" "$source" + exit "$result" + fi + exec /usr/bin/tee "$mapped" + ;; +chmod) + exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" + ;; +rm) + destination=${@: -1} + exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + ;; +plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 1 + ;; +esac +SH + +cat >"$fake_bin/magick" <<'SH' +#!/bin/bash +source="$1" +destination=${@: -1} +[[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" +SH + +cat >"$fake_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$fake_bin"/* + +printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +printf 'caller-selected logo\n' >"$test_tmp/logo.png" + +output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_SECRET="$secret" \ + TEST_ATTACK_LOG="$attack_log" \ + TEST_SUDO_LOG="$sudo_log" \ + bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +status=$? + +((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" + +expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' +actual_assets=$(sort -u "$attack_log") +[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" + +! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" +grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" +[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" + +if grep -F "$stages/" "$sudo_log" >/dev/null; then + fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" +fi + +pass "privileged publication uses pinned descriptors for every staged asset" From 0f15e2330f532fd018d4f3f44719a1866ec58121 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 27 Aug 2026 23:35:43 +0100 Subject: [PATCH 02/13] [Security] Harden Plymouth asset publication --- bin/omarchy-plymouth-set | 313 ++++++++++++++++++++------ bin/omarchy-refresh-plymouth | 12 +- test/shell.d/plymouth-set-test.sh | 362 ++++++++++++++++++++++++------ 3 files changed, 543 insertions(+), 144 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 56426f1f..f8f84c36 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -5,75 +5,258 @@ # omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png # omarchy:requires-sudo=true -# Configure the Plymouth boot theme with a custom background color, text color, and logo. -# Stages the change in a temp dir, then commits the staged files to /usr/share and -# rebuilds the initramfs. Also syncs the SDDM login screen (the post-logout -# screen) with the same colors and logo so boot/login stay visually unified. +set -euo pipefail -if (( $# != 3 )); then +# Configure the Plymouth boot theme with a custom background color, text color, +# and logo. Assets are prepared without privileges, pinned to their recorded +# hashes, then published one at a time through root-owned temporary files. + +refresh_default=false +if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then + refresh_default=true +elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -bg_hex="${1#\#}" -text_hex="${2#\#}" -logo_path="$3" +if ! $refresh_default; then + bg_hex="${1#\#}" + text_hex="${2#\#}" + logo_path="$3" -if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then - echo "Invalid background color: $1 (expected #RRGGBB)" >&2 - exit 1 + if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "Invalid background color: $1 (expected #RRGGBB)" >&2 + exit 1 + fi + + if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "Invalid text color: $2 (expected #RRGGBB)" >&2 + exit 1 + fi + + if [[ ! -f $logo_path ]]; then + echo "Logo file not found: $logo_path" >&2 + exit 1 + fi + + # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from + # ~/.config/omarchy/themes, where an installed theme can make it a symlink to + # anything. The copies below land in world-readable /usr/share, so following + # one would republish whatever it points at. + if [[ -L $logo_path ]]; then + echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 + exit 1 + fi fi -if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then - echo "Invalid text color: $2 (expected #RRGGBB)" >&2 - exit 1 -fi - -if [[ ! -f $logo_path ]]; then - echo "Logo file not found: $logo_path" >&2 - exit 1 -fi - -# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from -# ~/.config/omarchy/themes, where an installed theme can make it a symlink to -# anything. The copies below land in world-readable /usr/share, so following one -# would republish whatever it points at. -if [[ -L $logo_path ]]; then - echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 - exit 1 -fi - -bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') -bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') -bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') - theme_dir="/usr/share/plymouth/themes/omarchy" -staging_dir=$(mktemp -d) -trap 'rm -rf "$staging_dir"' EXIT +sddm_dir="/usr/share/sddm/themes/omarchy" +max_asset_size=$((64 * 1024 * 1024)) -# Publish a staged asset without ever asking a privileged process to resolve -# its user-writable source path. The shell opens source before sudo starts, so -# the inherited descriptor pins the bytes and a replacement symlink to a -# root-only file either loses the race or fails under the caller's permissions. -publish_asset() { +plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png +) +plymouth_default_assets=( + "${plymouth_theme_assets[@]}" + logos/oma.png +) +sddm_theme_assets=( + Main.qml + bullet.png + entry-failed.png + entry.png + lock-failed.png + lock.png + logo.png +) + +staging_dir=$(mktemp -d) +trap 'rm -rf -- "$staging_dir"' EXIT +chmod 0700 "$staging_dir" +plymouth_stage="$staging_dir/plymouth" +sddm_stage="$staging_dir/sddm" +mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + +copy_regular_file() { local source="$1" destination="$2" - sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" + + if [[ ! -f $source || -L $source ]]; then + echo "Refusing non-regular or symlinked asset: $source" >&2 + exit 1 + fi + + # -P ensures a source swapped to a symlink is copied as a symlink instead of + # followed; the post-copy check then rejects it. + cp -P --reflink=never -- "$source" "$destination" + if [[ ! -f $destination || -L $destination ]]; then + rm -f -- "$destination" + echo "Asset changed while it was being staged: $source" >&2 + exit 1 + fi + chmod 0600 "$destination" } -find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + -cp "$logo_path" "$staging_dir/logo.png" +if $refresh_default; then + assets_to_stage=("${plymouth_default_assets[@]}") +else + assets_to_stage=("${plymouth_theme_assets[@]}") +fi -sed -i \ - -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ - -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ - "$staging_dir/omarchy.script" - -for asset in bullet.png entry.png lock.png progress_bar.png; do - magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" +for asset in "${assets_to_stage[@]}"; do + copy_regular_file "$OMARCHY_PATH/default/plymouth/$asset" "$plymouth_stage/$asset" done -for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do - publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + copy_regular_file "$OMARCHY_PATH/default/sddm/omarchy/$asset" "$sddm_stage/$asset" + done + + copy_regular_file "$logo_path" "$plymouth_stage/logo.png" + copy_regular_file "$logo_path" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + sed -i \ + -e "s/#1a1b26/#$bg_hex/g" \ + -e "s/#ffffff/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done +fi + +declare -A staged_hashes=() +declare -A staged_sizes=() + +record_staged_asset() { + local source="$1" hash size + + if [[ ! -f $source || -L $source ]]; then + echo "Refusing non-regular or symlinked staged asset: $source" >&2 + exit 1 + fi + + size=$(stat -c %s -- "$source") + if (( size == 0 || size > max_asset_size )); then + echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 + exit 1 + fi + hash=$(sha256sum -- "$source") + staged_sizes["$source"]=$size + staged_hashes["$source"]=${hash%% *} +} + +# Record every asset before the first sudo prompt. Root verifies both values +# after consuming stdin, so an in-place rewrite after this point fails instead +# of changing what is published. This pins the recorded result, not the trust +# of an already-user-writable OMARCHY_PATH checkout. +for asset in "${assets_to_stage[@]}"; do + record_staged_asset "$plymouth_stage/$asset" +done +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + record_staged_asset "$sddm_stage/$asset" + done +fi + +publish_asset() { + local source="$1" destination="$2" + local expected_hash="${staged_hashes[$source]}" + local expected_size="${staged_sizes[$source]}" + + # The caller's shell opens source before sudo starts. Root reads only stdin, + # verifies the recorded bytes, and never opens or chmods the final pathname. + # Its temporary file is on the destination filesystem, so mv is atomic for + # this one asset and replaces a destination symlink instead of following it. + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH + + destination=$1 + expected_hash=$2 + expected_size=$3 + max_size=$4 + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] + [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] + (( expected_size > 0 && expected_size <= max_size )) + + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] + [[ -d $parent && ! -L $parent ]] + canonical_parent=$(realpath -e -- "$parent") + [[ $canonical_parent == "$parent" ]] + [[ $(stat -c %u -- "$parent") == 0 ]] + parent_mode=$(stat -c %a -- "$parent") + (( (8#$parent_mode & 0022) == 0 )) + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cleanup() { rm -f -- "$temporary"; } + trap cleanup EXIT HUP INT TERM + + head -c "$((expected_size + 1))" >"$temporary" + actual_size=$(stat -c %s -- "$temporary") + (( actual_size == expected_size )) + actual_hash=$(sha256sum -- "$temporary") + [[ ${actual_hash%% *} == "$expected_hash" ]] + + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + trap - EXIT HUP INT TERM + ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" +} + +remove_legacy_asset() { + local destination="$1" + + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH + + destination=$1 + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + [[ -d $parent && ! -L $parent ]] + canonical_parent=$(realpath -e -- "$parent") + [[ $canonical_parent == "$parent" ]] + [[ $(stat -c %u -- "$parent") == 0 ]] + parent_mode=$(stat -c %a -- "$parent") + (( (8#$parent_mode & 0022) == 0 )) + rm -f -- "$destination" + ' bash "$destination" +} + +for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" done sudo plymouth-set-default-theme omarchy @@ -83,21 +266,9 @@ else sudo mkinitcpio -P fi -# Sync the SDDM login screen with the same colors and logo. -sddm_dir="/usr/share/sddm/themes/omarchy" -sddm_template="$OMARCHY_PATH/default/sddm/omarchy/Main.qml" - -sed \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ - "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null - -publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 -for asset in bullet.png entry.png lock.png; do - publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 -done -for asset in entry lock; do - magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 -done -sudo rm -f "$sddm_dir/logo.svg" +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + remove_legacy_asset "$sddm_dir/logo.svg" +fi diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 58042dba..160e0c8a 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -3,11 +3,7 @@ # omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it. # omarchy:requires-sudo=true -sudo cp -r "$OMARCHY_PATH/default/plymouth/." /usr/share/plymouth/themes/omarchy/ -sudo plymouth-set-default-theme omarchy - -if omarchy-cmd-present limine-mkinitcpio; then - sudo limine-mkinitcpio -else - sudo mkinitcpio -P -fi +# Reuse the fixed-file publisher so root never resolves the source checkout or +# follows a destination symlink while restoring the packaged assets. +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$script_dir/omarchy-plymouth-set" --refresh-default diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 56b6447e..ba246eae 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -1,21 +1,37 @@ #!/bin/bash +set -uo pipefail + source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT +secret="$test_tmp/secret" +trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT + +plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png +) +plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) +sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # ~/.config/omarchy/themes. Both installed copies are world-readable, so a # symlink there must not republish whatever it points at. -secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" -output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) +output=$(OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" +(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" pass "a themed logo cannot republish a file it merely points at" @@ -120,66 +136,89 @@ run_unlock_action "default" pass "the unlock picker still applies a theme and still resets on default" -# Exercise the full publisher with sudo and ImageMagick shims. Immediately -# after the unprivileged shell opens each staged source, the sudo shim renames -# that source away and replaces its pathname with a symlink to a simulated -# root-only secret. Reading via the inherited stdin descriptor must still -# publish the original bytes. The shim restores the source after each read so -# every Plymouth and SDDM asset gets attacked independently. fake_bin="$test_tmp/bin" -fake_root="$test_tmp/root" +root_tools="$test_tmp/root-tools" stages="$test_tmp/stages" -attack_log="$test_tmp/attacked" -sudo_log="$test_tmp/sudo.log" -mkdir -p "$fake_bin" "$fake_root" "$stages" +mkdir -p "$fake_bin" "$root_tools" "$stages" cat >"$fake_bin/sudo" <<'SH' #!/bin/bash -printf '%s\n' "$*" >>"$TEST_SUDO_LOG" +set -u + +for argument in "$@"; do + if [[ $argument == *"$TEST_STAGES"* ]]; then + printf '%s\n' "$argument" >>"$TEST_LEAK_LOG" + fi +done case "$1" in -tee) - destination="$2" - mapped="$TEST_FAKE_ROOT$destination" - mkdir -p "$(dirname -- "$mapped")" +/bin/bash) + [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + code=$3 + shell_name=$4 + original_destination=$5 + printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) - asset=$(basename -- "$destination") - source="$stage/$asset" - pinned="$stage/.pinned-$asset" - - if [[ -n $stage && -f $source && ! -L $source ]]; then - mv -T -- "$source" "$pinned" - ln -s "$TEST_SECRET" "$source" - printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" - /usr/bin/tee "$mapped" - result=$? - rm -f -- "$source" - mv -T -- "$pinned" "$source" - exit "$result" + if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then + expected_size=${7:-0} + case "$original_destination" in + /usr/share/plymouth/themes/omarchy/*) + relative=${original_destination#/usr/share/plymouth/themes/omarchy/} + stage_kind=plymouth + ;; + /usr/share/sddm/themes/omarchy/*) + relative=${original_destination#/usr/share/sddm/themes/omarchy/} + stage_kind=sddm + ;; + *) exit 91 ;; + esac + stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) + source="$stage_root/$stage_kind/$relative" + /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" + printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" fi - exec /usr/bin/tee "$mapped" - ;; -chmod) - exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" - ;; -rm) - destination=${@: -1} - exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + + mapped_destination="$TEST_FAKE_ROOT$original_destination" + shift 5 + + # The production helper intentionally resets PATH. For this unprivileged + # simulation only, substitute stat/chown shims so a uid-1000 test directory + # behaves like the root-owned /usr/share directory used in production. + code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ + /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" exit 0 ;; *) echo "unexpected sudo command: $*" >&2 - exit 1 + exit 92 ;; esac SH +cat >"$root_tools/stat" <<'SH' +#!/bin/bash +last=${!#} +if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then + printf '0\n' + exit 0 +fi +exec /usr/bin/stat "$@" +SH + +cat >"$root_tools/chown" <<'SH' +#!/bin/bash +last=${!#} +[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +exit 0 +SH + cat >"$fake_bin/magick" <<'SH' #!/bin/bash -source="$1" +source=$1 destination=${@: -1} [[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" SH @@ -188,34 +227,227 @@ cat >"$fake_bin/omarchy-cmd-present" <<'SH' #!/bin/bash exit 1 SH -chmod +x "$fake_bin"/* -printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +chmod +x "$fake_bin"/* "$root_tools"/* + printf 'caller-selected logo\n' >"$test_tmp/logo.png" -output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ - TMPDIR="$stages" \ - OMARCHY_PATH="$ROOT" \ - TEST_FAKE_ROOT="$fake_root" \ - TEST_STAGES="$stages" \ - TEST_SECRET="$secret" \ - TEST_ATTACK_LOG="$attack_log" \ - TEST_SUDO_LOG="$sudo_log" \ - bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +setup_run() { + run_dir=$(mktemp -d "$test_tmp/run.XXXXXXXX") + fake_root="$run_dir/root" + sudo_log="$run_dir/sudo.log" + leak_log="$run_dir/leaked-stage-path.log" + mutate_log="$run_dir/mutated.log" + theme="$fake_root/usr/share/plymouth/themes/omarchy" + sddm="$fake_root/usr/share/sddm/themes/omarchy" + + mkdir -p "$theme/logos" "$sddm" + chmod 0755 \ + "$fake_root/usr" \ + "$fake_root/usr/share" \ + "$fake_root/usr/share/plymouth" \ + "$fake_root/usr/share/plymouth/themes" \ + "$theme" \ + "$theme/logos" \ + "$fake_root/usr/share/sddm" \ + "$fake_root/usr/share/sddm/themes" \ + "$sddm" + + local asset destination + for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + printf 'old plymouth %s\n' "$asset" >"$destination" + chmod 0600 "$destination" + done + for asset in "${sddm_theme_assets[@]}" metadata.desktop theme.conf; do + destination="$sddm/$asset" + printf 'old sddm %s\n' "$asset" >"$destination" + chmod 0600 "$destination" + done + + plymouth_victim="$run_dir/plymouth-victim" + sddm_victim="$run_dir/sddm-victim" + legacy_victim="$run_dir/legacy-victim" + printf 'PLYMOUTH VICTIM\n' >"$plymouth_victim" + printf 'SDDM VICTIM\n' >"$sddm_victim" + printf 'LEGACY VICTIM\n' >"$legacy_victim" + chmod 0600 "$plymouth_victim" "$sddm_victim" "$legacy_victim" + + rm -f "$theme/omarchy.script" "$sddm/Main.qml" + ln -s "$plymouth_victim" "$theme/omarchy.script" + ln -s "$sddm_victim" "$sddm/Main.qml" + ln -s "$legacy_victim" "$sddm/logo.svg" +} + +run_set() { + local requested_umask="$1" + shift + ( + umask "$requested_umask" + PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_ROOT_TOOLS="$root_tools" \ + TEST_SUDO_LOG="$sudo_log" \ + TEST_LEAK_LOG="$leak_log" \ + TEST_MUTATE_LOG="$mutate_log" \ + "$@" \ + /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" + ) +} + +assert_no_temporary_files() { + local directory="$1" leftovers + leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) + [[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers" +} + +for requested_umask in 022 027 077; do + setup_run + output=$(run_set "$requested_umask" env 2>&1) + status=$? + (( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output" + + for asset in "${plymouth_theme_assets[@]}"; do + destination="$theme/$asset" + [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" + [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" + [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" + [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" + done + for asset in "${sddm_theme_assets[@]}"; do + destination="$sddm/$asset" + [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" + [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" + [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" + [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" + done + + cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" + cmp -s "$test_tmp/logo.png" "$sddm/logo.png" || fail "SDDM receives the selected logo under umask $requested_umask" + grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask" + grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask" + + [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "Plymouth destination symlink never changes its victim" + [[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "Main.qml destination symlink never changes its victim" + [[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "legacy logo.svg removal never changes its victim" + [[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "legacy logo.svg is removed" + + [[ $(cat "$theme/logos/oma.png") == 'old plymouth logos/oma.png' && $(stat -c %a "$theme/logos/oma.png") == 600 ]] || fail "normal theme set does not broaden into the refresh-only nested asset" + [[ $(cat "$sddm/metadata.desktop") == 'old sddm metadata.desktop' ]] || fail "normal theme set leaves SDDM metadata unchanged" + [[ $(cat "$sddm/theme.conf") == 'old sddm theme.conf' ]] || fail "normal theme set leaves SDDM theme.conf unchanged" + [[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")" + [[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask" + assert_no_temporary_files "$fake_root" +done + +pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" + +# Swap the first staged source to an unreadable file after all hashes have been +# recorded but in the DEBUG hook immediately before Bash opens the redirection. +# The caller-side open must fail, so sudo never starts and nothing is published. +setup_run +preopen_hook="$run_dir/preopen-hook" +preopen_marker="$run_dir/preopen-marker" +printf 'ROOT ONLY\n' >"$secret" +chmod 000 "$secret" +cat >"$preopen_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && + $BASH_COMMAND == sudo\ /bin/bash\ -c* && + ! -e $TEST_PREOPEN_MARKER ]]; then + mv -T -- "$source" "$source.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$source" + printf "swapped\n" >"$TEST_PREOPEN_MARKER" + fi + ' DEBUG +fi +SH + +output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) +status=$? +chmod 0600 "$secret" + +(( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" +[[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" +if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then + fail "sudo started despite the caller-side open failure" +fi +assert_no_temporary_files "$fake_root" + +pass "an unreadable source swap before open fails without publication" + +# Rewrite a staged file in place after Bash has opened it but before root reads +# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +setup_run +mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' +output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) status=$? -((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" +(( status != 0 )) || fail "an in-place rewrite after open aborts publication" +[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" +[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +assert_no_temporary_files "$fake_root" -expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' -actual_assets=$(sort -u "$attack_log") -[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" +pass "recorded size and SHA-256 reject a same-inode rewrite after open" -! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" -grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" -[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" +# Root rejects both a symlinked parent and a group/world-writable parent before +# it creates a temporary file or touches the live destination. +setup_run +mv "$theme" "$theme.real" +ln -s "$theme.real" "$theme" +output=$(run_set 022 env 2>&1) +status=$? +(( status != 0 )) || fail "a symlinked destination parent is rejected" +[[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged" +assert_no_temporary_files "$fake_root" -if grep -F "$stages/" "$sudo_log" >/dev/null; then - fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" -fi +setup_run +chmod 0777 "$theme" +output=$(run_set 022 env 2>&1) +status=$? +(( status != 0 )) || fail "a writable destination parent is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged" +assert_no_temporary_files "$fake_root" -pass "privileged publication uses pinned descriptors for every staged asset" +pass "publication rejects symlinked and non-root-writable destination parents" + +# Refresh uses the same publisher but its explicit contract includes the +# packaged nested logos/oma.png asset. It must not touch the SDDM theme. +setup_run +output=$( + PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_ROOT_TOOLS="$root_tools" \ + TEST_SUDO_LOG="$sudo_log" \ + TEST_LEAK_LOG="$leak_log" \ + TEST_MUTATE_LOG="$mutate_log" \ + /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 +) +status=$? +(( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" + +for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" + [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" + [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" +done +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" +! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" +[[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" + +grep -Fq 'sudo /bin/bash -c' "$ROOT/bin/omarchy-plymouth-set" || fail "publisher invokes Bash by its trusted absolute path" +grep -Fq 'PATH=/usr/bin:/bin' "$ROOT/bin/omarchy-plymouth-set" || fail "root helper resets PATH before resolving utilities" + +pass "refresh safely publishes its complete fixed asset set, including logos/oma.png" From 20a23b8c169b69e84f3998ae26cdc65dd8860ed7 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Fri, 28 Aug 2026 19:37:04 +0100 Subject: [PATCH 03/13] [Security] Complete Plymouth publication coverage Keep SDDM color substitution collision-free for White themes, based on the fix proposed in #8469. Co-authored-by: itz4blitz --- bin/omarchy-plymouth-set | 9 ++++++-- test/shell.d/plymouth-set-test.sh | 36 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index f8f84c36..c9c663b5 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -135,9 +135,14 @@ if ! $refresh_default; then magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" done + # Substitute through unique tokens. Otherwise White's #ffffff background is + # immediately mistaken for the template's text placeholder by the next sed + # expression and rewritten to black. sed -i \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ + -e 's/#1a1b26/#__OMARCHY_SDDM_BG__/g' \ + -e 's/#ffffff/#__OMARCHY_SDDM_TEXT__/g' \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ "$sddm_stage/Main.qml" for asset in bullet.png entry.png lock.png; do diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index ba246eae..72fdca8c 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -22,6 +22,15 @@ plymouth_theme_assets=( plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) +# Keep the refresh allowlist synchronized with every packaged Plymouth asset. +# An added default file must make this test fail until its publication contract +# is explicitly reviewed and included above. +packaged_plymouth_assets=$(find "$ROOT/default/plymouth" -type f -printf '%P\n' | LC_ALL=C sort) +allowlisted_plymouth_assets=$(printf '%s\n' "${plymouth_default_assets[@]}" | LC_ALL=C sort) +[[ $packaged_plymouth_assets == "$allowlisted_plymouth_assets" ]] || + fail "Plymouth refresh allowlist differs from the packaged asset set" "$packaged_plymouth_assets" +pass "Plymouth refresh allowlist covers every packaged asset" + # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # ~/.config/omarchy/themes. Both installed copies are world-readable, so a # symlink there must not republish whatever it points at. @@ -279,9 +288,9 @@ setup_run() { ln -s "$legacy_victim" "$sddm/logo.svg" } -run_set() { - local requested_umask="$1" - shift +run_set_colors() { + local requested_umask="$1" background="$2" text="$3" + shift 3 ( umask "$requested_umask" PATH="$fake_bin:$ROOT/bin:$PATH" \ @@ -294,10 +303,16 @@ run_set() { TEST_LEAK_LOG="$leak_log" \ TEST_MUTATE_LOG="$mutate_log" \ "$@" \ - /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" + /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) } +run_set() { + local requested_umask="$1" + shift + run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@" +} + assert_no_temporary_files() { local directory="$1" leftovers leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) @@ -345,6 +360,19 @@ done pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" +# White uses #ffffff behind #000000 text. A direct two-expression sed first +# writes the white background and then consumes it as if it were the template's +# text placeholder, producing a black-on-black greeter. +setup_run +output=$(run_set_colors 022 '#ffffff' '#000000' env 2>&1) +status=$? +(( status == 0 )) || fail "White theme publishes through the safe asset pipeline" "$output" +grep -Fq 'color: "#ffffff"' "$sddm/Main.qml" || fail "White theme preserves its SDDM background color" +if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then + fail "SDDM color substitution left an intermediate token behind" +fi +pass "White theme keeps a white SDDM background instead of becoming black-on-black" + # Swap the first staged source to an unreadable file after all hashes have been # recorded but in the DEBUG hook immediately before Bash opens the redirection. # The caller-side open must fail, so sudo never starts and nothing is published. From a465dfa68141ff0100dce51c1b9d499b60f09247 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 14:42:47 +0200 Subject: [PATCH 04/13] Use the runtime path for Plymouth refresh --- bin/omarchy-refresh-plymouth | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 160e0c8a..fa47ffff 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -5,5 +5,4 @@ # Reuse the fixed-file publisher so root never resolves the source checkout or # follows a destination symlink while restoring the packaged assets. -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -exec "$script_dir/omarchy-plymouth-set" --refresh-default +exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default From 05fb36d3cc719fedbc534602f6a17c22eb2b928d Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 15:05:13 +0200 Subject: [PATCH 05/13] Build Plymouth assets in a root-owned stage --- bin/omarchy-plymouth-set | 422 ++++++++++++++---------------- test/shell.d/plymouth-set-test.sh | 112 ++++---- 2 files changed, 261 insertions(+), 273 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index c9c663b5..68f13ff8 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -7,19 +7,25 @@ set -euo pipefail -# Configure the Plymouth boot theme with a custom background color, text color, -# and logo. Assets are prepared without privileges, pinned to their recorded -# hashes, then published one at a time through root-owned temporary files. +# Build the authoritative theme in a root-owned directory, then publish each +# fixed destination atomically. The caller opens the selected logo before sudo, +# so the privileged process never resolves a user-controlled input path. refresh_default=false -if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then +if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then refresh_default=true elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -if ! $refresh_default; then +logo_fd= +if $refresh_default; then + mode=refresh + bg_hex= + text_hex= +else + mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -39,230 +45,215 @@ if ! $refresh_default; then exit 1 fi - # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from - # ~/.config/omarchy/themes, where an installed theme can make it a symlink to - # anything. The copies below land in world-readable /usr/share, so following - # one would republish whatever it points at. if [[ -L $logo_path ]]; then echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 exit 1 fi + + # Open the logo while still unprivileged. A replacement symlink to a root-only + # file therefore fails here instead of being followed after sudo starts. + if ! exec {logo_fd}<"$logo_path"; then + echo "Unable to open logo file as the current user: $logo_path" >&2 + exit 1 + fi + if [[ ! -f /proc/$$/fd/$logo_fd ]]; then + echo "Logo input is no longer a regular file: $logo_path" >&2 + exit 1 + fi fi -theme_dir="/usr/share/plymouth/themes/omarchy" -sddm_dir="/usr/share/sddm/themes/omarchy" -max_asset_size=$((64 * 1024 * 1024)) +run_root_transaction() { + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH -plymouth_theme_assets=( - bullet.png - entry.png - lock.png - logo.png - omarchy.plymouth - omarchy.script - preview-unlock.png - progress_bar.png - progress_box.png -) -plymouth_default_assets=( - "${plymouth_theme_assets[@]}" - logos/oma.png -) -sddm_theme_assets=( - Main.qml - bullet.png - entry-failed.png - entry.png - lock-failed.png - lock.png - logo.png -) + mode=$1 + source_root=$2 + bg_hex=$3 + text_hex=$4 + max_asset_size=$5 -staging_dir=$(mktemp -d) -trap 'rm -rf -- "$staging_dir"' EXIT -chmod 0700 "$staging_dir" -plymouth_stage="$staging_dir/plymouth" -sddm_stage="$staging_dir/sddm" -mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + [[ $mode == "set" || $mode == "refresh" ]] + [[ $source_root == /* ]] + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + [[ $max_asset_size =~ ^[0-9]+$ ]] + (( max_asset_size > 0 )) -copy_regular_file() { - local source="$1" destination="$2" + if [[ $mode == "set" ]]; then + [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] + [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] + fi - if [[ ! -f $source || -L $source ]]; then - echo "Refusing non-regular or symlinked asset: $source" >&2 - exit 1 - fi + theme_dir=/usr/share/plymouth/themes/omarchy + sddm_dir=/usr/share/sddm/themes/omarchy + plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png + ) + plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) + sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - # -P ensures a source swapped to a symlink is copied as a symlink instead of - # followed; the post-copy check then rejects it. - cp -P --reflink=never -- "$source" "$destination" - if [[ ! -f $destination || -L $destination ]]; then - rm -f -- "$destination" - echo "Asset changed while it was being staged: $source" >&2 - exit 1 - fi - chmod 0600 "$destination" + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_file() { + local source=$1 canonical uid file_mode size + + [[ -f $source && ! -L $source ]] + canonical=$(realpath -e -- "$source") + [[ $canonical == "$source" ]] + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + file_mode=$(stat -c %a -- "$source") + size=$(stat -c %s -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + (( size > 0 && size <= max_asset_size )) + } + + copy_trusted_file() { + local source=$1 destination=$2 + + validate_trusted_file "$source" + cp --reflink=never -- "$source" "$destination" + chown 0:0 -- "$destination" + chmod 0600 -- "$destination" + } + + staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) + temporary= + cleanup() { + [[ -z $temporary ]] || rm -f -- "$temporary" + rm -rf -- "$staging_dir" + } + trap cleanup EXIT HUP INT TERM + chown 0:0 -- "$staging_dir" + chmod 0700 -- "$staging_dir" + + plymouth_stage=$staging_dir/plymouth + sddm_stage=$staging_dir/sddm + mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" + + if [[ $mode == "refresh" ]]; then + assets_to_stage=("${plymouth_default_assets[@]}") + else + assets_to_stage=("${plymouth_theme_assets[@]}") + fi + + for asset in "${assets_to_stage[@]}"; do + copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" + done + + if [[ $mode == "set" ]]; then + # stdin was opened by the unprivileged caller. Read no more than the + # documented limit into the root-owned stage before doing other work. + head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" + logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") + (( logo_size > 0 && logo_size <= max_asset_size )) + chown 0:0 -- "$plymouth_stage/logo.png" + chmod 0600 -- "$plymouth_stage/logo.png" + cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) "BEGIN{printf \"%.3f\", n/255}") + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml" + sed -i \ + -e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \ + -e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done + chown -R 0:0 -- "$staging_dir" + find "$staging_dir" -type f -exec chmod 0600 -- {} + + fi + + publish_asset() { + local source=$1 destination=$2 parent filename source_size copied_size + + [[ -f $source && ! -L $source ]] + (( $(stat -c %u -- "$source") == 0 )) + source_size=$(stat -c %s -- "$source") + (( source_size > 0 && source_size <= max_asset_size )) + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]] + validate_trusted_directory "$parent" + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cp --reflink=never -- "$source" "$temporary" + copied_size=$(stat -c %s -- "$temporary") + (( copied_size == source_size )) + cmp -s -- "$source" "$temporary" + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + temporary= + } + + for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + + if [[ $mode == "set" ]]; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + validate_trusted_directory "$sddm_dir" + rm -f -- "$sddm_dir/logo.svg" + fi + ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } if $refresh_default; then - assets_to_stage=("${plymouth_default_assets[@]}") + run_root_transaction &2 - exit 1 - fi - - size=$(stat -c %s -- "$source") - if (( size == 0 || size > max_asset_size )); then - echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 - exit 1 - fi - hash=$(sha256sum -- "$source") - staged_sizes["$source"]=$size - staged_hashes["$source"]=${hash%% *} -} - -# Record every asset before the first sudo prompt. Root verifies both values -# after consuming stdin, so an in-place rewrite after this point fails instead -# of changing what is published. This pins the recorded result, not the trust -# of an already-user-writable OMARCHY_PATH checkout. -for asset in "${assets_to_stage[@]}"; do - record_staged_asset "$plymouth_stage/$asset" -done -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - record_staged_asset "$sddm_stage/$asset" - done -fi - -publish_asset() { - local source="$1" destination="$2" - local expected_hash="${staged_hashes[$source]}" - local expected_size="${staged_sizes[$source]}" - - # The caller's shell opens source before sudo starts. Root reads only stdin, - # verifies the recorded bytes, and never opens or chmods the final pathname. - # Its temporary file is on the destination filesystem, so mv is atomic for - # this one asset and replaces a destination symlink instead of following it. - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - expected_hash=$2 - expected_size=$3 - max_size=$4 - - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] - [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] - (( expected_size > 0 && expected_size <= max_size )) - - parent=${destination%/*} - filename=${destination##*/} - [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - - temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cleanup() { rm -f -- "$temporary"; } - trap cleanup EXIT HUP INT TERM - - head -c "$((expected_size + 1))" >"$temporary" - actual_size=$(stat -c %s -- "$temporary") - (( actual_size == expected_size )) - actual_hash=$(sha256sum -- "$temporary") - [[ ${actual_hash%% *} == "$expected_hash" ]] - - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" - sync -f -- "$temporary" - mv --no-copy -fT -- "$temporary" "$destination" - trap - EXIT HUP INT TERM - ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" -} - -remove_legacy_asset() { - local destination="$1" - - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - parent=${destination%/*} - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - rm -f -- "$destination" - ' bash "$destination" -} - -for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" -done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -270,10 +261,3 @@ if omarchy-cmd-present limine-mkinitcpio; then else sudo mkinitcpio -P fi - -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" - done - remove_legacy_asset "$sddm_dir/logo.svg" -fi diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 72fdca8c..b85d14f8 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -162,40 +162,20 @@ done case "$1" in /bin/bash) - [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + [[ ${2:-} == -c && $# == 9 ]] || exit 90 code=$3 shell_name=$4 - original_destination=$5 - printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - - if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then - expected_size=${7:-0} - case "$original_destination" in - /usr/share/plymouth/themes/omarchy/*) - relative=${original_destination#/usr/share/plymouth/themes/omarchy/} - stage_kind=plymouth - ;; - /usr/share/sddm/themes/omarchy/*) - relative=${original_destination#/usr/share/sddm/themes/omarchy/} - stage_kind=sddm - ;; - *) exit 91 ;; - esac - stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) - source="$stage_root/$stage_kind/$relative" - /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" - printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" - fi - - mapped_destination="$TEST_FAKE_ROOT$original_destination" - shift 5 + shift 4 + printf 'root transaction\n' >>"$TEST_SUDO_LOG" # The production helper intentionally resets PATH. For this unprivileged - # simulation only, substitute stat/chown shims so a uid-1000 test directory - # behaves like the root-owned /usr/share directory used in production. + # simulation only, substitute trusted tools and map fixed system destinations + # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} + code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ - /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" + /bin/bash -c "$code" "$shell_name" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" @@ -211,21 +191,29 @@ SH cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} -if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then +if [[ ${1:-} == -c && ${2:-} == %u ]]; then + if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + printf '1000\n' + exit 0 + fi printf '0\n' exit 0 fi +if [[ ${1:-} == -c && ${2:-} == %a && $last == /tmp ]]; then + printf '755\n' + exit 0 +fi exec /usr/bin/stat "$@" SH cat >"$root_tools/chown" <<'SH' #!/bin/bash last=${!#} -[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +[[ $last == "$TEST_FAKE_ROOT"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93 exit 0 SH -cat >"$fake_bin/magick" <<'SH' +cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 destination=${@: -1} @@ -246,7 +234,6 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" - mutate_log="$run_dir/mutated.log" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -301,7 +288,6 @@ run_set_colors() { TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ "$@" \ /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) @@ -330,14 +316,12 @@ for requested_umask in 022 027 077; do [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" done for asset in "${sddm_theme_assets[@]}"; do destination="$sddm/$asset" [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" done cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" @@ -373,9 +357,9 @@ if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then fi pass "White theme keeps a white SDDM background instead of becoming black-on-black" -# Swap the first staged source to an unreadable file after all hashes have been -# recorded but in the DEBUG hook immediately before Bash opens the redirection. -# The caller-side open must fail, so sudo never starts and nothing is published. +# Swap the selected logo to an unreadable file in the DEBUG hook immediately +# before Bash opens its descriptor. The caller-side open must fail, so sudo +# never starts and nothing is published. setup_run preopen_hook="$run_dir/preopen-hook" preopen_marker="$run_dir/preopen-marker" @@ -385,11 +369,10 @@ cat >"$preopen_hook" <<'SH' if [[ $0 == */bin/omarchy-plymouth-set ]]; then set -T trap ' - if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && - $BASH_COMMAND == sudo\ /bin/bash\ -c* && + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && ! -e $TEST_PREOPEN_MARKER ]]; then - mv -T -- "$source" "$source.before-preopen-swap" - ln -s -- "$TEST_SECRET" "$source" + mv -T -- "$logo_path" "$logo_path.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$logo_path" printf "swapped\n" >"$TEST_PREOPEN_MARKER" fi ' DEBUG @@ -399,32 +382,55 @@ SH output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) status=$? chmod 0600 "$secret" +rm -f "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-preopen-swap" "$test_tmp/logo.png" (( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" [[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" -if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then fail "sudo started despite the caller-side open failure" fi assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" -# Rewrite a staged file in place after Bash has opened it but before root reads -# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +# Plant both a malicious script and a root-file symlink where the old +# caller-owned stage lived. The privileged transaction must ignore that tree: +# executable/config assets come only from its root-trusted source and are built +# in its own root-owned stage. setup_run -mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' -output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) +attacker_stage="$stages/tmp.attacker" +mkdir -p "$attacker_stage/plymouth" +printf 'MALICIOUS BOOT SCRIPT\n' >"$attacker_stage/plymouth/omarchy.script" +ln -s "$secret" "$attacker_stage/plymouth/logo.png" + +output=$(run_set 022 env 2>&1) status=$? -(( status != 0 )) || fail "an in-place rewrite after open aborts publication" -[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" -[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" -[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +(( status == 0 )) || fail "a planted caller-owned stage cannot disrupt publication" "$output" +! grep -Rqs 'MALICIOUS BOOT SCRIPT' "$fake_root" || fail "caller-owned staged content reached the boot theme" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "the trusted Plymouth script replaces the planted destination symlink" +grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "the installed script was derived from the trusted packaged source" +unexpected_stages=$(find "$stages" -mindepth 1 -maxdepth 1 ! -name tmp.attacker -print) +[[ -z $unexpected_stages ]] || fail "the caller created an authoritative staging directory" "$unexpected_stages" assert_no_temporary_files "$fake_root" -pass "recorded size and SHA-256 reject a same-inode rewrite after open" +pass "caller-owned content cannot enter the root-owned boot-image stage" + +# A user-owned source checkout would put the same pre-hash race on the input +# side of the root stage. Refuse it before any fixed destination is replaced. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned packaged source tree is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +assert_no_temporary_files "$fake_root" + +pass "root rejects packaged assets that a desktop process could rewrite" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -459,7 +465,6 @@ output=$( TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 ) status=$? @@ -469,7 +474,6 @@ for asset in "${plymouth_default_assets[@]}"; do destination="$theme/$asset" cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" done [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" From d3b7810a7690044768a423f11cc5b1d7914799b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 16:00:50 +0200 Subject: [PATCH 06/13] Say why the privileged Plymouth transaction refused Every check inside the root shell is a bare [[ ]] or (( )) assertion that aborts under set -e, so a refusal exited with status 1 and no output at all. The floating-terminal wrapper then printed its green "Done!" for any status but 130, so a failed boot-theme change read as a success. The refusal a working machine actually hits is omarchy dev link, which points OMARCHY_PATH at a checkout the desktop user owns. Name that case outright and point at omarchy dev unlink; report every other rejection through an ERR trap that names what failed validation. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-plymouth-set | 35 ++++++++++++++++++++++++++++--- test/shell.d/plymouth-set-test.sh | 19 +++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 68f13ff8..51d37e45 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -64,23 +64,47 @@ fi run_root_transaction() { sudo /bin/bash -c ' - set -euo pipefail + set -eEuo pipefail PATH=/usr/bin:/bin export PATH + # Every check below is a bare assertion that aborts under set -e. Name the + # subject of each one so a refusal reaches the user instead of exiting mute. + failure_context="the privileged Plymouth transaction" + failure_reported= + report_failure() { + [[ -z $failure_reported ]] || return 0 + failure_reported=1 + printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2 + } + trap report_failure ERR + mode=$1 source_root=$2 bg_hex=$3 text_hex=$4 max_asset_size=$5 + failure_context="the arguments of the privileged transaction" [[ $mode == "set" || $mode == "refresh" ]] [[ $source_root == /* ]] - canonical_source_root=$(realpath -e -- "$source_root") - [[ $canonical_source_root == "$source_root" ]] [[ $max_asset_size =~ ^[0-9]+$ ]] (( max_asset_size > 0 )) + failure_context="the Omarchy source tree $source_root" + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + + # The one refusal a healthy machine can hit: omarchy dev link points + # OMARCHY_PATH at a checkout the desktop user can rewrite, and executable + # boot assets cannot come from there. Say so rather than failing mute. + source_root_uid=$(stat -c %u -- "$source_root") + if (( source_root_uid != 0 )); then + printf "omarchy-plymouth-set: %s is not root-owned, so it cannot supply executable boot assets.\n" "$source_root" >&2 + printf "omarchy-plymouth-set: this is expected on a development checkout; run omarchy dev unlink to publish from /usr/share/omarchy.\n" >&2 + exit 1 + fi + if [[ $mode == "set" ]]; then [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] @@ -105,10 +129,12 @@ run_root_transaction() { validate_trusted_directory() { local directory=$1 canonical uid directory_mode + failure_context="directory $directory" canonical=$(realpath -e -- "$directory") [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] while :; do + failure_context="directory $directory (must be root-owned and not group- or world-writable)" uid=$(stat -c %u -- "$directory") directory_mode=$(stat -c %a -- "$directory") (( uid == 0 )) @@ -122,6 +148,7 @@ run_root_transaction() { validate_trusted_file() { local source=$1 canonical uid file_mode size + failure_context="packaged source file $source" [[ -f $source && ! -L $source ]] canonical=$(realpath -e -- "$source") [[ $canonical == "$source" ]] @@ -170,6 +197,7 @@ run_root_transaction() { if [[ $mode == "set" ]]; then # stdin was opened by the unprivileged caller. Read no more than the # documented limit into the root-owned stage before doing other work. + failure_context="the selected logo (expected 1 to $max_asset_size bytes)" head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") (( logo_size > 0 && logo_size <= max_asset_size )) @@ -211,6 +239,7 @@ run_root_transaction() { publish_asset() { local source=$1 destination=$2 parent filename source_size copied_size + failure_context="destination $destination" [[ -f $source && ! -L $source ]] (( $(stat -c %u -- "$source") == 0 )) source_size=$(stat -c %s -- "$source") diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index b85d14f8..ae146c98 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -428,10 +428,27 @@ status=$? (( status != 0 )) || fail "a user-owned packaged source tree is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" [[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source says why it refused" "$output" assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" +# omarchy dev link points OMARCHY_PATH at a checkout the desktop user owns, so +# this refusal fires on a working machine, not only under attack. Every check in +# the privileged transaction is a bare assertion that aborts under set -e, so +# without a diagnostic the whole Plymouth menu would just close in silence. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected" +[[ $output == *"is not root-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" +[[ $output == *"omarchy dev unlink"* ]] || fail "the refusal names the way back to a trusted tree" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +pass "a development checkout is refused with an explanation instead of in silence" + # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. setup_run @@ -441,6 +458,7 @@ output=$(run_set 022 env 2>&1) status=$? (( status != 0 )) || fail "a symlinked destination parent is rejected" [[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected symlinked parent says why it refused" "$output" assert_no_temporary_files "$fake_root" setup_run @@ -449,6 +467,7 @@ output=$(run_set 022 env 2>&1) status=$? (( status != 0 )) || fail "a writable destination parent is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected writable parent says why it refused" "$output" assert_no_temporary_files "$fake_root" pass "publication rejects symlinked and non-root-writable destination parents" From 079d11651159d0e1850e45bb734be1f64b8328c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 16:01:01 +0200 Subject: [PATCH 07/13] Cover the Plymouth parent-chain walk and guard its test harness Deleting the whole ancestor walk from validate_trusted_directory and checking only the immediate parent left the suite green, so the invariant the design rests on had no coverage: a writable ancestor lets an attacker swap a validated directory out from under the leaf. Reject a run whose destination grandparent is world-writable while the destination itself is pristine. The harness also rewrites the root script's fixed /usr/share paths by string substitution. A drifted string silently no-ops that rewrite and would point the simulation at the real system tree, so assert each substitution landed. Assert the scratch directory exists too: a failed mktemp left it empty and the suite carried on building paths from it. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- test/shell.d/plymouth-set-test.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index ae146c98..77d21af3 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -5,6 +5,8 @@ set -uo pipefail source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) +[[ -n $test_tmp && -d $test_tmp ]] || + fail "the test creates its own scratch directory before touching anything" secret="$test_tmp/secret" trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT @@ -174,6 +176,13 @@ case "$1" in code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} + + # Each rewrite above silently no-ops if the production text drifts, which + # would point this simulation at the real /usr/share. Refuse instead. + [[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94 + [[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94 + [[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94 + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ /bin/bash -c "$code" "$shell_name" "$@" ;; @@ -472,6 +481,24 @@ assert_no_temporary_files "$fake_root" pass "publication rejects symlinked and non-root-writable destination parents" +# Walking the whole chain, not just the immediate parent, is what closes the +# rename race: a writable ancestor lets an attacker swap an entire validated +# directory out from under the leaf. Leave the destination itself pristine so +# only the ancestor can be at fault. +setup_run +chmod 0777 "$fake_root/usr/share/plymouth" +output=$(run_set 022 env 2>&1) +status=$? +chmod 0755 "$fake_root/usr/share/plymouth" + +(( status != 0 )) || fail "a writable destination ancestor is rejected" "$output" +[[ $(stat -c %a "$theme") == 755 ]] || fail "only the ancestor, not the destination, was untrustworthy" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable ancestor leaves the live destination unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected ancestor says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "publication walks the whole parent chain, not only the immediate parent" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run From 363db1f569a9017acca3619b0a6e22757c77696a Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 16:19:31 +0200 Subject: [PATCH 08/13] Keep Plymouth publishing working in dev mode --- bin/omarchy-plymouth-set | 96 +++++++++++++++++++++---------- test/shell.d/plymouth-set-test.sh | 47 ++++++++++++--- 2 files changed, 107 insertions(+), 36 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 51d37e45..f4c9b792 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -76,6 +76,9 @@ run_root_transaction() { [[ -z $failure_reported ]] || return 0 failure_reported=1 printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2 + if [[ -n ${failure_hint:-} ]]; then + printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2 + fi } trap report_failure ERR @@ -95,14 +98,65 @@ run_root_transaction() { canonical_source_root=$(realpath -e -- "$source_root") [[ $canonical_source_root == "$source_root" ]] - # The one refusal a healthy machine can hit: omarchy dev link points - # OMARCHY_PATH at a checkout the desktop user can rewrite, and executable - # boot assets cannot come from there. Say so rather than failing mute. + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + failure_context="directory $directory" + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + failure_context="directory $directory (must be root-owned and not group- or world-writable)" + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_configuration_file() { + local configuration=$1 canonical uid configuration_mode size + + failure_context="root configuration $configuration" + [[ -f $configuration && ! -L $configuration ]] + canonical=$(realpath -e -- "$configuration") + [[ $canonical == "$configuration" ]] + validate_trusted_directory "${configuration%/*}" + uid=$(stat -c %u -- "$configuration") + configuration_mode=$(stat -c %a -- "$configuration") + size=$(stat -c %s -- "$configuration") + (( uid == 0 )) + (( (8#$configuration_mode & 0022) == 0 )) + (( size > 0 && size <= 4096 )) + } + + # A packaged tree must be root-owned. A development checkout is the one + # deliberate exception: omarchy dev link records its canonical path in a + # root-owned /etc/omarchy.conf. That is already an explicit decision to run + # privileged Omarchy commands from user-editable code in the checkout, so + # reading its packaged assets does not widen the development trust boundary. + development_source=false source_root_uid=$(stat -c %u -- "$source_root") if (( source_root_uid != 0 )); then - printf "omarchy-plymouth-set: %s is not root-owned, so it cannot supply executable boot assets.\n" "$source_root" >&2 - printf "omarchy-plymouth-set: this is expected on a development checkout; run omarchy dev unlink to publish from /usr/share/omarchy.\n" >&2 - exit 1 + omarchy_conf=/etc/omarchy.conf + failure_context="$source_root is user-owned and $omarchy_conf must contain its trusted dev-link authorization; run omarchy dev link to authorize it" + failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree" + validate_trusted_configuration_file "$omarchy_conf" + + quoted_source_root=$source_root + quoted_source_root=${quoted_source_root//\\/\\\\} + quoted_source_root=${quoted_source_root//\"/\\\"} + quoted_source_root=${quoted_source_root//\$/\\\$} + quoted_source_root=${quoted_source_root//\`/\\\`} + expected_config_line="export OMARCHY_PATH=\"$quoted_source_root\"" + mapfile -t omarchy_config_lines <"$omarchy_conf" + (( ${#omarchy_config_lines[@]} == 1 )) + [[ ${omarchy_config_lines[0]} == "$expected_config_line" ]] + development_source=true + failure_hint= fi if [[ $mode == "set" ]]; then @@ -126,25 +180,6 @@ run_root_transaction() { plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - validate_trusted_directory() { - local directory=$1 canonical uid directory_mode - - failure_context="directory $directory" - canonical=$(realpath -e -- "$directory") - [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] - - while :; do - failure_context="directory $directory (must be root-owned and not group- or world-writable)" - uid=$(stat -c %u -- "$directory") - directory_mode=$(stat -c %a -- "$directory") - (( uid == 0 )) - (( (8#$directory_mode & 0022) == 0 )) - [[ $directory == "/" ]] && break - directory=${directory%/*} - [[ -n $directory ]] || directory=/ - done - } - validate_trusted_file() { local source=$1 canonical uid file_mode size @@ -152,13 +187,16 @@ run_root_transaction() { [[ -f $source && ! -L $source ]] canonical=$(realpath -e -- "$source") [[ $canonical == "$source" ]] - validate_trusted_directory "${source%/*}" - uid=$(stat -c %u -- "$source") file_mode=$(stat -c %a -- "$source") size=$(stat -c %s -- "$source") - (( uid == 0 )) - (( (8#$file_mode & 0022) == 0 )) (( size > 0 && size <= max_asset_size )) + + if ! $development_source; then + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + fi } copy_trusted_file() { diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 77d21af3..8a40cbf7 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -174,12 +174,14 @@ case "$1" in # simulation only, substitute trusted tools and map fixed system destinations # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/omarchy_conf=\/etc\/omarchy.conf/omarchy_conf=$TEST_OMARCHY_CONF} code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} # Each rewrite above silently no-ops if the production text drifts, which # would point this simulation at the real /usr/share. Refuse instead. [[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94 + [[ $code == *"omarchy_conf=$TEST_OMARCHY_CONF"* ]] || exit 94 [[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94 [[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94 @@ -243,6 +245,7 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" + omarchy_conf="$run_dir/omarchy.conf" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -295,6 +298,7 @@ run_set_colors() { TEST_FAKE_ROOT="$fake_root" \ TEST_STAGES="$stages" \ TEST_ROOT_TOOLS="$root_tools" \ + TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ "$@" \ @@ -442,21 +446,49 @@ assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" -# omarchy dev link points OMARCHY_PATH at a checkout the desktop user owns, so -# this refusal fires on a working machine, not only under attack. Every check in -# the privileged transaction is a bare assertion that aborts under set -e, so -# without a diagnostic the whole Plymouth menu would just close in silence. +# A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical +# checkout recorded by root in /etc/omarchy.conf is the supported dev-link +# exception; an unrelated or stale authorization must not weaken the check. setup_run output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) status=$? (( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected" -[[ $output == *"is not root-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" -[[ $output == *"omarchy dev unlink"* ]] || fail "the refusal names the way back to a trusted tree" "$output" +[[ $output == *"user-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" +[[ $output == *"omarchy dev link"* ]] || fail "the refusal names how to authorize a development checkout" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged" assert_no_temporary_files "$fake_root" -pass "a development checkout is refused with an explanation instead of in silence" +setup_run +printf 'export OMARCHY_PATH="/some/other/checkout"\n' >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a stale dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0666 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a writable dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output" +cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets" + +pass "only the checkout explicitly authorized by omarchy dev link may be user-owned" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -509,6 +541,7 @@ output=$( TEST_FAKE_ROOT="$fake_root" \ TEST_STAGES="$stages" \ TEST_ROOT_TOOLS="$root_tools" \ + TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 From d67a7c00bcb7276182635bdf12b4f8ca3b18fafe Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:39:33 +0200 Subject: [PATCH 09/13] Cover the ownership and mode checks the publisher rests on Mutating each root-side control one at a time showed three that no test could see: a destination directory root does not own, a single user-owned asset inside an otherwise root-owned packaged directory, and an asset left group- or world-writable by its own mode. Deleting any of the three left the suite green, because the existing cases mark a whole tree untrusted and are caught by the directory check before the per-file one is reached. The harness already had the hook for the ownership pair: TEST_UNTRUSTED_SOURCE makes the stat shim report a chosen prefix as uid 1000, so those two only need it pointed at a destination directory and at a single file rather than at a whole tree. A mode has to be real, so that case stages a copy of the packaged tree the shim reports as root-owned and loosens one asset in it. The empty logo is refused by the destination size bound rather than the caller-side one, so that case pins the behaviour without isolating the check; the two bounds are exactly redundant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki --- test/shell.d/plymouth-set-test.sh | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 8a40cbf7..194879eb 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -531,6 +531,69 @@ assert_no_temporary_files "$fake_root" pass "publication walks the whole parent chain, not only the immediate parent" +# Mode is not the only thing that decides a destination directory. One that is +# merely user-owned still lets its owner put the file back after we publish, so +# ownership has to refuse it even when 0755 looks harmless. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$theme" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned destination directory is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned destination directory keeps its live file" +[[ $output == *"refusing to publish"* ]] || fail "a rejected destination directory says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "publication refuses a destination directory root does not own" + +# The packaged tree is validated file by file, not only directory by directory. +# A single user-owned asset inside an otherwise root-owned directory is still +# content a desktop process can rewrite, and the directory check cannot see it. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth/bullet.png" 2>&1) +status=$? + +(( status != 0 )) || fail "a single user-owned packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "one untrusted asset leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged asset says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root checks every packaged asset, not only its directory" + +# Ownership is not the only way a packaged asset stays rewritable: a group- or +# world-writable mode does it too. Stage a tree the shim reports as root-owned +# so only the real mode can decide, then loosen one asset. +setup_run +writable_root=$(mktemp -d "$test_tmp/writable-source.XXXXXXXX") +writable_root=$(realpath -e -- "$writable_root") +mkdir -p "$writable_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_root/default/" +chmod 0666 "$writable_root/default/plymouth/bullet.png" +output=$(run_set 022 env OMARCHY_PATH="$writable_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a world-writable packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged asset leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected writable asset says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root refuses a packaged asset its own mode leaves rewritable" + +# The caller streams the logo to root over a descriptor, so root is the only +# place its length can be judged. An empty selection must not become an empty +# published logo. +setup_run +cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep" +: >"$test_tmp/logo.png" +output=$(run_set 022 env 2>&1) +status=$? +mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png" + +(( status != 0 )) || fail "an empty logo is rejected" "$output" +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an empty logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "an empty logo cannot be published" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run From aac1b009e4ce00916d45842c88d7096aa2c0c5b6 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 17:47:42 +0200 Subject: [PATCH 10/13] Cover non-regular Plymouth logo descriptors --- test/shell.d/plymouth-set-test.sh | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 194879eb..a2a7935a 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -409,6 +409,43 @@ assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" +# Opening a directory read-only succeeds on Linux, but the resulting descriptor +# is not a regular file. Swap one in immediately before exec: this gets past the +# open itself and makes the /proc descriptor check the only caller-side control +# that can stop sudo from starting. +setup_run +nonregular_hook="$run_dir/nonregular-hook" +nonregular_marker="$run_dir/nonregular-marker" +cat >"$nonregular_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && + ! -e $TEST_NONREGULAR_MARKER ]]; then + mv -T -- "$logo_path" "$logo_path.before-nonregular-swap" + mkdir -- "$logo_path" + printf "swapped\n" >"$TEST_NONREGULAR_MARKER" + fi + ' DEBUG +fi +SH + +output=$(TEST_NONREGULAR_MARKER="$nonregular_marker" BASH_ENV="$nonregular_hook" run_set 077 env 2>&1) +status=$? +rmdir "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-nonregular-swap" "$test_tmp/logo.png" + +(( status != 0 )) || fail "a non-regular opened logo descriptor aborts publication" +[[ -s $nonregular_marker ]] || fail "the non-regular pre-open source swap ran deterministically" "$output" +[[ $output == *"no longer a regular file"* ]] || fail "the descriptor check says why it refused the opened directory" "$output" +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then + fail "sudo started despite the non-regular opened logo descriptor" +fi +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "a non-regular opened logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "the caller refuses an opened descriptor that is not a regular file" + # Plant both a malicious script and a root-file symlink where the old # caller-owned stage lived. The privileged transaction must ignore that tree: # executable/config assets come only from its root-trusted source and are built From 1b92b7f5be8683a71c9d176d5a5fc859518143b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 18:03:57 +0200 Subject: [PATCH 11/13] Correct the dev-link help now that Plymouth follows the link omarchy-dev-link told people /usr/share/plymouth was not covered by a link and to reach for omarchy-dev-pkg-test. Since the publisher authorizes a dev-linked checkout out of the root-owned /etc/omarchy.conf, the Plymouth and SDDM themes are republished from the checkout like every other $OMARCHY_PATH-resolved tree, so the help was left describing the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-dev-link | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-dev-link b/bin/omarchy-dev-link index ccaa404f..b09473b5 100755 --- a/bin/omarchy-dev-link +++ b/bin/omarchy-dev-link @@ -34,8 +34,12 @@ systemd, shell, or app-launcher environment; reboot to make every layer agree. Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/, themes/, applications/, config/. Files installed at fixed system paths (/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user -creation, /usr/share/plymouth) are NOT covered — for those, use -omarchy-dev-pkg-test to build and install the package from the checkout. +creation) are NOT covered — for those, use omarchy-dev-pkg-test to build +and install the package from the checkout. + +The Plymouth and SDDM themes under /usr/share are the exception: omarchy +plymouth set and omarchy refresh plymouth republish them from the checkout, +reading this link's authorization out of the root-owned /etc/omarchy.conf. Also writes $sudoers_file so sudo resolves omarchy-* from the checkout instead of the packaged copies. That part takes effect From e47784be1146d5b1807b33691caafc74fee3f452 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 21:02:45 +0200 Subject: [PATCH 12/13] Name the dev-link authorization when it is the thing that failed Validating /etc/omarchy.conf walks its parent chain to /, and that walk leaves its own subject behind in the global failure_context. The comparison that follows -- the one deciding whether the authorization actually names this checkout -- sets no context of its own, so an unauthorized development tree refused with: refusing to publish: directory / (must be root-owned and not group- or world-writable) failed validation naming a directory that had just passed, and pointing the reader at a filesystem problem that is not there. Seen on a worker VM running an unauthorized checkout, where / is 0555 root-owned. The hint line underneath was already right, which is what kept this from being worse. Restore the context before the comparison so the first line agrees with it, and assert both halves: that the refusal names the authorization, and that it does not blame the root directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki --- bin/omarchy-plymouth-set | 8 ++++++++ test/shell.d/plymouth-set-test.sh | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index f4c9b792..ff35b449 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -146,6 +146,14 @@ run_root_transaction() { failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree" validate_trusted_configuration_file "$omarchy_conf" + # validate_trusted_configuration_file walks /etc up to / and leaves its own + # subject behind in failure_context. Without restoring ours, a checkout + # that simply is not the authorized one refuses with "directory / must be + # root-owned and not group- or world-writable" -- naming a directory that + # passed, and sending the reader after a filesystem problem that is not + # there. + failure_context="the dev-link authorization in $omarchy_conf, which must name $source_root" + quoted_source_root=$source_root quoted_source_root=${quoted_source_root//\\/\\\\} quoted_source_root=${quoted_source_root//\"/\\\"} diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index a2a7935a..c88f49fb 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -504,6 +504,13 @@ status=$? (( status != 0 )) || fail "a stale dev-link authorization is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged" +# The refusal has to name the authorization. Validating /etc/omarchy.conf walks +# its parents and leaves that walk's subject in failure_context, so without +# restoring ours this refuses with "directory / must be root-owned and not +# group- or world-writable" -- accusing a directory that passed and pointing the +# reader at a filesystem problem that does not exist. +[[ $output == *"$omarchy_conf"* ]] || fail "a stale dev-link refusal names the authorization it rejected" "$output" +[[ $output != *"directory / "* ]] || fail "a stale dev-link refusal does not blame the root directory" "$output" assert_no_temporary_files "$fake_root" setup_run From 70e79c40ae52ac44e8a8399d835e9e0ccf0ed6f2 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 18:19:51 -0400 Subject: [PATCH 13/13] Harden Plymouth and SDDM reset publication --- bin/omarchy-plymouth-reset | 6 +- bin/omarchy-plymouth-set | 115 ++++++--- bin/omarchy-refresh-sddm | 5 +- test/shell.d/plymouth-set-test.sh | 387 ++++++++++++++++++++++++++---- 4 files changed, 428 insertions(+), 85 deletions(-) diff --git a/bin/omarchy-plymouth-reset b/bin/omarchy-plymouth-reset index 797d3cb0..fa3a3696 100755 --- a/bin/omarchy-plymouth-reset +++ b/bin/omarchy-plymouth-reset @@ -3,5 +3,7 @@ # omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen # omarchy:requires-sudo=true -omarchy-refresh-plymouth -omarchy-refresh-sddm +set -euo pipefail + +"$OMARCHY_PATH/bin/omarchy-refresh-plymouth" +"$OMARCHY_PATH/bin/omarchy-refresh-sddm" diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index ff35b449..44c32b75 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -11,21 +11,39 @@ set -euo pipefail # fixed destination atomically. The caller opens the selected logo before sudo, # so the privileged process never resolves a user-controlled input path. -refresh_default=false -if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then - refresh_default=true -elif (( $# != 3 )); then +usage() { echo "Usage: omarchy-plymouth-set " >&2 exit 1 +} + +if (( $# == 3 )); then + mode=set +elif (( $# == 1 )); then + case "$1" in + --refresh-default) + mode=refresh-plymouth + ;; + --refresh-sddm-default) + mode=refresh-sddm + ;; + *) + usage + ;; + esac +else + usage +fi + +if (( EUID == 0 )); then + echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2 + exit 1 fi logo_fd= -if $refresh_default; then - mode=refresh +if [[ $mode != "set" ]]; then bg_hex= text_hex= else - mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -67,6 +85,7 @@ run_root_transaction() { set -eEuo pipefail PATH=/usr/bin:/bin export PATH + umask 077 # Every check below is a bare assertion that aborts under set -e. Name the # subject of each one so a refusal reaches the user instead of exiting mute. @@ -89,7 +108,7 @@ run_root_transaction() { max_asset_size=$5 failure_context="the arguments of the privileged transaction" - [[ $mode == "set" || $mode == "refresh" ]] + [[ $mode == "set" || $mode == "refresh-plymouth" || $mode == "refresh-sddm" ]] [[ $source_root == /* ]] [[ $max_asset_size =~ ^[0-9]+$ ]] (( max_asset_size > 0 )) @@ -187,6 +206,22 @@ run_root_transaction() { ) plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) + sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf) + + plymouth_assets=() + sddm_assets=() + case "$mode" in + set) + plymouth_assets=("${plymouth_theme_assets[@]}") + sddm_assets=("${sddm_theme_assets[@]}") + ;; + refresh-plymouth) + plymouth_assets=("${plymouth_default_assets[@]}") + ;; + refresh-sddm) + sddm_assets=("${sddm_default_assets[@]}") + ;; + esac validate_trusted_file() { local source=$1 canonical uid file_mode size @@ -211,9 +246,7 @@ run_root_transaction() { local source=$1 destination=$2 validate_trusted_file "$source" - cp --reflink=never -- "$source" "$destination" - chown 0:0 -- "$destination" - chmod 0600 -- "$destination" + install -o 0 -g 0 -m 0600 -- "$source" "$destination" } staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) @@ -230,13 +263,7 @@ run_root_transaction() { sddm_stage=$staging_dir/sddm mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" - if [[ $mode == "refresh" ]]; then - assets_to_stage=("${plymouth_default_assets[@]}") - else - assets_to_stage=("${plymouth_theme_assets[@]}") - fi - - for asset in "${assets_to_stage[@]}"; do + for asset in "${plymouth_assets[@]}"; do copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" done @@ -280,6 +307,20 @@ run_root_transaction() { done chown -R 0:0 -- "$staging_dir" find "$staging_dir" -type f -exec chmod 0600 -- {} + + elif (( ${#sddm_assets[@]} )); then + for asset in "${sddm_assets[@]}"; do + copy_trusted_file "$source_root/default/sddm/omarchy/$asset" "$sddm_stage/$asset" + done + fi + + if (( ${#plymouth_assets[@]} )); then + validate_trusted_directory "$theme_dir" + if [[ $mode == "refresh-plymouth" ]]; then + validate_trusted_directory "$theme_dir/logos" + fi + fi + if (( ${#sddm_assets[@]} )); then + validate_trusted_directory "$sddm_dir" fi publish_asset() { @@ -298,23 +339,23 @@ run_root_transaction() { validate_trusted_directory "$parent" temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cp --reflink=never -- "$source" "$temporary" + install -o 0 -g 0 -m 0644 -- "$source" "$temporary" copied_size=$(stat -c %s -- "$temporary") (( copied_size == source_size )) cmp -s -- "$source" "$temporary" - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" sync -f -- "$temporary" mv --no-copy -fT -- "$temporary" "$destination" temporary= } - for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" - done + if (( ${#plymouth_assets[@]} )); then + for asset in "${plymouth_assets[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + fi - if [[ $mode == "set" ]]; then - for asset in "${sddm_theme_assets[@]}"; do + if (( ${#sddm_assets[@]} )); then + for asset in "${sddm_assets[@]}"; do publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" done validate_trusted_directory "$sddm_dir" @@ -323,16 +364,18 @@ run_root_transaction() { ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } -if $refresh_default; then - run_root_transaction /dev/null; then + output=$(unshare --user --map-root-user env OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$secret" 2>&1) + status=$? + (( status != 0 )) || fail "omarchy-plymouth-set refuses to run as root" + [[ $output == *"as your user"* && $output == *"not under sudo"* ]] || + fail "the root refusal explains how to invoke the publisher safely" "$output" +else + grep -A2 -Eq '^if \(\( EUID == 0 \)\); then$' "$ROOT/bin/omarchy-plymouth-set" || + fail "omarchy-plymouth-set retains its root-invocation guard" +fi +pass "the logo descriptor can only be opened by an unprivileged caller" + # Style > Unlock picks a theme by name and hands the answer to # omarchy-launch-floating-terminal-with-presentation, which joins its arguments # into a script and runs that with `bash -c`. So the name is shell source @@ -84,16 +110,26 @@ cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB' printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME" STUB -# Stands in for the real wrapper, which is a shell-string API: it interpolates -# "$*" into a script and hands that to `bash -c`. The grep below is what keeps -# this stub honest if the wrapper ever stops working that way. -cat >"$stub_dir/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +# Run the real presentation wrapper while replacing only its terminal launcher. +# The launcher stub executes the final `bash -c` locally instead of opening a +# terminal window. +ln -s "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" "$stub_dir/omarchy-launch-floating-terminal-with-presentation" + +cat >"$stub_dir/omarchy-restart-gum" <<'STUB' #!/bin/bash -exec bash -c "omarchy-show-logo; $*; omarchy-show-done" +: STUB -grep -Fq 'bash -c "$presentation_script"' "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" || - fail "the presentation wrapper still runs its argument as a shell string, as the stub above assumes" +cat >"$stub_dir/setsid" <<'STUB' +#!/bin/bash +while (( $# >= 3 )); do + if [[ $1 == "bash" && $2 == "-c" ]]; then + exec bash -c "$3" + fi + shift +done +exit 97 +STUB # Records what actually arrived, so a name that survived as data is told apart # from one that arrived split or partly eaten. @@ -203,7 +239,8 @@ cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} if [[ ${1:-} == -c && ${2:-} == %u ]]; then - if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + if [[ (-n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"*) || + (-n ${TEST_UNTRUSTED_CONFIGURATION:-} && $last == "$TEST_UNTRUSTED_CONFIGURATION"*) ]]; then printf '1000\n' exit 0 fi @@ -224,6 +261,35 @@ last=${!#} exit 0 SH +cat >"$root_tools/install" <<'SH' +#!/bin/bash +mode= +while (( $# )); do + case "$1" in + -o | -g) + shift 2 + ;; + -m) + mode=$2 + shift 2 + ;; + --) + shift + break + ;; + *) + exit 96 + ;; + esac +done + +(( $# == 2 )) || exit 96 +[[ $mode == "0600" || $mode == "0644" ]] || exit 96 +destination=$2 +[[ $destination == "$TEST_FAKE_ROOT"* || $destination == /tmp/omarchy-plymouth.* ]] || exit 93 +exec /usr/bin/install -m "$mode" -- "$1" "$destination" +SH + cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 @@ -267,7 +333,7 @@ setup_run() { printf 'old plymouth %s\n' "$asset" >"$destination" chmod 0600 "$destination" done - for asset in "${sddm_theme_assets[@]}" metadata.desktop theme.conf; do + for asset in "${sddm_default_assets[@]}"; do destination="$sddm/$asset" printf 'old sddm %s\n' "$asset" >"$destination" chmod 0600 "$destination" @@ -287,9 +353,26 @@ setup_run() { ln -s "$legacy_victim" "$sddm/logo.svg" } -run_set_colors() { - local requested_umask="$1" background="$2" text="$3" - shift 3 +setup_fresh_run() { + local asset destination + + setup_run + for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + rm -f -- "$destination" + /usr/bin/install -m 0644 -- "$ROOT/default/plymouth/$asset" "$destination" + done + for asset in "${sddm_default_assets[@]}"; do + destination="$sddm/$asset" + rm -f -- "$destination" + /usr/bin/install -m 0644 -- "$ROOT/default/sddm/omarchy/$asset" "$destination" + done + rm -f -- "$sddm/logo.svg" +} + +run_in_fake_root() { + local requested_umask="$1" + shift ( umask "$requested_umask" PATH="$fake_bin:$ROOT/bin:$PATH" \ @@ -301,25 +384,56 @@ run_set_colors() { TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - "$@" \ - /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" + "$@" ) } +run_set_colors() { + local requested_umask="$1" background="$2" text="$3" + shift 3 + run_in_fake_root "$requested_umask" "$@" \ + /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" +} + run_set() { local requested_umask="$1" shift run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@" } +run_refresh_plymouth() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" +} + +run_refresh_sddm() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-sddm" +} + +run_reset() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-plymouth-reset" +} + assert_no_temporary_files() { local directory="$1" leftovers leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) [[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers" } +assert_packaged_assets() { + local context=$1 source_dir=$2 destination_dir=$3 + shift 3 + + local asset destination + for asset in "$@"; do + destination="$destination_dir/$asset" + cmp -s "$source_dir/$asset" "$destination" || fail "$context publishes the packaged $asset bytes" + [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || + fail "$context publishes $asset as a regular mode-0644 file" + done +} + for requested_umask in 022 027 077; do - setup_run + setup_fresh_run output=$(run_set "$requested_umask" env 2>&1) status=$? (( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output" @@ -342,20 +456,35 @@ for requested_umask in 022 027 077; do grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask" grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask" - [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "Plymouth destination symlink never changes its victim" - [[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "Main.qml destination symlink never changes its victim" - [[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "legacy logo.svg removal never changes its victim" - [[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "legacy logo.svg is removed" - - [[ $(cat "$theme/logos/oma.png") == 'old plymouth logos/oma.png' && $(stat -c %a "$theme/logos/oma.png") == 600 ]] || fail "normal theme set does not broaden into the refresh-only nested asset" - [[ $(cat "$sddm/metadata.desktop") == 'old sddm metadata.desktop' ]] || fail "normal theme set leaves SDDM metadata unchanged" - [[ $(cat "$sddm/theme.conf") == 'old sddm theme.conf' ]] || fail "normal theme set leaves SDDM theme.conf unchanged" + cmp -s "$ROOT/default/plymouth/logos/oma.png" "$theme/logos/oma.png" || fail "theme set leaves the packaged nested logo unchanged" + cmp -s "$ROOT/default/sddm/omarchy/metadata.desktop" "$sddm/metadata.desktop" || fail "theme set leaves packaged SDDM metadata unchanged" + cmp -s "$ROOT/default/sddm/omarchy/theme.conf" "$sddm/theme.conf" || fail "theme set leaves packaged SDDM configuration unchanged" [[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")" [[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask" + grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "theme set activates the published Plymouth theme" + grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "theme set rebuilds the initramfs" assert_no_temporary_files "$fake_root" done -pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" +pass "a fresh installation receives complete mode-0644 Plymouth and SDDM theme files across restrictive umasks" + +# An upgraded machine may already contain restrictive modes, destination +# symlinks, and the legacy SDDM logo. Setting a theme must replace only the +# destination entries and must never write through those symlinks. +setup_run +output=$(run_set 022 env 2>&1) +status=$? + +(( status == 0 )) || fail "theme set repairs migrated Plymouth and SDDM destinations" "$output" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "theme set replaces a migrated Plymouth destination symlink" +[[ -f $sddm/Main.qml && ! -L $sddm/Main.qml ]] || fail "theme set replaces a migrated SDDM destination symlink" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "theme set never changes a Plymouth symlink victim" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "theme set never changes an SDDM symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "theme set never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "theme set removes the legacy SDDM logo" +assert_no_temporary_files "$fake_root" + +pass "theme set repairs migrated destinations without following existing symlinks" # White uses #ffffff behind #000000 text. A direct two-expression sed first # writes the white background and then consumes it as if it were the template's @@ -483,6 +612,29 @@ assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" +# A packaged filename may not redirect root to some other readable file. Use +# the explicitly authorized development-source path so its ordinary file-mode +# checks are intentionally skipped and only the leaf symlink/canonical-file +# checks can decide this case. +setup_run +symlink_source_root=$(mktemp -d "$test_tmp/symlink-source.XXXXXXXX") +symlink_source_root=$(realpath -e -- "$symlink_source_root") +mkdir -p "$symlink_source_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$symlink_source_root/default/" +rm -f "$symlink_source_root/default/plymouth/bullet.png" +ln -s "$secret" "$symlink_source_root/default/plymouth/bullet.png" +printf 'export OMARCHY_PATH="%s"\n' "$symlink_source_root" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env OMARCHY_PATH="$symlink_source_root" TEST_UNTRUSTED_SOURCE="$symlink_source_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a symlinked packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a packaged source symlink leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source symlink says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root never follows a packaged asset symlink" + # A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical # checkout recorded by root in /etc/omarchy.conf is the supported dev-link # exception; an unrelated or stale authorization must not weaken the check. @@ -523,6 +675,28 @@ status=$? [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged" assert_no_temporary_files "$fake_root" +setup_run +authorization_target="$run_dir/authorization-target" +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$authorization_target" +chmod 0644 "$authorization_target" +ln -s "$authorization_target" "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a symlinked dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" TEST_UNTRUSTED_CONFIGURATION="$omarchy_conf" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + setup_run printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" chmod 0644 "$omarchy_conf" @@ -532,7 +706,7 @@ status=$? (( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output" cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets" -pass "only the checkout explicitly authorized by omarchy dev link may be user-owned" +pass "only a regular root-owned authorization may name the exact development checkout" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -603,6 +777,25 @@ assert_no_temporary_files "$fake_root" pass "root checks every packaged asset, not only its directory" +# Keep every file root-owned and mode 0644 while making only its containing +# directory writable. Per-file checks cannot close the rename race in that +# state; the packaged source parent-chain walk must reject it. +setup_run +writable_directory_root=$(mktemp -d "$test_tmp/writable-directory-source.XXXXXXXX") +writable_directory_root=$(realpath -e -- "$writable_directory_root") +mkdir -p "$writable_directory_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_directory_root/default/" +chmod 0777 "$writable_directory_root/default/plymouth" +output=$(run_set 022 env OMARCHY_PATH="$writable_directory_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a writable packaged source directory is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged directory leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged directory says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root validates the packaged source parent chain before copying" + # Ownership is not the only way a packaged asset stays rewritable: a group- or # world-writable mode does it too. Stage a tree the shim reports as root-owned # so only the real mode can decide, then loosen one asset. @@ -638,34 +831,138 @@ assert_no_temporary_files "$fake_root" pass "an empty logo cannot be published" +# Bound the descriptor read as well as the final destination. A sparse file +# makes the real 64 MiB + 1 byte boundary deterministic without storing a +# large fixture in the repository. +setup_run +cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep" +truncate -s "$((64 * 1024 * 1024 + 1))" "$test_tmp/logo.png" +output=$(run_set 022 env 2>&1) +status=$? +mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png" + +(( status != 0 )) || fail "an oversized logo is rejected" "$output" +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an oversized logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "a logo larger than the publication bound cannot be published" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run -output=$( - PATH="$fake_bin:$ROOT/bin:$PATH" \ - TMPDIR="$stages" \ - OMARCHY_PATH="$ROOT" \ - TEST_FAKE_ROOT="$fake_root" \ - TEST_STAGES="$stages" \ - TEST_ROOT_TOOLS="$root_tools" \ - TEST_OMARCHY_CONF="$omarchy_conf" \ - TEST_SUDO_LOG="$sudo_log" \ - TEST_LEAK_LOG="$leak_log" \ - /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 -) +output=$(run_refresh_plymouth 2>&1) status=$? (( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" -for asset in "${plymouth_default_assets[@]}"; do - destination="$theme/$asset" - cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" - [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" -done +assert_packaged_assets "Plymouth refresh" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" [[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" - -grep -Fq 'sudo /bin/bash -c' "$ROOT/bin/omarchy-plymouth-set" || fail "publisher invokes Bash by its trusted absolute path" -grep -Fq 'PATH=/usr/bin:/bin' "$ROOT/bin/omarchy-plymouth-set" || fail "root helper resets PATH before resolving utilities" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "Plymouth refresh activates the restored theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "Plymouth refresh rebuilds the initramfs" pass "refresh safely publishes its complete fixed asset set, including logos/oma.png" + +# SDDM refresh has the same fixed-file contract but must leave Plymouth and the +# boot image alone. It also replaces legacy destination symlinks without +# changing their victims. +setup_run +output=$(run_refresh_sddm 2>&1) +status=$? +(( status == 0 )) || fail "SDDM refresh succeeds through the safe publisher" "$output" + +assert_packaged_assets "SDDM refresh" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "SDDM refresh leaves Plymouth unchanged" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "SDDM refresh never changes a destination symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "SDDM refresh never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "SDDM refresh removes the legacy logo.svg" +! grep -Fq 'command plymouth-set-default-theme' "$sudo_log" || fail "SDDM refresh does not activate Plymouth" +! grep -Fq 'command mkinitcpio' "$sudo_log" || fail "SDDM refresh does not rebuild the initramfs" +[[ ! -s $leak_log ]] || fail "SDDM refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" + +pass "SDDM refresh safely restores its complete packaged asset set without rebuilding Plymouth" + +# A fresh package installation already contains the complete default file set. +# Reset must be safe and idempotent in that ordinary state. +setup_fresh_run +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "reset succeeds on a fresh installation" "$output" + +assert_packaged_assets "fresh reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "fresh reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "fresh reset activates the packaged Plymouth theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "fresh reset rebuilds the initramfs" +assert_no_temporary_files "$fake_root" + +pass "reset is safe and idempotent on a fresh package installation" + +# Exercise an existing hostile state: destination symlinks stand in for an +# upgraded machine that may already contain artifacts planted through the old +# paths. Each refresh must replace its own entries without following them. +setup_run +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "combined Plymouth and SDDM reset succeeds" "$output" + +assert_packaged_assets "migrated reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "migrated reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "reset never changes a Plymouth destination symlink victim" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "reset never changes an SDDM destination symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "reset never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "reset removes the legacy logo.svg" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "reset activates the restored Plymouth theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "reset rebuilds the initramfs" +[[ ! -s $leak_log ]] || fail "reset never gives root a user-writable source pathname" "$(cat "$leak_log")" + +pass "reset safely repairs a migrated Plymouth and SDDM installation" + +# A damaged installation may retain its package-owned directories while some +# destination files are missing. Reset must recreate every allowlisted leaf. +setup_run +for asset in "${plymouth_default_assets[@]}"; do + rm -f -- "$theme/$asset" +done +for asset in "${sddm_default_assets[@]}"; do + rm -f -- "$sddm/$asset" +done +rm -f -- "$sddm/logo.svg" +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "reset repairs missing Plymouth and SDDM destinations" "$output" +assert_packaged_assets "missing-file reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "missing-file reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" + +pass "reset recreates missing files in package-owned destination trees" + +# The two refreshes are independently hardened. If Plymouth succeeds and SDDM +# then refuses its unsafe destination, the completed Plymouth refresh remains +# valid while SDDM and its symlink victims remain unchanged. +setup_run +chmod 0777 "$sddm" +output=$(run_reset 2>&1) +status=$? + +(( status != 0 )) || fail "reset rejects an unsafe SDDM destination" "$output" +assert_packaged_assets "Plymouth before SDDM refusal" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "the successful Plymouth refresh never changes its old symlink victim" +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "a rejected reset leaves SDDM unchanged" +[[ $output == *"refusing to publish"* ]] || fail "an unsafe reset destination says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "an SDDM refusal cannot make either refresh follow an unsafe destination" + +# A packaged source that fails the root trust checks must stop the combined +# reset; it cannot fall through into a second legacy SDDM copy. +setup_run +output=$(run_reset env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "reset rejects an untrusted packaged source" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted reset source leaves Plymouth unchanged" +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "an untrusted reset source leaves SDDM unchanged" +[[ $output == *"refusing to publish"* ]] || fail "an untrusted reset source says why it refused" "$output" +[[ $(grep -c '^root transaction$' "$sudo_log") == 1 ]] || fail "reset stops before SDDM when Plymouth refuses" +assert_no_temporary_files "$fake_root" + +pass "a reset refusal cannot fall through to an unhardened SDDM copy"