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:
commit
2541eeee3d
115 files changed
+6771
-441
No files matched your search
+152
@@ -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
|
||||
Executable
+330
@@ -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"
|
||||
Executable
+184
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Executable
+276
@@ -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"
|
||||
@@ -144,11 +144,12 @@ pass "keyboard prompt propagates Esc and Ctrl+C without dying under set -e"
|
||||
|
||||
# Username
|
||||
|
||||
TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:dhh" "0:david"
|
||||
TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:cups-browsed" "0:dhh" "0:david"
|
||||
assert_status 0 "username prompt accepts a valid name"
|
||||
[[ $(field username) == "david" ]] || fail "username prompt keeps re-asking until the name is valid"
|
||||
assert_notices "username prompt explains each rejection" "Username must be alphanumeric with no spaces
|
||||
Username is reserved for system
|
||||
Username is reserved for system
|
||||
That username already exists on this machine"
|
||||
pass "username prompt rejects malformed, reserved, and taken names"
|
||||
|
||||
|
||||
@@ -92,6 +92,83 @@ done
|
||||
|
||||
pass "a URL whose name would climb out of the themes directory never reaches git"
|
||||
|
||||
# The derived name outlives the clone: it is the theme's directory name, and
|
||||
# Style > Unlock builds a command line out of the name the picker returned. A
|
||||
# repo whose name carries shell syntax would hand that picker its own command,
|
||||
# so the name is refused here rather than quoted at each place it lands.
|
||||
for url in \
|
||||
"https://example.com/omarchy-a';id;'b-theme.git" \
|
||||
'https://example.com/a$(id).git' \
|
||||
'https://example.com/a`id`.git' \
|
||||
"https://example.com/a b.git" \
|
||||
"https://example.com/-a.git"; do
|
||||
if install_theme "$url"; then
|
||||
fail "omarchy-theme-install refuses the derived name from '$url'"
|
||||
fi
|
||||
|
||||
[[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")"
|
||||
done
|
||||
|
||||
pass "a URL whose name would be shell syntax never reaches git"
|
||||
|
||||
# And the check is an allowlist, so the punctuation a real theme name uses has
|
||||
# to keep working.
|
||||
install_theme "https://github.com/example/omarchy-tokyo_night.2-theme.git" ||
|
||||
fail "omarchy-theme-install accepts the punctuation a theme name uses"
|
||||
grep -Fq "/themes/tokyo_night.2" "$git_calls" ||
|
||||
fail "omarchy-theme-install derives a name carrying an underscore and a dot" "$(cat "$git_calls")"
|
||||
|
||||
pass "a theme name may still hold an underscore, a dot, and a dash"
|
||||
|
||||
# A plus is neither path-climb nor shell syntax, and a leading underscore is
|
||||
# neither the `..` climb nor the dash that reads as an option, so the allowlist
|
||||
# keeps both rather than stranding a repo that names itself with them.
|
||||
install_theme "https://github.com/example/omarchy-c++-theme.git" ||
|
||||
fail "omarchy-theme-install accepts a name holding a plus"
|
||||
grep -Fq "/themes/c++" "$git_calls" ||
|
||||
fail "omarchy-theme-install derives a name carrying a plus" "$(cat "$git_calls")"
|
||||
|
||||
install_theme "https://github.com/example/_private.git" ||
|
||||
fail "omarchy-theme-install accepts a name starting with an underscore"
|
||||
grep -Fq "/themes/_private" "$git_calls" ||
|
||||
fail "omarchy-theme-install derives a name starting with an underscore" "$(cat "$git_calls")"
|
||||
|
||||
pass "a plus and a leading underscore are still usable theme names"
|
||||
|
||||
# git reads a colon before any slash as the scp-style separator, so the path
|
||||
# after it does not have to hold one. Without that reading, the whole URL becomes
|
||||
# the theme name and the allowlist above refuses a repo that clones fine.
|
||||
install_theme "git@example.com:omarchy-blue-theme.git" ||
|
||||
fail "omarchy-theme-install accepts a home-relative scp-style URL"
|
||||
grep -Fq "/themes/blue" "$git_calls" ||
|
||||
fail "omarchy-theme-install names the theme after the repo, not the whole URL" "$(cat "$git_calls")"
|
||||
|
||||
# A colon that is part of a local path, not an scp separator, keeps its prefix.
|
||||
install_theme "/srv/git:mirrors/omarchy-blue-theme.git" ||
|
||||
fail "omarchy-theme-install accepts a local path holding a colon"
|
||||
grep -Fq "/themes/blue" "$git_calls" ||
|
||||
fail "omarchy-theme-install reads a colon after a slash as part of the path" "$(cat "$git_calls")"
|
||||
|
||||
pass "an scp-style URL with no slash after the colon still names the theme"
|
||||
|
||||
# The allowlist is a bracket range, and a range follows the locale's collation
|
||||
# rather than ASCII: under en_US.UTF-8 an unpinned `[a-z]` takes in `é`, so the
|
||||
# same URL would install on one desktop and be refused on the next.
|
||||
if locale -a 2>/dev/null | grep -qix 'en_US.utf-\?8'; then
|
||||
for locale_name in C en_US.UTF-8; do
|
||||
if LC_ALL=$locale_name install_theme "https://github.com/example/omarchy-café-theme.git"; then
|
||||
fail "omarchy-theme-install refuses a non-ASCII theme name under LC_ALL=$locale_name" "$(cat "$git_calls")"
|
||||
fi
|
||||
|
||||
[[ ! -s $git_calls ]] ||
|
||||
fail "omarchy-theme-install refuses a non-ASCII name before running git" "$(cat "$git_calls")"
|
||||
done
|
||||
|
||||
pass "the accepted set does not move with the desktop's locale"
|
||||
else
|
||||
pass "no en_US.UTF-8 locale; skipping the locale-pinning check"
|
||||
fi
|
||||
|
||||
# basename reads a leading dash as an option once the scp-style prefix is gone.
|
||||
install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL"
|
||||
grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command gio
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-launch-webapp" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >>"$OMARCHY_TEST_ARGV"
|
||||
SH
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
export HOME="$test_tmp/home"
|
||||
export PATH="$mock_bin:$PATH"
|
||||
export OMARCHY_TEST_ARGV="$test_tmp/argv"
|
||||
|
||||
applications="$HOME/.local/share/applications"
|
||||
|
||||
install_webapp() {
|
||||
bash "$ROOT/bin/omarchy-webapp-install" "$@" >/dev/null
|
||||
}
|
||||
|
||||
desktop_value() {
|
||||
sed -n "s/^$2=//p" "$1" | head -1
|
||||
}
|
||||
|
||||
# gio launch returns before the entry it spawned has run, so poll for the argv the
|
||||
# stub records rather than reading the log once.
|
||||
launched_argument() {
|
||||
local file="$1" attempt
|
||||
|
||||
: >"$OMARCHY_TEST_ARGV"
|
||||
gio launch "$file" >/dev/null 2>&1 || return 1
|
||||
for ((attempt = 0; attempt < 200; attempt++)); do
|
||||
[[ -s $OMARCHY_TEST_ARGV ]] && break
|
||||
sleep 0.01
|
||||
done
|
||||
|
||||
head -1 "$OMARCHY_TEST_ARGV"
|
||||
}
|
||||
|
||||
# The Exec quoting escapes a dollar sign with a backslash, and the file syntax has
|
||||
# to escape that backslash in turn. Left single, GLib reads \$ as an invalid escape
|
||||
# and refuses the whole entry, so the web app vanishes from the launcher.
|
||||
install_webapp 'Dollar App' 'https://example.com/a$b' someicon
|
||||
dollar_file="$applications/Dollar App.desktop"
|
||||
|
||||
[[ -f $dollar_file ]] || fail "web app install writes a desktop entry"
|
||||
|
||||
[[ $(desktop_value "$dollar_file" Exec) == 'omarchy-launch-webapp "https://example.com/a\\$b"' ]] ||
|
||||
fail "Exec escapes the backslash its own quoting introduced" "$(desktop_value "$dollar_file" Exec)"
|
||||
pass "Exec escapes the backslash its own quoting introduced"
|
||||
|
||||
[[ $(launched_argument "$dollar_file") == 'https://example.com/a$b' ]] ||
|
||||
fail "a URL containing a dollar sign reaches the browser unchanged"
|
||||
pass "a URL containing a dollar sign reaches the browser unchanged"
|
||||
|
||||
# An unescaped % is read as a Desktop Entry field code and eaten, so ?q=a%20b used
|
||||
# to arrive as ?q=a0b.
|
||||
install_webapp 'Percent App' 'https://example.com/s?q=a%20b' someicon
|
||||
percent_file="$applications/Percent App.desktop"
|
||||
|
||||
[[ $(launched_argument "$percent_file") == 'https://example.com/s?q=a%20b' ]] ||
|
||||
fail "a percent-encoded URL reaches the browser unchanged"
|
||||
pass "a percent-encoded URL reaches the browser unchanged"
|
||||
|
||||
# A lone backslash is not a Desktop Entry escape sequence, so GLib cannot interpret
|
||||
# a value that contains one.
|
||||
install_webapp 'Back\slash App' 'https://example.com' someicon
|
||||
backslash_file="$applications/Back\slash App.desktop"
|
||||
|
||||
[[ $(desktop_value "$backslash_file" Name) == 'Back\\slash App' ]] ||
|
||||
fail "a backslash in the app name is escaped" "$(desktop_value "$backslash_file" Name)"
|
||||
pass "a backslash in the app name is escaped"
|
||||
|
||||
# The property the escaping exists for: a newline in a value must not be able to
|
||||
# start a second key line.
|
||||
inject_name=$(printf 'Inject\nExec=evil')
|
||||
install_webapp "$inject_name" 'https://example.com' someicon
|
||||
inject_file="$applications/$inject_name.desktop"
|
||||
|
||||
(( $(grep -c '^Exec=' "$inject_file") == 1 )) ||
|
||||
fail "a newline in the app name cannot inject a second Exec" "$(cat "$inject_file")"
|
||||
pass "a newline in the app name cannot inject a second Exec"
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
home="$tmpdir/home"
|
||||
mkdir -p "$home/.local/share/applications"
|
||||
|
||||
install_webapp() {
|
||||
HOME="$home" "$ROOT/bin/omarchy-webapp-install" "$@"
|
||||
}
|
||||
|
||||
desktop_for() {
|
||||
printf '%s' "$home/.local/share/applications/$1.desktop"
|
||||
}
|
||||
|
||||
if install_webapp "Example" "https://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install accepts an https URL" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
|
||||
desktop=$(desktop_for Example)
|
||||
[[ -f $desktop ]] || fail "webapp install writes a desktop file"
|
||||
grep -Fxq 'Name=Example' "$desktop" || fail "webapp install writes the app name"
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp "https://example.com"' "$desktop" ||
|
||||
fail "webapp install launches the https URL" "$(cat "$desktop")"
|
||||
pass "webapp install writes an https desktop entry"
|
||||
|
||||
if install_webapp "Plain" "example.org/app" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install prefixes a schemeless URL with https" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp "https://example.org/app"' "$(desktop_for Plain)" ||
|
||||
fail "webapp install stores the prefixed https URL" "$(cat "$(desktop_for Plain)")"
|
||||
pass "webapp install prefixes a schemeless URL with https"
|
||||
|
||||
if install_webapp "Local" "https://localhost:47990" "webapp" "omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install keeps a custom https exec" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors' "$(desktop_for Local)" ||
|
||||
fail "webapp install writes the custom exec" "$(cat "$(desktop_for Local)")"
|
||||
pass "webapp install keeps a custom https exec"
|
||||
|
||||
for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp://example.com" "ext://x"; do
|
||||
if install_webapp "Bad" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "webapp install refuses '$url'"
|
||||
fi
|
||||
grep -Fq 'must be http or https' "$tmpdir/err" ||
|
||||
fail "webapp install names the scheme refusal for '$url'" "$(cat "$tmpdir/err")"
|
||||
[[ ! -e $(desktop_for Bad) ]] || fail "webapp install does not write a desktop file for '$url'"
|
||||
done
|
||||
pass "webapp install refuses non-http(s) URLs"
|
||||
|
||||
# Raw whitespace is not valid URL data, and before Exec argument quoting it
|
||||
# split browser flags or additional URLs into separate arguments.
|
||||
for url in \
|
||||
" javascript:alert(1)" \
|
||||
" file:///etc/passwd" \
|
||||
"https://example.com data:text/html,hi" \
|
||||
"https://example.com/ --user-agent=INJECTION_PROOF_MARKER_12345"; do
|
||||
if install_webapp "Sneak" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "webapp install refuses whitespace in '$url'" "$(cat "$(desktop_for Sneak)")"
|
||||
fi
|
||||
grep -Fq 'must not contain whitespace' "$tmpdir/err" ||
|
||||
fail "webapp install names the whitespace refusal for '$url'" "$(cat "$tmpdir/err")"
|
||||
[[ ! -e $(desktop_for Sneak) ]] || fail "webapp install writes no desktop file for '$url'"
|
||||
done
|
||||
pass "webapp install refuses a URL carrying whitespace"
|
||||
|
||||
# Schemes are case-insensitive, and HTTPS://example.com installed before the
|
||||
# scheme test existed.
|
||||
if install_webapp "Upper" "HTTPS://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
:
|
||||
else
|
||||
fail "webapp install accepts an uppercase scheme" "$(cat "$tmpdir/err")"
|
||||
fi
|
||||
grep -Fxq 'Exec=omarchy-launch-webapp "HTTPS://example.com"' "$(desktop_for Upper)" ||
|
||||
fail "webapp install keeps the uppercase scheme" "$(cat "$(desktop_for Upper)")"
|
||||
pass "webapp install accepts an uppercase http scheme"
|
||||
|
||||
# The interactive prompt fetches the site's icon, so a refused URL must be
|
||||
# refused before anything dereferences it.
|
||||
stubs="$tmpdir/stubs"
|
||||
mkdir -p "$stubs"
|
||||
|
||||
cat >"$stubs/gum" <<'GUM'
|
||||
#!/bin/bash
|
||||
count=$(cat "$GUM_COUNT" 2>/dev/null || echo 0)
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$count" >"$GUM_COUNT"
|
||||
sed -n "${count}p" "$GUM_ANSWERS"
|
||||
GUM
|
||||
|
||||
cat >"$stubs/curl" <<'CURL'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$CURL_LOG"
|
||||
exit 1
|
||||
CURL
|
||||
|
||||
chmod +x "$stubs/gum" "$stubs/curl"
|
||||
|
||||
printf 'Evil\nfile:///etc/passwd\n' >"$tmpdir/answers"
|
||||
: >"$tmpdir/gum-count"
|
||||
: >"$tmpdir/curl-log"
|
||||
|
||||
if GUM_ANSWERS="$tmpdir/answers" GUM_COUNT="$tmpdir/gum-count" CURL_LOG="$tmpdir/curl-log" \
|
||||
PATH="$stubs:$PATH" HOME="$home" "$ROOT/bin/omarchy-webapp-install" \
|
||||
>"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "interactive webapp install refuses a file: URL" "$(cat "$tmpdir/out")"
|
||||
fi
|
||||
grep -Fq 'must be http or https' "$tmpdir/err" ||
|
||||
fail "interactive webapp install names the scheme refusal" "$(cat "$tmpdir/err")"
|
||||
[[ ! -s $tmpdir/curl-log ]] ||
|
||||
fail "interactive webapp install refuses before fetching the URL" "$(cat "$tmpdir/curl-log")"
|
||||
[[ ! -e $(desktop_for Evil) ]] || fail "interactive webapp install writes no desktop file"
|
||||
pass "interactive webapp install refuses a bad URL before fetching it"
|
||||
@@ -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"
|
||||
@@ -1,72 +1,119 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The Windows VM compose file is written by an elevated, input-validated writer
|
||||
# into a root-owned directory. These tests pin the security-critical behavior:
|
||||
# no input can inject a host-root bind mount or a privileged flag, the password
|
||||
# survives both the YAML and the compose-interpolation layer, only known
|
||||
# privileged actions dispatch, and legacy configs migrate without redownloading.
|
||||
# Security regression coverage for the Windows VM compose/mount boundary.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Bind mounts need CAP_SYS_ADMIN in a private mount namespace. Keep the
|
||||
# caller's uid so the non-root development path is exercised.
|
||||
if [[ ${OMARCHY_WINDOWS_TEST_NAMESPACE:-0} != 1 ]]; then
|
||||
if unshare --user --map-current-user --keep-caps --mount true 2>/dev/null; then
|
||||
exec env OMARCHY_WINDOWS_TEST_NAMESPACE=1 \
|
||||
unshare --user --map-current-user --keep-caps --mount --propagation private bash "$0"
|
||||
fi
|
||||
pass "unprivileged mount namespaces unavailable; skipping Windows VM mount runtime tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
export OMARCHY_WINDOWS_DIR="$TMPDIR/win"
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
# Source the command's functions; the dispatcher just prints usage for "help".
|
||||
set -- help
|
||||
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
||||
COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml"
|
||||
|
||||
write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED
|
||||
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \
|
||||
unmount_all() {
|
||||
local path
|
||||
resolve_caller >/dev/null 2>&1 || return 0
|
||||
for path in "$EXPECTED_SHARED" "$EXPECTED_STORAGE"; do
|
||||
while mountpoint -q -- "$path" 2>/dev/null; do umount -- "$path" || break; done
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
unmount_all
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
write() { # RAM CORES DISK USER PASS TZ
|
||||
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \
|
||||
"$@" | __priv_write_compose
|
||||
}
|
||||
|
||||
# --- valid compose, with the dangerous bits pinned and unreachable by input ---
|
||||
rm -f "$COMPOSE"
|
||||
write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows
|
||||
fd_count() { find "/proc/$$/fd" -mindepth 1 -maxdepth 1 -printf x | wc -c; }
|
||||
|
||||
reset_case() {
|
||||
unmount_all
|
||||
rm -rf "$OMARCHY_WINDOWS_DIR" "$HOME/.windows" "$HOME/Windows"
|
||||
mkdir -p "$HOME"
|
||||
}
|
||||
|
||||
# Fixed protected anchors consume the pinned source inodes.
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G alice s3cret Europe/Copenhagen
|
||||
resolve_caller
|
||||
[[ -f $COMPOSE ]] || fail "writer produced a compose file"
|
||||
grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned"
|
||||
grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned"
|
||||
grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount"
|
||||
pass "writer emits a pinned compose with no host-root mount"
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor"
|
||||
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE" || fail "web console is not password protected"
|
||||
[[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories"
|
||||
[[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source"
|
||||
[[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source"
|
||||
[[ $(stat -Lc '%a' "$EXPECTED_STORAGE") == 700 && $(stat -Lc '%a' "$EXPECTED_SHARED") == 700 ]] || fail "mount leaves are not private"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "compose contains host-root bind"
|
||||
pass "writer emits fixed anchors bound to exact private source inodes"
|
||||
|
||||
# --- injection attempts are rejected, no file written ---
|
||||
# Input cannot widen a mount or compose field.
|
||||
rm -f "$COMPOSE"
|
||||
write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted"
|
||||
[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username"
|
||||
write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted"
|
||||
write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted"
|
||||
pass "injection attempts in username, path, and RAM are rejected"
|
||||
write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username accepted"
|
||||
[[ ! -f $COMPOSE ]] || fail "bad input wrote compose"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller storage affected compose"
|
||||
grep -q -- '- /:/storage' "$COMPOSE" && fail "host root accepted as storage"
|
||||
write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM accepted"
|
||||
pass "input cannot inject a host path or compose field"
|
||||
|
||||
# --- password survives YAML (" \) and compose interpolation ($) ---
|
||||
rm -f "$COMPOSE"
|
||||
tricky='p@$$w:rd$HOME"x\y'
|
||||
write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows
|
||||
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation"
|
||||
recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")")
|
||||
[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape"
|
||||
pass "password with \" \\ and \$ round-trips"
|
||||
write 8G 4 64G bob "$tricky" UTC
|
||||
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "dollar not escaped"
|
||||
[[ $(unescape "$(read_compose_value PASSWORD "$COMPOSE")") == "$tricky" ]] || fail "password did not round-trip"
|
||||
pass "password with quote, backslash, and dollar round-trips"
|
||||
|
||||
# --- only known privileged actions may dispatch ---
|
||||
for action in write_compose up up_wait down status remove; do
|
||||
valid_priv_action "$action" || fail "known privileged action rejected: $action"
|
||||
valid_priv_action "$action" || fail "known action rejected: $action"
|
||||
done
|
||||
for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do
|
||||
valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]"
|
||||
valid_priv_action "$action" && fail "action whitelist accepted: [$action]"
|
||||
done
|
||||
pass "privileged action whitelist accepts known actions and rejects the rest"
|
||||
pass "privileged action dispatch is allowlisted"
|
||||
|
||||
# --- legacy per-user compose migrates into the root-owned location ---
|
||||
# A rogue process could have rewritten the user-owned legacy compose to bind
|
||||
# mount host / into the guest, so migration must ignore its volume paths and
|
||||
# reconstruct them from the current user's $HOME.
|
||||
rm -rf "$OMARCHY_WINDOWS_DIR"
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME/.config/windows"
|
||||
# A PATH symlink to bash must never become the pkexec target. Hide the packaged
|
||||
# file from priv_target's stat checks to exercise the historical fallback.
|
||||
attack_bin="$TMPDIR/attack-bin"
|
||||
mkdir -p "$attack_bin"
|
||||
ln -s /bin/bash "$attack_bin/omarchy-windows-vm"
|
||||
printf 'printf exploited >"$TMPDIR/exploited"\n' >"$TMPDIR/__priv"
|
||||
stat() {
|
||||
[[ ${!#} == /usr/bin/omarchy-windows-vm ]] && return 1
|
||||
command stat "$@"
|
||||
}
|
||||
PATH="$attack_bin:$PATH" priv_target >/dev/null 2>&1 && fail "PATH symlink became a privileged target"
|
||||
unset -f stat
|
||||
[[ ! -e $TMPDIR/exploited ]] || fail "attacker __priv script executed"
|
||||
pass "pkexec target is only the canonical packaged regular file, never a PATH symlink"
|
||||
|
||||
# Legacy migration keeps directories and legitimate symlinks in place.
|
||||
reset_case
|
||||
external_shared="$TMPDIR/external-shared"
|
||||
mkdir -m 0755 -p "$HOME/.windows" "$external_shared" "$HOME/.config/windows"
|
||||
ln -s "$external_shared" "$HOME/Windows"
|
||||
touch "$HOME/.windows/existing-disk" "$external_shared/existing-shared-file"
|
||||
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
|
||||
COMPOSE_FILE="$COMPOSE"
|
||||
cat >"$LEGACY_COMPOSE_FILE" <<'LEG'
|
||||
@@ -83,42 +130,364 @@ services:
|
||||
- /./:/storage
|
||||
- /etc:/shared
|
||||
LEG
|
||||
# In production the write elevates via pkexec; here run it in-process.
|
||||
priv() { local a=$1; shift; "__priv_$a" "$@"; }
|
||||
priv() { local action=$1; shift; "__priv_$action" "$@"; }
|
||||
migrate_legacy_compose
|
||||
[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose"
|
||||
grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings"
|
||||
grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume"
|
||||
grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file"
|
||||
grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path"
|
||||
[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose"
|
||||
pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes"
|
||||
resolve_caller
|
||||
[[ -f $COMPOSE ]] || fail "migration did not write compose"
|
||||
grep -q 'USERNAME: "legacyuser"' "$COMPOSE" || fail "migration lost settings"
|
||||
[[ -f $HOME/.windows/existing-disk && -f $external_shared/existing-shared-file ]] || fail "migration lost data"
|
||||
[[ ! -L $HOME/.windows && $(readlink "$HOME/Windows") == "$external_shared" ]] || fail "migration consumed source path"
|
||||
[[ $(stat -Lc '%a' "$HOME/.windows") == 700 && $(stat -Lc '%a' "$external_shared") == 700 ]] || fail "migration did not harden legacy directories"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "migration copied malicious storage"
|
||||
grep -q -- '- /etc:/shared' "$COMPOSE" && fail "migration copied malicious share"
|
||||
[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration left legacy compose"
|
||||
pass "migration preserves data and symlinks while hardening permissions"
|
||||
|
||||
# --- bring-up refuses a symlinked mount source (a symlink redirects the
|
||||
# privileged bind mount the same way traversal would; the string check on
|
||||
# the stored path cannot see it) ---
|
||||
rm -f "$COMPOSE"
|
||||
mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare"
|
||||
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare"
|
||||
assert_mounts_safe || fail "real directory mount sources are accepted"
|
||||
ln -sfn / "$TMPDIR/evilshare"
|
||||
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare"
|
||||
assert_mounts_safe && fail "a symlinked mount source must be refused"
|
||||
pass "bring-up refuses a symlinked mount source"
|
||||
# Bring-up re-proves compose trust, cardinality, and mounted identities.
|
||||
assert_mounts_safe || fail "verified sources rejected"
|
||||
sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "tampered host path accepted"
|
||||
sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE"
|
||||
printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted"
|
||||
write 16G 6 128G legacyuser legacypass America/New_York
|
||||
sed -i 's/PROTECT: "Y"/PROTECT: "N"/' "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "unprotected web console accepted"
|
||||
sed -i 's/PROTECT: "N"/PROTECT: "Y"/' "$COMPOSE"
|
||||
printf ' PROTECT: "N"\n' >>"$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "duplicate web protection setting accepted"
|
||||
sed -i '$d' "$COMPOSE"
|
||||
chmod 0666 "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "writable compose accepted"
|
||||
chmod 0640 "$COMPOSE"
|
||||
pass "bring-up rejects tampered, duplicate, unprotected, and writable compose inputs"
|
||||
|
||||
# --- valid_path rejects traversal and non-normalized paths ---
|
||||
for p in /home/u/.windows /var/lib/omarchy/windows; do
|
||||
valid_path "$p" || fail "valid_path rejected a normal path: $p"
|
||||
# Both sources are pinned before a bind; bad symlinks stay untouched.
|
||||
reset_case
|
||||
mkdir -p "$HOME/.windows"
|
||||
ln -s / "$HOME/Windows"
|
||||
before_fds=$(fd_count)
|
||||
prepare_user_mount_sources 2>/dev/null && fail "root symlink passed user preflight"
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "rejected symlink consumed"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "root symlink passed privileged preflight"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "one source mounted before other failed"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "source preflight leaked FD"
|
||||
find "$CALLER_DATA_ROOT" -name 'rejected-*' -print -quit | grep -q . && fail "source was quarantined"
|
||||
pass "invalid second source leaves paths and anchors untouched and leaks no FD"
|
||||
|
||||
# Distinct caller-owned symlink targets are supported and remain links.
|
||||
reset_case
|
||||
external_storage="$TMPDIR/external-storage"
|
||||
external_shared2="$TMPDIR/external-shared-2"
|
||||
mkdir -p "$external_storage" "$external_shared2"
|
||||
ln -s "$external_storage" "$HOME/.windows"
|
||||
ln -s "$external_shared2" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G symlinked pw UTC
|
||||
resolve_caller
|
||||
[[ $(readlink "$HOME/.windows") == "$external_storage" && $(readlink "$HOME/Windows") == "$external_shared2" ]] || fail "writer replaced symlinks"
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned"
|
||||
pass "legitimate caller-owned symlinks remain in place"
|
||||
|
||||
# Reproduce the original post-validation race at the last possible moment:
|
||||
# replace the familiar shared path with / only after the final guard returns,
|
||||
# inside the mocked Docker Compose invocation. Compose must still consume the
|
||||
# protected anchor bound to the inode that was validated earlier.
|
||||
raced_shared="$HOME/Windows.before-race"
|
||||
shared_id_before_race=$(stat -Lc '%d:%i' "$external_shared2")
|
||||
race_ran=0
|
||||
dc() {
|
||||
[[ $1 == up && ${2:-} == -d ]] || return 1
|
||||
mv -T -- "$HOME/Windows" "$raced_shared"
|
||||
ln -s / "$HOME/Windows"
|
||||
race_ran=1
|
||||
[[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$shared_id_before_race" ]] || return 1
|
||||
}
|
||||
__priv_up || fail "post-validation home-path swap changed the Docker mount source"
|
||||
(( race_ran == 1 )) || fail "post-validation race hook did not run"
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "race did not replace the familiar shared path"
|
||||
rm "$HOME/Windows"
|
||||
mv -T -- "$raced_shared" "$HOME/Windows"
|
||||
unset -f dc
|
||||
pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode"
|
||||
|
||||
# Run the same attack as a genuinely concurrent process. A successful bring-up
|
||||
# deliberately waits inside the Docker boundary until the attacker has replaced
|
||||
# the familiar path with /, then verifies that the real bind anchor still names
|
||||
# the caller-owned directory that was pinned before the race.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
touch "$HOME/Windows/safe-marker"
|
||||
write 4G 2 64G concurrent pw UTC
|
||||
resolve_caller
|
||||
concurrent_shared_id=$(stat -Lc '%d:%i' "$HOME/Windows")
|
||||
host_root_id=$(stat -Lc '%d:%i' /)
|
||||
race_source="$HOME/Windows.race-source"
|
||||
race_stop="$TMPDIR/stop-concurrent-race"
|
||||
race_swaps="$TMPDIR/concurrent-race-swaps"
|
||||
(
|
||||
set +e
|
||||
while [[ ! -e $race_stop ]]; do
|
||||
if [[ -d $HOME/Windows && ! -L $HOME/Windows ]] && mv -T -- "$HOME/Windows" "$race_source" 2>/dev/null; then
|
||||
ln -s / "$HOME/Windows" 2>/dev/null || true
|
||||
printf x >>"$race_swaps"
|
||||
sleep 0.002
|
||||
fi
|
||||
if [[ -L $HOME/Windows ]]; then
|
||||
rm -f -- "$HOME/Windows"
|
||||
mv -T -- "$race_source" "$HOME/Windows" 2>/dev/null || true
|
||||
sleep 0.005
|
||||
fi
|
||||
done
|
||||
) &
|
||||
racer_pid=$!
|
||||
concurrent_dc_calls=0
|
||||
dc() {
|
||||
local attempt
|
||||
[[ $1 == up && ${2:-} == -d ]] || return 1
|
||||
for ((attempt = 0; attempt < 20000; attempt++)); do
|
||||
if [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]] || return 1
|
||||
((concurrent_dc_calls++))
|
||||
[[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") != "$host_root_id" ]] || return 1
|
||||
[[ -f $EXPECTED_SHARED/safe-marker ]]
|
||||
}
|
||||
for ((attempt = 0; attempt < 200; attempt++)); do
|
||||
if __priv_up 2>/dev/null; then break; fi
|
||||
done
|
||||
for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do
|
||||
valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p"
|
||||
done
|
||||
pass "valid_path accepts normalized paths and rejects traversal"
|
||||
touch "$race_stop"
|
||||
wait "$racer_pid"
|
||||
unset -f dc
|
||||
if [[ -L $HOME/Windows ]]; then rm -f -- "$HOME/Windows"; fi
|
||||
if [[ ! -e $HOME/Windows && -d $race_source ]]; then mv -T -- "$race_source" "$HOME/Windows"; fi
|
||||
[[ -s $race_swaps ]] || fail "concurrent attacker never swapped the shared path"
|
||||
((concurrent_dc_calls > 0)) || fail "concurrent race never reached Docker while the familiar path named host root"
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || fail "concurrent race changed the protected shared inode"
|
||||
pass "a concurrent home-path swap cannot redirect Docker away from the pinned shared inode"
|
||||
|
||||
# --- credentials are stored privately and round-trip (incl. = in password) ---
|
||||
export CREDENTIALS_FILE="$TMPDIR/creds"
|
||||
write_credentials 'carol' 'p=a$$w"x'
|
||||
[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600"
|
||||
[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips"
|
||||
[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips"
|
||||
pass "credentials are written 0600 and round-trip"
|
||||
# Same-inode sources fail before mounting and close both descriptors.
|
||||
reset_case
|
||||
same="$TMPDIR/same-source"
|
||||
mkdir -p "$same"
|
||||
ln -s "$same" "$HOME/.windows"
|
||||
ln -s "$same" "$HOME/Windows"
|
||||
before_fds=$(fd_count)
|
||||
prepare_user_mount_sources 2>/dev/null && fail "same source passed user preflight"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "same source passed root preflight"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "same source left mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "same source leaked FDs"
|
||||
pass "storage and shared must differ and failure closes FDs"
|
||||
|
||||
# Ancestor/descendant aliases are just as destructive as same-inode aliases:
|
||||
# removal must never recurse from storage into shared (or accept the inverse).
|
||||
reset_case
|
||||
shared_inside="$TMPDIR/shared-inside-storage"
|
||||
mkdir -p "$shared_inside/storage/shared"
|
||||
ln -s "$shared_inside/storage" "$HOME/.windows"
|
||||
ln -s "$shared_inside/storage/shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
write 4G 2 64G nested pw UTC 2>/dev/null && fail "shared-inside-storage sources were accepted"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "shared-inside-storage failure left a mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "shared-inside-storage failure leaked FDs"
|
||||
|
||||
reset_case
|
||||
storage_inside="$TMPDIR/storage-inside-shared"
|
||||
mkdir -p "$storage_inside/shared/storage"
|
||||
ln -s "$storage_inside/shared/storage" "$HOME/.windows"
|
||||
ln -s "$storage_inside/shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
write 4G 2 64G nested pw UTC 2>/dev/null && fail "storage-inside-shared sources were accepted"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "storage-inside-shared failure left a mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "storage-inside-shared failure leaked FDs"
|
||||
pass "pinned-FD ancestry checks reject overlap in both directions before mounting"
|
||||
|
||||
# Exact bind-alias bypass regression: the shared FD's visible parent is the
|
||||
# alias directory, but its inode is still reachable below storage.
|
||||
reset_case
|
||||
alias_under="$TMPDIR/bind-alias-under"
|
||||
alias_shared="$TMPDIR/bind-alias-shared"
|
||||
mkdir -p "$alias_under/storage/shared" "$alias_shared"
|
||||
mount --no-canonicalize --bind "$alias_under/storage/shared" "$alias_shared"
|
||||
ln -s "$alias_under/storage" "$HOME/.windows"
|
||||
ln -s "$alias_shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
resolve_caller
|
||||
open_mount_source "$LEGACY_STORAGE" storage
|
||||
alias_storage_fd=$OPENED_MOUNT_FD
|
||||
alias_storage_id=$OPENED_MOUNT_ID
|
||||
open_mount_source "$LEGACY_SHARED" shared
|
||||
alias_shared_fd=$OPENED_MOUNT_FD
|
||||
pinned_dir_contains "$alias_storage_id" "$alias_shared_fd" && fail "bind-alias repro unexpectedly shared the underlying parent walk"
|
||||
pinned_tree_contains "$alias_storage_fd" "$alias_shared_fd" || fail "tree-rooted discovery missed the bind-alias inode"
|
||||
exec {alias_storage_fd}<&-
|
||||
exec {alias_shared_fd}<&-
|
||||
write 4G 2 64G alias pw UTC
|
||||
resolve_caller
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted a shared bind alias into storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "bind-alias removal refusal changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "bind-alias removal refusal changed mounts"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "bind-alias removal refusal leaked FDs"
|
||||
unmount_all
|
||||
umount -- "$alias_shared"
|
||||
pass "cheap startup permits a bind alias, but bounded removal discovery refuses it"
|
||||
|
||||
# A late writer failure rolls back both newly-created binds.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
mv() { return 1; }
|
||||
write 4G 2 64G rollback pw UTC 2>/dev/null && fail "forced writer failure succeeded"
|
||||
unset -f mv
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "writer failure left binds"
|
||||
[[ ! -f $COMPOSE ]] || fail "writer failure replaced compose"
|
||||
pass "atomic writer failure rolls back both new bind mounts"
|
||||
|
||||
# Revalidate ancestry during removal: move the already-bound shared inode below
|
||||
# storage, keep its familiar path as a symlink, and prove nothing is deleted.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G moved pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mv "$HOME/Windows" "$HOME/.windows/moved-shared"
|
||||
ln -s "$HOME/.windows/moved-shared" "$HOME/Windows"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted a shared inode moved below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/.windows/moved-shared/keep.txt && -f $COMPOSE ]] || fail "overlap rejection changed disk, shared data, or compose"
|
||||
pass "removal revalidates pinned ancestry and leaves moved shared data untouched"
|
||||
|
||||
# Even when both familiar paths remain disjoint, a same-filesystem bind of the
|
||||
# pinned shared inode introduced below storage must stop removal before change.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G removal-alias pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mkdir "$HOME/.windows/shared-bind-alias"
|
||||
mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/shared-bind-alias"
|
||||
__priv_remove 2>/dev/null && fail "removal missed a shared bind alias introduced below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "removal bind-alias rejection changed state"
|
||||
umount -- "$HOME/.windows/shared-bind-alias"
|
||||
pass "removal tree discovery catches a shared alias not used by either home path"
|
||||
|
||||
# A direct alias on another filesystem is still visited by find -xdev at its
|
||||
# mountpoint and must be rejected, while unrelated separate filesystems remain
|
||||
# supported by the root suite.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
mount -t tmpfs -o uid="$(id -u)",gid="$(id -g)",mode=0700,size=8m crossdev-shared "$HOME/Windows"
|
||||
touch "$HOME/Windows/keep.txt"
|
||||
write 4G 2 64G crossdev-alias pw UTC
|
||||
touch "$HOME/.windows/disk.img"
|
||||
mkdir "$HOME/.windows/crossdev-shared-alias"
|
||||
mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/crossdev-shared-alias"
|
||||
__priv_remove 2>/dev/null && fail "removal missed a different-device shared alias below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "cross-device alias rejection changed state"
|
||||
umount -- "$HOME/.windows/crossdev-shared-alias"
|
||||
unmount_all
|
||||
umount -- "$HOME/Windows"
|
||||
pass "removal catches a direct different-filesystem shared alias at the xdev boundary"
|
||||
|
||||
# Recursive alias discovery is destructive-removal-only and bounded. A hung or
|
||||
# failing scanner must fail closed before the disk, share, compose, or mounts
|
||||
# are changed.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G scan-failure pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
scan_helper="$TMPDIR/tree-scan-helper"
|
||||
saved_tree_scan_find=$TREE_SCAN_FIND
|
||||
saved_tree_scan_timeout=$TREE_SCAN_TIMEOUT_SECONDS
|
||||
saved_tree_scan_kill_after=$TREE_SCAN_KILL_AFTER_SECONDS
|
||||
printf '#!/bin/bash\n/bin/sleep 10\n' >"$scan_helper"
|
||||
chmod 0700 "$scan_helper"
|
||||
TREE_SCAN_FIND=$scan_helper
|
||||
TREE_SCAN_TIMEOUT_SECONDS=0.05
|
||||
TREE_SCAN_KILL_AFTER_SECONDS=0.05
|
||||
__priv_remove 2>/dev/null && fail "removal continued after its containment scan timed out"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "timed-out containment scan changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "timed-out containment scan changed mounts"
|
||||
|
||||
printf '#!/bin/bash\nexit 42\n' >"$scan_helper"
|
||||
__priv_remove 2>/dev/null && fail "removal continued after its containment scanner failed"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed containment scan changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "failed containment scan changed mounts"
|
||||
TREE_SCAN_FIND=$saved_tree_scan_find
|
||||
TREE_SCAN_TIMEOUT_SECONDS=$saved_tree_scan_timeout
|
||||
TREE_SCAN_KILL_AFTER_SECONDS=$saved_tree_scan_kill_after
|
||||
pass "removal scan timeout and errors fail closed without changing VM state"
|
||||
|
||||
# Removal rejects stacks, then deletes disk only through verified binds.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G remove pw UTC
|
||||
resolve_caller
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mount --no-canonicalize --bind "$HOME/.windows" "$EXPECTED_STORAGE"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted stacked storage mount"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "rejected removal changed state"
|
||||
umount -- "$EXPECTED_STORAGE"
|
||||
dc() { return 1; }
|
||||
__priv_remove 2>/dev/null && fail "removal deleted data after docker-compose down failed"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed down changed data or compose"
|
||||
dc() { :; }
|
||||
__priv_remove
|
||||
[[ ! -e $HOME/.windows/disk.img ]] || fail "removal preserved disk data"
|
||||
[[ -e $HOME/Windows/keep.txt ]] || fail "removal deleted shared data"
|
||||
[[ ! -f $COMPOSE ]] || fail "removal left compose"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "removal left binds"
|
||||
pass "removal rejects stacks, deletes disk, and preserves shared files"
|
||||
|
||||
# Credentials replace a planted link rather than following it, and a failed
|
||||
# atomic rename preserves the last complete private file.
|
||||
credentials_dir="$TMPDIR/credentials"
|
||||
CREDENTIALS_FILE="$credentials_dir/credentials"
|
||||
credentials_victim="$TMPDIR/credentials-victim"
|
||||
mkdir -m 0755 -p "$credentials_dir"
|
||||
printf 'victim\n' >"$credentials_victim"
|
||||
ln -s "$credentials_victim" "$CREDENTIALS_FILE"
|
||||
write_credentials carol 'p=a$$w"x'
|
||||
[[ -f $CREDENTIALS_FILE && ! -L $CREDENTIALS_FILE ]] || fail "credentials did not replace a planted symlink"
|
||||
[[ $(stat -c '%a' "$credentials_dir") == 700 && $(stat -c '%a' "$CREDENTIALS_FILE") == 600 ]] || fail "credentials path is not private"
|
||||
[[ $(cat "$credentials_victim") == victim ]] || fail "credentials write changed a symlink victim"
|
||||
[[ $(read_credential USERNAME) == carol && $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "credentials did not round-trip"
|
||||
credentials_before=$(cat "$CREDENTIALS_FILE")
|
||||
mv() { return 1; }
|
||||
write_credentials changed replacement 2>/dev/null && fail "forced credentials rename failure succeeded"
|
||||
unset -f mv
|
||||
[[ $(cat "$CREDENTIALS_FILE") == "$credentials_before" ]] || fail "failed credentials rename replaced the live file"
|
||||
! find "$credentials_dir" -name '.credentials.*' -print -quit | grep -q . || fail "failed credentials write left a temporary file"
|
||||
pass "credentials are atomically replaced as a private regular file"
|
||||
|
||||
# Free-space accounting follows the real storage target.
|
||||
reset_case
|
||||
mkdir -p "$external_storage" "$HOME/Windows"
|
||||
ln -s "$external_storage" "$HOME/.windows"
|
||||
prepare_user_mount_sources
|
||||
df_log="$TMPDIR/df-path"
|
||||
df() {
|
||||
printf '%s\n' "${!#}" >"$df_log"
|
||||
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nmock 104857600 0 94371840 0%% /mock\n'
|
||||
}
|
||||
[[ $(available_storage_gb) == 90 ]] || fail "free-space parsed wrong value"
|
||||
unset -f df
|
||||
[[ $(cat "$df_log") == "$external_storage" ]] || fail "free-space used home filesystem"
|
||||
pass "disk-space checks follow the storage symlink target"
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
# Exercise the real EUID-0/PKEXEC_UID boundary in an isolated user+mount namespace.
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
if ((EUID != 0)); then
|
||||
if unshare --user --map-auto --map-root-user --mount true 2>/dev/null; then
|
||||
exec unshare --user --map-auto --map-root-user --mount --propagation private bash "$0"
|
||||
fi
|
||||
pass "automatic subordinate-id namespace unavailable; skipping root Windows VM boundary probe"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
# Hide host state before creating the production paths used by the root helper.
|
||||
mount -t tmpfs -o mode=0755,size=8m run-test /run
|
||||
mkdir -p /run/lock
|
||||
mount -t tmpfs -o mode=0755,size=16m var-test /var
|
||||
mkdir -p /var/lib/omarchy
|
||||
mount -t tmpfs -o mode=0755,size=16m home-parent /home
|
||||
mkdir /home/alice
|
||||
mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice
|
||||
|
||||
export HOME=/home/alice
|
||||
unset OMARCHY_WINDOWS_DIR
|
||||
set -- help
|
||||
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
||||
|
||||
# The namespace maps the host filesystem's uid 0 to nobody. Only / remains on
|
||||
# that filesystem; all paths the helper mutates are isolated tmpfs mounts.
|
||||
stat() {
|
||||
if [[ ${!#} == / && $* == *"%u"* ]]; then printf '0\n'; return; fi
|
||||
command stat "$@"
|
||||
}
|
||||
|
||||
TEST_PASSWD_HOME=/home/alice
|
||||
getent() {
|
||||
if [[ $1 == passwd && ${2:-} == 1000 ]]; then
|
||||
printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME"
|
||||
return 0
|
||||
fi
|
||||
return 2
|
||||
}
|
||||
|
||||
assert_no_runtime_mutation() {
|
||||
[[ ! -e /var/lib/omarchy/windows && ! -L /var/lib/omarchy/windows ]] ||
|
||||
fail "$1 mutated the production runtime"
|
||||
}
|
||||
|
||||
unset PKEXEC_UID
|
||||
resolve_caller 2>/dev/null && fail "root accepted missing PKEXEC_UID"
|
||||
assert_no_runtime_mutation "missing PKEXEC_UID"
|
||||
PKEXEC_UID=0
|
||||
resolve_caller 2>/dev/null && fail "root accepted PKEXEC_UID=0"
|
||||
assert_no_runtime_mutation "zero PKEXEC_UID"
|
||||
PKEXEC_UID=not-a-number
|
||||
resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID"
|
||||
assert_no_runtime_mutation "nonnumeric PKEXEC_UID"
|
||||
PKEXEC_UID=1001
|
||||
resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd"
|
||||
assert_no_runtime_mutation "missing passwd entry"
|
||||
|
||||
PKEXEC_UID=1000
|
||||
resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller"
|
||||
assert_no_runtime_mutation "wrong-owned home"
|
||||
chown 1000:1000 /home/alice
|
||||
|
||||
chmod 0777 /home
|
||||
resolve_caller 2>/dev/null && fail "root accepted writable home parent"
|
||||
assert_no_runtime_mutation "writable parent"
|
||||
chmod 0755 /home
|
||||
|
||||
mkdir /home/real-alice
|
||||
chown 1000:1000 /home/real-alice
|
||||
ln -s /home/real-alice /home/link-alice
|
||||
TEST_PASSWD_HOME=/home/link-alice
|
||||
resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home"
|
||||
assert_no_runtime_mutation "symlinked home"
|
||||
TEST_PASSWD_HOME=/home/alice
|
||||
resolve_caller || fail "valid root PKEXEC_UID/home boundary was rejected"
|
||||
pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and writable-parent boundaries without mutation"
|
||||
|
||||
# Put each familiar source on its own filesystem. Both start with legacy 0755
|
||||
# permissions and world-readable payloads to prove migration hardens the leaves.
|
||||
mkdir /home/storage-target /home/shared-target
|
||||
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target
|
||||
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target
|
||||
ln -s /home/storage-target /home/alice/.windows
|
||||
ln -s /home/shared-target /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/.windows /home/alice/Windows
|
||||
printf disk >/home/storage-target/disk.img
|
||||
printf shared >/home/shared-target/shared.txt
|
||||
chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt
|
||||
chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt
|
||||
|
||||
home_dev=$(command stat -Lc '%d' /home/alice)
|
||||
storage_dev=$(command stat -Lc '%d' /home/storage-target)
|
||||
[[ $home_dev != "$storage_dev" ]] || fail "storage target did not land on a separate filesystem"
|
||||
|
||||
with_vm_lock prepare_caller_mounts || fail "root could not create verified production bind anchors"
|
||||
resolve_caller
|
||||
[[ $(readlink /home/alice/.windows) == /home/storage-target &&
|
||||
$(readlink /home/alice/Windows) == /home/shared-target ]] || fail "root consumed legitimate symlinks"
|
||||
[[ $(command stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(command stat -Lc '%d:%i' /home/storage-target) ]] || fail "storage bind identity differs from pinned source"
|
||||
[[ $(command stat -Lc '%d:%i' "$EXPECTED_SHARED") == $(command stat -Lc '%d:%i' /home/shared-target) ]] || fail "shared bind identity differs from pinned source"
|
||||
[[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem"
|
||||
[[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 &&
|
||||
$(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes"
|
||||
[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 &&
|
||||
$(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700"
|
||||
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then
|
||||
fail "another local account read the VM disk through its anchor"
|
||||
fi
|
||||
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then
|
||||
fail "another local account read shared files through their anchor"
|
||||
fi
|
||||
pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account"
|
||||
|
||||
# Existing production boundary components are never repaired in place when
|
||||
# their ownership or write permissions are unsafe. Both the preparation path
|
||||
# and the final pre-Docker guard must fail closed without disturbing the binds.
|
||||
chmod 0731 "$MOUNT_ROOT"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a group-writable mount boundary instead of rejecting it"
|
||||
mounts_ready 2>/dev/null && fail "final guard accepted a group-writable mount boundary"
|
||||
[[ $(command stat -Lc '%a' "$MOUNT_ROOT") == 731 ]] || fail "rejection unexpectedly changed the writable boundary"
|
||||
chmod 0711 "$MOUNT_ROOT"
|
||||
|
||||
chown 1000:1000 "$USERS_DIR"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a caller-owned mount boundary instead of rejecting it"
|
||||
mounts_ready 2>/dev/null && fail "final guard accepted a caller-owned mount boundary"
|
||||
[[ $(command stat -Lc '%u' "$USERS_DIR") == 1000 ]] || fail "rejection unexpectedly changed the boundary owner"
|
||||
chown root:root "$USERS_DIR"
|
||||
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 &&
|
||||
$(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "boundary rejection changed the verified mount pair"
|
||||
mounts_ready || fail "restored production boundaries were rejected"
|
||||
pass "root rejects wrong-owned and group-writable production mount boundaries without mutation"
|
||||
|
||||
expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}')
|
||||
actual_space=$(available_storage_gb)
|
||||
[[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem"
|
||||
[[ $(command df -P -- /home/alice | awk 'NR==2 {print int($4/1024/1024)}') != "$actual_space" ]] || fail "test filesystems do not distinguish home from storage"
|
||||
pass "disk-space accounting measures the actual storage filesystem, not home"
|
||||
|
||||
# Exercise the real root writer and final guard against the production paths.
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=alice\nPASSWORD=pw\nTZ=UTC\n' |
|
||||
with_vm_lock __priv_write_compose
|
||||
[[ $(command stat -Lc '%u:%a' "$COMPOSE_FILE") == 0:640 ]] || fail "root compose ownership/mode is wrong"
|
||||
with_vm_lock assert_mounts_safe || fail "final root mount/compose assertion rejected the verified pair"
|
||||
pass "root writer and final pre-Docker guard revalidate the pinned production mounts"
|
||||
|
||||
# Upgrade the exact sibling-anchor pair emitted by the earlier fix without
|
||||
# moving or replacing either familiar home symlink.
|
||||
sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE"
|
||||
sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE"
|
||||
sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE"
|
||||
compose_needs_security_migration || fail "previous protected compose was not recognized for upgrade"
|
||||
with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors"
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor"
|
||||
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "upgrade did not protect the web console"
|
||||
[[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link"
|
||||
pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary"
|
||||
|
||||
# A compose that already uses the fixed anchors still needs an authorized
|
||||
# upgrade when it predates web-console authentication.
|
||||
sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE"
|
||||
compose_needs_security_migration || fail "unprotected fixed-anchor compose was not recognized for upgrade"
|
||||
with_vm_lock assert_mounts_safe || fail "root could not protect an existing fixed-anchor compose"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "fixed-anchor upgrade did not protect the web console"
|
||||
pass "existing fixed-anchor compose gains web-console authentication"
|
||||
|
||||
# Preflight both sources before either bind on a clean anchor pair.
|
||||
umount "$EXPECTED_SHARED"
|
||||
umount "$EXPECTED_STORAGE"
|
||||
rm /home/alice/Windows
|
||||
ln -s / /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/Windows
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind"
|
||||
[[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink"
|
||||
pass "root preflights both sources before mounting either and preserves rejection evidence"
|
||||
|
||||
# mountpoint(1) follows symlinks, so explicitly pin the invariant that even a
|
||||
# root-planted anchor symlink to the expected mounted source is rejected.
|
||||
rm /home/alice/Windows
|
||||
ln -s /home/shared-target /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/Windows
|
||||
rmdir "$EXPECTED_STORAGE"
|
||||
ln -s /home/storage-target "$EXPECTED_STORAGE"
|
||||
storage_id=$(command stat -Lc '%d:%i' /home/storage-target)
|
||||
mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && fail "symlink mount anchor passed final identity check"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root followed a symlink mount anchor"
|
||||
[[ -L $EXPECTED_STORAGE ]] || fail "rejected anchor symlink was consumed"
|
||||
pass "final guard rejects a symlink even when it resolves to the expected mounted source"
|
||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
windows_vm_command="$ROOT/bin/omarchy-windows-vm"
|
||||
windows_vm_rules="$ROOT/default/hypr/apps/windows-vm.lua"
|
||||
|
||||
rg -q '^ restart: "no"$' "$windows_vm_command" ||
|
||||
fail "Windows VM uses manual startup by default"
|
||||
@@ -14,3 +15,15 @@ if rg -q '^ restart: unless-stopped$' "$windows_vm_command"; then
|
||||
fail "Windows VM does not restart automatically at boot"
|
||||
fi
|
||||
pass "Windows VM does not restart automatically at boot"
|
||||
|
||||
# Tolerate either shell quoting of the argument -- what must not drift is the
|
||||
# title itself, since the Hyprland rule below matches on it.
|
||||
rg -q 'title:"?Windows VM - Omarchy"' "$windows_vm_command" ||
|
||||
fail "Windows VM launches FreeRDP with its expected title"
|
||||
rg -q 'class = "\^xfreerdp\$", title = "\^Windows VM - Omarchy\$"' "$windows_vm_rules" ||
|
||||
fail "Windows VM opacity rule targets its FreeRDP window"
|
||||
rg -q 'tag = "-default-opacity"' "$windows_vm_rules" ||
|
||||
fail "Windows VM opts out of default opacity"
|
||||
rg -q 'opacity = "1 1"' "$windows_vm_rules" ||
|
||||
fail "Windows VM stays fully opaque"
|
||||
pass "Windows VM stays fully opaque"
|
||||
Reference in new issue
Block a user