Merge quattro into hermes-agent

Catches the branch up on 94 commits so what lands here is reviewed against
current quattro, and so #8611 contributes its own five files rather than
dragging a partial catch-up in behind it.
This commit is contained in:
David Heinemeier Hansson committed 2026-08-30 11:58:37 +02:00
commit 2541eeee3d
115 files changed
+6771 -441

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=$(mktemp -d)
# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the
# old wrapper would have formed, so it cannot use a random mktemp name. Track whether
# we created it and remove it on exit only then -- never touch a path we did not create.
tmp_cache="/tmp/omarchy-brightness-display-apple.device"
created_tmp_cache=0
cleanup() {
rm -rf "$TMPDIR"
# Remove the /tmp decoy only if this test is the one that created it.
if (( created_tmp_cache )); then
rm -f "$tmp_cache"
fi
}
trap cleanup EXIT
# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol
# invocation, make detection deterministic by having --detect report no device,
# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's
# detect_apple_display_device returns before it ever runs asdcontrol, so the
# reject cases assert on the negative: a refused cache value is never handed to
# `asdcontrol <dev> -- <step>`. Blind-trust validation would hand it over and be
# caught here.
stub_dir="$TMPDIR/stubs"
mkdir -p "$stub_dir"
asd_log="$TMPDIR/asdcontrol.log"
cat >"$stub_dir/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
chmod +x "$stub_dir/sudo"
cat >"$stub_dir/asdcontrol" <<STUB
#!/bin/bash
printf '%s\n' "\$*" >>"$asd_log"
# --detect reports nothing, so detection never yields a device.
if [[ \$1 == "--detect" ]]; then
exit 0
fi
# A brightness read (a lone device arg) returns a plausible value; a set
# (<device> -- <step>) just succeeds.
if [[ \$# -eq 1 ]]; then
printf '%s: BRIGHTNESS=30000\n' "\$1"
fi
exit 0
STUB
chmod +x "$stub_dir/asdcontrol"
cat >"$stub_dir/omarchy-osd" <<'STUB'
#!/bin/bash
exit 0
STUB
chmod +x "$stub_dir/omarchy-osd"
run_wrapper() {
# $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper.
local xdg="$1"
shift
: >"$asd_log"
if [[ -n $xdg ]]; then
XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \
omarchy-brightness-display-apple "$@" 2>&1 || true
else
env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \
omarchy-brightness-display-apple "$@" 2>&1 || true
fi
}
# --- A cache value that is not a hiddev character device is rejected ----------
xdg_dir="$TMPDIR/xdg"
mkdir -p "$xdg_dir"
cache_file="$xdg_dir/omarchy-brightness-display-apple.device"
regular_file="$TMPDIR/not-a-device"
: >"$regular_file"
poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil")
# The cases above all fail on the pathname prefix, so none of them reaches the -c
# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path
# that matches the hiddev glob but is not a character device is what -c is for,
# and it is the realistic stale cache: the display replugs, the interface
# renumbers, and the cached node is simply gone. Add it only when the host really
# has no such node, so a machine with the display attached cannot fail here.
if [[ ! -e /dev/hiddev999 ]]; then
poisons+=("/dev/hiddev999")
fi
for poison in "${poisons[@]}"; do
printf '%s\n' "$poison" >"$cache_file"
output=$(run_wrapper "$xdg_dir" "+5%")
if grep -qF -- "$poison -- +5%" "$asd_log"; then
fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output"
fi
done
pass "wrapper rejects a cached path that is not a hiddev character device"
# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path
# that does not exist. The remaining arm -- a path under /dev that exists, matches
# the glob, and is not a character device -- cannot be built without root, since
# only real device nodes live there.
# --- A legitimate cached hiddev node is trusted (only where HW is present) ----
real_hiddev=""
for candidate in /dev/usb/hiddev* /dev/hiddev*; do
if [[ -c $candidate ]]; then
real_hiddev="$candidate"
break
fi
done
if [[ -n $real_hiddev ]]; then
printf '%s\n' "$real_hiddev" >"$cache_file"
run_wrapper "$xdg_dir" "+5%" >/dev/null
grep -qF -- "$real_hiddev -- +5%" "$asd_log" ||
fail "wrapper did not trust a valid cached hiddev node: $real_hiddev"
pass "wrapper trusts a cached hiddev character device without re-detecting"
else
pass "no /dev/hiddev* character device present; skipping the valid-cache case"
fi
# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------
# Assert on the open, not on the contents. A decoy holding a rejectable path proves
# nothing: the validation above refuses it whether or not the /tmp fallback is still
# there, so that assertion passes against both wrappers. A FIFO with no writer blocks
# whoever opens it, so a wrapper that consults the path hangs and one that ignores it
# exits -- which separates the two. mkfifo is atomic and fails outright if the path is
# taken, so it neither overwrites a file nor follows a symlink; the fixed path is
# required, being exactly the path the old code would have formed. Clear the flag as
# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this
# run's EXIT trap.
if mkfifo "$tmp_cache" 2>/dev/null; then
created_tmp_cache=1
status=0
env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \
timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$?
rm -f "$tmp_cache"
created_tmp_cache=0
(( status != 124 )) ||
fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \
"it blocked reading the FIFO decoy at $tmp_cache"
pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset"
else
pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case"
fi
+330
View File
@@ -0,0 +1,330 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
export OMARCHY_PATH="$ROOT"
export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning"
source "$ROOT/install/helpers/browser-policy.sh"
# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged.
unprivileged_as_root() {
if [[ $1 == "install" ]]; then
shift
local args=()
local skip=0
local arg
for arg in "$@"; do
if (( skip )); then
skip=0
continue
fi
case $arg in
-o|-g) skip=1 ;;
*) args+=("$arg") ;;
esac
done
command install "${args[@]}"
else
"$@"
fi
}
write_dir=$test_tmp/writable
mkdir -p "$write_dir"
browser_policy_install_color "$write_dir" "#aabbcc" ||
fail "theme colour writes into a writable policy directory"
grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null ||
fail "theme colour writes BrowserThemeColor"
mode=$(stat -c '%a' "$write_dir/color.json")
[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode"
pass "theme colour writes a 0644 color.json"
if (( EUID == 0 )); then
pass "running as root; skipping the mktemp-failure check"
else
chmod u+w "$write_dir"
export TMPDIR=$test_tmp/missing-tmp
if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then
fail "theme colour fails when mktemp cannot create a file"
fi
unset TMPDIR
grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null ||
fail "a failed mktemp leaves an existing color.json intact"
pass "a failed mktemp does not truncate color.json"
fi
printf 'original\n' >"$test_tmp/pwn"
rm -f "$write_dir/color.json"
ln -s "$test_tmp/pwn" "$write_dir/color.json"
browser_policy_install_color "$write_dir" "#aabbcc" ||
fail "theme colour replaces a planted color.json symlink"
[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] ||
fail "theme colour unlinks a planted color.json symlink instead of writing through it"
grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged"
pass "theme colour does not follow a planted color.json symlink"
plant_write=$test_tmp/plant-dir
mkdir -p "$plant_write/color.json/nested"
printf 'inside\n' >"$plant_write/color.json/nested/x"
browser_policy_install_color "$plant_write" "#aabbcc" ||
fail "theme colour replaces a planted color.json directory"
[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] ||
fail "theme colour does not write into a planted color.json directory"
pass "theme colour does not write into a planted color.json directory"
missing_dir=$test_tmp/missing
browser_policy_install_color "$missing_dir" "#aabbcc" ||
fail "theme colour skips a policy directory that does not exist"
[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory"
pass "theme colour skips a missing policy directory"
if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then
fail "theme colour rejects hex without a leading #"
fi
if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then
fail "theme colour rejects uppercase hex"
fi
pass "theme colour accepts only # plus six lowercase hex digits"
planted_dir=$test_tmp/planted
mkdir -p "$planted_dir/evil"
printf 'evil\n' >"$planted_dir/evil/f"
printf 'old\n' >"$planted_dir/color.json"
as_root() { unprivileged_as_root "$@"; }
browser_policy_setup_dir "$planted_dir"
[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory"
[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json"
[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place"
mode=$(stat -c '%a' "$planted_dir")
[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode"
pass "policy setup drops non-root files and non-empty subdirectories"
owned=$test_tmp/not-root
mkdir -p "$owned"
chmod 755 "$owned"
if browser_policy_dir_hardened "$owned"; then
fail "a user-owned 0755 directory is not treated as hardened"
fi
pass "a hardened directory must be root-owned"
saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}")
parent_root=$test_tmp/parents
mkdir -p "$parent_root/etc/chromium/policies/managed/keep"
printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x"
chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies"
chmod 755 "$parent_root/etc/chromium/policies/managed"
BROWSER_POLICY_PARENT_DIRS=(
"$parent_root/etc/chromium"
"$parent_root/etc/chromium/policies"
)
as_root() { unprivileged_as_root "$@"; }
if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then
fail "a world-writable policy parent is not treated as hardened"
fi
browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed"
mode=$(stat -c '%a' "$parent_root/etc/chromium")
[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode"
mode=$(stat -c '%a' "$parent_root/etc/chromium/policies")
[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode"
[[ -d $parent_root/etc/chromium/policies/managed/keep ]] ||
fail "parent repair does not purge the managed directory"
pass "policy parent directories are tightened to 0755 without purging the leaf"
symlink_root=$test_tmp/symlink-parents
mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed"
printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json"
ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium"
BROWSER_POLICY_PARENT_DIRS=(
"$symlink_root/etc/chromium"
"$symlink_root/etc/chromium/policies"
)
as_root() { unprivileged_as_root "$@"; }
browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed"
[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink"
[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] ||
fail "setup recreates /etc/chromium as a real directory"
[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] ||
fail "setup recreates /etc/chromium/policies as a real directory"
[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] ||
fail "setup does not keep policy that lived behind a planted parent symlink"
grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" ||
fail "replacing a parent symlink does not delete the symlink target"
BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}")
pass "policy setup does not follow a planted parent symlink"
leaf_link_root=$test_tmp/leaf-link
mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker"
printf 'planted\n' >"$leaf_link_root/attacker/evil.json"
chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies"
ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed"
BROWSER_POLICY_PARENT_DIRS=(
"$leaf_link_root/etc/chromium"
"$leaf_link_root/etc/chromium/policies"
)
as_root() { unprivileged_as_root "$@"; }
if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then
fail "a planted managed symlink is not treated as hardened"
fi
browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed"
[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] ||
fail "setup replaces a planted managed symlink"
[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] ||
fail "setup recreates managed as a real directory"
[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] ||
fail "setup does not keep policy that lived behind a planted managed symlink"
grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" ||
fail "replacing a managed symlink does not delete the symlink target"
BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}")
pass "policy setup does not follow a planted managed symlink"
fx_link_root=$test_tmp/fx-link
mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt"
printf 'planted\n' >"$fx_link_root/attacker/policies.json"
ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen"
as_root() { unprivileged_as_root "$@"; }
if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then
fail "a planted Firefox distribution symlink is not treated as hardened"
fi
browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" ||
fail "Firefox setup replaces a planted distribution symlink"
[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink"
[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] ||
fail "Firefox setup recreates the distribution directory"
[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] ||
fail "Firefox setup writes policies.json into the recreated directory"
grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" ||
fail "replacing a Firefox distribution symlink does not delete the symlink target"
pass "Firefox setup does not follow a planted distribution symlink"
[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] ||
fail "theme colour converts an RGB triple to hex"
[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] ||
fail "theme colour accepts a trailing newline"
[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] ||
fail "theme colour pads single-digit components"
[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] ||
fail "theme colour tolerates surrounding whitespace"
[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] ||
fail "theme colour treats leading zeros as decimal"
for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \
"1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do
[[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] ||
fail "theme colour falls back to the stock grey for '$malformed'"
done
pass "theme colour is six hex digits or the stock grey"
for theme in "$ROOT"/themes/*/chromium.theme; do
[[ -f $theme ]] || continue
rgb=$(<$theme)
hex=$(browser_policy_theme_hex "$rgb")
[[ $hex =~ ^#[0-9a-f]{6}$ ]] ||
fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")"
if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then
fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")"
fi
done
pass "shipped chromium.theme files parse as RGB triples"
grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex"
grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy"
if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then
fail "omarchy-theme-set-browser does not hand unvetted theme words to printf"
fi
pass "omarchy-theme-set-browser validates the theme colour"
fx_policy=$test_tmp/policies.json
printf '%s\n' '{"policies":{}}' >"$fx_policy"
chmod 644 "$fx_policy"
if browser_policy_firefox_policy_file_ok "$fx_policy"; then
fail "a user-owned policies.json is not treated as hardened"
fi
ln -sf "$fx_policy" "$test_tmp/policies-link.json"
if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then
fail "a policies.json symlink is not treated as hardened"
fi
pass "Firefox policy files must be root-owned regular files without group or other write"
dist=$test_tmp/distribution
mkdir -p "$dist"
printf 'original\n' >"$test_tmp/firefox-pwn"
ln -s "$test_tmp/firefox-pwn" "$dist/policies.json"
as_root() { unprivileged_as_root "$@"; }
browser_policy_install_firefox_policies "$dist" ||
fail "Firefox policy install replaces a planted policies.json symlink"
[[ -f $dist/policies.json && ! -L $dist/policies.json ]] ||
fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it"
grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged"
grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies"
pass "Firefox policy install does not follow a planted policies.json symlink"
dir_dist=$test_tmp/distribution-dir
mkdir -p "$dir_dist"
mkdir "$dir_dist/policies.json"
as_root() { unprivileged_as_root "$@"; }
if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then
fail "Firefox policy install refuses a planted policies.json directory"
fi
[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place"
pass "Firefox policy install does not write into a planted policies.json directory"
grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
fail "omarchy-theme-set-browser exits non-zero when a policy write fails"
pass "omarchy-theme-set-browser exits non-zero when a policy write fails"
# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a
# handler ending on a false test turns a clean run into a failure and aborts the
# migration that calls this through omarchy-theme-set-browser.
policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy")
[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler"
eval "$policy_cleanup"
staged=""
cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged"
staged=$test_tmp/staged-policy
: >"$staged"
cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file"
[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file"
unset -f cleanup
pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status"
grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null ||
fail "the policy-directory migration hardens Firefox even when the theme refresh fails"
pass "the policy-directory migration does not abort on a failed theme refresh"
policy_files=(
"$ROOT/bin/omarchy-install-browser"
"$ROOT/bin/omarchy-provision-owner"
"$ROOT/bin/omarchy-theme-set-browser"
"$ROOT/bin/omarchy-theme-set-browser-policy"
"$ROOT/bin/omarchy-upgrade-to-quattro"
"$ROOT/install/config/theme-system.sh"
"$ROOT/install/config/browser-policy.sh"
"$ROOT/install/helpers/browser-policy.sh"
"$ROOT/migrations/1787515927.sh"
)
if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then
fail "browser policy setup is not world-writable and does not use omarchy-browser-policy"
fi
pass "browser policy setup is not world-writable"
mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations")
(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}"
grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null ||
fail "the policy-directory migration repairs managed directories"
if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then
fail "the policy-directory migration does not grant a browser-policy group"
fi
grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null ||
fail "the policy-directory migration covers Firefox and Zen"
grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null ||
fail "the policy-directory migration keeps a trusted Firefox policies.json"
grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null ||
fail "the shared helper names the Zen distribution directory"
pass "a migration locks existing policy directories"
+184
View File
@@ -0,0 +1,184 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
helper="$ROOT/bin/omarchy-theme-set-browser-policy"
setter="$ROOT/bin/omarchy-theme-set-browser"
sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser"
rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads
# as "any arguments" -- or widening the glob to `*` would let the grant carry
# something other than a color while leaving this line looking right.
rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file")
[[ $rules == "$rule" ]] ||
fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules"
if command -v visudo >/dev/null; then
visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses"
fi
grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names"
grep -E 'sudo -n -l -l' "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing"
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" ||
fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root"
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true)
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root"
pass "browser policy sudoers rule is scoped to a single color argument"
for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \
/etc/opt/edge/policies/managed /etc/brave/policies/managed; do
grep -Fx " $dir" "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list"
done
policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /')
((policy_dir_count == 4)) ||
fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \
"got: $policy_dir_count"
grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy installs color.json with install -T"
if grep -E 'mv -f' "$helper" >/dev/null; then
fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory"
fi
pass "browser policy helper writes a fixed set of policy directories"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/pkexec" <<'SH'
#!/bin/bash
printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG"
SH
chmod +x "$stub_bin/pkexec"
# STUB_GRANTED empty stands for an install whose omarchy-settings predates the
# sudoers file. The default is granted, matching a current Omarchy.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
if [[ $1 == -n && $2 == -l ]]; then
if [[ ${STUB_GRANTED-granted} == "granted" ]]; then
echo " Options: !authenticate"
else
echo " Matched: ${!#}"
fi
exit 0
fi
printf 'sudo %s\n' "$*" >"$ELEVATION_LOG"
SH
chmod +x "$stub_bin/sudo"
if ((EUID == 0)); then
pass "running as root; skipping the elevation checks, which would rewrite this machine's browser policy"
else
elevation_for() {
: >"$test_tmp/elevation"
ELEVATION_LOG="$test_tmp/elevation" \
PATH="$stub_bin:$PATH" \
bash "$helper" "$@" </dev/null >/dev/null 2>&1 || true
cat "$test_tmp/elevation"
}
elevation=$(elevation_for 1c2027)
[[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \
"got: $elevation"
dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027)
[[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \
"got: $dev_linked"
pass "browser policy helper elevates a valid color through the sudo grant"
ungranted=$(STUB_GRANTED="" elevation_for 1c2027)
[[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \
"got: $ungranted"
pass "browser policy helper falls back to polkit wherever the grant does not reach"
for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \
'$(id)' "1c2027;id" "#1c2027"; do
if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \
bash "$helper" "$bad" </dev/null >/dev/null 2>&1; then
fail "omarchy-theme-set-browser-policy rejects '$bad'"
fi
rejected=$(elevation_for "$bad")
[[ -z $rejected ]] ||
fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected"
done
if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff </dev/null >/dev/null 2>&1; then
fail "omarchy-theme-set-browser-policy rejects more than one argument"
fi
pass "browser policy helper accepts nothing but six lowercase hex digits"
fi
setter_bin="$test_tmp/setter-bin"
mkdir -p "$setter_bin"
cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >"$COLOR_LOG"
SH
chmod +x "$setter_bin/omarchy-theme-set-browser-policy"
cat >"$setter_bin/omarchy-cmd-present" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$setter_bin/omarchy-cmd-present"
setter_home="$test_tmp/home"
theme_dir="$setter_home/.local/state/omarchy/current/theme"
mkdir -p "$theme_dir"
color_for_theme() {
: >"$test_tmp/color"
if [[ $# -gt 0 ]]; then
printf '%s' "$1" >"$theme_dir/chromium.theme"
else
rm -f "$theme_dir/chromium.theme"
fi
HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \
OMARCHY_PATH="$ROOT" bash "$setter" </dev/null >/dev/null 2>&1 || true
cat "$test_tmp/color"
}
[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] ||
fail "omarchy-theme-set-browser converts an RGB triple to six hex digits"
[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] ||
fail "omarchy-theme-set-browser accepts a trailing newline"
[[ $(color_for_theme "0,0,0") == "000000" ]] ||
fail "omarchy-theme-set-browser pads single-digit components"
[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] ||
fail "omarchy-theme-set-browser tolerates surrounding whitespace"
for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \
"1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do
color=$(color_for_theme "$malformed")
[[ $color == "1c2027" ]] ||
fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color"
done
[[ $(color_for_theme) == "1c2027" ]] ||
fail "omarchy-theme-set-browser falls back to the stock colour with no theme file"
pass "browser theme color is derived as six hex digits or falls back to the stock grey"
@@ -28,8 +28,18 @@ write_stale_preferences() {
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
REAL_PYTHON=$(command -v python3)
cat >"$stub_bin/python3" <<'STUB'
#!/bin/bash
exit 127
STUB
chmod +x "$stub_bin/python3"
# Test stubs must delegate to the system interpreter, not a user shim that can
# route python3 back through the stubs and recurse.
REAL_PYTHON=$(PATH="$stub_bin:$PATH" command -p -v python3)
[[ $REAL_PYTHON != "$stub_bin/python3" ]] || fail "real Python resolution bypasses user shims"
export REAL_PYTHON
rm -f "$stub_bin/python3"
run_migration() {
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1
+328
View File
@@ -54,6 +54,334 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user-
fail "crash capture is no longer on by default for new installs"
pass "crash capture is on by default"
require_command jq
# The per-program mute, driven through the real watcher with a stubbed journal:
# these prove what a person sees -- a toast arriving or not -- where asserting
# that a flag file was read would prove only that a flag file was read.
watch_bin="$TMPDIR/watch-bin"
watch_home="$TMPDIR/watch-home"
NOTIFY_LOG="$TMPDIR/notify-log"
JOURNAL_ENTRIES="$TMPDIR/journal-entries"
mkdir -p "$watch_bin" "$watch_home"
cat >"$watch_bin/journalctl" <<'SH'
#!/bin/bash
cat "$JOURNAL_ENTRIES"
SH
cat >"$watch_bin/omarchy-default-agent" <<'SH'
#!/bin/bash
echo claude
SH
cat >"$watch_bin/omarchy-notification-wait" <<'SH'
#!/bin/bash
exit 0
SH
cat >"$watch_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$NOTIFY_LOG"
SH
chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \
"$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send"
reset_entries() {
: >"$JOURNAL_ENTRIES"
}
# One core dump as systemd-coredump journals it. The UID must be this user's, or
# the watcher discards it as somebody else's crash before anything under test.
crash_entry() {
local comm="$1" exe="$2"
jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \
'{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242",
COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES"
}
# The stubbed journalctl ends after the entries, so the watcher's loop ends too.
# Its exit status is asserted rather than discarded: a watcher that dies on a
# muted crash notifies about nothing afterwards, which every assertion below
# that expects silence would otherwise read as success.
run_watch() {
local status=0
: >"$NOTIFY_LOG"
PATH="$watch_bin:$ROOT/bin:$PATH" \
JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \
NOTIFY_LOG="$NOTIFY_LOG" \
HOME="$watch_home" \
"$ROOT/bin/omarchy-crash-watch" || status=$?
(( status == 0 )) ||
fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it"
}
# Through the real command rather than writing the flag by hand: these assertions
# are then the guard that the thing the diagnosis runs and the thing the watcher
# reads have not drifted apart.
mute() {
HOME="$watch_home" PATH="$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null
}
announced() {
grep -Fq "Process crashed: $1" "$NOTIFY_LOG"
}
reset_entries
crash_entry hyprland /usr/bin/hyprland
run_watch
announced hyprland ||
fail "a crash nobody muted still announces itself"
pass "a crash nobody muted still announces itself"
mute hyprland on
run_watch
! announced hyprland ||
fail "muting a program stops the crash notifications the diagnosis offered to stop"
pass "muting a program stops its crash notifications"
reset_entries
crash_entry nautilus /usr/bin/nautilus
run_watch
announced nautilus ||
fail "muting one program silences every other program, which is the global toggle's job and not this one's"
pass "muting one program leaves every other program announcing"
mute hyprland off
reset_entries
crash_entry hyprland /usr/bin/hyprland
run_watch
announced hyprland ||
fail "un-muting a program brings its crash notifications back"
pass "un-muting a program brings its crash notifications back"
# The diagnosis tells the user to mute the name the toast showed them, so the
# toast has to show the name the watcher checks. COMM is truncated to 15
# characters and the executable's basename is not, and announcing the truncated
# one would leave a dutifully-followed mute matching nothing forever.
reset_entries
crash_entry chromium-browse /usr/lib/chromium/chromium-browser
run_watch
announced chromium-browser ||
fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing"
pass "the toast announces the name the mute is keyed on"
mute chromium-browser on
run_watch
! announced chromium-browser ||
fail "the mute is keyed on the name the notification announced, not on the truncated COMM"
pass "muting the announced name silences a program whose COMM was truncated"
# A muted crash must not end the watcher. Restart=always would paper over it
# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which
# never replays the crashes it missed while it was away.
reset_entries
crash_entry chromium-browse /usr/lib/chromium/chromium-browser
crash_entry nautilus /usr/bin/nautilus
run_watch
announced nautilus ||
fail "a muted crash stops the watcher reading the journal, losing every crash after it"
pass "a muted crash does not stop the watcher reading the next one"
# A process can set its own comm to anything prctl takes, slashes included, and
# a crash with no recorded executable falls back to it. A name that climbed out
# of crash-ignore/ would let a crashing program silence itself against an
# unrelated flag -- and have the diagnosis write one there on the user's behalf.
# The fixture carries two slashes so that dropping only the first is not mistaken
# for dropping all of them.
reset_entries
crash_entry a/../bar-off -
sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off"
touch "$sibling_flag"
run_watch
announced bar-off ||
fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification"
pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle"
rm -f "$sibling_flag"
# Stripping to the last component does not always leave a component. An empty
# name is no kind of array subscript and no kind of toast, and a dot component
# names a directory the mute would touch and then never match.
for empty_comm in / a/ . ..; do
reset_entries
crash_entry "$empty_comm" -
run_watch
announced unknown ||
fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on"
done
pass "a comm that strips down to nothing or a dot still announces under a name a mute can use"
# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field
# collapses and every field after it shifts along one -- the pid becomes a path,
# the crash reads as somebody else's, and it is dropped without a word.
reset_entries
crash_entry "" -
crash_entry nautilus /usr/bin/nautilus
run_watch
announced unknown ||
fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it"
announced nautilus ||
fail "an empty comm derails the rest of the journal entry"
pass "an empty comm is announced rather than parsed into the next field"
# Only "." and ".." are special. A leading dot is an ordinary filename, and
# folding those into the fallback would have one program's mute silence another.
for dotted_comm in .hidden ...; do
reset_entries
crash_entry "$dotted_comm" -
run_watch
announced "$dotted_comm" ||
fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes"
done
pass "a leading dot is an ordinary name rather than a special component"
# And the name it settles on is mutable like any other.
mute unknown on
reset_entries
crash_entry / -
run_watch
! announced unknown ||
fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced"
pass "the fallback name can be muted like any other"
mute unknown off
# What omarchy-crash-mute does on its own. That it agrees with the watcher is
# already covered above, which drives it for every mute it makes.
mute_home="$TMPDIR/mute-home"
mkdir -p "$mute_home"
crash_mute() {
HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@"
}
mute_flag() {
[[ $1 == "--" ]] && shift
printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1"
}
crash_mute | grep -Fq "No programs muted" ||
fail "an empty mute list prints nothing, so a user cannot tell it from a broken command"
pass "the command says so when nothing is muted"
crash_mute hyprland >/dev/null
crash_mute | grep -Fqx hyprland ||
fail "a muted program is missing from the list, so a mute cannot be found again to lift it"
pass "the command lists what it muted"
# The watcher keys on the basename, so the command has to take the path a crash
# recorded and land on the same flag the watcher will look for.
crash_mute /usr/lib/chromium/chromium-browser >/dev/null
[[ -f $(mute_flag chromium-browser) ]] ||
fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag"
pass "the command reduces a path to the name the watcher checks"
crash_mute hyprland off >/dev/null
[[ ! -f $(mute_flag hyprland) ]] ||
fail "off leaves the program muted, making the mute a one-way door"
pass "the command un-mutes"
# Muting is not flipping. The diagnosis offers this on a program the user may
# already have muted, and asking for a mute twice has to leave it muted.
crash_mute hyprland >/dev/null
crash_mute hyprland >/dev/null
[[ -f $(mute_flag hyprland) ]] ||
fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on"
pass "asking to mute twice leaves it muted"
# A program may legitimately be called .hidden, and a mute nobody can see is a
# mute nobody can lift.
crash_mute .hidden >/dev/null
crash_mute | grep -Fqx .hidden ||
fail "a mute on a dotted name is missing from the list, so it can never be found and lifted"
pass "the list shows a name that begins with a dot"
# It turns what it is given into a path, so it has to refuse whatever is not one
# component of one.
for bad_name in . .. /; do
! crash_mute "$bad_name" >/dev/null 2>&1 ||
fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read"
done
pass "the command refuses a name that is not a name"
! crash_mute hyprland sideways >/dev/null 2>&1 ||
fail "an action it does not know is treated as a mute, so a typo silences a program"
pass "the command refuses an action it does not know"
# And says what it refused, or the user retypes the same thing. Captured rather
# than piped: the command exits non-zero here, which pipefail would surface as
# the pipeline's status and read as a failed assertion.
refusal=$(crash_mute hyprland sideways 2>&1) || true
grep -Fq "Not an action" <<<"$refusal" ||
fail "an unknown action is refused without naming it, leaving the user nothing to correct"
pass "the command names the action it refused"
crash_mute ../bar-off >/dev/null
[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] ||
fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead"
pass "the command cannot be talked into writing outside crash-ignore/"
# A program may be called -h, and the router answers that with its own help
# before the command runs. A leading -- is the way through, so it has to be
# consumed rather than taken for the program name.
crash_mute -- -h >/dev/null 2>&1 ||
fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all"
[[ -f $(mute_flag -- -h) ]] ||
fail "a leading -- is taken for the program name, so muting -h mutes something else"
pass "a leading -- lets a program named like a flag be muted"
# toggle is advertised, so it has to flip both ways rather than quietly mute.
crash_mute toggler off >/dev/null
crash_mute toggler toggle >/dev/null
[[ -f $(mute_flag toggler) ]] ||
fail "toggle does not mute an un-muted program"
crash_mute toggler toggle >/dev/null
[[ ! -f $(mute_flag toggler) ]] ||
fail "toggle mutes but never un-mutes, so the advertised action only goes one way"
pass "toggle flips a mute both ways"
# The listing means what the watcher means, and the watcher honours a regular
# file. Anything else in there is not a mute, however much it looks like one.
mkdir -p "$(mute_flag notactuallymuted)"
! crash_mute | grep -Fqx notactuallymuted ||
fail "a directory is reported as muted while that program's crashes keep arriving"
pass "the listing counts only the flags the watcher honours"
rmdir "$(mute_flag notactuallymuted)"
# A mute that could not be written must not be reported as one. Without this the
# command can print success for a flag that was never created.
failing_bin="$TMPDIR/failing-bin"
mkdir -p "$failing_bin"
cat >"$failing_bin/omarchy-toggle" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$failing_bin/omarchy-toggle"
status=0
refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$?
(( status != 0 )) ||
fail "a mute that could not be written exits zero, so nothing downstream learns it failed"
! grep -Fq "Muted crash notifications" <<<"$refusal" ||
fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not"
pass "a mute that could not be written is not reported as one"
skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md"
grep -Fq 'omarchy-crash-mute' "$skill" ||
fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out"
pass "the diagnosis names the command that mutes"
grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" ||
fail "the crash group has no description, so the router lists a group it cannot describe"
pass "the crash group is described in the router"
run_node_test <<'JS'
const fs = require('fs')
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
+246
View File
@@ -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 -1
View File
@@ -459,7 +459,7 @@ assert_bypass() {
assert_launch pi pi "Review this project"
assert_launch omp omp --auto-approve -- "Review this project"
assert_launch opencode opencode --auto --prompt "Review this project"
assert_launch ori ori code --prompt "Review this project"
assert_launch ori ori code --interactive --prompt "Review this project"
assert_launch claude claude --permission-mode auto -- "Review this project"
assert_launch codex codex --approve-for-me -- "Review this project"
assert_launch crush crush run "Review this project"
+45 -5
View File
@@ -61,11 +61,13 @@ if [[ $installer == "omarchy-install-browser" && ${OMARCHY_TEST_REAL_BROWSER_INS
fi
case $installer in
omarchy-pkg-add)
omarchy-pkg-add|omarchy-pkg-aur-add)
package=$1
printf 'pkg:%s\n' "$package" >>"$OMARCHY_TEST_INSTALL_LOG"
case $package in
chromium) command=chromium ;;
firefox) command=firefox ;;
zen-browser-bin) command=zen-browser ;;
cursor-bin) command=cursor ;;
sublime-text-4) command=sublime_text ;;
vim) command=vim ;;
@@ -107,6 +109,7 @@ SH
for installer in \
omarchy-pkg-add \
omarchy-pkg-aur-add \
omarchy-install-browser \
omarchy-install-terminal \
omarchy-install-editor-vscode \
@@ -205,10 +208,17 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu
[[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds"
cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" ||
fail "Chromium browser installer copies the default flags"
grep -Fxq 'sudo:mkdir -p /etc/chromium/policies/managed' "$setup_log" ||
fail "Chromium browser installer creates its policy directory"
grep -Fxq 'sudo:chmod a+rw /etc/chromium/policies/managed' "$setup_log" ||
fail "Chromium browser installer makes its policy directory writable"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" ||
fail "Chromium browser installer creates a root-owned Chromium policy parent"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" ||
fail "Chromium browser installer creates a root-owned Chromium policies parent"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies/managed' "$setup_log" ||
fail "Chromium browser installer creates a root-owned managed policy directory"
grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
fail "Chromium browser installer drops non-root files from its policy directory"
if grep -E 'groupadd|usermod|omarchy-browser-policy' "$setup_log" >/dev/null; then
fail "Chromium browser installer does not create a browser-policy group" "$(cat "$setup_log")"
fi
grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" ||
fail "Chromium browser installer registers the Copy URL host"
grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" ||
@@ -217,6 +227,36 @@ grep -Fxq 'omarchy-theme-set-browser:' "$setup_log" ||
fail "Chromium browser installer applies the current theme"
pass "Chromium browser installer restores the complete Omarchy setup"
: >"$install_log"
: >"$setup_log"
rm -f "$installed_dir/firefox"
OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install firefox >/dev/null
[[ $(<"$install_log") == "pkg:firefox" ]] || fail "Firefox browser installer installs the package"
[[ $(omarchy-default-browser) == "firefox" ]] || fail "Firefox becomes the default after its full installer succeeds"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /usr/lib/firefox/distribution' "$setup_log" ||
fail "Firefox browser installer creates its distribution directory"
grep -Fxq 'sudo:find /usr/lib/firefox/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
fail "Firefox browser installer drops non-root files from its distribution directory"
grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /usr/lib/firefox/distribution/policies.json" "$setup_log" ||
fail "Firefox browser installer copies policies.json without following a destination symlink"
[[ -e $installed_dir/firefox ]] || fail "Firefox browser installer marks firefox installed"
pass "Firefox browser installer restores the complete Omarchy setup"
: >"$install_log"
: >"$setup_log"
rm -f "$installed_dir/zen-browser"
OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install zen >/dev/null
[[ $(<"$install_log") == "pkg:zen-browser-bin" ]] || fail "Zen browser installer installs the package"
[[ $(omarchy-default-browser) == "zen" ]] || fail "Zen becomes the default after its full installer succeeds"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /opt/zen-browser/distribution' "$setup_log" ||
fail "Zen browser installer creates its distribution directory"
grep -Fxq 'sudo:find /opt/zen-browser/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
fail "Zen browser installer drops non-root files from its distribution directory"
grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /opt/zen-browser/distribution/policies.json" "$setup_log" ||
fail "Zen browser installer copies policies.json without following a destination symlink"
[[ -e $installed_dir/zen-browser ]] || fail "Zen browser installer marks zen-browser installed"
pass "Zen browser installer restores the complete Omarchy setup"
omarchy-default-browser zen
rm -f "$installed_dir/chromium"
if OMARCHY_TEST_REAL_BROWSER_INSTALL=true OMARCHY_TEST_INSTALL_FAIL=true \
+145
View File
@@ -18,6 +18,151 @@ assertEqual(
'notifications strip inline image tags'
)
// The body renders as StyledText, which fetches <img src> over the network. The
// invariant that matters is not a particular output string but that no tag Qt
// would honour as an image survives, so assert that directly. Tags are bounded
// the conservative way the stripper bounds them: a `<` opens a tag that runs to
// the next `>`. Qt's own bound can be longer, since a `>` inside a quoted
// attribute value does not close a tag there — which only ever splits one Qt
// tag into several here, so a name this helper reads is a name Qt reads too.
function survivingTagNames(text) {
const names = []
let i = 0
while (i < text.length) {
const open = text.indexOf('<', i)
if (open === -1) break
const close = text.indexOf('>', open)
const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1)
// Read the name the way Qt does, skipping anything that is not part of it.
// Matching the separator with \s instead would give this helper the same
// blind spot as the code it is checking — Qt skips U+0085 and \s does not —
// and an assertion that shares the implementation's bug proves nothing.
const name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag)
if (name) names.push(name[1].toLowerCase())
i = close === -1 ? text.length : close + 1
}
return names
}
// Assert on styledBody, not sanitizeBody: styledBody is the string the card
// binds to the StyledText, so it is the only one Qt ever parses. Checking the
// sanitizer's output instead would pass a body whose surviving tag the newline
// rewrite later splits open.
function assertNoImageSurvives(body, description) {
const out = notifications.styledBody(body, 'Slack', '')
const names = survivingTagNames(out)
assert(
!names.includes('img'),
description,
`input: ${body}\noutput: ${out}\ntags: ${JSON.stringify(names)}`
)
}
assertNoImageSurvives(
'<img src="http://host/plain.png">',
'notifications leave no image tag for a plain payload'
)
// A payload spliced inside the literal "<img" prefix. Qt reads ONE malformed
// tag named `im` here and renders nothing; a stripper that deleted the inner
// match would close the halves up into a live <img> the input never had.
assertNoImageSurvives(
'<im<img src="http://host/decoy.png">g src="http://host/beacon.png">',
'notifications leave no image tag when a payload is spliced inside <img'
)
assertNoImageSurvives(
'<im<im<img src=a>g src=b>g src="http://host/deep.png">',
'notifications leave no image tag for a doubly nested payload'
)
assertNoImageSurvives(
'<img<img src="http://host/twin.png">',
'notifications leave no image tag when the outer tag is itself named img'
)
assertNoImageSurvives(
'< img src="http://host/spaced.png">',
'notifications leave no image tag when whitespace follows the angle bracket'
)
// Qt skips the separator between `<` and the tag name with QChar::isSpace(),
// which counts U+0085 NEL. JavaScript's \s does not. Reading the name with \s
// finds none here, keeps the tag, and Qt then reads `img` and fetches it —
// measured against Qt 6.11.2, where this exact body makes a StyledText Text
// issue an outbound GET. Asserted on the whole output rather than through
// assertNoImageSurvives so it holds even if that helper is ever loosened.
assertEqual(
notifications.sanitizeBody('<\u0085img src="http://host/nel.png">after', 'Slack', ''),
'after',
'notifications strip an image tag whose separator is U+0085, which Qt skips but \\s does not'
)
assertNoImageSurvives(
'<\u0085img src="http://host/nel2.png">',
'notifications leave no image tag when U+0085 follows the angle bracket'
)
// The card rewrites newlines to <br/> for the StyledText, which puts tag syntax
// inside a tag the stripper kept: `<x`, newline, `<img …>` is one tag named `x`
// to both the stripper and Qt, and the rewrite splits it into `<x<br/>` and a
// live image tag. Measured against Qt 6.11.2 — the rewritten form issues the GET
// and the original does not — so the strip has to run after the rewrite, which
// is what styledBody() does.
assertNoImageSurvives(
'<x\n<img src="http://host/split.png">',
'notifications leave no image tag when a newline rewrite splits a kept tag'
)
assertNoImageSurvives(
'<x\r\n<img src="http://host/split-crlf.png">',
'notifications leave no image tag when a CRLF rewrite splits a kept tag'
)
assertEqual(
notifications.styledBody('<x\n<img src="http://host/split.png">', 'Slack', ''),
'<x<br/>',
'notifications drop the image half of a tag the newline rewrite splits'
)
// The rewrite itself still happens, and body markup other than images survives it.
assertEqual(
notifications.styledBody('<b>bold</b>\nsecond line', 'Slack', ''),
'<b>bold</b><br/>second line',
'notifications keep body markup and the line break the card renders'
)
// The order above is only worth anything if the card actually renders it, and no
// JavaScript assertion can see a QML binding. Pin the binding itself: the rewrite
// belongs in the logic module, where the strip runs after it.
const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8')
assert(
/readonly property string styledBody: NotificationLogic\.styledBody\(body, app, appIcon\)/.test(cardQml),
'the notification card renders the body that was stripped after the newline rewrite'
)
assert(
!/<br\/>/.test(cardQml),
'the notification card does not rewrite newlines itself, which would leave tag syntax unchecked'
)
assertEqual(
notifications.sanitizeBody('trailing <img src="http://host/z.png"', 'Slack', ''),
'trailing ',
'notifications strip an unterminated image tag the renderer would close itself'
)
assertEqual(
notifications.sanitizeBody('<IMG SRC="http://host/u.png">shout', 'Slack', ''),
'shout',
'notifications strip image tags regardless of case'
)
assertEqual(
notifications.sanitizeBody('<b>bold</b> and <a href="http://host">link</a>', 'Slack', ''),
'<b>bold</b> and <a href="http://host">link</a>',
'notifications keep the body markup the body-markup capability advertises'
)
assertEqual(
notifications.sanitizeBody('<a href="https://example.com">example.com</a> Message body', 'Chromium', ''),
'Message body',
+949 -24
View File
@@ -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"
+33 -3
View File
@@ -27,16 +27,41 @@ cat >"$TMPDIR/bin/usermod" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/usermod.calls"
STUB
chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod"
cat >"$TMPDIR/bin/groupadd" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/groupadd.calls"
STUB
cat >"$TMPDIR/bin/install" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/install.calls"
STUB
cat >"$TMPDIR/bin/find" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/find.calls"
STUB
cat >"$TMPDIR/bin/sudo" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/sudo.calls"
exec "\$@"
STUB
chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo}
export PATH="$TMPDIR/bin:$PATH"
export OMARCHY_PATH="$ROOT"
# No install user (deferred-provisioning install): input recorded, usermod not called.
# No install user (deferred-provisioning install): groups recorded, usermod not called.
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh"
[[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user"
grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded"
! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" ||
fail "browser-policy group must not be recorded"
[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user"
[[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null ||
fail "browser-policy group is not created"
grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null ||
fail "browser-policy directory is created root-owned"
pass "deferred provisioning records groups without calling usermod"
# The docker group is root-equivalent and must never be granted automatically.
@@ -45,17 +70,22 @@ pass "docker group is not recorded at install"
# Missing user (defensive): no usermod either.
OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh"
[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user"
pass "missing install user defers group grants"
# Re-running never duplicates entries.
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh"
[[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh"
pass "group recording is idempotent"
# Existing user: usermod applies the recorded groups, and docker is never among them.
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh"
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh"
grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user"
! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" ||
fail "usermod must not grant browser-policy to the install user"
! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user"
pass "existing install user gets input but never docker"
pass "existing install user gets input but never docker or browser-policy"
+373
View File
@@ -0,0 +1,373 @@
"""Report every QML Text that renders a non-literal value without a textFormat.
Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per
violation, exits 1 on an unreadable tree). Lives in its own file rather than a
heredoc so the test can run it over fixtures and prove it still fails when it
should — a guard nothing can fail is a guard nobody should trust.
Two limits are deliberate, because a line scanner cannot close them. It reads
each Text element's own declaration, so text assigned from somewhere else —
`Binding { target: label; property: "text" }`, `PropertyChanges`, a
`Component.onCompleted` assignment, a `property alias` onto a child's text —
is invisible to it. And a regex literal containing a brace throws off the brace
depth. Neither shape exists in this tree; both would need a QML parser.
"""
import os
import re
import sys
from pathlib import Path
BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S)
def strip_block_comments(text):
"""Blank out /* */ comments, keeping every newline so line numbers hold.
strip_noise() only knows `//`, so before this a block comment between a
type name and its brace — `Text /* why */ {` — hid the element from
OPEN_ELEMENT and from the unscannable-form check alike, and the block
passed with no textFormat at all.
"""
out = []
i = 0
quote = None
while i < len(text):
c = text[i]
if quote:
if c == '\\':
out.append(text[i:i + 2])
i += 2
continue
if c == quote:
quote = None
out.append(c)
i += 1
continue
if c in '"\'':
quote = c
out.append(c)
i += 1
continue
if c == '/' and text.startswith('//', i):
end = text.find('\n', i)
if end == -1:
break
out.append(text[i:end])
i = end
continue
if c == '/' and text.startswith('/*', i):
end = text.find('*/', i + 2)
end = len(text) if end == -1 else end + 2
out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end]))
i = end
continue
out.append(c)
i += 1
return ''.join(out)
# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is
# the same element and was skipped, because the name compared unequal to `Text`.
TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text'
OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$')
INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$')
INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{')
PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:')
STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b')
# A binding that runs onto the next line: this line ends on an operator, or the
# next line opens with one.
TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$')
LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])')
def strip_noise(line, keep_strings=False):
out = []
i = 0
quote = None
while i < len(line):
c = line[i]
if quote:
if keep_strings:
out.append(c)
if c == '\\':
if keep_strings and i + 1 < len(line):
out.append(line[i + 1])
i += 2
continue
if c == quote:
quote = None
if not keep_strings:
out.append('S')
i += 1
continue
if c in '"\'':
quote = c
if keep_strings:
out.append(c)
i += 1
continue
if c == '/' and i + 1 < len(line) and line[i + 1] == '/':
break
out.append(c)
i += 1
return ''.join(out)
def is_pure_literal(expr):
residue = STRING_LITERAL.sub('', expr)
residue = re.sub(r'[\s+]', '', residue)
return residue == '' and STRING_LITERAL.search(expr) is not None
def binding_expression(lines, start):
"""The whole right-hand side of the binding beginning on line `start`.
The literal exemption has to be judged on the complete expression. Reading
only the physical `text:` line would exempt `text: "prefix"` while
`+ externalValue` sits underneath, letting a dynamic AutoText binding
through. Reading a wrapped concatenation of literals as dynamic would be
the opposite error, so follow the expression to its end either way.
"""
parts = []
parens = brackets = 0
i = start
while i < len(lines):
parts.append(strip_noise(lines[i], keep_strings=True))
counted = strip_noise(lines[i])
parens += counted.count('(') - counted.count(')')
brackets += counted.count('[') - counted.count(']')
# Look past blank and comment-only lines for the continuation. A
# comment or a blank line dropped into a wrapped expression does not
# end it, and stopping there would read `text: "prefix"` as the whole
# binding and exempt it as a literal while `+ externalValue` waits
# below — the exact misreading this function exists to prevent.
following = ''
for ahead in range(i + 1, len(lines)):
candidate = strip_noise(lines[ahead])
if candidate.strip():
following = candidate
break
continues = (parens > 0 or brackets > 0
or TRAILING_OPERATOR.search(counted.rstrip())
or LEADING_OPERATOR.match(following))
if not continues:
break
i += 1
chunk = ' '.join(parts)
return chunk.split(':', 1)[1] if ':' in chunk else chunk
def exempt_as_literal(lines, tline):
"""True when the binding is only string literals, however many lines."""
return is_pure_literal(binding_expression(lines, tline))
def blocks(lines):
stack = []
done = []
depth = 0
for idx, raw in enumerate(lines):
code = strip_noise(raw)
opened = OPEN_ELEMENT.search(code)
prop = PROP.match(code)
if (prop and stack and stack[-1]['depth'] == depth
and not opened and not PROPERTY_DECL.match(code)):
stack[-1]['props'].setdefault(prop.group(1), idx)
n_open = code.count('{')
n_close = code.count('}')
depth += n_open - n_close
if opened and n_open > 0:
# OPEN_ELEMENT anchors at the end of the line, so the element it
# matched is the innermost one opened here and its depth is the
# depth after every brace on the line.
stack.append({'name': opened.group(1), 'depth': depth,
'props': {}, 'start': idx})
while stack and depth < stack[-1]['depth']:
done.append(stack.pop())
done.extend(stack)
return done
INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}')
INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)')
# As a property of this block, not as a substring: `visible: root.textFormatEnabled`
# used to read as a declaration and exempt the element.
INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:')
def inline_violations(lines, rel):
"""Whole Text blocks written on one line.
OPEN_ELEMENT anchors at the end of the line, so the brace scanner never
sees these. A Repeater delegate is a plausible place for one.
"""
out = []
for idx, raw in enumerate(lines):
code = strip_noise(raw, keep_strings=True)
for match in INLINE_TEXT.finditer(code):
body = match.group(1)
if INLINE_TEXT_FORMAT.search(body):
continue
# A component root written on one line needs the default whether or
# not this line binds `text`, for the same reason the block form
# does: every caller supplies the binding.
if INLINE_COMPONENT_ONELINE.match(code):
out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat')
continue
binding = INLINE_BINDING.search(body)
if not binding or is_pure_literal(binding.group(1)):
continue
out.append(f'{rel}:{idx + 1}: inline Text block without textFormat')
return out
# `Text { text: someValue` with the block carrying on below is valid QML and is
# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the
# line so the brace tracker never opens the block, and INLINE_TEXT needs the
# closing brace on the same line. A dynamic AutoText binding written that way
# passes this file in silence, which is the one failure a test like this must
# not have.
#
# Rather than teach a line scanner to parse QML, require the two forms it can
# read: the whole block on one line, or nothing after the opening brace. Every
# Text in this tree is already written that way, so keeping to it costs nothing.
UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S')
BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$')
UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the '
'opening brace last on the line, or write the whole block on '
'one line with no nested braces')
COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$')
def opens_component(lines, start):
"""True when the Text block at `start` is a component root declared above it."""
for back in range(start - 1, -1, -1):
code = strip_noise(lines[back]).strip()
if not code:
continue
return bool(COMPONENT_OPENER.match(lines[back]))
return False
def unscannable_violations(lines, rel):
out = []
for idx, raw in enumerate(lines):
code = strip_noise(raw)
# `Text` with its brace on the next line. OPEN_ELEMENT needs both on
# one line, so the block is never opened and everything in it is
# attributed to the enclosing element instead.
if BARE_TEXT_OPENER.search(code):
following = ''
for ahead in range(idx + 1, len(lines)):
candidate = strip_noise(lines[ahead]).strip()
if candidate:
following = candidate
break
if following.startswith('{'):
out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}')
continue
for match in UNSCANNABLE_TEXT.finditer(code):
# A complete one-line block with no nested braces is fine —
# inline_violations reads those. Count rather than looking for a
# `}`, because `Text { text: ({ a: external }).a }` closes on this
# line yet INLINE_TEXT's brace-free body pattern cannot match it,
# so treating any `}` as "handled elsewhere" would drop it.
rest = code[match.end() - 1:]
depth = 1
closed = False
for char in rest:
if char == '{':
depth += 1
elif char == '}':
depth -= 1
if depth == 0:
closed = True
break
if closed and '{' not in rest:
continue
out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}')
return out
root = Path(sys.argv[1])
found = []
scanned = 0
def unreadable(error):
# rglob() swallows a directory it cannot enter, so a shell/ subtree with no
# read permission scanned as though it were empty and the run reported
# success. Same failure as an empty tree, and it fails the same way.
raise SystemExit(f'cannot read {error.filename}: {error.strerror}')
qml = []
for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable):
dirnames.sort()
qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml'))
for path in sorted(qml):
scanned += 1
lines = strip_block_comments(path.read_text()).splitlines()
rel = path.relative_to(root)
found.extend(inline_violations(lines, rel))
found.extend(unscannable_violations(lines, rel))
for b in blocks(lines):
if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']:
continue
# Read the block's own properties. A nested child declaring textFormat
# says nothing about its parent, so `Text { Text { textFormat: ... } }`
# must still report the outer element.
# The root element of a component takes its binding from callers, so it
# needs the default whether or not this file binds `text`. Require both
# depth 1 and column 0: the scanner attributes one element per line, so
# a `Row { Text {` line would report depth 1 for a nested block, and
# falling through to the binding check below is the safe reading.
# Indentation is not what makes it a root; depth 1 is. A `Row { Text {`
# line still reads as `Row` here, so leading whitespace can be ignored
# without letting a nested block be mistaken for the file's root.
if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'):
found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat')
continue
# A QML inline component is a root for the same reason, and the rule
# above cannot see one: `component InfoValue: Text {` sits inside
# another element, so its depth is not 1 and its line does not start
# with `Text`. Its `text` comes from every caller, so the file it lives
# in never binds it and the binding check below lets it through in
# silence. Only one file-level root Text exists in this tree, so
# without this the root rule is very nearly dead code.
# `component Info:` may also put its `Text {` on the following line,
# which INLINE_COMPONENT cannot match and which then reads as an
# ordinary nested block with no binding of its own — a caller's dynamic
# text passing in silence.
if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']):
found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat')
continue
if 'text' not in b['props']:
continue
tline = b['props']['text']
if exempt_as_literal(lines, tline):
continue
found.append(f'{rel}:{tline + 1}: text binding without textFormat')
# A scan that read nothing reports nothing, and an all-clear from a run that
# never opened a file is the one result this test must never give. Only a
# checkout with no shell/ QML at all reaches this.
if scanned == 0:
raise SystemExit('no .qml files found under shell/; the scan read nothing')
for line in found:
print(line)
+276
View File
@@ -0,0 +1,276 @@
#!/bin/bash
# A QML Text element with no textFormat uses Text.AutoText. Qt then runs
# mightBeRichText() over the string and promotes it to Text.RichText when it
# looks like markup, and RichText fetches <img src="http://..."> through
# QQuickPixmap. Any string that reaches such an element from outside the shell
# — a notification summary, an MPRIS track title, a window title, an SSID, a
# Bluetooth device name, clipboard content, a weather API response — can
# therefore make the shell issue an unauthenticated outbound GET with no user
# interaction.
#
# The promotion needs only that the attacker contribute the first `<` in the
# string, on the first line. A fixed label in front of the value does not
# protect it, and neither does .toUpperCase(), because the parser lowercases
# the tag before looking it up.
#
# So require an explicit textFormat on every Text whose text: binding is not a
# bare string literal. A literal carries no external data, so AutoText has
# nothing to promote; this test is what catches the edit that later turns such
# a literal into an expression.
#
# The scan itself lives in qml-text-format-scan.py. It is run twice: over the
# real tree, and over the fixtures below, which are the forms that have already
# slipped past it once. A guard nothing can fail is a guard nobody should trust,
# and every one of those fixtures passed silently before it was written down.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command python3
SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py"
violations=$(python3 "$SCAN" "$ROOT")
if [[ -n $violations ]]; then
count=$(printf '%s\n' "$violations" | wc -l)
fail "every Text with a dynamic text binding declares textFormat" \
"$violations
$count Text element(s) rely on Text.AutoText for a non-literal binding.
Add an explicit textFormat. Text.PlainText is right for anything that renders
data from outside the shell; use Text.StyledText only where markup is a
deliberate, documented feature, and strip <img> before it reaches the renderer."
fi
pass "every Text with a dynamic text binding declares textFormat"
# The scanner's own tests. Each fixture is a Text that renders external data
# with no textFormat, written in a form that once passed. `caught` asserts the
# scan reports something; `clean` asserts it does not, so the fixtures prove the
# scanner can fail rather than that it fails at everything.
fixture_root=$(mktemp -d)
trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT
function scan_fixture {
local name=$1
local dir="$fixture_root/$name"
mkdir -p "$dir/shell/Ui"
cat > "$dir/shell/Ui/Fixture.qml"
python3 "$SCAN" "$dir" 2>&1
}
function caught {
local name=$1 description=$2 output
output=$(scan_fixture "$name" || true)
if [[ -z $output ]]; then
fail "$description" "the scan reported nothing for fixture $name"
fi
pass "$description"
}
function clean {
local name=$1 description=$2 output
output=$(scan_fixture "$name" || true)
if [[ -n $output ]]; then
fail "$description" "the scan reported: $output"
fi
pass "$description"
}
caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
text: external
}
}
QML
clean literal "the scan leaves a string literal alone" <<'QML'
import QtQuick
Item {
Text {
text: "a literal"
}
}
QML
clean declared "the scan leaves a declared textFormat alone" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
textFormat: Text.PlainText
text: external
}
}
QML
# strip_noise() knew `//` and not `/* */`, so a block comment between the type
# name and its brace hid the whole element from every rule.
caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML'
import QtQuick
Item {
property string external: "x"
Text /* explanation */ {
text: external
}
}
QML
caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML'
import QtQuick
Item {
property string external: "x"
/*
* Text { text: "not this one" }
*/
Text {
text: external
}
}
QML
# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to
# `Text` and was skipped outright.
caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text {
text: external
}
}
QML
# textFormat was matched as a substring, so any property whose name merely
# started that way exempted the element.
caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text { text: external }
}
QML
caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text { text: external
color: "red"
}
}
QML
caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML'
import QtQuick
Item {
property string external: "x"
property bool textFormatEnabled: true
Text { text: external; visible: textFormatEnabled }
}
QML
# A component root takes its text from every caller, so the file it lives in
# never binds it. The one-line form was covered; this one was not.
caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML'
import QtQuick
Item {
component Info:
Text {
}
}
QML
caught component-one-line "the scan reads a component root written on one line" <<'QML'
import QtQuick
Item {
component Info: Text { color: "red" }
}
QML
# Forms the scanner cannot read are reported rather than passed, which is the
# whole reason it can be a line scanner at all.
caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML'
import QtQuick
Item {
property string external: "x"
Text
{
text: external
}
}
QML
caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML'
import QtQuick
Item {
property string external: "x"
Text { text: external
color: "red"
}
}
QML
# A wrapped binding is judged whole: a literal first line says nothing about
# what is concatenated onto it below.
caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
text: "prefix"
+ external
}
}
QML
clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML'
import QtQuick
Item {
Text {
text: "one"
+ "two"
}
}
QML
# A nested child's textFormat says nothing about its parent.
caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML'
import QtQuick
Text {
text: external.value
Text {
textFormat: Text.PlainText
text: "literal"
}
}
QML
# A scan that reads less than the tree holds must not report success. Both of
# these once did.
empty_root=$(mktemp -d)
mkdir -p "$empty_root/shell"
if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then
rm -rf "$empty_root"
fail "the scan fails when it reads no files" "an empty shell/ tree exited 0"
fi
rm -rf "$empty_root"
pass "the scan fails when it reads no files"
blind_root="$fixture_root/blind"
mkdir -p "$blind_root/shell/Ui/locked"
printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml"
printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml"
chmod 000 "$blind_root/shell/Ui/locked"
if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then
chmod 755 "$blind_root/shell/Ui/locked"
fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0"
fi
chmod 755 "$blind_root/shell/Ui/locked"
pass "the scan fails when a directory hides files from it"
+2 -1
View File
@@ -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"
+77
View File
@@ -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")"
+18
View File
@@ -67,6 +67,24 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null
grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null
pass "Omarchy 4 upgrade configures lock screen authentication for the target user"
grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade uses the shared browser-policy helper"
grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper"
if grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null; then
fail "Omarchy 4 upgrade does not create a browser-policy group"
fi
grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade creates a root-owned Chromium policy directory"
grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade hardens every Chromium-family policy directory"
grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set"
if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw|2775' "$upgrade_to_quattro" >/dev/null; then
fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory"
fi
pass "Omarchy 4 upgrade locks the Chromium policy directory to root"
grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null
grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null
grep -F 'systemd-networkd.socket' "$upgrade_to_quattro" >/dev/null
+92
View File
@@ -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"
+124
View File
@@ -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"
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
mkdir -p "$tmp_dir/bin" "$tmp_dir/home"
for stub in gtk-update-icon-cache update-desktop-database omarchy-notification-send; do
printf '#!/bin/bash\n:\n' >"$tmp_dir/bin/$stub"
chmod +x "$tmp_dir/bin/$stub"
done
run_install() {
HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" \
"$ROOT/bin/omarchy-webapp-install" "$@"
}
run_remove() {
HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \
"$ROOT/bin/omarchy-webapp-remove" "$@"
}
apps_dir="$tmp_dir/home/.local/share/applications"
icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps"
# A URL typed into the name field is the reported way in. Every slash used to
# become a directory level, leaving a launcher nothing could address. Assert on
# the message: creating the launcher directly in the applications directory
# already makes the redirect fail on its own, so a bare non-zero exit would pass
# just as well with no validation at all.
output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) &&
fail "webapp install rejects a name containing a slash"
[[ $output == *"App name cannot contain '/'"* ]] ||
fail "webapp install says why it refused a slashed name" "$output"
[[ -e "$apps_dir/http:" ]] &&
fail "webapp install does not create a directory from a slashed name"
pass "webapp install rejects a name that would nest the launcher"
# The name was a path fragment until something said otherwise, so ../ climbed
# out of the applications directory entirely and wrote wherever it landed.
if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then
fail "webapp install rejects a name that climbs out of the applications directory"
fi
[[ -e "$tmp_dir/escaped.desktop" ]] &&
fail "webapp install writes no launcher outside the applications directory"
pass "webapp install refuses a name that would escape the applications directory"
# The interactive prompt reads the name long before it is used as a path, and
# fetches the site icon in between. Rejecting only at the write leaves that icon
# behind in the user's icon theme, once per attempt.
mkdir -p "$tmp_dir/ibin"
cp "$tmp_dir/bin"/* "$tmp_dir/ibin/"
cat >"$tmp_dir/ibin/gum" <<'STUB'
#!/bin/bash
count_file="${GUM_STUB_COUNT:?}"
count=$(cat "$count_file" 2>/dev/null || echo 0)
count=$((count + 1))
echo "$count" >"$count_file"
if (( count == 1 )); then
echo "http://example.test/oops"
else
echo "https://example.com"
fi
STUB
cat >"$tmp_dir/ibin/curl" <<'STUB'
#!/bin/bash
# Answer any download with a real PNG so the icon fetch reports success.
out=""
prev=""
for arg in "$@"; do
[[ $prev == "-o" ]] && out="$arg"
prev="$arg"
done
if [[ -n $out ]]; then
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out"
fi
STUB
chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl"
if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \
GUM_STUB_COUNT="$tmp_dir/gum-count" \
"$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then
fail "interactive webapp install rejects a name containing a slash"
fi
if compgen -G "$icons_dir/*.png" >/dev/null; then
fail "interactive webapp install downloads no icon for a name it refuses" \
"$(ls "$icons_dir")"
fi
pass "webapp install refuses a slashed name before fetching its icon"
# A normal name still installs and removes.
run_install "Example App" "https://example.com" hey >/dev/null
[[ -f "$apps_dir/Example App.desktop" ]] ||
fail "webapp install writes the launcher for an ordinary name"
run_remove "Example App" >/dev/null
[[ -f "$apps_dir/Example App.desktop" ]] &&
fail "webapp remove deletes the launcher it installed"
pass "webapp install and remove round-trip an ordinary name"
# Anything installed by an older version can still be nested. Removal has to
# reach it, which a path rebuilt from the displayed name never could.
mkdir -p "$apps_dir/http:/127.0.0.1:4000"
cat >"$apps_dir/http:/127.0.0.1:4000/.desktop" <<'DESKTOP'
[Desktop Entry]
Name=http://127.0.0.1:4000
Exec=omarchy-launch-webapp https://127.0.0.1:4000
Type=Application
DESKTOP
# This is the name the picker shows for that file: the script strips .desktop
# from the path and then takes the basename, which lands on the directory.
run_remove "127.0.0.1:4000" >/dev/null
[[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] &&
fail "webapp remove deletes a launcher left nested by an older install"
pass "webapp remove reaches a nested legacy launcher"
# Removing by name on a machine with no applications directory yet must stay
# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr.
noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \
"$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null)
[[ -n $noise ]] &&
fail "webapp remove stays quiet with no applications directory" "$noise"
pass "webapp remove stays quiet when there is no applications directory"
+444 -75
View File
@@ -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"
+13
View File
@@ -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"