Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158e8cfb3a | ||
|
|
e229927671 | ||
|
|
70e79c40ae | ||
|
|
c720f0b981 | ||
|
|
5236f4426c | ||
|
|
2002fb35ed | ||
|
|
e47784be11 | ||
|
|
96d5682460 | ||
|
|
bf20c94ea0 | ||
|
|
bb5b178e5f | ||
|
|
1b92b7f5be | ||
|
|
aac1b009e4 | ||
|
|
1dbc7d5bce | ||
|
|
d67a7c00bc | ||
|
|
24c18df5b7 | ||
|
|
e9ba17e52e | ||
|
|
eb76684c60 | ||
|
|
56fbaf4689 | ||
|
|
363db1f569 | ||
|
|
11fa6b9809 | ||
|
|
bf10b75150 | ||
|
|
079d116511 | ||
|
|
d3b7810a76 | ||
|
|
05fb36d3cc | ||
|
|
a465dfa681 | ||
|
|
20a23b8c16 | ||
|
|
0f15e2330f | ||
|
|
2b91fdc0d3 | ||
|
|
0b3f1b7ead | ||
|
|
f20bf0a21b | ||
|
|
9da8824098 | ||
|
|
169ad00a84 | ||
|
|
74997fd523 | ||
|
|
4fc14173b7 | ||
|
|
a165185a3f | ||
|
|
9382410026 | ||
|
|
b07374f03c | ||
|
|
68fc0cf6e6 | ||
|
|
2b923cf5bd | ||
|
|
521f1ae9ac | ||
|
|
c2587dff08 | ||
|
|
5c336885d2 | ||
|
|
877f1e96ef | ||
|
|
3b0e899029 | ||
|
|
187c268d68 | ||
|
|
75e51f0b95 | ||
|
|
c34d20ca14 | ||
|
|
b91180a808 | ||
|
|
12c350e404 | ||
|
|
8bee78bc63 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+365
-76
@@ -5,88 +5,377 @@
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} +
|
||||
cp "$logo_path" "$staging_dir/logo.png"
|
||||
|
||||
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"
|
||||
done
|
||||
|
||||
sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"
|
||||
sudo plymouth-set-default-theme omarchy
|
||||
|
||||
if omarchy-cmd-present limine-mkinitcpio; then
|
||||
sudo limine-mkinitcpio
|
||||
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
|
||||
sudo mkinitcpio -P
|
||||
usage
|
||||
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"
|
||||
if (( EUID == 0 )); then
|
||||
echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s/#1a1b26/#$bg_hex/g" \
|
||||
-e "s/#ffffff/#$text_hex/g" \
|
||||
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null
|
||||
logo_fd=
|
||||
if [[ $mode != "set" ]]; then
|
||||
bg_hex=
|
||||
text_hex=
|
||||
else
|
||||
bg_hex="${1#\#}"
|
||||
text_hex="${2#\#}"
|
||||
logo_path="$3"
|
||||
|
||||
sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"
|
||||
for asset in bullet.png entry.png lock.png; do
|
||||
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
|
||||
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"
|
||||
done
|
||||
sudo rm -f "$sddm_dir/logo.svg"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
run_root_transaction() {
|
||||
sudo /bin/bash -c '
|
||||
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.
|
||||
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
|
||||
if [[ -n ${failure_hint:-} ]]; then
|
||||
printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2
|
||||
fi
|
||||
}
|
||||
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-plymouth" || $mode == "refresh-sddm" ]]
|
||||
[[ $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" ]]
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
# 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//\"/\\\"}
|
||||
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
|
||||
[[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]
|
||||
[[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]
|
||||
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)
|
||||
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
|
||||
|
||||
failure_context="packaged source file $source"
|
||||
[[ -f $source && ! -L $source ]]
|
||||
canonical=$(realpath -e -- "$source")
|
||||
[[ $canonical == "$source" ]]
|
||||
file_mode=$(stat -c %a -- "$source")
|
||||
size=$(stat -c %s -- "$source")
|
||||
(( 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() {
|
||||
local source=$1 destination=$2
|
||||
|
||||
validate_trusted_file "$source"
|
||||
install -o 0 -g 0 -m 0600 -- "$source" "$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"
|
||||
|
||||
for asset in "${plymouth_assets[@]}"; 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.
|
||||
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 ))
|
||||
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 -- {} +
|
||||
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() {
|
||||
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")
|
||||
(( 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")
|
||||
install -o 0 -g 0 -m 0644 -- "$source" "$temporary"
|
||||
copied_size=$(stat -c %s -- "$temporary")
|
||||
(( copied_size == source_size ))
|
||||
cmp -s -- "$source" "$temporary"
|
||||
sync -f -- "$temporary"
|
||||
mv --no-copy -fT -- "$temporary" "$destination"
|
||||
temporary=
|
||||
}
|
||||
|
||||
if (( ${#plymouth_assets[@]} )); then
|
||||
for asset in "${plymouth_assets[@]}"; do
|
||||
publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset"
|
||||
done
|
||||
fi
|
||||
|
||||
if (( ${#sddm_assets[@]} )); then
|
||||
for asset in "${sddm_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 [[ $mode == "set" ]]; then
|
||||
run_root_transaction <&"$logo_fd"
|
||||
else
|
||||
run_root_transaction </dev/null
|
||||
fi
|
||||
|
||||
if [[ $mode != "refresh-sddm" ]]; then
|
||||
sudo plymouth-set-default-theme omarchy
|
||||
|
||||
if omarchy-cmd-present limine-mkinitcpio; then
|
||||
sudo limine-mkinitcpio
|
||||
else
|
||||
sudo mkinitcpio -P
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
# 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.
|
||||
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
# omarchy:summary=Refresh the SDDM theme from default
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
sudo rm -rf /usr/share/sddm/themes/omarchy
|
||||
sudo cp -r "$OMARCHY_PATH/default/sddm/omarchy" /usr/share/sddm/themes/omarchy
|
||||
# Reuse the fixed-file publisher so root never resolves an untrusted source or
|
||||
# follows a destination symlink while restoring the packaged SDDM theme.
|
||||
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-sddm-default
|
||||
|
||||
@@ -23,16 +23,26 @@ omarchy-git-url-check "$REPO_URL" || exit 1
|
||||
|
||||
THEMES_DIR="$HOME/.config/omarchy/themes"
|
||||
|
||||
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
|
||||
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path.
|
||||
# git reads a URL as scp-style when a colon appears before any slash, so the path
|
||||
# after it need not hold one: `git@host:omarchy-blue-theme.git` is a repo in that
|
||||
# user's home, and leaving its prefix on names the theme after the whole URL.
|
||||
REPO_PATH="$REPO_URL"
|
||||
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}"
|
||||
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}"
|
||||
THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
|
||||
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
||||
|
||||
# The name comes from the URL and is joined into a path that is about to be
|
||||
# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading
|
||||
# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`.
|
||||
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||
# The name comes from the URL, is joined into a path that is about to be
|
||||
# removed, and then names a directory the rest of Omarchy passes around by
|
||||
# name: Style > Unlock builds a command line out of the one the picker
|
||||
# returned. So it is held to the characters a theme name needs rather than
|
||||
# screened for the harm of the day -- a repo called `..` would take
|
||||
# ~/.config/omarchy with it, and one called `a';'id` would carry its own
|
||||
# command into that picker. The leading character is kept out of `.` and `-`,
|
||||
# which also covers `host:-s/foo.git` leaving basename with `.git`.
|
||||
# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in
|
||||
# `é` under en_US.UTF-8. Pin the locale so the set is the one written here.
|
||||
if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); then
|
||||
echo "Error: '$REPO_URL' does not give a usable theme name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1328,7 +1328,7 @@ apply_system_transition() {
|
||||
'
|
||||
fi
|
||||
as_root install -d -m 0755 /usr/lib/chromium
|
||||
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
|
||||
printf '%s\n' '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
|
||||
as_root tee /usr/lib/chromium/initial_preferences >/dev/null
|
||||
|
||||
# Deliberately do NOT add the user to the docker group. That group is
|
||||
@@ -1364,7 +1364,6 @@ EOF
|
||||
as_root systemctl disable docker.service >/dev/null 2>&1 || true
|
||||
|
||||
enable_system_service cups.service
|
||||
enable_system_service cups-browsed.service
|
||||
enable_system_service avahi-daemon.service
|
||||
enable_system_service linux-modules-cleanup.service
|
||||
enable_system_service docker.socket
|
||||
|
||||
+80
-14
@@ -54,6 +54,34 @@ download_icon() {
|
||||
[[ -s $2 && $(file -b --mime-type "$2") == image/* ]]
|
||||
}
|
||||
|
||||
# Chromium --app= treats javascript:, file:, and data: as a document to
|
||||
# run. Prefix schemeless input with https as before, then refuse anything
|
||||
# that is not http(s).
|
||||
normalize_webapp_url() {
|
||||
local url=$1
|
||||
if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
|
||||
url="https://$url"
|
||||
fi
|
||||
printf '%s' "$url"
|
||||
}
|
||||
|
||||
# Raw whitespace must be percent-encoded in a URL. Refuse it before serializing
|
||||
# the desktop entry; before Exec argument quoting, it also split browser flags
|
||||
# and additional URLs into separate arguments. Schemes are case-insensitive.
|
||||
require_http_url() {
|
||||
local url=$1
|
||||
|
||||
if [[ $url =~ [[:space:]] ]]; then
|
||||
echo "Error: web app URL must not contain whitespace." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! ${url,,} =~ ^https?:// ]]; then
|
||||
echo "Error: web app URL must be http or https." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_site_icon() {
|
||||
local site_url="$1" dest="$2"
|
||||
local origin page icon_url
|
||||
@@ -77,14 +105,44 @@ fetch_site_icon() {
|
||||
download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest"
|
||||
}
|
||||
|
||||
desktop_string_escape() {
|
||||
# Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"):
|
||||
# a raw newline would start a new key line and let a value inject a second
|
||||
# Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value
|
||||
# written into the .desktop file passes through here.
|
||||
#
|
||||
# Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space
|
||||
# and exits at end of input, so a `:a;N;$!ba` slurp skips every following s///
|
||||
# for a value with no newline in it - which is every value except the injection
|
||||
# attempt this exists to stop.
|
||||
local value="$1"
|
||||
|
||||
value=${value//\\/\\\\}
|
||||
value=${value//$'\t'/\\t}
|
||||
value=${value//$'\r'/\\r}
|
||||
value=${value//$'\n'/\\n}
|
||||
[[ $value == " "* ]] && value="\\s${value# }"
|
||||
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
desktop_exec_arg() {
|
||||
# One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes
|
||||
# " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's
|
||||
# URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only).
|
||||
local escaped
|
||||
escaped=$(printf '%s' "$1" \
|
||||
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g')
|
||||
printf '"%s"' "$escaped"
|
||||
}
|
||||
|
||||
if (( $# < 3 )); then
|
||||
echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m"
|
||||
APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app")
|
||||
require_plain_name "$APP_NAME"
|
||||
APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com")
|
||||
if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
|
||||
APP_URL="https://$APP_URL"
|
||||
fi
|
||||
APP_URL=$(normalize_webapp_url "$APP_URL")
|
||||
require_http_url "$APP_URL"
|
||||
|
||||
# Try to fetch the site's icon automatically first.
|
||||
mkdir -p "$ICON_DIR"
|
||||
@@ -101,10 +159,8 @@ if (( $# < 3 )); then
|
||||
INTERACTIVE_MODE=true
|
||||
else
|
||||
APP_NAME="$1"
|
||||
APP_URL="$2"
|
||||
if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
|
||||
APP_URL="https://$APP_URL"
|
||||
fi
|
||||
APP_URL=$(normalize_webapp_url "$2")
|
||||
require_http_url "$APP_URL"
|
||||
ICON_REF="$3"
|
||||
CUSTOM_EXEC="$4" # Optional custom exec command
|
||||
MIME_TYPES="$5" # Optional mime types
|
||||
@@ -143,29 +199,39 @@ else
|
||||
ICON_VALUE=$(icon_name_from_ref "$ICON_REF")
|
||||
fi
|
||||
|
||||
# Use custom exec if provided, otherwise default behavior
|
||||
EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}"
|
||||
# Default Exec quotes the URL as one Exec-spec argument; the whole line then gets
|
||||
# the file-syntax escaping below (unescaped first at read time per spec, so the
|
||||
# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only.
|
||||
if [[ -n $CUSTOM_EXEC ]]; then
|
||||
EXEC_COMMAND=$CUSTOM_EXEC
|
||||
else
|
||||
EXEC_COMMAND="omarchy-launch-webapp $(desktop_exec_arg "$APP_URL")"
|
||||
fi
|
||||
|
||||
# Create application .desktop file
|
||||
DESKTOP_DIR="$HOME/.local/share/applications"
|
||||
DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop"
|
||||
mkdir -p "$DESKTOP_DIR"
|
||||
|
||||
name_field=$(desktop_string_escape "$APP_NAME")
|
||||
exec_field=$(desktop_string_escape "$EXEC_COMMAND")
|
||||
icon_field=$(desktop_string_escape "$ICON_VALUE")
|
||||
|
||||
cat >"$DESKTOP_FILE" <<EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=$APP_NAME
|
||||
Comment=$APP_NAME
|
||||
Exec=$EXEC_COMMAND
|
||||
Name=$name_field
|
||||
Comment=$name_field
|
||||
Exec=$exec_field
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=$ICON_VALUE
|
||||
Icon=$icon_field
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
# Add mime types if provided
|
||||
if [[ -n $MIME_TYPES ]]; then
|
||||
echo "MimeType=$MIME_TYPES" >>"$DESKTOP_FILE"
|
||||
printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE"
|
||||
fi
|
||||
|
||||
chmod +x "$DESKTOP_FILE"
|
||||
|
||||
+894
-125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
-- Keep the Windows VM display opaque instead of applying the default window opacity.
|
||||
o.window({ class = "^xfreerdp$", title = "^Windows VM - Omarchy$" }, {
|
||||
tag = "-default-opacity",
|
||||
opacity = "1 1",
|
||||
})
|
||||
@@ -103,7 +103,7 @@
|
||||
// Style
|
||||
"style.theme": {"icon":"","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""},
|
||||
"style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""},
|
||||
"style.unlock": {"icon":"","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"},
|
||||
"style.unlock": {"icon":"","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme $(printf %q \"$unlock\")\"; fi"},
|
||||
"style.font": {"icon":"","label":"Font","provider":"fonts"},
|
||||
"style.bar": {"icon":"","label":"Menu Bar"},
|
||||
"style.bar.position": {"icon":"","label":"Position"},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Omarchy override of cups-browsed's shipped config. The only behavioural
|
||||
# change vs the upstream default (all-commented) is enabling auto-registration
|
||||
# of remote IPP printers discovered via Avahi/mDNS.
|
||||
CreateRemotePrinters Yes
|
||||
# Keep state away from /var/cache/cups, which is writable by the account CUPS
|
||||
# uses for print filters. cups-browsed is the only writer to this directory.
|
||||
CacheDir /var/cache/cups-browsed
|
||||
|
||||
# Auto-create queues only for modern driverless IPP printers. Remote queues
|
||||
# exported by another CUPS server can still be added manually when needed.
|
||||
CreateIPPPrinterQueues Driverless
|
||||
CreateRemoteCUPSPrinterQueues No
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#
|
||||
# File/directory/user/group configuration file for the CUPS scheduler.
|
||||
# See "man cups-files.conf" for a complete description of this file.
|
||||
#
|
||||
|
||||
# List of events that are considered fatal errors for the scheduler...
|
||||
#FatalErrors config
|
||||
|
||||
# Strip domain in local username?
|
||||
#StripUserDomain No
|
||||
|
||||
# Do we call fsync() after writing configuration or status files?
|
||||
#SyncOnClose No
|
||||
|
||||
# Default user and group for filters/backends/helper programs; this cannot be
|
||||
# any user or group that resolves to ID 0 for security reasons...
|
||||
User 209
|
||||
Group 209
|
||||
|
||||
# Administrator user group, used to match @SYSTEM in cupsd.conf policy rules...
|
||||
# This cannot contain the Group value for security reasons...
|
||||
SystemGroup cups-browsed sys root
|
||||
|
||||
|
||||
# Are Unix domain socket peer credentials used for authorization?
|
||||
PeerCred on
|
||||
|
||||
# User that is substituted for unauthenticated (remote) root accesses...
|
||||
#RemoteRoot remroot
|
||||
|
||||
# Do we allow file: device URIs other than to /dev/null?
|
||||
#FileDevice No
|
||||
|
||||
# Permissions for configuration and log files...
|
||||
#ConfigFilePerm 0640
|
||||
#LogFilePerm 0644
|
||||
|
||||
# Location of the file logging all access to the scheduler; may be the name
|
||||
# "syslog". If not an absolute path, the value of ServerRoot is used as the
|
||||
# root directory. Also see the "AccessLogLevel" directive in cupsd.conf.
|
||||
AccessLog /var/log/cups/access_log
|
||||
|
||||
# Location of cache files used by the scheduler...
|
||||
#CacheDir /var/cache/cups
|
||||
|
||||
# Location of data files used by the scheduler...
|
||||
#DataDir /usr/share/cups
|
||||
|
||||
# Location of the static web content served by the scheduler...
|
||||
#DocumentRoot /usr/share/cups/doc
|
||||
|
||||
# Location of the file logging all messages produced by the scheduler and any
|
||||
# helper programs; may be the name "syslog". If not an absolute path, the value
|
||||
# of ServerRoot is used as the root directory. Also see the "LogLevel"
|
||||
# directive in cupsd.conf.
|
||||
ErrorLog /var/log/cups/error_log
|
||||
|
||||
# Location of the file logging all pages printed by the scheduler and any
|
||||
# helper programs; may be the name "syslog". If not an absolute path, the value
|
||||
# of ServerRoot is used as the root directory. Also see the "PageLogFormat"
|
||||
# directive in cupsd.conf.
|
||||
PageLog /var/log/cups/page_log
|
||||
|
||||
# Location of the file listing all of the local printers...
|
||||
#Printcap /etc/printcap
|
||||
|
||||
# Format of the Printcap file...
|
||||
#PrintcapFormat bsd
|
||||
#PrintcapFormat plist
|
||||
#PrintcapFormat solaris
|
||||
|
||||
# Location of all spool files...
|
||||
#RequestRoot /var/spool/cups
|
||||
|
||||
# Location of helper programs...
|
||||
#ServerBin /usr/lib/cups
|
||||
|
||||
# SSL/TLS keychain for the scheduler...
|
||||
#ServerKeychain ssl
|
||||
|
||||
# Location of other configuration files...
|
||||
#ServerRoot /etc/cups
|
||||
|
||||
# Location of scheduler state files...
|
||||
#StateDir /run/cups
|
||||
|
||||
# Location of scheduler/helper temporary files. This directory is emptied on
|
||||
# scheduler startup and cannot be one of the standard (public) temporary
|
||||
# directory locations for security reasons...
|
||||
#TempDir /var/spool/cups/tmp
|
||||
@@ -0,0 +1,11 @@
|
||||
[Service]
|
||||
User=cups-browsed
|
||||
Group=cups-browsed
|
||||
CacheDirectory=cups-browsed
|
||||
CacheDirectoryMode=0750
|
||||
UMask=0027
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
@@ -0,0 +1 @@
|
||||
u cups-browsed - "CUPS printer discovery" / -
|
||||
@@ -1,16 +1,14 @@
|
||||
# Enable services only. Installs are followed by reboot, so don't start/reload
|
||||
# daemons mid-install. UFW and hardware-gated services stay in their own scripts.
|
||||
systemctl enable cups.service
|
||||
systemctl enable cups-browsed.service
|
||||
systemctl enable avahi-daemon.service
|
||||
systemctl enable linux-modules-cleanup.service
|
||||
systemctl enable docker.socket
|
||||
systemctl enable systemd-resolved.service
|
||||
systemctl enable NetworkManager.service
|
||||
# Don't let network-online.target (pulled in by cups-browsed) hold up
|
||||
# graphical.target waiting for DHCP/Wi-Fi association. Nothing in the session
|
||||
# needs to block on the network. Mirrors the systemd-networkd-wait-online mask
|
||||
# in install/hardware/network.sh.
|
||||
# Don't let network-online.target hold up graphical.target waiting for
|
||||
# DHCP/Wi-Fi association. Nothing in the session needs to block on the network.
|
||||
# Mirrors the systemd-networkd-wait-online mask in install/hardware/network.sh.
|
||||
systemctl mask NetworkManager-wait-online.service
|
||||
systemctl enable power-profiles-daemon.service
|
||||
systemctl enable sddm.service
|
||||
|
||||
@@ -6,7 +6,8 @@ ln -snf /usr/share/icons/Adwaita/symbolic/actions/go-next-symbolic.svg \
|
||||
/usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg
|
||||
gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true
|
||||
|
||||
# Default Chromium to follow system appearance ("device") instead of dark
|
||||
# Seed Chromium's first run: follow system appearance ("device") instead of dark,
|
||||
# and skip the terms-of-service dialog Chromium 151 turned on by default.
|
||||
mkdir -p /usr/lib/chromium
|
||||
echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \
|
||||
echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \
|
||||
/usr/lib/chromium/initial_preferences
|
||||
|
||||
@@ -17,9 +17,8 @@ chromium
|
||||
clang
|
||||
cliamp
|
||||
cups
|
||||
cups-browsed
|
||||
cups-filters
|
||||
cups-pdf
|
||||
cups-pk-helper
|
||||
ddcutil
|
||||
docker
|
||||
docker-buildx
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf
|
||||
cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist
|
||||
|
||||
# omarchy-settings skips this override until cups-browsed is actually present
|
||||
# to avoid pacman creating cups-browsed.conf.pacnew during ISO package install.
|
||||
if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -d /etc/cups ]]; then
|
||||
cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf
|
||||
rm -f /etc/cups/cups-browsed.conf.pacnew
|
||||
# Wait for CUPS to own the file, the way omarchy-settings does, so pacman does
|
||||
# not turn the override into a .pacnew during ISO package installation.
|
||||
if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then
|
||||
install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf
|
||||
rm -f /etc/cups/cups-files.conf.pacnew
|
||||
fi
|
||||
|
||||
source "$OMARCHY_INSTALL/hardware/pacman.sh"
|
||||
|
||||
@@ -79,7 +79,7 @@ Turkish|trq
|
||||
Ukrainian|ua'
|
||||
|
||||
OMARCHY_USERNAME_PATTERN='^[a-z_][a-z0-9_-]*[$]?$'
|
||||
OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$'
|
||||
OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|cups-browsed|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$'
|
||||
OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$'
|
||||
OMARCHY_HOSTNAME_DEFAULT='omarchy'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Omarchy offers an easy way to run Windows through a Docker VM. You can install i
|
||||
|
||||
Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself.
|
||||
|
||||
The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`.
|
||||
The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The browser prompts for the same username and password before opening the console.
|
||||
|
||||

|
||||
|
||||
@@ -26,9 +26,15 @@ omarchy windows vm launch # start and connect
|
||||
|
||||
## Sharing files
|
||||
|
||||
The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk lives in `~/.windows`.
|
||||
The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`.
|
||||
|
||||
The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine.
|
||||
Those familiar home paths stay on their own filesystems. They can also be symlinks to directories you own, which is useful when the virtual disk lives on a larger drive. The installer measures free space on the filesystem that actually contains `~/.windows`, not necessarily the filesystem containing your home directory.
|
||||
|
||||
Keep the disk and shared paths as separate, non-overlapping directories. Removal deliberately empties the disk directory but preserves the shared directory. Immediately before deletion, Omarchy performs a bounded containment check and refuses to remove anything if that check times out or cannot prove the two trees are separate.
|
||||
|
||||
Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents.
|
||||
|
||||
The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The web console also requires the configured Windows username and password, preventing another local account from driving the VM through port 8006.
|
||||
|
||||
## Limits and licensing
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro
|
||||
|
||||
If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation.
|
||||
|
||||
That leftover `[themename]` becomes the theme's directory name, so it has to be one Omarchy can hand around safely: it must start with a letter, a digit, or an underscore, and the rest may hold letters, digits, `.`, `_`, `+`, and `-`. Capitals are lowercased for you, but anything else — a space, a quote, a non-English character — is refused at install time rather than turned into a directory name. So `omarchy-tokyo-night-theme`, `omarchy-flexoki_light-theme`, and `omarchy-c++-theme` all install fine.
|
||||
|
||||
Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those.
|
||||
|
||||
You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site).
|
||||
|
||||
+7
-1
@@ -44,7 +44,13 @@ The plain open source Chromium build doesn't ship with the OAuth credentials tha
|
||||
|
||||
### How do I add a printer?
|
||||
|
||||
Printing is set up and running out of the box, so a printer on your network is usually already discovered. Launch _Print Settings_ from the app launcher (`Super + Space`) to see what's there, add one by hand, or set the default. Printing to a PDF file works without any printer at all.
|
||||
Printing is set up and running out of the box, and you add each printer yourself from _Print Settings_ in the app launcher (`Super + Space`).
|
||||
|
||||
Choose _Add_, and give it a moment to look: a printer plugged in over USB, and most network printers, are found for you. If yours isn't in the list, pick _Network Printer > Internet Printing Protocol (ipp)_ and enter its address — the printer's own display or its web page will tell you what that is, usually something like `192.168.1.50` with a queue of `ipp/print`. _Forward_ then offers a driver, where a modern printer works best on the driverless _IPP Everywhere_ profile and an older one wants the model's own driver.
|
||||
|
||||
Right-click a printer and choose _Set as Default_ to pick which one your apps reach for first, and _Properties_ to set paper size, duplex and quality.
|
||||
|
||||
Automatic discovery, where printers on the network appear without being added, is temporarily switched off while it's reworked, which is why the first step above is yours rather than automatic. Printing to a PDF file works without any printer at all.
|
||||
|
||||
### How do I change where screenshots or screenrecordings are saved?
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
echo "Skip Chromium's new first-run EULA on machines already on Quattro"
|
||||
|
||||
# Chromium 151 flipped MasterPrefs::eula_required from false to true, so an
|
||||
# unconfigured first run now stops on a blank terms-of-service dialog before the
|
||||
# browser opens. Omarchy answers that in the seed it writes next to the Chromium
|
||||
# binary, but that seed is only laid down by a fresh install and by the one-time
|
||||
# 3.x upgrade, so machines already on Quattro never receive it. Retrofit it here.
|
||||
#
|
||||
# The literal is deliberately duplicated rather than sourced: a migration repairs
|
||||
# the state of its own moment, and must not drift when the seed later changes.
|
||||
|
||||
chromium_prefs="/usr/lib/chromium/initial_preferences"
|
||||
chromium_seed='{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}'
|
||||
|
||||
if [[ $(cat "$chromium_prefs" 2>/dev/null) != "$chromium_seed" ]]; then
|
||||
sudo mkdir -p "$(dirname "$chromium_prefs")"
|
||||
echo "$chromium_seed" | sudo tee "$chromium_prefs" >/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,57 @@
|
||||
echo "Separate printer discovery from root and print-filter access"
|
||||
|
||||
machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}"
|
||||
|
||||
[[ ! -e $machine_marker ]] || exit 0
|
||||
|
||||
# Existing releases allowed a desktop user or shared group named cups-browsed,
|
||||
# which systemd-sysusers would silently reuse for passwordless CUPS access.
|
||||
if omarchy-pkg-present cups; then
|
||||
cups_browsed_account=$(getent passwd cups-browsed || true)
|
||||
cups_browsed_group=$(getent group cups-browsed || true)
|
||||
|
||||
if [[ -n $cups_browsed_account || -n $cups_browsed_group ]]; then
|
||||
IFS=: read -r _ _ cups_browsed_uid cups_browsed_gid cups_browsed_description cups_browsed_home cups_browsed_shell <<<"$cups_browsed_account"
|
||||
IFS=: read -r _ _ cups_browsed_group_gid cups_browsed_group_members <<<"$cups_browsed_group"
|
||||
other_primary_user=$(getent passwd | awk -F: -v gid="$cups_browsed_gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }')
|
||||
|
||||
if [[ ! $cups_browsed_uid =~ ^[0-9]+$ || ! $cups_browsed_group_gid =~ ^[0-9]+$ ]] ||
|
||||
((cups_browsed_uid <= 0 || cups_browsed_uid >= 1000)) ||
|
||||
[[ $cups_browsed_gid != $cups_browsed_group_gid ]] ||
|
||||
[[ $cups_browsed_description != "CUPS printer discovery" || $cups_browsed_home != "/" || $cups_browsed_shell != "/usr/bin/nologin" ]] ||
|
||||
[[ -n $cups_browsed_group_members || -n $other_primary_user ]]; then
|
||||
echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2
|
||||
false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# CUPS-PDF accepts a job-controlled post-processing command in a backend that
|
||||
# CUPS launches as root. Native application print-to-file support replaces it.
|
||||
omarchy-pkg-drop cups-pdf
|
||||
|
||||
# system-config-printer uses this helper to request printer administration
|
||||
# through Polkit now that the desktop user's wheel group is no longer @SYSTEM.
|
||||
if omarchy-pkg-present cups; then
|
||||
omarchy-pkg-add cups-pk-helper
|
||||
fi
|
||||
|
||||
# Stop the root-running daemon before changing the authorization it relies on.
|
||||
if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then
|
||||
sudo systemctl stop cups-browsed.service
|
||||
fi
|
||||
|
||||
if omarchy-pkg-present cups; then
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl try-reload-or-restart cups.service
|
||||
fi
|
||||
|
||||
# Resume on whether the unit is enabled, not on whether it was running when this
|
||||
# run started: an interrupted earlier run leaves it stopped, and a retry that
|
||||
# recomputed that would skip the restart and still write the marker below. A
|
||||
# masked or disabled unit reports not-enabled and is left alone.
|
||||
if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then
|
||||
sudo systemctl restart cups-browsed.service
|
||||
fi
|
||||
|
||||
sudo install -Dm644 /dev/null "$machine_marker"
|
||||
@@ -0,0 +1,72 @@
|
||||
echo "Temporarily remove automatic printer discovery"
|
||||
|
||||
machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}"
|
||||
|
||||
[[ ! -e $machine_marker ]] || exit 0
|
||||
omarchy-pkg-present cups-browsed || exit 0
|
||||
|
||||
# Check the full removal transaction before changing the service or queues.
|
||||
pacman -Rs --print cups-browsed >/dev/null
|
||||
|
||||
# Disable the unit while its package still owns the unit file so systemd can
|
||||
# remove the enable symlink cleanly.
|
||||
if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then
|
||||
sudo systemctl disable --now cups-browsed.service >/dev/null
|
||||
elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then
|
||||
sudo systemctl stop cups-browsed.service >/dev/null
|
||||
fi
|
||||
|
||||
# cups-browsed leaves its implicitclass queues behind when stopped. Remove idle
|
||||
# discovery queues before removing the backend they require, but leave queues
|
||||
# with jobs for the user to resolve.
|
||||
#
|
||||
# A healthy CUPS server with no configured printers reports this condition on
|
||||
# stderr and exits 1. Treat that as an empty queue list; every other failure
|
||||
# keeps the migration pending so it can be retried.
|
||||
if queue_report=$(LC_ALL=C lpstat -v 2>&1); then
|
||||
:
|
||||
elif [[ $queue_report == "lpstat: No destinations added." ]]; then
|
||||
queue_report=""
|
||||
else
|
||||
printf '%s\n' "$queue_report" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generated_queues=$(printf '%s\n' "$queue_report" |
|
||||
sed -n 's|^device for \(.*\): implicitclass://.*|\1|p')
|
||||
|
||||
while IFS= read -r queue; do
|
||||
[[ -n $queue ]] || continue
|
||||
|
||||
if ! reject_error=$(sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue" 2>&1); then
|
||||
if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then
|
||||
printf '%s\n' "$reject_error" >&2
|
||||
exit 1
|
||||
else
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if job_report=$(LC_ALL=C lpstat -o "$queue" 2>&1); then
|
||||
[[ -z $job_report ]] || continue
|
||||
elif LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then
|
||||
printf '%s\n' "$job_report" >&2
|
||||
exit 1
|
||||
else
|
||||
# The queue disappeared after the initial snapshot, which is already the
|
||||
# desired state.
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! delete_error=$(sudo lpadmin -x "$queue" 2>&1); then
|
||||
# Treat a concurrent disappearance as success. A queue that still exists
|
||||
# means CUPS did not complete the deletion, so retry the migration later.
|
||||
if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then
|
||||
printf '%s\n' "$delete_error" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done <<<"$generated_queues"
|
||||
|
||||
omarchy-pkg-drop cups-browsed >/dev/null
|
||||
sudo install -Dm644 /dev/null "$machine_marker"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
for package in cups cups-filters system-config-printer cups-pk-helper; do
|
||||
pacman -Q "$package" >/dev/null 2>&1 || fail "printing packages are installed" "$package is missing"
|
||||
done
|
||||
pass "printing packages are installed"
|
||||
|
||||
! pacman -Q cups-pdf >/dev/null 2>&1 || fail "the root CUPS-PDF backend is absent"
|
||||
pass "the root CUPS-PDF backend is absent"
|
||||
|
||||
! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is absent"
|
||||
! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null ||
|
||||
fail "automatic printer discovery is not enabled"
|
||||
! systemctl is-active --quiet cups-browsed.service 2>/dev/null ||
|
||||
fail "automatic printer discovery is not running"
|
||||
! pgrep -x cups-browsed >/dev/null 2>&1 || fail "no cups-browsed process exists"
|
||||
pass "automatic printer discovery is not installed or running"
|
||||
|
||||
for path in \
|
||||
/etc/cups/cups-browsed.conf \
|
||||
/etc/cups/cups-browsed.conf.pacsave \
|
||||
/etc/cups/cups-browsed.conf.pacnew \
|
||||
/usr/bin/cups-browsed \
|
||||
/usr/lib/cups/backend/implicitclass \
|
||||
/usr/lib/systemd/system/cups-browsed.service \
|
||||
/etc/systemd/system/multi-user.target.wants/cups-browsed.service; do
|
||||
[[ ! -e $path && ! -L $path ]] ||
|
||||
fail "automatic printer discovery leaves no package files" "$path still exists"
|
||||
done
|
||||
pass "automatic printer discovery leaves no package files"
|
||||
|
||||
systemctl is-enabled --quiet cups.service || fail "CUPS is enabled"
|
||||
systemctl is-active --quiet cups.service || fail "CUPS is running"
|
||||
timeout 10 lpstat -r >/dev/null 2>&1 || fail "the CUPS scheduler answers"
|
||||
pass "CUPS is enabled, running, and answering"
|
||||
|
||||
policy_metadata=$(stat -c '%U:%G %a' /etc/cups/cups-files.conf)
|
||||
[[ $policy_metadata == "root:cups 640" ]] ||
|
||||
fail "the CUPS authorization policy is protected" "$policy_metadata"
|
||||
pass "the CUPS authorization policy is protected"
|
||||
|
||||
if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v </dev/null 2>&1); then
|
||||
fail "the desktop user cannot administer CUPS without authentication"
|
||||
elif [[ $lpinfo_output != *"Forbidden"* ]]; then
|
||||
fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output"
|
||||
fi
|
||||
pass "CUPS denies unauthenticated desktop administration"
|
||||
@@ -47,7 +47,7 @@ verify_services() {
|
||||
local unit
|
||||
|
||||
for unit in \
|
||||
avahi-daemon.service cups.service cups-browsed.service docker.socket \
|
||||
avahi-daemon.service docker.socket \
|
||||
NetworkManager.service power-profiles-daemon.service sddm.service \
|
||||
systemd-resolved.service ufw.service; do
|
||||
systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
packages="$ROOT/install/omarchy-base.packages"
|
||||
cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf"
|
||||
cups_files_conf="$ROOT/etc/cups/cups-files.conf"
|
||||
sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf"
|
||||
service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf"
|
||||
|
||||
# Only discovery goes. Everything else printing needs stays, or this stops
|
||||
# being a removal of one daemon and becomes a removal of printing.
|
||||
grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set"
|
||||
grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set"
|
||||
grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set"
|
||||
grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed"
|
||||
! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed"
|
||||
|
||||
# Automatic discovery is temporarily out of the default install while it is
|
||||
# reworked. The hardened configuration below stays as the baseline discovery
|
||||
# comes back onto.
|
||||
! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set"
|
||||
! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" ||
|
||||
fail "a fresh install does not enable a discovery service it no longer installs"
|
||||
! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" ||
|
||||
fail "the Quattro upgrade does not enable a discovery service it no longer installs"
|
||||
|
||||
pass "the base install keeps CUPS and Polkit administration, without automatic discovery"
|
||||
|
||||
# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is
|
||||
# applied after the ISO installs that package. cups-browsed is absent, so the
|
||||
# installer must not write any of its package-owned configuration.
|
||||
post_install_pacman="$ROOT/install/post-install/pacman.sh"
|
||||
|
||||
! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" ||
|
||||
fail "a fresh install does not write configuration for absent printer discovery"
|
||||
grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" ||
|
||||
fail "the CUPS authorization override waits for the file it replaces"
|
||||
|
||||
pass "the fresh install applies CUPS hardening without writing discovery configuration"
|
||||
|
||||
grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" ||
|
||||
fail "cups-browsed keeps state outside the print-filter cache"
|
||||
grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" ||
|
||||
fail "automatic queues are limited to driverless IPP printers"
|
||||
grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" ||
|
||||
fail "remote CUPS queues are not created automatically"
|
||||
! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" ||
|
||||
fail "the unsupported CreateRemotePrinters directive is gone"
|
||||
|
||||
pass "cups-browsed uses explicit supported discovery policy and an isolated cache"
|
||||
|
||||
grep -qxF 'SystemGroup cups-browsed sys root' "$cups_files_conf" ||
|
||||
fail "only the printer discovery account receives passwordless CUPS administration"
|
||||
grep -qxF 'PeerCred on' "$cups_files_conf" ||
|
||||
fail "the packaged CUPS policy enables peer credentials"
|
||||
[[ $(grep -ciE '^[[:space:]]*SystemGroup[[:space:]]' "$cups_files_conf") == 1 ]] ||
|
||||
fail "the packaged CUPS policy has one SystemGroup directive"
|
||||
[[ $(grep -ciE '^[[:space:]]*PeerCred[[:space:]]' "$cups_files_conf") == 1 ]] ||
|
||||
fail "the packaged CUPS policy has one PeerCred directive"
|
||||
[[ ! -e $ROOT/install/config/printing.sh ]] ||
|
||||
fail "printing policy is not rewritten by an install script"
|
||||
! grep -q 'config/printing.sh' "$ROOT/install/config/all.sh" "$ROOT/migrations/1787815267.sh" ||
|
||||
fail "neither install nor update invokes a printing rewrite script"
|
||||
|
||||
pass "CUPS authorization ships as a canonical package override"
|
||||
|
||||
grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" ||
|
||||
fail "a locked cups-browsed system account is declared"
|
||||
|
||||
for setting in \
|
||||
'User=cups-browsed' \
|
||||
'Group=cups-browsed' \
|
||||
'CacheDirectory=cups-browsed' \
|
||||
'CacheDirectoryMode=0750' \
|
||||
'UMask=0027' \
|
||||
'NoNewPrivileges=yes' \
|
||||
'ProtectSystem=strict' \
|
||||
'ProtectHome=yes' \
|
||||
'PrivateTmp=yes' \
|
||||
'RestrictSUIDSGID=yes'; do
|
||||
grep -qxF "$setting" "$service_dropin" ||
|
||||
fail "cups-browsed service hardening includes $setting"
|
||||
done
|
||||
|
||||
! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" ||
|
||||
fail "cups-browsed is not granted an unverified network capability"
|
||||
|
||||
pass "cups-browsed runs as its confined service account without added capabilities"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations"
|
||||
|
||||
passwd_db="$test_tmp/passwd"
|
||||
group_db="$test_tmp/group"
|
||||
touch "$passwd_db" "$group_db"
|
||||
|
||||
cat >"$mock_bin/getent" <<'SH'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
passwd) database="$OMARCHY_CUPS_TEST_PASSWD" ;;
|
||||
group) database="$OMARCHY_CUPS_TEST_GROUP" ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
|
||||
if (($# == 1)); then
|
||||
cat "$database"
|
||||
else
|
||||
awk -F: -v name="$2" '$1 == name { print; found = 1 } END { exit !found }' "$database"
|
||||
fi
|
||||
SH
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "cups" || $1 == "cups-browsed" ]]
|
||||
SH
|
||||
for command in omarchy-pkg-add omarchy-pkg-drop; do
|
||||
cat >"$mock_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
SH
|
||||
done
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
cat >"$mock_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
exec "$@"
|
||||
SH
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
log="$test_tmp/actions.log"
|
||||
touch "$log"
|
||||
export OMARCHY_CUPS_TEST_LOG="$log"
|
||||
export OMARCHY_CUPS_TEST_PASSWD="$passwd_db"
|
||||
export OMARCHY_CUPS_TEST_GROUP="$group_db"
|
||||
|
||||
printf 'cups-browsed:x:1000:1000:Desktop user:/home/cups-browsed:/usr/bin/bash\n' >"$passwd_db"
|
||||
printf 'cups-browsed:x:1000:\n' >"$group_db"
|
||||
if PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/desktop-collision-marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then
|
||||
fail "the migration accepts an existing desktop user named cups-browsed"
|
||||
fi
|
||||
[[ ! -s $log ]] || fail "an account collision stops the migration before changing the system"
|
||||
|
||||
printf 'alice:x:1000:947:Desktop user:/home/alice:/usr/bin/bash\n' >"$passwd_db"
|
||||
printf 'cups-browsed:x:947:alice\n' >"$group_db"
|
||||
if PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/group-collision-marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then
|
||||
fail "the migration accepts an existing cups-browsed group with members"
|
||||
fi
|
||||
[[ ! -s $log ]] || fail "a group collision stops the migration before changing the system"
|
||||
|
||||
printf 'cups-browsed:x:947:947:CUPS printer discovery:/:/usr/bin/nologin\n' >"$passwd_db"
|
||||
printf 'cups-browsed:x:947:\n' >"$group_db"
|
||||
|
||||
pass "the migration rejects account and group collisions before changing printing"
|
||||
|
||||
marker="$test_tmp/var/lib/omarchy/migrations/1787815267"
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
|
||||
grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" ||
|
||||
fail "the migration removes CUPS-PDF"
|
||||
grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" ||
|
||||
fail "the migration installs authenticated printer administration"
|
||||
grep -qxF $'systemctl\tstop cups-browsed.service' "$log" ||
|
||||
fail "the migration stops the root cups-browsed process before reconfiguration"
|
||||
grep -qxF $'systemctl\tdaemon-reload' "$log" ||
|
||||
fail "the migration reloads the hardened service"
|
||||
grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" ||
|
||||
fail "the migration reloads the packaged CUPS authorization"
|
||||
grep -qxF $'systemctl\trestart cups-browsed.service' "$log" ||
|
||||
fail "the migration resumes an active cups-browsed service"
|
||||
[[ -f $marker ]] || fail "the migration records machine-wide completion"
|
||||
|
||||
actions_after_first_run=$(wc -l <"$log")
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
[[ $(wc -l <"$log") == "$actions_after_first_run" ]] ||
|
||||
fail "the machine-wide migration repeats privileged work"
|
||||
|
||||
pass "the migration safely converts an active existing installation once"
|
||||
|
||||
# An interrupted earlier run leaves cups-browsed stopped. A retry still needs
|
||||
# to resume an enabled service before recording completion.
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
[[ $1 == "is-active" ]] && exit 1
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/systemctl"
|
||||
|
||||
retry_log="$test_tmp/retry.log"
|
||||
retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry"
|
||||
|
||||
OMARCHY_CUPS_TEST_LOG="$retry_log" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
|
||||
grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" ||
|
||||
fail "the retry resumes cups-browsed after an interrupted earlier run"
|
||||
|
||||
pass "a run following an interrupted one still resumes printer discovery"
|
||||
|
||||
# A masked or disabled unit is deliberately left alone.
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
[[ $1 == "is-active" || $1 == "is-enabled" ]] && exit 1
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/systemctl"
|
||||
|
||||
masked_log="$test_tmp/masked.log"
|
||||
masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked"
|
||||
|
||||
OMARCHY_CUPS_TEST_LOG="$masked_log" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
|
||||
! grep -qxF $'systemctl\trestart cups-browsed.service' "$masked_log" ||
|
||||
fail "the migration leaves a masked or disabled cups-browsed alone"
|
||||
[[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked"
|
||||
|
||||
pass "a masked or disabled cups-browsed is left alone and does not fail the migration"
|
||||
@@ -1,43 +1,968 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
[[ -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
|
||||
|
||||
source_dir="$test_tmp/source"
|
||||
theme_dir="$test_tmp/theme"
|
||||
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)
|
||||
sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf)
|
||||
|
||||
mkdir -m 0700 "$source_dir"
|
||||
mkdir -m 0755 "$theme_dir"
|
||||
touch "$source_dir/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"
|
||||
|
||||
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"
|
||||
# SDDM reset is also a fixed-file publication contract. New packaged files
|
||||
# must fail this test until their trust and reset behavior are reviewed.
|
||||
packaged_sddm_assets=$(find "$ROOT/default/sddm/omarchy" -type f -printf '%P\n' | LC_ALL=C sort)
|
||||
allowlisted_sddm_assets=$(printf '%s\n' "${sddm_default_assets[@]}" | LC_ALL=C sort)
|
||||
[[ $packaged_sddm_assets == "$allowlisted_sddm_assets" ]] ||
|
||||
fail "SDDM refresh allowlist differs from the packaged asset set" "$packaged_sddm_assets"
|
||||
pass "SDDM refresh allowlist covers every packaged asset"
|
||||
|
||||
# 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.
|
||||
secret="$test_tmp/secret"
|
||||
# ~/.config/omarchy/themes. Both installed copies are world-readable, so a
|
||||
# symlink there must not republish whatever it points at.
|
||||
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"
|
||||
[[ $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"
|
||||
|
||||
# The descriptor must be opened before elevation. Invoking the whole command
|
||||
# as root would make the pre-open race privileged again, so refuse that shape
|
||||
# before sudo or any publication can begin. User namespaces let this run
|
||||
# without real privilege; retain a structural assertion on hosts that disable
|
||||
# them so the invariant never becomes an untested skip.
|
||||
if unshare --user --map-root-user true 2>/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
|
||||
# unless the action quotes it -- and the name is a directory name under
|
||||
# ~/.config/omarchy/themes, which a theme installed from a git repo gets from
|
||||
# the repo URL. `a';id;'b` is a legal directory name.
|
||||
require_command node
|
||||
|
||||
unlock_action=$(node -e '
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js"))
|
||||
const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8"))
|
||||
process.stdout.write(items.find(item => item.id === "style.unlock").action)
|
||||
')
|
||||
|
||||
[[ -n $unlock_action ]] || fail "the shipped menu still carries a style.unlock action"
|
||||
|
||||
stub_dir="$test_tmp/stubs"
|
||||
mkdir -p "$stub_dir"
|
||||
|
||||
canary="$test_tmp/canary"
|
||||
set_args="$test_tmp/set-args"
|
||||
reset_marker="$test_tmp/reset-ran"
|
||||
|
||||
# What a name that got reparsed would reach. It is a command rather than a
|
||||
# `touch` so that no quoting of the test's own paths is involved.
|
||||
cat >"$stub_dir/omarchy-test-canary" <<STUB
|
||||
#!/bin/bash
|
||||
printf 'ran\n' >"$canary"
|
||||
STUB
|
||||
|
||||
cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME"
|
||||
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
|
||||
:
|
||||
STUB
|
||||
|
||||
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.
|
||||
cat >"$stub_dir/omarchy-plymouth-set-by-theme" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$#" "$@" >"$OMARCHY_TEST_SET_ARGS"
|
||||
STUB
|
||||
|
||||
cat >"$stub_dir/omarchy-plymouth-reset" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'ran\n' >"$OMARCHY_TEST_RESET_MARKER"
|
||||
STUB
|
||||
|
||||
for command in omarchy-show-logo omarchy-show-done; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_dir/$command"
|
||||
done
|
||||
|
||||
chmod +x "$stub_dir"/*
|
||||
|
||||
run_unlock_action() {
|
||||
rm -f "$canary" "$set_args" "$reset_marker"
|
||||
|
||||
PATH="$stub_dir:$PATH" \
|
||||
OMARCHY_TEST_UNLOCK_NAME="$1" \
|
||||
OMARCHY_TEST_SET_ARGS="$set_args" \
|
||||
OMARCHY_TEST_RESET_MARKER="$reset_marker" \
|
||||
bash -c "$unlock_action" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# A directory name cannot hold a slash or a NUL, and everything else is fair
|
||||
# game -- these are the shapes that would run on the way to the picker.
|
||||
for name in "a';omarchy-test-canary;'b" 'a$(omarchy-test-canary)b' 'a`omarchy-test-canary`b' 'a b' '-a'; do
|
||||
run_unlock_action "$name"
|
||||
|
||||
[[ ! -e $canary ]] || fail "a theme name reaches the unlock screen as data, not as shell" "ran for: $name"
|
||||
[[ $(cat "$set_args" 2>/dev/null) == $'1\n'"$name" ]] ||
|
||||
fail "the unlock screen gets the theme name whole" "$name: $(cat "$set_args" 2>/dev/null)"
|
||||
done
|
||||
|
||||
pass "a theme name cannot carry a command into the unlock screen"
|
||||
|
||||
# The two ordinary paths still work: a named theme is applied, and `default`
|
||||
# resets rather than being looked up as a theme.
|
||||
run_unlock_action "tokyo-night"
|
||||
[[ $(cat "$set_args" 2>/dev/null) == $'1\ntokyo-night' ]] ||
|
||||
fail "an ordinary theme name still reaches omarchy-plymouth-set-by-theme" "$(cat "$set_args" 2>/dev/null)"
|
||||
|
||||
run_unlock_action "default"
|
||||
[[ -e $reset_marker ]] || fail "picking default still resets the unlock screen"
|
||||
[[ ! -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"
|
||||
|
||||
fake_bin="$test_tmp/bin"
|
||||
root_tools="$test_tmp/root-tools"
|
||||
stages="$test_tmp/stages"
|
||||
mkdir -p "$fake_bin" "$root_tools" "$stages"
|
||||
|
||||
cat >"$fake_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
set -u
|
||||
|
||||
for argument in "$@"; do
|
||||
if [[ $argument == *"$TEST_STAGES"* ]]; then
|
||||
printf '%s\n' "$argument" >>"$TEST_LEAK_LOG"
|
||||
fi
|
||||
done
|
||||
|
||||
case "$1" in
|
||||
/bin/bash)
|
||||
[[ ${2:-} == -c && $# == 9 ]] || exit 90
|
||||
code=$3
|
||||
shell_name=$4
|
||||
shift 4
|
||||
printf 'root transaction\n' >>"$TEST_SUDO_LOG"
|
||||
|
||||
# The production helper intentionally resets PATH. For this unprivileged
|
||||
# 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
|
||||
|
||||
PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \
|
||||
/bin/bash -c "$code" "$shell_name" "$@"
|
||||
;;
|
||||
plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio)
|
||||
printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unexpected sudo command: $*" >&2
|
||||
exit 92
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat >"$root_tools/stat" <<'SH'
|
||||
#!/bin/bash
|
||||
last=${!#}
|
||||
if [[ ${1:-} == -c && ${2:-} == %u ]]; 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
|
||||
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"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93
|
||||
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
|
||||
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"/* "$root_tools"/*
|
||||
|
||||
printf 'caller-selected logo\n' >"$test_tmp/logo.png"
|
||||
|
||||
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"
|
||||
omarchy_conf="$run_dir/omarchy.conf"
|
||||
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_default_assets[@]}"; 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"
|
||||
}
|
||||
|
||||
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" \
|
||||
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" \
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
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_fresh_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"
|
||||
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"
|
||||
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"
|
||||
|
||||
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 "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
|
||||
# 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 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"
|
||||
printf 'ROOT ONLY\n' >"$secret"
|
||||
chmod 000 "$secret"
|
||||
cat >"$preopen_hook" <<'SH'
|
||||
if [[ $0 == */bin/omarchy-plymouth-set ]]; then
|
||||
set -T
|
||||
trap '
|
||||
if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* &&
|
||||
! -e $TEST_PREOPEN_MARKER ]]; then
|
||||
mv -T -- "$logo_path" "$logo_path.before-preopen-swap"
|
||||
ln -s -- "$TEST_SECRET" "$logo_path"
|
||||
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"
|
||||
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 '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"
|
||||
|
||||
# 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
|
||||
# in its own root-owned stage.
|
||||
setup_run
|
||||
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 "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 "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"
|
||||
[[ $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"
|
||||
|
||||
# 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.
|
||||
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 == *"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"
|
||||
|
||||
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"
|
||||
# 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
|
||||
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
|
||||
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"
|
||||
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 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.
|
||||
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"
|
||||
[[ $output == *"refusing to publish"* ]] || fail "a rejected symlinked parent says why it refused" "$output"
|
||||
assert_no_temporary_files "$fake_root"
|
||||
|
||||
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"
|
||||
[[ $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"
|
||||
|
||||
# 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"
|
||||
|
||||
# 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"
|
||||
|
||||
# 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.
|
||||
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"
|
||||
|
||||
# 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=$(run_refresh_plymouth 2>&1)
|
||||
status=$?
|
||||
(( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output"
|
||||
|
||||
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 '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"
|
||||
|
||||
@@ -144,11 +144,12 @@ pass "keyboard prompt propagates Esc and Ctrl+C without dying under set -e"
|
||||
|
||||
# Username
|
||||
|
||||
TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:dhh" "0:david"
|
||||
TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:cups-browsed" "0:dhh" "0:david"
|
||||
assert_status 0 "username prompt accepts a valid name"
|
||||
[[ $(field username) == "david" ]] || fail "username prompt keeps re-asking until the name is valid"
|
||||
assert_notices "username prompt explains each rejection" "Username must be alphanumeric with no spaces
|
||||
Username is reserved for system
|
||||
Username is reserved for system
|
||||
That username already exists on this machine"
|
||||
pass "username prompt rejects malformed, reserved, and taken names"
|
||||
|
||||
|
||||
@@ -92,6 +92,83 @@ done
|
||||
|
||||
pass "a URL whose name would climb out of the themes directory never reaches git"
|
||||
|
||||
# The derived name outlives the clone: it is the theme's directory name, and
|
||||
# Style > Unlock builds a command line out of the name the picker returned. A
|
||||
# repo whose name carries shell syntax would hand that picker its own command,
|
||||
# so the name is refused here rather than quoted at each place it lands.
|
||||
for url in \
|
||||
"https://example.com/omarchy-a';id;'b-theme.git" \
|
||||
'https://example.com/a$(id).git' \
|
||||
'https://example.com/a`id`.git' \
|
||||
"https://example.com/a b.git" \
|
||||
"https://example.com/-a.git"; do
|
||||
if install_theme "$url"; then
|
||||
fail "omarchy-theme-install refuses the derived name from '$url'"
|
||||
fi
|
||||
|
||||
[[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")"
|
||||
done
|
||||
|
||||
pass "a URL whose name would be shell syntax never reaches git"
|
||||
|
||||
# And the check is an allowlist, so the punctuation a real theme name uses has
|
||||
# to keep working.
|
||||
install_theme "https://github.com/example/omarchy-tokyo_night.2-theme.git" ||
|
||||
fail "omarchy-theme-install accepts the punctuation a theme name uses"
|
||||
grep -Fq "/themes/tokyo_night.2" "$git_calls" ||
|
||||
fail "omarchy-theme-install derives a name carrying an underscore and a dot" "$(cat "$git_calls")"
|
||||
|
||||
pass "a theme name may still hold an underscore, a dot, and a dash"
|
||||
|
||||
# A plus is neither path-climb nor shell syntax, and a leading underscore is
|
||||
# neither the `..` climb nor the dash that reads as an option, so the allowlist
|
||||
# keeps both rather than stranding a repo that names itself with them.
|
||||
install_theme "https://github.com/example/omarchy-c++-theme.git" ||
|
||||
fail "omarchy-theme-install accepts a name holding a plus"
|
||||
grep -Fq "/themes/c++" "$git_calls" ||
|
||||
fail "omarchy-theme-install derives a name carrying a plus" "$(cat "$git_calls")"
|
||||
|
||||
install_theme "https://github.com/example/_private.git" ||
|
||||
fail "omarchy-theme-install accepts a name starting with an underscore"
|
||||
grep -Fq "/themes/_private" "$git_calls" ||
|
||||
fail "omarchy-theme-install derives a name starting with an underscore" "$(cat "$git_calls")"
|
||||
|
||||
pass "a plus and a leading underscore are still usable theme names"
|
||||
|
||||
# git reads a colon before any slash as the scp-style separator, so the path
|
||||
# after it does not have to hold one. Without that reading, the whole URL becomes
|
||||
# the theme name and the allowlist above refuses a repo that clones fine.
|
||||
install_theme "git@example.com:omarchy-blue-theme.git" ||
|
||||
fail "omarchy-theme-install accepts a home-relative scp-style URL"
|
||||
grep -Fq "/themes/blue" "$git_calls" ||
|
||||
fail "omarchy-theme-install names the theme after the repo, not the whole URL" "$(cat "$git_calls")"
|
||||
|
||||
# A colon that is part of a local path, not an scp separator, keeps its prefix.
|
||||
install_theme "/srv/git:mirrors/omarchy-blue-theme.git" ||
|
||||
fail "omarchy-theme-install accepts a local path holding a colon"
|
||||
grep -Fq "/themes/blue" "$git_calls" ||
|
||||
fail "omarchy-theme-install reads a colon after a slash as part of the path" "$(cat "$git_calls")"
|
||||
|
||||
pass "an scp-style URL with no slash after the colon still names the theme"
|
||||
|
||||
# The allowlist is a bracket range, and a range follows the locale's collation
|
||||
# rather than ASCII: under en_US.UTF-8 an unpinned `[a-z]` takes in `é`, so the
|
||||
# same URL would install on one desktop and be refused on the next.
|
||||
if locale -a 2>/dev/null | grep -qix 'en_US.utf-\?8'; then
|
||||
for locale_name in C en_US.UTF-8; do
|
||||
if LC_ALL=$locale_name install_theme "https://github.com/example/omarchy-café-theme.git"; then
|
||||
fail "omarchy-theme-install refuses a non-ASCII theme name under LC_ALL=$locale_name" "$(cat "$git_calls")"
|
||||
fi
|
||||
|
||||
[[ ! -s $git_calls ]] ||
|
||||
fail "omarchy-theme-install refuses a non-ASCII name before running git" "$(cat "$git_calls")"
|
||||
done
|
||||
|
||||
pass "the accepted set does not move with the desktop's locale"
|
||||
else
|
||||
pass "no en_US.UTF-8 locale; skipping the locale-pinning check"
|
||||
fi
|
||||
|
||||
# basename reads a leading dash as an option once the scp-style prefix is gone.
|
||||
install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL"
|
||||
grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")"
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command gio
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-launch-webapp" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >>"$OMARCHY_TEST_ARGV"
|
||||
SH
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
export HOME="$test_tmp/home"
|
||||
export PATH="$mock_bin:$PATH"
|
||||
export OMARCHY_TEST_ARGV="$test_tmp/argv"
|
||||
|
||||
applications="$HOME/.local/share/applications"
|
||||
|
||||
install_webapp() {
|
||||
bash "$ROOT/bin/omarchy-webapp-install" "$@" >/dev/null
|
||||
}
|
||||
|
||||
desktop_value() {
|
||||
sed -n "s/^$2=//p" "$1" | head -1
|
||||
}
|
||||
|
||||
# gio launch returns before the entry it spawned has run, so poll for the argv the
|
||||
# stub records rather than reading the log once.
|
||||
launched_argument() {
|
||||
local file="$1" attempt
|
||||
|
||||
: >"$OMARCHY_TEST_ARGV"
|
||||
gio launch "$file" >/dev/null 2>&1 || return 1
|
||||
for ((attempt = 0; attempt < 200; attempt++)); do
|
||||
[[ -s $OMARCHY_TEST_ARGV ]] && break
|
||||
sleep 0.01
|
||||
done
|
||||
|
||||
head -1 "$OMARCHY_TEST_ARGV"
|
||||
}
|
||||
|
||||
# The Exec quoting escapes a dollar sign with a backslash, and the file syntax has
|
||||
# to escape that backslash in turn. Left single, GLib reads \$ as an invalid escape
|
||||
# and refuses the whole entry, so the web app vanishes from the launcher.
|
||||
install_webapp 'Dollar App' 'https://example.com/a$b' someicon
|
||||
dollar_file="$applications/Dollar App.desktop"
|
||||
|
||||
[[ -f $dollar_file ]] || fail "web app install writes a desktop entry"
|
||||
|
||||
[[ $(desktop_value "$dollar_file" Exec) == 'omarchy-launch-webapp "https://example.com/a\\$b"' ]] ||
|
||||
fail "Exec escapes the backslash its own quoting introduced" "$(desktop_value "$dollar_file" Exec)"
|
||||
pass "Exec escapes the backslash its own quoting introduced"
|
||||
|
||||
[[ $(launched_argument "$dollar_file") == 'https://example.com/a$b' ]] ||
|
||||
fail "a URL containing a dollar sign reaches the browser unchanged"
|
||||
pass "a URL containing a dollar sign reaches the browser unchanged"
|
||||
|
||||
# An unescaped % is read as a Desktop Entry field code and eaten, so ?q=a%20b used
|
||||
# to arrive as ?q=a0b.
|
||||
install_webapp 'Percent App' 'https://example.com/s?q=a%20b' someicon
|
||||
percent_file="$applications/Percent App.desktop"
|
||||
|
||||
[[ $(launched_argument "$percent_file") == 'https://example.com/s?q=a%20b' ]] ||
|
||||
fail "a percent-encoded URL reaches the browser unchanged"
|
||||
pass "a percent-encoded URL reaches the browser unchanged"
|
||||
|
||||
# A lone backslash is not a Desktop Entry escape sequence, so GLib cannot interpret
|
||||
# a value that contains one.
|
||||
install_webapp 'Back\slash App' 'https://example.com' someicon
|
||||
backslash_file="$applications/Back\slash App.desktop"
|
||||
|
||||
[[ $(desktop_value "$backslash_file" Name) == 'Back\\slash App' ]] ||
|
||||
fail "a backslash in the app name is escaped" "$(desktop_value "$backslash_file" Name)"
|
||||
pass "a backslash in the app name is escaped"
|
||||
|
||||
# The property the escaping exists for: a newline in a value must not be able to
|
||||
# start a second key line.
|
||||
inject_name=$(printf 'Inject\nExec=evil')
|
||||
install_webapp "$inject_name" 'https://example.com' someicon
|
||||
inject_file="$applications/$inject_name.desktop"
|
||||
|
||||
(( $(grep -c '^Exec=' "$inject_file") == 1 )) ||
|
||||
fail "a newline in the app name cannot inject a second Exec" "$(cat "$inject_file")"
|
||||
pass "a newline in the app name cannot inject a second Exec"
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
home="$tmpdir/home"
|
||||
mkdir -p "$home/.local/share/applications"
|
||||
|
||||
install_webapp() {
|
||||
HOME="$home" "$ROOT/bin/omarchy-webapp-install" "$@"
|
||||
}
|
||||
|
||||
desktop_for() {
|
||||
printf '%s' "$home/.local/share/applications/$1.desktop"
|
||||
}
|
||||
|
||||
if install_webapp "Example" "https://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install accepts an https URL" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
|
||||
desktop=$(desktop_for Example)
|
||||
[[ -f $desktop ]] || fail "webapp install writes a desktop file"
|
||||
grep -Fxq 'Name=Example' "$desktop" || fail "webapp install writes the app name"
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp "https://example.com"' "$desktop" ||
|
||||
fail "webapp install launches the https URL" "$(cat "$desktop")"
|
||||
pass "webapp install writes an https desktop entry"
|
||||
|
||||
if install_webapp "Plain" "example.org/app" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install prefixes a schemeless URL with https" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp "https://example.org/app"' "$(desktop_for Plain)" ||
|
||||
fail "webapp install stores the prefixed https URL" "$(cat "$(desktop_for Plain)")"
|
||||
pass "webapp install prefixes a schemeless URL with https"
|
||||
|
||||
if install_webapp "Local" "https://localhost:47990" "webapp" "omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install keeps a custom https exec" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors' "$(desktop_for Local)" ||
|
||||
fail "webapp install writes the custom exec" "$(cat "$(desktop_for Local)")"
|
||||
pass "webapp install keeps a custom https exec"
|
||||
|
||||
for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp://example.com" "ext://x"; do
|
||||
if install_webapp "Bad" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "webapp install refuses '$url'"
|
||||
fi
|
||||
grep -Fq 'must be http or https' "$tmpdir/err" ||
|
||||
fail "webapp install names the scheme refusal for '$url'" "$(cat "$tmpdir/err")"
|
||||
[[ ! -e $(desktop_for Bad) ]] || fail "webapp install does not write a desktop file for '$url'"
|
||||
done
|
||||
pass "webapp install refuses non-http(s) URLs"
|
||||
|
||||
# Raw whitespace is not valid URL data, and before Exec argument quoting it
|
||||
# split browser flags or additional URLs into separate arguments.
|
||||
for url in \
|
||||
" javascript:alert(1)" \
|
||||
" file:///etc/passwd" \
|
||||
"https://example.com data:text/html,hi" \
|
||||
"https://example.com/ --user-agent=INJECTION_PROOF_MARKER_12345"; do
|
||||
if install_webapp "Sneak" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "webapp install refuses whitespace in '$url'" "$(cat "$(desktop_for Sneak)")"
|
||||
fi
|
||||
grep -Fq 'must not contain whitespace' "$tmpdir/err" ||
|
||||
fail "webapp install names the whitespace refusal for '$url'" "$(cat "$tmpdir/err")"
|
||||
[[ ! -e $(desktop_for Sneak) ]] || fail "webapp install writes no desktop file for '$url'"
|
||||
done
|
||||
pass "webapp install refuses a URL carrying whitespace"
|
||||
|
||||
# Schemes are case-insensitive, and HTTPS://example.com installed before the
|
||||
# scheme test existed.
|
||||
if install_webapp "Upper" "HTTPS://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install accepts an uppercase scheme" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp "HTTPS://example.com"' "$(desktop_for Upper)" ||
|
||||
fail "webapp install keeps the uppercase scheme" "$(cat "$(desktop_for Upper)")"
|
||||
pass "webapp install accepts an uppercase http scheme"
|
||||
|
||||
# The interactive prompt fetches the site's icon, so a refused URL must be
|
||||
# refused before anything dereferences it.
|
||||
stubs="$tmpdir/stubs"
|
||||
mkdir -p "$stubs"
|
||||
|
||||
cat >"$stubs/gum" <<'GUM'
|
||||
#!/bin/bash
|
||||
count=$(cat "$GUM_COUNT" 2>/dev/null || echo 0)
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$count" >"$GUM_COUNT"
|
||||
sed -n "${count}p" "$GUM_ANSWERS"
|
||||
GUM
|
||||
|
||||
cat >"$stubs/curl" <<'CURL'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$CURL_LOG"
|
||||
exit 1
|
||||
CURL
|
||||
|
||||
chmod +x "$stubs/gum" "$stubs/curl"
|
||||
|
||||
printf 'Evil\nfile:///etc/passwd\n' >"$tmpdir/answers"
|
||||
: >"$tmpdir/gum-count"
|
||||
: >"$tmpdir/curl-log"
|
||||
|
||||
if GUM_ANSWERS="$tmpdir/answers" GUM_COUNT="$tmpdir/gum-count" CURL_LOG="$tmpdir/curl-log" \
|
||||
PATH="$stubs:$PATH" HOME="$home" "$ROOT/bin/omarchy-webapp-install" \
|
||||
>"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "interactive webapp install refuses a file: URL" "$(cat "$tmpdir/out")"
|
||||
fi
|
||||
grep -Fq 'must be http or https' "$tmpdir/err" ||
|
||||
fail "interactive webapp install names the scheme refusal" "$(cat "$tmpdir/err")"
|
||||
[[ ! -s $tmpdir/curl-log ]] ||
|
||||
fail "interactive webapp install refuses before fetching the URL" "$(cat "$tmpdir/curl-log")"
|
||||
[[ ! -e $(desktop_for Evil) ]] || fail "interactive webapp install writes no desktop file"
|
||||
pass "interactive webapp install refuses a bad URL before fetching it"
|
||||
@@ -1,72 +1,119 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The Windows VM compose file is written by an elevated, input-validated writer
|
||||
# into a root-owned directory. These tests pin the security-critical behavior:
|
||||
# no input can inject a host-root bind mount or a privileged flag, the password
|
||||
# survives both the YAML and the compose-interpolation layer, only known
|
||||
# privileged actions dispatch, and legacy configs migrate without redownloading.
|
||||
# Security regression coverage for the Windows VM compose/mount boundary.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Bind mounts need CAP_SYS_ADMIN in a private mount namespace. Keep the
|
||||
# caller's uid so the non-root development path is exercised.
|
||||
if [[ ${OMARCHY_WINDOWS_TEST_NAMESPACE:-0} != 1 ]]; then
|
||||
if unshare --user --map-current-user --keep-caps --mount true 2>/dev/null; then
|
||||
exec env OMARCHY_WINDOWS_TEST_NAMESPACE=1 \
|
||||
unshare --user --map-current-user --keep-caps --mount --propagation private bash "$0"
|
||||
fi
|
||||
pass "unprivileged mount namespaces unavailable; skipping Windows VM mount runtime tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
export OMARCHY_WINDOWS_DIR="$TMPDIR/win"
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
# Source the command's functions; the dispatcher just prints usage for "help".
|
||||
set -- help
|
||||
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
||||
COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml"
|
||||
|
||||
write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED
|
||||
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \
|
||||
unmount_all() {
|
||||
local path
|
||||
resolve_caller >/dev/null 2>&1 || return 0
|
||||
for path in "$EXPECTED_SHARED" "$EXPECTED_STORAGE"; do
|
||||
while mountpoint -q -- "$path" 2>/dev/null; do umount -- "$path" || break; done
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
unmount_all
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
write() { # RAM CORES DISK USER PASS TZ
|
||||
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \
|
||||
"$@" | __priv_write_compose
|
||||
}
|
||||
|
||||
# --- valid compose, with the dangerous bits pinned and unreachable by input ---
|
||||
rm -f "$COMPOSE"
|
||||
write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows
|
||||
fd_count() { find "/proc/$$/fd" -mindepth 1 -maxdepth 1 -printf x | wc -c; }
|
||||
|
||||
reset_case() {
|
||||
unmount_all
|
||||
rm -rf "$OMARCHY_WINDOWS_DIR" "$HOME/.windows" "$HOME/Windows"
|
||||
mkdir -p "$HOME"
|
||||
}
|
||||
|
||||
# Fixed protected anchors consume the pinned source inodes.
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G alice s3cret Europe/Copenhagen
|
||||
resolve_caller
|
||||
[[ -f $COMPOSE ]] || fail "writer produced a compose file"
|
||||
grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned"
|
||||
grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned"
|
||||
grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount"
|
||||
pass "writer emits a pinned compose with no host-root mount"
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor"
|
||||
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE" || fail "web console is not password protected"
|
||||
[[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories"
|
||||
[[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source"
|
||||
[[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source"
|
||||
[[ $(stat -Lc '%a' "$EXPECTED_STORAGE") == 700 && $(stat -Lc '%a' "$EXPECTED_SHARED") == 700 ]] || fail "mount leaves are not private"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "compose contains host-root bind"
|
||||
pass "writer emits fixed anchors bound to exact private source inodes"
|
||||
|
||||
# --- injection attempts are rejected, no file written ---
|
||||
# Input cannot widen a mount or compose field.
|
||||
rm -f "$COMPOSE"
|
||||
write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted"
|
||||
[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username"
|
||||
write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted"
|
||||
write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted"
|
||||
pass "injection attempts in username, path, and RAM are rejected"
|
||||
write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username accepted"
|
||||
[[ ! -f $COMPOSE ]] || fail "bad input wrote compose"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller storage affected compose"
|
||||
grep -q -- '- /:/storage' "$COMPOSE" && fail "host root accepted as storage"
|
||||
write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM accepted"
|
||||
pass "input cannot inject a host path or compose field"
|
||||
|
||||
# --- password survives YAML (" \) and compose interpolation ($) ---
|
||||
rm -f "$COMPOSE"
|
||||
tricky='p@$$w:rd$HOME"x\y'
|
||||
write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows
|
||||
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation"
|
||||
recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")")
|
||||
[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape"
|
||||
pass "password with \" \\ and \$ round-trips"
|
||||
write 8G 4 64G bob "$tricky" UTC
|
||||
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "dollar not escaped"
|
||||
[[ $(unescape "$(read_compose_value PASSWORD "$COMPOSE")") == "$tricky" ]] || fail "password did not round-trip"
|
||||
pass "password with quote, backslash, and dollar round-trips"
|
||||
|
||||
# --- only known privileged actions may dispatch ---
|
||||
for action in write_compose up up_wait down status remove; do
|
||||
valid_priv_action "$action" || fail "known privileged action rejected: $action"
|
||||
valid_priv_action "$action" || fail "known action rejected: $action"
|
||||
done
|
||||
for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do
|
||||
valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]"
|
||||
valid_priv_action "$action" && fail "action whitelist accepted: [$action]"
|
||||
done
|
||||
pass "privileged action whitelist accepts known actions and rejects the rest"
|
||||
pass "privileged action dispatch is allowlisted"
|
||||
|
||||
# --- legacy per-user compose migrates into the root-owned location ---
|
||||
# A rogue process could have rewritten the user-owned legacy compose to bind
|
||||
# mount host / into the guest, so migration must ignore its volume paths and
|
||||
# reconstruct them from the current user's $HOME.
|
||||
rm -rf "$OMARCHY_WINDOWS_DIR"
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME/.config/windows"
|
||||
# A PATH symlink to bash must never become the pkexec target. Hide the packaged
|
||||
# file from priv_target's stat checks to exercise the historical fallback.
|
||||
attack_bin="$TMPDIR/attack-bin"
|
||||
mkdir -p "$attack_bin"
|
||||
ln -s /bin/bash "$attack_bin/omarchy-windows-vm"
|
||||
printf 'printf exploited >"$TMPDIR/exploited"\n' >"$TMPDIR/__priv"
|
||||
stat() {
|
||||
[[ ${!#} == /usr/bin/omarchy-windows-vm ]] && return 1
|
||||
command stat "$@"
|
||||
}
|
||||
PATH="$attack_bin:$PATH" priv_target >/dev/null 2>&1 && fail "PATH symlink became a privileged target"
|
||||
unset -f stat
|
||||
[[ ! -e $TMPDIR/exploited ]] || fail "attacker __priv script executed"
|
||||
pass "pkexec target is only the canonical packaged regular file, never a PATH symlink"
|
||||
|
||||
# Legacy migration keeps directories and legitimate symlinks in place.
|
||||
reset_case
|
||||
external_shared="$TMPDIR/external-shared"
|
||||
mkdir -m 0755 -p "$HOME/.windows" "$external_shared" "$HOME/.config/windows"
|
||||
ln -s "$external_shared" "$HOME/Windows"
|
||||
touch "$HOME/.windows/existing-disk" "$external_shared/existing-shared-file"
|
||||
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
|
||||
COMPOSE_FILE="$COMPOSE"
|
||||
cat >"$LEGACY_COMPOSE_FILE" <<'LEG'
|
||||
@@ -83,42 +130,364 @@ services:
|
||||
- /./:/storage
|
||||
- /etc:/shared
|
||||
LEG
|
||||
# In production the write elevates via pkexec; here run it in-process.
|
||||
priv() { local a=$1; shift; "__priv_$a" "$@"; }
|
||||
priv() { local action=$1; shift; "__priv_$action" "$@"; }
|
||||
migrate_legacy_compose
|
||||
[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose"
|
||||
grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings"
|
||||
grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume"
|
||||
grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file"
|
||||
grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path"
|
||||
[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose"
|
||||
pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes"
|
||||
resolve_caller
|
||||
[[ -f $COMPOSE ]] || fail "migration did not write compose"
|
||||
grep -q 'USERNAME: "legacyuser"' "$COMPOSE" || fail "migration lost settings"
|
||||
[[ -f $HOME/.windows/existing-disk && -f $external_shared/existing-shared-file ]] || fail "migration lost data"
|
||||
[[ ! -L $HOME/.windows && $(readlink "$HOME/Windows") == "$external_shared" ]] || fail "migration consumed source path"
|
||||
[[ $(stat -Lc '%a' "$HOME/.windows") == 700 && $(stat -Lc '%a' "$external_shared") == 700 ]] || fail "migration did not harden legacy directories"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "migration copied malicious storage"
|
||||
grep -q -- '- /etc:/shared' "$COMPOSE" && fail "migration copied malicious share"
|
||||
[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration left legacy compose"
|
||||
pass "migration preserves data and symlinks while hardening permissions"
|
||||
|
||||
# --- bring-up refuses a symlinked mount source (a symlink redirects the
|
||||
# privileged bind mount the same way traversal would; the string check on
|
||||
# the stored path cannot see it) ---
|
||||
rm -f "$COMPOSE"
|
||||
mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare"
|
||||
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare"
|
||||
assert_mounts_safe || fail "real directory mount sources are accepted"
|
||||
ln -sfn / "$TMPDIR/evilshare"
|
||||
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare"
|
||||
assert_mounts_safe && fail "a symlinked mount source must be refused"
|
||||
pass "bring-up refuses a symlinked mount source"
|
||||
# Bring-up re-proves compose trust, cardinality, and mounted identities.
|
||||
assert_mounts_safe || fail "verified sources rejected"
|
||||
sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "tampered host path accepted"
|
||||
sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE"
|
||||
printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted"
|
||||
write 16G 6 128G legacyuser legacypass America/New_York
|
||||
sed -i 's/PROTECT: "Y"/PROTECT: "N"/' "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "unprotected web console accepted"
|
||||
sed -i 's/PROTECT: "N"/PROTECT: "Y"/' "$COMPOSE"
|
||||
printf ' PROTECT: "N"\n' >>"$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "duplicate web protection setting accepted"
|
||||
sed -i '$d' "$COMPOSE"
|
||||
chmod 0666 "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "writable compose accepted"
|
||||
chmod 0640 "$COMPOSE"
|
||||
pass "bring-up rejects tampered, duplicate, unprotected, and writable compose inputs"
|
||||
|
||||
# --- valid_path rejects traversal and non-normalized paths ---
|
||||
for p in /home/u/.windows /var/lib/omarchy/windows; do
|
||||
valid_path "$p" || fail "valid_path rejected a normal path: $p"
|
||||
# Both sources are pinned before a bind; bad symlinks stay untouched.
|
||||
reset_case
|
||||
mkdir -p "$HOME/.windows"
|
||||
ln -s / "$HOME/Windows"
|
||||
before_fds=$(fd_count)
|
||||
prepare_user_mount_sources 2>/dev/null && fail "root symlink passed user preflight"
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "rejected symlink consumed"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "root symlink passed privileged preflight"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "one source mounted before other failed"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "source preflight leaked FD"
|
||||
find "$CALLER_DATA_ROOT" -name 'rejected-*' -print -quit | grep -q . && fail "source was quarantined"
|
||||
pass "invalid second source leaves paths and anchors untouched and leaks no FD"
|
||||
|
||||
# Distinct caller-owned symlink targets are supported and remain links.
|
||||
reset_case
|
||||
external_storage="$TMPDIR/external-storage"
|
||||
external_shared2="$TMPDIR/external-shared-2"
|
||||
mkdir -p "$external_storage" "$external_shared2"
|
||||
ln -s "$external_storage" "$HOME/.windows"
|
||||
ln -s "$external_shared2" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G symlinked pw UTC
|
||||
resolve_caller
|
||||
[[ $(readlink "$HOME/.windows") == "$external_storage" && $(readlink "$HOME/Windows") == "$external_shared2" ]] || fail "writer replaced symlinks"
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned"
|
||||
pass "legitimate caller-owned symlinks remain in place"
|
||||
|
||||
# Reproduce the original post-validation race at the last possible moment:
|
||||
# replace the familiar shared path with / only after the final guard returns,
|
||||
# inside the mocked Docker Compose invocation. Compose must still consume the
|
||||
# protected anchor bound to the inode that was validated earlier.
|
||||
raced_shared="$HOME/Windows.before-race"
|
||||
shared_id_before_race=$(stat -Lc '%d:%i' "$external_shared2")
|
||||
race_ran=0
|
||||
dc() {
|
||||
[[ $1 == up && ${2:-} == -d ]] || return 1
|
||||
mv -T -- "$HOME/Windows" "$raced_shared"
|
||||
ln -s / "$HOME/Windows"
|
||||
race_ran=1
|
||||
[[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$shared_id_before_race" ]] || return 1
|
||||
}
|
||||
__priv_up || fail "post-validation home-path swap changed the Docker mount source"
|
||||
(( race_ran == 1 )) || fail "post-validation race hook did not run"
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "race did not replace the familiar shared path"
|
||||
rm "$HOME/Windows"
|
||||
mv -T -- "$raced_shared" "$HOME/Windows"
|
||||
unset -f dc
|
||||
pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode"
|
||||
|
||||
# Run the same attack as a genuinely concurrent process. A successful bring-up
|
||||
# deliberately waits inside the Docker boundary until the attacker has replaced
|
||||
# the familiar path with /, then verifies that the real bind anchor still names
|
||||
# the caller-owned directory that was pinned before the race.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
touch "$HOME/Windows/safe-marker"
|
||||
write 4G 2 64G concurrent pw UTC
|
||||
resolve_caller
|
||||
concurrent_shared_id=$(stat -Lc '%d:%i' "$HOME/Windows")
|
||||
host_root_id=$(stat -Lc '%d:%i' /)
|
||||
race_source="$HOME/Windows.race-source"
|
||||
race_stop="$TMPDIR/stop-concurrent-race"
|
||||
race_swaps="$TMPDIR/concurrent-race-swaps"
|
||||
(
|
||||
set +e
|
||||
while [[ ! -e $race_stop ]]; do
|
||||
if [[ -d $HOME/Windows && ! -L $HOME/Windows ]] && mv -T -- "$HOME/Windows" "$race_source" 2>/dev/null; then
|
||||
ln -s / "$HOME/Windows" 2>/dev/null || true
|
||||
printf x >>"$race_swaps"
|
||||
sleep 0.002
|
||||
fi
|
||||
if [[ -L $HOME/Windows ]]; then
|
||||
rm -f -- "$HOME/Windows"
|
||||
mv -T -- "$race_source" "$HOME/Windows" 2>/dev/null || true
|
||||
sleep 0.005
|
||||
fi
|
||||
done
|
||||
) &
|
||||
racer_pid=$!
|
||||
concurrent_dc_calls=0
|
||||
dc() {
|
||||
local attempt
|
||||
[[ $1 == up && ${2:-} == -d ]] || return 1
|
||||
for ((attempt = 0; attempt < 20000; attempt++)); do
|
||||
if [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]] || return 1
|
||||
((concurrent_dc_calls++))
|
||||
[[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") != "$host_root_id" ]] || return 1
|
||||
[[ -f $EXPECTED_SHARED/safe-marker ]]
|
||||
}
|
||||
for ((attempt = 0; attempt < 200; attempt++)); do
|
||||
if __priv_up 2>/dev/null; then break; fi
|
||||
done
|
||||
for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do
|
||||
valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p"
|
||||
done
|
||||
pass "valid_path accepts normalized paths and rejects traversal"
|
||||
touch "$race_stop"
|
||||
wait "$racer_pid"
|
||||
unset -f dc
|
||||
if [[ -L $HOME/Windows ]]; then rm -f -- "$HOME/Windows"; fi
|
||||
if [[ ! -e $HOME/Windows && -d $race_source ]]; then mv -T -- "$race_source" "$HOME/Windows"; fi
|
||||
[[ -s $race_swaps ]] || fail "concurrent attacker never swapped the shared path"
|
||||
((concurrent_dc_calls > 0)) || fail "concurrent race never reached Docker while the familiar path named host root"
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || fail "concurrent race changed the protected shared inode"
|
||||
pass "a concurrent home-path swap cannot redirect Docker away from the pinned shared inode"
|
||||
|
||||
# --- credentials are stored privately and round-trip (incl. = in password) ---
|
||||
export CREDENTIALS_FILE="$TMPDIR/creds"
|
||||
write_credentials 'carol' 'p=a$$w"x'
|
||||
[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600"
|
||||
[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips"
|
||||
[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips"
|
||||
pass "credentials are written 0600 and round-trip"
|
||||
# Same-inode sources fail before mounting and close both descriptors.
|
||||
reset_case
|
||||
same="$TMPDIR/same-source"
|
||||
mkdir -p "$same"
|
||||
ln -s "$same" "$HOME/.windows"
|
||||
ln -s "$same" "$HOME/Windows"
|
||||
before_fds=$(fd_count)
|
||||
prepare_user_mount_sources 2>/dev/null && fail "same source passed user preflight"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "same source passed root preflight"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "same source left mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "same source leaked FDs"
|
||||
pass "storage and shared must differ and failure closes FDs"
|
||||
|
||||
# Ancestor/descendant aliases are just as destructive as same-inode aliases:
|
||||
# removal must never recurse from storage into shared (or accept the inverse).
|
||||
reset_case
|
||||
shared_inside="$TMPDIR/shared-inside-storage"
|
||||
mkdir -p "$shared_inside/storage/shared"
|
||||
ln -s "$shared_inside/storage" "$HOME/.windows"
|
||||
ln -s "$shared_inside/storage/shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
write 4G 2 64G nested pw UTC 2>/dev/null && fail "shared-inside-storage sources were accepted"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "shared-inside-storage failure left a mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "shared-inside-storage failure leaked FDs"
|
||||
|
||||
reset_case
|
||||
storage_inside="$TMPDIR/storage-inside-shared"
|
||||
mkdir -p "$storage_inside/shared/storage"
|
||||
ln -s "$storage_inside/shared/storage" "$HOME/.windows"
|
||||
ln -s "$storage_inside/shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
write 4G 2 64G nested pw UTC 2>/dev/null && fail "storage-inside-shared sources were accepted"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "storage-inside-shared failure left a mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "storage-inside-shared failure leaked FDs"
|
||||
pass "pinned-FD ancestry checks reject overlap in both directions before mounting"
|
||||
|
||||
# Exact bind-alias bypass regression: the shared FD's visible parent is the
|
||||
# alias directory, but its inode is still reachable below storage.
|
||||
reset_case
|
||||
alias_under="$TMPDIR/bind-alias-under"
|
||||
alias_shared="$TMPDIR/bind-alias-shared"
|
||||
mkdir -p "$alias_under/storage/shared" "$alias_shared"
|
||||
mount --no-canonicalize --bind "$alias_under/storage/shared" "$alias_shared"
|
||||
ln -s "$alias_under/storage" "$HOME/.windows"
|
||||
ln -s "$alias_shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
resolve_caller
|
||||
open_mount_source "$LEGACY_STORAGE" storage
|
||||
alias_storage_fd=$OPENED_MOUNT_FD
|
||||
alias_storage_id=$OPENED_MOUNT_ID
|
||||
open_mount_source "$LEGACY_SHARED" shared
|
||||
alias_shared_fd=$OPENED_MOUNT_FD
|
||||
pinned_dir_contains "$alias_storage_id" "$alias_shared_fd" && fail "bind-alias repro unexpectedly shared the underlying parent walk"
|
||||
pinned_tree_contains "$alias_storage_fd" "$alias_shared_fd" || fail "tree-rooted discovery missed the bind-alias inode"
|
||||
exec {alias_storage_fd}<&-
|
||||
exec {alias_shared_fd}<&-
|
||||
write 4G 2 64G alias pw UTC
|
||||
resolve_caller
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted a shared bind alias into storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "bind-alias removal refusal changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "bind-alias removal refusal changed mounts"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "bind-alias removal refusal leaked FDs"
|
||||
unmount_all
|
||||
umount -- "$alias_shared"
|
||||
pass "cheap startup permits a bind alias, but bounded removal discovery refuses it"
|
||||
|
||||
# A late writer failure rolls back both newly-created binds.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
mv() { return 1; }
|
||||
write 4G 2 64G rollback pw UTC 2>/dev/null && fail "forced writer failure succeeded"
|
||||
unset -f mv
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "writer failure left binds"
|
||||
[[ ! -f $COMPOSE ]] || fail "writer failure replaced compose"
|
||||
pass "atomic writer failure rolls back both new bind mounts"
|
||||
|
||||
# Revalidate ancestry during removal: move the already-bound shared inode below
|
||||
# storage, keep its familiar path as a symlink, and prove nothing is deleted.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G moved pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mv "$HOME/Windows" "$HOME/.windows/moved-shared"
|
||||
ln -s "$HOME/.windows/moved-shared" "$HOME/Windows"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted a shared inode moved below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/.windows/moved-shared/keep.txt && -f $COMPOSE ]] || fail "overlap rejection changed disk, shared data, or compose"
|
||||
pass "removal revalidates pinned ancestry and leaves moved shared data untouched"
|
||||
|
||||
# Even when both familiar paths remain disjoint, a same-filesystem bind of the
|
||||
# pinned shared inode introduced below storage must stop removal before change.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G removal-alias pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mkdir "$HOME/.windows/shared-bind-alias"
|
||||
mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/shared-bind-alias"
|
||||
__priv_remove 2>/dev/null && fail "removal missed a shared bind alias introduced below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "removal bind-alias rejection changed state"
|
||||
umount -- "$HOME/.windows/shared-bind-alias"
|
||||
pass "removal tree discovery catches a shared alias not used by either home path"
|
||||
|
||||
# A direct alias on another filesystem is still visited by find -xdev at its
|
||||
# mountpoint and must be rejected, while unrelated separate filesystems remain
|
||||
# supported by the root suite.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
mount -t tmpfs -o uid="$(id -u)",gid="$(id -g)",mode=0700,size=8m crossdev-shared "$HOME/Windows"
|
||||
touch "$HOME/Windows/keep.txt"
|
||||
write 4G 2 64G crossdev-alias pw UTC
|
||||
touch "$HOME/.windows/disk.img"
|
||||
mkdir "$HOME/.windows/crossdev-shared-alias"
|
||||
mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/crossdev-shared-alias"
|
||||
__priv_remove 2>/dev/null && fail "removal missed a different-device shared alias below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "cross-device alias rejection changed state"
|
||||
umount -- "$HOME/.windows/crossdev-shared-alias"
|
||||
unmount_all
|
||||
umount -- "$HOME/Windows"
|
||||
pass "removal catches a direct different-filesystem shared alias at the xdev boundary"
|
||||
|
||||
# Recursive alias discovery is destructive-removal-only and bounded. A hung or
|
||||
# failing scanner must fail closed before the disk, share, compose, or mounts
|
||||
# are changed.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G scan-failure pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
scan_helper="$TMPDIR/tree-scan-helper"
|
||||
saved_tree_scan_find=$TREE_SCAN_FIND
|
||||
saved_tree_scan_timeout=$TREE_SCAN_TIMEOUT_SECONDS
|
||||
saved_tree_scan_kill_after=$TREE_SCAN_KILL_AFTER_SECONDS
|
||||
printf '#!/bin/bash\n/bin/sleep 10\n' >"$scan_helper"
|
||||
chmod 0700 "$scan_helper"
|
||||
TREE_SCAN_FIND=$scan_helper
|
||||
TREE_SCAN_TIMEOUT_SECONDS=0.05
|
||||
TREE_SCAN_KILL_AFTER_SECONDS=0.05
|
||||
__priv_remove 2>/dev/null && fail "removal continued after its containment scan timed out"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "timed-out containment scan changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "timed-out containment scan changed mounts"
|
||||
|
||||
printf '#!/bin/bash\nexit 42\n' >"$scan_helper"
|
||||
__priv_remove 2>/dev/null && fail "removal continued after its containment scanner failed"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed containment scan changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "failed containment scan changed mounts"
|
||||
TREE_SCAN_FIND=$saved_tree_scan_find
|
||||
TREE_SCAN_TIMEOUT_SECONDS=$saved_tree_scan_timeout
|
||||
TREE_SCAN_KILL_AFTER_SECONDS=$saved_tree_scan_kill_after
|
||||
pass "removal scan timeout and errors fail closed without changing VM state"
|
||||
|
||||
# Removal rejects stacks, then deletes disk only through verified binds.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G remove pw UTC
|
||||
resolve_caller
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mount --no-canonicalize --bind "$HOME/.windows" "$EXPECTED_STORAGE"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted stacked storage mount"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "rejected removal changed state"
|
||||
umount -- "$EXPECTED_STORAGE"
|
||||
dc() { return 1; }
|
||||
__priv_remove 2>/dev/null && fail "removal deleted data after docker-compose down failed"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed down changed data or compose"
|
||||
dc() { :; }
|
||||
__priv_remove
|
||||
[[ ! -e $HOME/.windows/disk.img ]] || fail "removal preserved disk data"
|
||||
[[ -e $HOME/Windows/keep.txt ]] || fail "removal deleted shared data"
|
||||
[[ ! -f $COMPOSE ]] || fail "removal left compose"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "removal left binds"
|
||||
pass "removal rejects stacks, deletes disk, and preserves shared files"
|
||||
|
||||
# Credentials replace a planted link rather than following it, and a failed
|
||||
# atomic rename preserves the last complete private file.
|
||||
credentials_dir="$TMPDIR/credentials"
|
||||
CREDENTIALS_FILE="$credentials_dir/credentials"
|
||||
credentials_victim="$TMPDIR/credentials-victim"
|
||||
mkdir -m 0755 -p "$credentials_dir"
|
||||
printf 'victim\n' >"$credentials_victim"
|
||||
ln -s "$credentials_victim" "$CREDENTIALS_FILE"
|
||||
write_credentials carol 'p=a$$w"x'
|
||||
[[ -f $CREDENTIALS_FILE && ! -L $CREDENTIALS_FILE ]] || fail "credentials did not replace a planted symlink"
|
||||
[[ $(stat -c '%a' "$credentials_dir") == 700 && $(stat -c '%a' "$CREDENTIALS_FILE") == 600 ]] || fail "credentials path is not private"
|
||||
[[ $(cat "$credentials_victim") == victim ]] || fail "credentials write changed a symlink victim"
|
||||
[[ $(read_credential USERNAME) == carol && $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "credentials did not round-trip"
|
||||
credentials_before=$(cat "$CREDENTIALS_FILE")
|
||||
mv() { return 1; }
|
||||
write_credentials changed replacement 2>/dev/null && fail "forced credentials rename failure succeeded"
|
||||
unset -f mv
|
||||
[[ $(cat "$CREDENTIALS_FILE") == "$credentials_before" ]] || fail "failed credentials rename replaced the live file"
|
||||
! find "$credentials_dir" -name '.credentials.*' -print -quit | grep -q . || fail "failed credentials write left a temporary file"
|
||||
pass "credentials are atomically replaced as a private regular file"
|
||||
|
||||
# Free-space accounting follows the real storage target.
|
||||
reset_case
|
||||
mkdir -p "$external_storage" "$HOME/Windows"
|
||||
ln -s "$external_storage" "$HOME/.windows"
|
||||
prepare_user_mount_sources
|
||||
df_log="$TMPDIR/df-path"
|
||||
df() {
|
||||
printf '%s\n' "${!#}" >"$df_log"
|
||||
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nmock 104857600 0 94371840 0%% /mock\n'
|
||||
}
|
||||
[[ $(available_storage_gb) == 90 ]] || fail "free-space parsed wrong value"
|
||||
unset -f df
|
||||
[[ $(cat "$df_log") == "$external_storage" ]] || fail "free-space used home filesystem"
|
||||
pass "disk-space checks follow the storage symlink target"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
# Exercise the real EUID-0/PKEXEC_UID boundary in an isolated user+mount namespace.
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
if ((EUID != 0)); then
|
||||
if unshare --user --map-auto --map-root-user --mount true 2>/dev/null; then
|
||||
exec unshare --user --map-auto --map-root-user --mount --propagation private bash "$0"
|
||||
fi
|
||||
pass "automatic subordinate-id namespace unavailable; skipping root Windows VM boundary probe"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
# Hide host state before creating the production paths used by the root helper.
|
||||
mount -t tmpfs -o mode=0755,size=8m run-test /run
|
||||
mkdir -p /run/lock
|
||||
mount -t tmpfs -o mode=0755,size=16m var-test /var
|
||||
mkdir -p /var/lib/omarchy
|
||||
mount -t tmpfs -o mode=0755,size=16m home-parent /home
|
||||
mkdir /home/alice
|
||||
mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice
|
||||
|
||||
export HOME=/home/alice
|
||||
unset OMARCHY_WINDOWS_DIR
|
||||
set -- help
|
||||
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
||||
|
||||
# The namespace maps the host filesystem's uid 0 to nobody. Only / remains on
|
||||
# that filesystem; all paths the helper mutates are isolated tmpfs mounts.
|
||||
stat() {
|
||||
if [[ ${!#} == / && $* == *"%u"* ]]; then printf '0\n'; return; fi
|
||||
command stat "$@"
|
||||
}
|
||||
|
||||
TEST_PASSWD_HOME=/home/alice
|
||||
getent() {
|
||||
if [[ $1 == passwd && ${2:-} == 1000 ]]; then
|
||||
printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME"
|
||||
return 0
|
||||
fi
|
||||
return 2
|
||||
}
|
||||
|
||||
assert_no_runtime_mutation() {
|
||||
[[ ! -e /var/lib/omarchy/windows && ! -L /var/lib/omarchy/windows ]] ||
|
||||
fail "$1 mutated the production runtime"
|
||||
}
|
||||
|
||||
unset PKEXEC_UID
|
||||
resolve_caller 2>/dev/null && fail "root accepted missing PKEXEC_UID"
|
||||
assert_no_runtime_mutation "missing PKEXEC_UID"
|
||||
PKEXEC_UID=0
|
||||
resolve_caller 2>/dev/null && fail "root accepted PKEXEC_UID=0"
|
||||
assert_no_runtime_mutation "zero PKEXEC_UID"
|
||||
PKEXEC_UID=not-a-number
|
||||
resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID"
|
||||
assert_no_runtime_mutation "nonnumeric PKEXEC_UID"
|
||||
PKEXEC_UID=1001
|
||||
resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd"
|
||||
assert_no_runtime_mutation "missing passwd entry"
|
||||
|
||||
PKEXEC_UID=1000
|
||||
resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller"
|
||||
assert_no_runtime_mutation "wrong-owned home"
|
||||
chown 1000:1000 /home/alice
|
||||
|
||||
chmod 0777 /home
|
||||
resolve_caller 2>/dev/null && fail "root accepted writable home parent"
|
||||
assert_no_runtime_mutation "writable parent"
|
||||
chmod 0755 /home
|
||||
|
||||
mkdir /home/real-alice
|
||||
chown 1000:1000 /home/real-alice
|
||||
ln -s /home/real-alice /home/link-alice
|
||||
TEST_PASSWD_HOME=/home/link-alice
|
||||
resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home"
|
||||
assert_no_runtime_mutation "symlinked home"
|
||||
TEST_PASSWD_HOME=/home/alice
|
||||
resolve_caller || fail "valid root PKEXEC_UID/home boundary was rejected"
|
||||
pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and writable-parent boundaries without mutation"
|
||||
|
||||
# Put each familiar source on its own filesystem. Both start with legacy 0755
|
||||
# permissions and world-readable payloads to prove migration hardens the leaves.
|
||||
mkdir /home/storage-target /home/shared-target
|
||||
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target
|
||||
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target
|
||||
ln -s /home/storage-target /home/alice/.windows
|
||||
ln -s /home/shared-target /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/.windows /home/alice/Windows
|
||||
printf disk >/home/storage-target/disk.img
|
||||
printf shared >/home/shared-target/shared.txt
|
||||
chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt
|
||||
chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt
|
||||
|
||||
home_dev=$(command stat -Lc '%d' /home/alice)
|
||||
storage_dev=$(command stat -Lc '%d' /home/storage-target)
|
||||
[[ $home_dev != "$storage_dev" ]] || fail "storage target did not land on a separate filesystem"
|
||||
|
||||
with_vm_lock prepare_caller_mounts || fail "root could not create verified production bind anchors"
|
||||
resolve_caller
|
||||
[[ $(readlink /home/alice/.windows) == /home/storage-target &&
|
||||
$(readlink /home/alice/Windows) == /home/shared-target ]] || fail "root consumed legitimate symlinks"
|
||||
[[ $(command stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(command stat -Lc '%d:%i' /home/storage-target) ]] || fail "storage bind identity differs from pinned source"
|
||||
[[ $(command stat -Lc '%d:%i' "$EXPECTED_SHARED") == $(command stat -Lc '%d:%i' /home/shared-target) ]] || fail "shared bind identity differs from pinned source"
|
||||
[[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem"
|
||||
[[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 &&
|
||||
$(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes"
|
||||
[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 &&
|
||||
$(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700"
|
||||
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then
|
||||
fail "another local account read the VM disk through its anchor"
|
||||
fi
|
||||
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then
|
||||
fail "another local account read shared files through their anchor"
|
||||
fi
|
||||
pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account"
|
||||
|
||||
# Existing production boundary components are never repaired in place when
|
||||
# their ownership or write permissions are unsafe. Both the preparation path
|
||||
# and the final pre-Docker guard must fail closed without disturbing the binds.
|
||||
chmod 0731 "$MOUNT_ROOT"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a group-writable mount boundary instead of rejecting it"
|
||||
mounts_ready 2>/dev/null && fail "final guard accepted a group-writable mount boundary"
|
||||
[[ $(command stat -Lc '%a' "$MOUNT_ROOT") == 731 ]] || fail "rejection unexpectedly changed the writable boundary"
|
||||
chmod 0711 "$MOUNT_ROOT"
|
||||
|
||||
chown 1000:1000 "$USERS_DIR"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a caller-owned mount boundary instead of rejecting it"
|
||||
mounts_ready 2>/dev/null && fail "final guard accepted a caller-owned mount boundary"
|
||||
[[ $(command stat -Lc '%u' "$USERS_DIR") == 1000 ]] || fail "rejection unexpectedly changed the boundary owner"
|
||||
chown root:root "$USERS_DIR"
|
||||
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 &&
|
||||
$(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "boundary rejection changed the verified mount pair"
|
||||
mounts_ready || fail "restored production boundaries were rejected"
|
||||
pass "root rejects wrong-owned and group-writable production mount boundaries without mutation"
|
||||
|
||||
expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}')
|
||||
actual_space=$(available_storage_gb)
|
||||
[[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem"
|
||||
[[ $(command df -P -- /home/alice | awk 'NR==2 {print int($4/1024/1024)}') != "$actual_space" ]] || fail "test filesystems do not distinguish home from storage"
|
||||
pass "disk-space accounting measures the actual storage filesystem, not home"
|
||||
|
||||
# Exercise the real root writer and final guard against the production paths.
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=alice\nPASSWORD=pw\nTZ=UTC\n' |
|
||||
with_vm_lock __priv_write_compose
|
||||
[[ $(command stat -Lc '%u:%a' "$COMPOSE_FILE") == 0:640 ]] || fail "root compose ownership/mode is wrong"
|
||||
with_vm_lock assert_mounts_safe || fail "final root mount/compose assertion rejected the verified pair"
|
||||
pass "root writer and final pre-Docker guard revalidate the pinned production mounts"
|
||||
|
||||
# Upgrade the exact sibling-anchor pair emitted by the earlier fix without
|
||||
# moving or replacing either familiar home symlink.
|
||||
sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE"
|
||||
sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE"
|
||||
sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE"
|
||||
compose_needs_security_migration || fail "previous protected compose was not recognized for upgrade"
|
||||
with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors"
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor"
|
||||
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "upgrade did not protect the web console"
|
||||
[[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link"
|
||||
pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary"
|
||||
|
||||
# A compose that already uses the fixed anchors still needs an authorized
|
||||
# upgrade when it predates web-console authentication.
|
||||
sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE"
|
||||
compose_needs_security_migration || fail "unprotected fixed-anchor compose was not recognized for upgrade"
|
||||
with_vm_lock assert_mounts_safe || fail "root could not protect an existing fixed-anchor compose"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "fixed-anchor upgrade did not protect the web console"
|
||||
pass "existing fixed-anchor compose gains web-console authentication"
|
||||
|
||||
# Preflight both sources before either bind on a clean anchor pair.
|
||||
umount "$EXPECTED_SHARED"
|
||||
umount "$EXPECTED_STORAGE"
|
||||
rm /home/alice/Windows
|
||||
ln -s / /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/Windows
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind"
|
||||
[[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink"
|
||||
pass "root preflights both sources before mounting either and preserves rejection evidence"
|
||||
|
||||
# mountpoint(1) follows symlinks, so explicitly pin the invariant that even a
|
||||
# root-planted anchor symlink to the expected mounted source is rejected.
|
||||
rm /home/alice/Windows
|
||||
ln -s /home/shared-target /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/Windows
|
||||
rmdir "$EXPECTED_STORAGE"
|
||||
ln -s /home/storage-target "$EXPECTED_STORAGE"
|
||||
storage_id=$(command stat -Lc '%d:%i' /home/storage-target)
|
||||
mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && fail "symlink mount anchor passed final identity check"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root followed a symlink mount anchor"
|
||||
[[ -L $EXPECTED_STORAGE ]] || fail "rejected anchor symlink was consumed"
|
||||
pass "final guard rejects a symlink even when it resolves to the expected mounted source"
|
||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
windows_vm_command="$ROOT/bin/omarchy-windows-vm"
|
||||
windows_vm_rules="$ROOT/default/hypr/apps/windows-vm.lua"
|
||||
|
||||
rg -q '^ restart: "no"$' "$windows_vm_command" ||
|
||||
fail "Windows VM uses manual startup by default"
|
||||
@@ -14,3 +15,15 @@ if rg -q '^ restart: unless-stopped$' "$windows_vm_command"; then
|
||||
fail "Windows VM does not restart automatically at boot"
|
||||
fi
|
||||
pass "Windows VM does not restart automatically at boot"
|
||||
|
||||
# Tolerate either shell quoting of the argument -- what must not drift is the
|
||||
# title itself, since the Hyprland rule below matches on it.
|
||||
rg -q 'title:"?Windows VM - Omarchy"' "$windows_vm_command" ||
|
||||
fail "Windows VM launches FreeRDP with its expected title"
|
||||
rg -q 'class = "\^xfreerdp\$", title = "\^Windows VM - Omarchy\$"' "$windows_vm_rules" ||
|
||||
fail "Windows VM opacity rule targets its FreeRDP window"
|
||||
rg -q 'tag = "-default-opacity"' "$windows_vm_rules" ||
|
||||
fail "Windows VM opts out of default opacity"
|
||||
rg -q 'opacity = "1 1"' "$windows_vm_rules" ||
|
||||
fail "Windows VM stays fully opaque"
|
||||
pass "Windows VM stays fully opaque"
|
||||
|
||||
Reference in New Issue
Block a user