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"