Build Plymouth assets in a root-owned stage

This commit is contained in:
Erik Melton
2026-08-29 15:05:13 +02:00
parent a465dfa681
commit 05fb36d3cc
2 changed files with 261 additions and 273 deletions
+203 -219
View File
@@ -7,19 +7,25 @@
set -euo pipefail set -euo pipefail
# Configure the Plymouth boot theme with a custom background color, text color, # Build the authoritative theme in a root-owned directory, then publish each
# and logo. Assets are prepared without privileges, pinned to their recorded # fixed destination atomically. The caller opens the selected logo before sudo,
# hashes, then published one at a time through root-owned temporary files. # so the privileged process never resolves a user-controlled input path.
refresh_default=false refresh_default=false
if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then
refresh_default=true refresh_default=true
elif (( $# != 3 )); then elif (( $# != 3 )); then
echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2 echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2
exit 1 exit 1
fi fi
if ! $refresh_default; then logo_fd=
if $refresh_default; then
mode=refresh
bg_hex=
text_hex=
else
mode=set
bg_hex="${1#\#}" bg_hex="${1#\#}"
text_hex="${2#\#}" text_hex="${2#\#}"
logo_path="$3" logo_path="$3"
@@ -39,230 +45,215 @@ if ! $refresh_default; then
exit 1 exit 1
fi 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 if [[ -L $logo_path ]]; then
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
exit 1 exit 1
fi 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 fi
theme_dir="/usr/share/plymouth/themes/omarchy" run_root_transaction() {
sddm_dir="/usr/share/sddm/themes/omarchy" sudo /bin/bash -c '
max_asset_size=$((64 * 1024 * 1024)) set -euo pipefail
PATH=/usr/bin:/bin
export PATH
plymouth_theme_assets=( mode=$1
bullet.png source_root=$2
entry.png bg_hex=$3
lock.png text_hex=$4
logo.png max_asset_size=$5
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) [[ $mode == "set" || $mode == "refresh" ]]
trap 'rm -rf -- "$staging_dir"' EXIT [[ $source_root == /* ]]
chmod 0700 "$staging_dir" canonical_source_root=$(realpath -e -- "$source_root")
plymouth_stage="$staging_dir/plymouth" [[ $canonical_source_root == "$source_root" ]]
sddm_stage="$staging_dir/sddm" [[ $max_asset_size =~ ^[0-9]+$ ]]
mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" (( max_asset_size > 0 ))
copy_regular_file() { if [[ $mode == "set" ]]; then
local source="$1" destination="$2" [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]
[[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]
fi
if [[ ! -f $source || -L $source ]]; then theme_dir=/usr/share/plymouth/themes/omarchy
echo "Refusing non-regular or symlinked asset: $source" >&2 sddm_dir=/usr/share/sddm/themes/omarchy
exit 1 plymouth_theme_assets=(
fi 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 validate_trusted_directory() {
# followed; the post-copy check then rejects it. local directory=$1 canonical uid directory_mode
cp -P --reflink=never -- "$source" "$destination"
if [[ ! -f $destination || -L $destination ]]; then canonical=$(realpath -e -- "$directory")
rm -f -- "$destination" [[ $canonical == "$directory" && -d $directory && ! -L $directory ]]
echo "Asset changed while it was being staged: $source" >&2
exit 1 while :; do
fi uid=$(stat -c %u -- "$directory")
chmod 0600 "$destination" 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 if $refresh_default; then
assets_to_stage=("${plymouth_default_assets[@]}") run_root_transaction </dev/null
else else
assets_to_stage=("${plymouth_theme_assets[@]}") run_root_transaction <&"$logo_fd"
fi fi
for asset in "${assets_to_stage[@]}"; do
copy_regular_file "$OMARCHY_PATH/default/plymouth/$asset" "$plymouth_stage/$asset"
done
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
# 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/#__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
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 sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then if omarchy-cmd-present limine-mkinitcpio; then
@@ -270,10 +261,3 @@ if omarchy-cmd-present limine-mkinitcpio; then
else else
sudo mkinitcpio -P sudo mkinitcpio -P
fi 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
+58 -54
View File
@@ -162,40 +162,20 @@ done
case "$1" in case "$1" in
/bin/bash) /bin/bash)
[[ ${2:-} == -c && $# -ge 5 ]] || exit 90 [[ ${2:-} == -c && $# == 9 ]] || exit 90
code=$3 code=$3
shell_name=$4 shell_name=$4
original_destination=$5 shift 4
printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" printf 'root transaction\n' >>"$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
# The production helper intentionally resets PATH. For this unprivileged # The production helper intentionally resets PATH. For this unprivileged
# simulation only, substitute stat/chown shims so a uid-1000 test directory # simulation only, substitute trusted tools and map fixed system destinations
# behaves like the root-owned /usr/share directory used in production. # under the disposable fake root.
code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} 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" \ 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) plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio)
printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG"
@@ -211,21 +191,29 @@ SH
cat >"$root_tools/stat" <<'SH' cat >"$root_tools/stat" <<'SH'
#!/bin/bash #!/bin/bash
last=${!#} 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' printf '0\n'
exit 0 exit 0
fi fi
if [[ ${1:-} == -c && ${2:-} == %a && $last == /tmp ]]; then
printf '755\n'
exit 0
fi
exec /usr/bin/stat "$@" exec /usr/bin/stat "$@"
SH SH
cat >"$root_tools/chown" <<'SH' cat >"$root_tools/chown" <<'SH'
#!/bin/bash #!/bin/bash
last=${!#} last=${!#}
[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 [[ $last == "$TEST_FAKE_ROOT"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93
exit 0 exit 0
SH SH
cat >"$fake_bin/magick" <<'SH' cat >"$root_tools/magick" <<'SH'
#!/bin/bash #!/bin/bash
source=$1 source=$1
destination=${@: -1} destination=${@: -1}
@@ -246,7 +234,6 @@ setup_run() {
fake_root="$run_dir/root" fake_root="$run_dir/root"
sudo_log="$run_dir/sudo.log" sudo_log="$run_dir/sudo.log"
leak_log="$run_dir/leaked-stage-path.log" leak_log="$run_dir/leaked-stage-path.log"
mutate_log="$run_dir/mutated.log"
theme="$fake_root/usr/share/plymouth/themes/omarchy" theme="$fake_root/usr/share/plymouth/themes/omarchy"
sddm="$fake_root/usr/share/sddm/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy"
@@ -301,7 +288,6 @@ run_set_colors() {
TEST_ROOT_TOOLS="$root_tools" \ TEST_ROOT_TOOLS="$root_tools" \
TEST_SUDO_LOG="$sudo_log" \ TEST_SUDO_LOG="$sudo_log" \
TEST_LEAK_LOG="$leak_log" \ TEST_LEAK_LOG="$leak_log" \
TEST_MUTATE_LOG="$mutate_log" \
"$@" \ "$@" \
/bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" /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" [[ -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" [[ $(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" [[ -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 done
for asset in "${sddm_theme_assets[@]}"; do for asset in "${sddm_theme_assets[@]}"; do
destination="$sddm/$asset" destination="$sddm/$asset"
[[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" [[ -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" [[ $(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" [[ -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 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" "$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 fi
pass "White theme keeps a white SDDM background instead of becoming black-on-black" 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 # Swap the selected logo to an unreadable file in the DEBUG hook immediately
# recorded but in the DEBUG hook immediately before Bash opens the redirection. # before Bash opens its descriptor. The caller-side open must fail, so sudo
# The caller-side open must fail, so sudo never starts and nothing is published. # never starts and nothing is published.
setup_run setup_run
preopen_hook="$run_dir/preopen-hook" preopen_hook="$run_dir/preopen-hook"
preopen_marker="$run_dir/preopen-marker" preopen_marker="$run_dir/preopen-marker"
@@ -385,11 +369,10 @@ cat >"$preopen_hook" <<'SH'
if [[ $0 == */bin/omarchy-plymouth-set ]]; then if [[ $0 == */bin/omarchy-plymouth-set ]]; then
set -T set -T
trap ' trap '
if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* &&
$BASH_COMMAND == sudo\ /bin/bash\ -c* &&
! -e $TEST_PREOPEN_MARKER ]]; then ! -e $TEST_PREOPEN_MARKER ]]; then
mv -T -- "$source" "$source.before-preopen-swap" mv -T -- "$logo_path" "$logo_path.before-preopen-swap"
ln -s -- "$TEST_SECRET" "$source" ln -s -- "$TEST_SECRET" "$logo_path"
printf "swapped\n" >"$TEST_PREOPEN_MARKER" printf "swapped\n" >"$TEST_PREOPEN_MARKER"
fi fi
' DEBUG ' 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) output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1)
status=$? status=$?
chmod 0600 "$secret" 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" (( status != 0 )) || fail "an unreadable pre-open source swap aborts publication"
[[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" [[ -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 "$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" [[ $(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" fail "sudo started despite the caller-side open failure"
fi fi
assert_no_temporary_files "$fake_root" assert_no_temporary_files "$fake_root"
pass "an unreadable source swap before open fails without publication" 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 # Plant both a malicious script and a root-file symlink where the old
# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. # 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 setup_run
mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' attacker_stage="$stages/tmp.attacker"
output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) 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=$?
(( status != 0 )) || fail "an in-place rewrite after open aborts publication" (( status == 0 )) || fail "a planted caller-owned stage cannot disrupt publication" "$output"
[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" ! grep -Rqs 'MALICIOUS BOOT SCRIPT' "$fake_root" || fail "caller-owned staged content reached the boot theme"
[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" [[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "the trusted Plymouth script replaces the planted destination symlink"
[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" 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" 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 # Root rejects both a symlinked parent and a group/world-writable parent before
# it creates a temporary file or touches the live destination. # it creates a temporary file or touches the live destination.
@@ -459,7 +465,6 @@ output=$(
TEST_ROOT_TOOLS="$root_tools" \ TEST_ROOT_TOOLS="$root_tools" \
TEST_SUDO_LOG="$sudo_log" \ TEST_SUDO_LOG="$sudo_log" \
TEST_LEAK_LOG="$leak_log" \ TEST_LEAK_LOG="$leak_log" \
TEST_MUTATE_LOG="$mutate_log" \
/bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1
) )
status=$? status=$?
@@ -469,7 +474,6 @@ for asset in "${plymouth_default_assets[@]}"; do
destination="$theme/$asset" destination="$theme/$asset"
cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" 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" [[ -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 done
[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" [[ -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" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets"