Merge remote-tracking branch 'origin/quattro' into quattro

# Conflicts:
#	default/pacman/pacman-stable.conf
This commit is contained in:
2026-08-28 23:18:35 -04:00
119 changed files with 5422 additions and 127 deletions
+14 -1
View File
@@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin"
cat >"$TEST_HOME/bin/codex" <<'EOF'
#!/bin/bash
if [[ -n ${CODEX_ARGS_FILE:-} ]]; then
printf '%s\0' "$@" >"$CODEX_ARGS_FILE"
fi
while read -r request; do
id=$(jq -r '.id // empty' <<<"$request")
method=$(jq -r '.method // empty' <<<"$request")
@@ -40,9 +44,18 @@ cat >"$session" <<EOF
{"timestamp":"$timestamp","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":180,"cached_input_tokens":110,"output_tokens":30,"reasoning_output_tokens":8,"total_tokens":210},"last_token_usage":{"input_tokens":80,"cached_input_tokens":50,"output_tokens":10,"reasoning_output_tokens":3,"total_tokens":90}}}}
EOF
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" CODEX_ARGS_FILE="$TEST_HOME/codex-args" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
"$ROOT/bin/omarchy-agent-usage-codex")
# NUL-separated, so the assertion sees argument boundaries: a single "-a on-request"
# would flatten to the same text as two arguments but is not a policy codex accepts.
expected_args=(-s read-only -a on-request app-server)
mapfile -d '' -t codex_args <"$TEST_HOME/codex-args"
[[ ${codex_args[*]@Q} == "${expected_args[*]@Q}" ]] ||
fail "Codex collector uses the supported approval policy" "${codex_args[*]@Q}"
pass "Codex collector uses the supported approval policy"
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "210" ]] ||
fail "Codex collector counts each turn once" "$result"
pass "Codex collector counts each turn once"
+152
View File
@@ -0,0 +1,152 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=$(mktemp -d)
# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the
# old wrapper would have formed, so it cannot use a random mktemp name. Track whether
# we created it and remove it on exit only then -- never touch a path we did not create.
tmp_cache="/tmp/omarchy-brightness-display-apple.device"
created_tmp_cache=0
cleanup() {
rm -rf "$TMPDIR"
# Remove the /tmp decoy only if this test is the one that created it.
if (( created_tmp_cache )); then
rm -f "$tmp_cache"
fi
}
trap cleanup EXIT
# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol
# invocation, make detection deterministic by having --detect report no device,
# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's
# detect_apple_display_device returns before it ever runs asdcontrol, so the
# reject cases assert on the negative: a refused cache value is never handed to
# `asdcontrol <dev> -- <step>`. Blind-trust validation would hand it over and be
# caught here.
stub_dir="$TMPDIR/stubs"
mkdir -p "$stub_dir"
asd_log="$TMPDIR/asdcontrol.log"
cat >"$stub_dir/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
chmod +x "$stub_dir/sudo"
cat >"$stub_dir/asdcontrol" <<STUB
#!/bin/bash
printf '%s\n' "\$*" >>"$asd_log"
# --detect reports nothing, so detection never yields a device.
if [[ \$1 == "--detect" ]]; then
exit 0
fi
# A brightness read (a lone device arg) returns a plausible value; a set
# (<device> -- <step>) just succeeds.
if [[ \$# -eq 1 ]]; then
printf '%s: BRIGHTNESS=30000\n' "\$1"
fi
exit 0
STUB
chmod +x "$stub_dir/asdcontrol"
cat >"$stub_dir/omarchy-osd" <<'STUB'
#!/bin/bash
exit 0
STUB
chmod +x "$stub_dir/omarchy-osd"
run_wrapper() {
# $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper.
local xdg="$1"
shift
: >"$asd_log"
if [[ -n $xdg ]]; then
XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \
omarchy-brightness-display-apple "$@" 2>&1 || true
else
env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \
omarchy-brightness-display-apple "$@" 2>&1 || true
fi
}
# --- A cache value that is not a hiddev character device is rejected ----------
xdg_dir="$TMPDIR/xdg"
mkdir -p "$xdg_dir"
cache_file="$xdg_dir/omarchy-brightness-display-apple.device"
regular_file="$TMPDIR/not-a-device"
: >"$regular_file"
poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil")
# The cases above all fail on the pathname prefix, so none of them reaches the -c
# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path
# that matches the hiddev glob but is not a character device is what -c is for,
# and it is the realistic stale cache: the display replugs, the interface
# renumbers, and the cached node is simply gone. Add it only when the host really
# has no such node, so a machine with the display attached cannot fail here.
if [[ ! -e /dev/hiddev999 ]]; then
poisons+=("/dev/hiddev999")
fi
for poison in "${poisons[@]}"; do
printf '%s\n' "$poison" >"$cache_file"
output=$(run_wrapper "$xdg_dir" "+5%")
if grep -qF -- "$poison -- +5%" "$asd_log"; then
fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output"
fi
done
pass "wrapper rejects a cached path that is not a hiddev character device"
# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path
# that does not exist. The remaining arm -- a path under /dev that exists, matches
# the glob, and is not a character device -- cannot be built without root, since
# only real device nodes live there.
# --- A legitimate cached hiddev node is trusted (only where HW is present) ----
real_hiddev=""
for candidate in /dev/usb/hiddev* /dev/hiddev*; do
if [[ -c $candidate ]]; then
real_hiddev="$candidate"
break
fi
done
if [[ -n $real_hiddev ]]; then
printf '%s\n' "$real_hiddev" >"$cache_file"
run_wrapper "$xdg_dir" "+5%" >/dev/null
grep -qF -- "$real_hiddev -- +5%" "$asd_log" ||
fail "wrapper did not trust a valid cached hiddev node: $real_hiddev"
pass "wrapper trusts a cached hiddev character device without re-detecting"
else
pass "no /dev/hiddev* character device present; skipping the valid-cache case"
fi
# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------
# Assert on the open, not on the contents. A decoy holding a rejectable path proves
# nothing: the validation above refuses it whether or not the /tmp fallback is still
# there, so that assertion passes against both wrappers. A FIFO with no writer blocks
# whoever opens it, so a wrapper that consults the path hangs and one that ignores it
# exits -- which separates the two. mkfifo is atomic and fails outright if the path is
# taken, so it neither overwrites a file nor follows a symlink; the fixed path is
# required, being exactly the path the old code would have formed. Clear the flag as
# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this
# run's EXIT trap.
if mkfifo "$tmp_cache" 2>/dev/null; then
created_tmp_cache=1
status=0
env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \
timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$?
rm -f "$tmp_cache"
created_tmp_cache=0
(( status != 124 )) ||
fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \
"it blocked reading the FIFO decoy at $tmp_cache"
pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset"
else
pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case"
fi
+330
View File
@@ -0,0 +1,330 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
export OMARCHY_PATH="$ROOT"
export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning"
source "$ROOT/install/helpers/browser-policy.sh"
# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged.
unprivileged_as_root() {
if [[ $1 == "install" ]]; then
shift
local args=()
local skip=0
local arg
for arg in "$@"; do
if (( skip )); then
skip=0
continue
fi
case $arg in
-o|-g) skip=1 ;;
*) args+=("$arg") ;;
esac
done
command install "${args[@]}"
else
"$@"
fi
}
write_dir=$test_tmp/writable
mkdir -p "$write_dir"
browser_policy_install_color "$write_dir" "#aabbcc" ||
fail "theme colour writes into a writable policy directory"
grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null ||
fail "theme colour writes BrowserThemeColor"
mode=$(stat -c '%a' "$write_dir/color.json")
[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode"
pass "theme colour writes a 0644 color.json"
if (( EUID == 0 )); then
pass "running as root; skipping the mktemp-failure check"
else
chmod u+w "$write_dir"
export TMPDIR=$test_tmp/missing-tmp
if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then
fail "theme colour fails when mktemp cannot create a file"
fi
unset TMPDIR
grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null ||
fail "a failed mktemp leaves an existing color.json intact"
pass "a failed mktemp does not truncate color.json"
fi
printf 'original\n' >"$test_tmp/pwn"
rm -f "$write_dir/color.json"
ln -s "$test_tmp/pwn" "$write_dir/color.json"
browser_policy_install_color "$write_dir" "#aabbcc" ||
fail "theme colour replaces a planted color.json symlink"
[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] ||
fail "theme colour unlinks a planted color.json symlink instead of writing through it"
grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged"
pass "theme colour does not follow a planted color.json symlink"
plant_write=$test_tmp/plant-dir
mkdir -p "$plant_write/color.json/nested"
printf 'inside\n' >"$plant_write/color.json/nested/x"
browser_policy_install_color "$plant_write" "#aabbcc" ||
fail "theme colour replaces a planted color.json directory"
[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] ||
fail "theme colour does not write into a planted color.json directory"
pass "theme colour does not write into a planted color.json directory"
missing_dir=$test_tmp/missing
browser_policy_install_color "$missing_dir" "#aabbcc" ||
fail "theme colour skips a policy directory that does not exist"
[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory"
pass "theme colour skips a missing policy directory"
if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then
fail "theme colour rejects hex without a leading #"
fi
if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then
fail "theme colour rejects uppercase hex"
fi
pass "theme colour accepts only # plus six lowercase hex digits"
planted_dir=$test_tmp/planted
mkdir -p "$planted_dir/evil"
printf 'evil\n' >"$planted_dir/evil/f"
printf 'old\n' >"$planted_dir/color.json"
as_root() { unprivileged_as_root "$@"; }
browser_policy_setup_dir "$planted_dir"
[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory"
[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json"
[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place"
mode=$(stat -c '%a' "$planted_dir")
[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode"
pass "policy setup drops non-root files and non-empty subdirectories"
owned=$test_tmp/not-root
mkdir -p "$owned"
chmod 755 "$owned"
if browser_policy_dir_hardened "$owned"; then
fail "a user-owned 0755 directory is not treated as hardened"
fi
pass "a hardened directory must be root-owned"
saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}")
parent_root=$test_tmp/parents
mkdir -p "$parent_root/etc/chromium/policies/managed/keep"
printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x"
chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies"
chmod 755 "$parent_root/etc/chromium/policies/managed"
BROWSER_POLICY_PARENT_DIRS=(
"$parent_root/etc/chromium"
"$parent_root/etc/chromium/policies"
)
as_root() { unprivileged_as_root "$@"; }
if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then
fail "a world-writable policy parent is not treated as hardened"
fi
browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed"
mode=$(stat -c '%a' "$parent_root/etc/chromium")
[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode"
mode=$(stat -c '%a' "$parent_root/etc/chromium/policies")
[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode"
[[ -d $parent_root/etc/chromium/policies/managed/keep ]] ||
fail "parent repair does not purge the managed directory"
pass "policy parent directories are tightened to 0755 without purging the leaf"
symlink_root=$test_tmp/symlink-parents
mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed"
printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json"
ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium"
BROWSER_POLICY_PARENT_DIRS=(
"$symlink_root/etc/chromium"
"$symlink_root/etc/chromium/policies"
)
as_root() { unprivileged_as_root "$@"; }
browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed"
[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink"
[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] ||
fail "setup recreates /etc/chromium as a real directory"
[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] ||
fail "setup recreates /etc/chromium/policies as a real directory"
[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] ||
fail "setup does not keep policy that lived behind a planted parent symlink"
grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" ||
fail "replacing a parent symlink does not delete the symlink target"
BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}")
pass "policy setup does not follow a planted parent symlink"
leaf_link_root=$test_tmp/leaf-link
mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker"
printf 'planted\n' >"$leaf_link_root/attacker/evil.json"
chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies"
ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed"
BROWSER_POLICY_PARENT_DIRS=(
"$leaf_link_root/etc/chromium"
"$leaf_link_root/etc/chromium/policies"
)
as_root() { unprivileged_as_root "$@"; }
if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then
fail "a planted managed symlink is not treated as hardened"
fi
browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed"
[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] ||
fail "setup replaces a planted managed symlink"
[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] ||
fail "setup recreates managed as a real directory"
[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] ||
fail "setup does not keep policy that lived behind a planted managed symlink"
grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" ||
fail "replacing a managed symlink does not delete the symlink target"
BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}")
pass "policy setup does not follow a planted managed symlink"
fx_link_root=$test_tmp/fx-link
mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt"
printf 'planted\n' >"$fx_link_root/attacker/policies.json"
ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen"
as_root() { unprivileged_as_root "$@"; }
if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then
fail "a planted Firefox distribution symlink is not treated as hardened"
fi
browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" ||
fail "Firefox setup replaces a planted distribution symlink"
[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink"
[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] ||
fail "Firefox setup recreates the distribution directory"
[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] ||
fail "Firefox setup writes policies.json into the recreated directory"
grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" ||
fail "replacing a Firefox distribution symlink does not delete the symlink target"
pass "Firefox setup does not follow a planted distribution symlink"
[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] ||
fail "theme colour converts an RGB triple to hex"
[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] ||
fail "theme colour accepts a trailing newline"
[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] ||
fail "theme colour pads single-digit components"
[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] ||
fail "theme colour tolerates surrounding whitespace"
[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] ||
fail "theme colour treats leading zeros as decimal"
for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \
"1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do
[[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] ||
fail "theme colour falls back to the stock grey for '$malformed'"
done
pass "theme colour is six hex digits or the stock grey"
for theme in "$ROOT"/themes/*/chromium.theme; do
[[ -f $theme ]] || continue
rgb=$(<$theme)
hex=$(browser_policy_theme_hex "$rgb")
[[ $hex =~ ^#[0-9a-f]{6}$ ]] ||
fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")"
if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then
fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")"
fi
done
pass "shipped chromium.theme files parse as RGB triples"
grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex"
grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy"
if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then
fail "omarchy-theme-set-browser does not hand unvetted theme words to printf"
fi
pass "omarchy-theme-set-browser validates the theme colour"
fx_policy=$test_tmp/policies.json
printf '%s\n' '{"policies":{}}' >"$fx_policy"
chmod 644 "$fx_policy"
if browser_policy_firefox_policy_file_ok "$fx_policy"; then
fail "a user-owned policies.json is not treated as hardened"
fi
ln -sf "$fx_policy" "$test_tmp/policies-link.json"
if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then
fail "a policies.json symlink is not treated as hardened"
fi
pass "Firefox policy files must be root-owned regular files without group or other write"
dist=$test_tmp/distribution
mkdir -p "$dist"
printf 'original\n' >"$test_tmp/firefox-pwn"
ln -s "$test_tmp/firefox-pwn" "$dist/policies.json"
as_root() { unprivileged_as_root "$@"; }
browser_policy_install_firefox_policies "$dist" ||
fail "Firefox policy install replaces a planted policies.json symlink"
[[ -f $dist/policies.json && ! -L $dist/policies.json ]] ||
fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it"
grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged"
grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies"
pass "Firefox policy install does not follow a planted policies.json symlink"
dir_dist=$test_tmp/distribution-dir
mkdir -p "$dir_dist"
mkdir "$dir_dist/policies.json"
as_root() { unprivileged_as_root "$@"; }
if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then
fail "Firefox policy install refuses a planted policies.json directory"
fi
[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place"
pass "Firefox policy install does not write into a planted policies.json directory"
grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
fail "omarchy-theme-set-browser exits non-zero when a policy write fails"
pass "omarchy-theme-set-browser exits non-zero when a policy write fails"
# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a
# handler ending on a false test turns a clean run into a failure and aborts the
# migration that calls this through omarchy-theme-set-browser.
policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy")
[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler"
eval "$policy_cleanup"
staged=""
cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged"
staged=$test_tmp/staged-policy
: >"$staged"
cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file"
[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file"
unset -f cleanup
pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status"
grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null ||
fail "the policy-directory migration hardens Firefox even when the theme refresh fails"
pass "the policy-directory migration does not abort on a failed theme refresh"
policy_files=(
"$ROOT/bin/omarchy-install-browser"
"$ROOT/bin/omarchy-provision-owner"
"$ROOT/bin/omarchy-theme-set-browser"
"$ROOT/bin/omarchy-theme-set-browser-policy"
"$ROOT/bin/omarchy-upgrade-to-quattro"
"$ROOT/install/config/theme-system.sh"
"$ROOT/install/config/browser-policy.sh"
"$ROOT/install/helpers/browser-policy.sh"
"$ROOT/migrations/1787515927.sh"
)
if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then
fail "browser policy setup is not world-writable and does not use omarchy-browser-policy"
fi
pass "browser policy setup is not world-writable"
mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations")
(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}"
grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null ||
fail "the policy-directory migration repairs managed directories"
if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then
fail "the policy-directory migration does not grant a browser-policy group"
fi
grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null ||
fail "the policy-directory migration covers Firefox and Zen"
grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null ||
fail "the policy-directory migration keeps a trusted Firefox policies.json"
grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null ||
fail "the shared helper names the Zen distribution directory"
pass "a migration locks existing policy directories"
+184
View File
@@ -0,0 +1,184 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
helper="$ROOT/bin/omarchy-theme-set-browser-policy"
setter="$ROOT/bin/omarchy-theme-set-browser"
sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser"
rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads
# as "any arguments" -- or widening the glob to `*` would let the grant carry
# something other than a color while leaving this line looking right.
rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file")
[[ $rules == "$rule" ]] ||
fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules"
if command -v visudo >/dev/null; then
visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses"
fi
grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names"
grep -E 'sudo -n -l -l' "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing"
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" ||
fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root"
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true)
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root"
pass "browser policy sudoers rule is scoped to a single color argument"
for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \
/etc/opt/edge/policies/managed /etc/brave/policies/managed; do
grep -Fx " $dir" "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list"
done
policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /')
((policy_dir_count == 4)) ||
fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \
"got: $policy_dir_count"
grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null ||
fail "omarchy-theme-set-browser-policy installs color.json with install -T"
if grep -E 'mv -f' "$helper" >/dev/null; then
fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory"
fi
pass "browser policy helper writes a fixed set of policy directories"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/pkexec" <<'SH'
#!/bin/bash
printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG"
SH
chmod +x "$stub_bin/pkexec"
# STUB_GRANTED empty stands for an install whose omarchy-settings predates the
# sudoers file. The default is granted, matching a current Omarchy.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
if [[ $1 == -n && $2 == -l ]]; then
if [[ ${STUB_GRANTED-granted} == "granted" ]]; then
echo " Options: !authenticate"
else
echo " Matched: ${!#}"
fi
exit 0
fi
printf 'sudo %s\n' "$*" >"$ELEVATION_LOG"
SH
chmod +x "$stub_bin/sudo"
if ((EUID == 0)); then
pass "running as root; skipping the elevation checks, which would rewrite this machine's browser policy"
else
elevation_for() {
: >"$test_tmp/elevation"
ELEVATION_LOG="$test_tmp/elevation" \
PATH="$stub_bin:$PATH" \
bash "$helper" "$@" </dev/null >/dev/null 2>&1 || true
cat "$test_tmp/elevation"
}
elevation=$(elevation_for 1c2027)
[[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \
"got: $elevation"
dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027)
[[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \
"got: $dev_linked"
pass "browser policy helper elevates a valid color through the sudo grant"
ungranted=$(STUB_GRANTED="" elevation_for 1c2027)
[[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \
"got: $ungranted"
pass "browser policy helper falls back to polkit wherever the grant does not reach"
for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \
'$(id)' "1c2027;id" "#1c2027"; do
if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \
bash "$helper" "$bad" </dev/null >/dev/null 2>&1; then
fail "omarchy-theme-set-browser-policy rejects '$bad'"
fi
rejected=$(elevation_for "$bad")
[[ -z $rejected ]] ||
fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected"
done
if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff </dev/null >/dev/null 2>&1; then
fail "omarchy-theme-set-browser-policy rejects more than one argument"
fi
pass "browser policy helper accepts nothing but six lowercase hex digits"
fi
setter_bin="$test_tmp/setter-bin"
mkdir -p "$setter_bin"
cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >"$COLOR_LOG"
SH
chmod +x "$setter_bin/omarchy-theme-set-browser-policy"
cat >"$setter_bin/omarchy-cmd-present" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$setter_bin/omarchy-cmd-present"
setter_home="$test_tmp/home"
theme_dir="$setter_home/.local/state/omarchy/current/theme"
mkdir -p "$theme_dir"
color_for_theme() {
: >"$test_tmp/color"
if [[ $# -gt 0 ]]; then
printf '%s' "$1" >"$theme_dir/chromium.theme"
else
rm -f "$theme_dir/chromium.theme"
fi
HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \
OMARCHY_PATH="$ROOT" bash "$setter" </dev/null >/dev/null 2>&1 || true
cat "$test_tmp/color"
}
[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] ||
fail "omarchy-theme-set-browser converts an RGB triple to six hex digits"
[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] ||
fail "omarchy-theme-set-browser accepts a trailing newline"
[[ $(color_for_theme "0,0,0") == "000000" ]] ||
fail "omarchy-theme-set-browser pads single-digit components"
[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] ||
fail "omarchy-theme-set-browser tolerates surrounding whitespace"
for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \
"1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do
color=$(color_for_theme "$malformed")
[[ $color == "1c2027" ]] ||
fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color"
done
[[ $(color_for_theme) == "1c2027" ]] ||
fail "omarchy-theme-set-browser falls back to the stock colour with no theme file"
pass "browser theme color is derived as six hex digits or falls back to the stock grey"
@@ -28,8 +28,18 @@ write_stale_preferences() {
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
REAL_PYTHON=$(command -v python3)
cat >"$stub_bin/python3" <<'STUB'
#!/bin/bash
exit 127
STUB
chmod +x "$stub_bin/python3"
# Test stubs must delegate to the system interpreter, not a user shim that can
# route python3 back through the stubs and recurse.
REAL_PYTHON=$(PATH="$stub_bin:$PATH" command -p -v python3)
[[ $REAL_PYTHON != "$stub_bin/python3" ]] || fail "real Python resolution bypasses user shims"
export REAL_PYTHON
rm -f "$stub_bin/python3"
run_migration() {
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1
+328
View File
@@ -54,6 +54,334 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user-
fail "crash capture is no longer on by default for new installs"
pass "crash capture is on by default"
require_command jq
# The per-program mute, driven through the real watcher with a stubbed journal:
# these prove what a person sees -- a toast arriving or not -- where asserting
# that a flag file was read would prove only that a flag file was read.
watch_bin="$TMPDIR/watch-bin"
watch_home="$TMPDIR/watch-home"
NOTIFY_LOG="$TMPDIR/notify-log"
JOURNAL_ENTRIES="$TMPDIR/journal-entries"
mkdir -p "$watch_bin" "$watch_home"
cat >"$watch_bin/journalctl" <<'SH'
#!/bin/bash
cat "$JOURNAL_ENTRIES"
SH
cat >"$watch_bin/omarchy-default-agent" <<'SH'
#!/bin/bash
echo claude
SH
cat >"$watch_bin/omarchy-notification-wait" <<'SH'
#!/bin/bash
exit 0
SH
cat >"$watch_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$NOTIFY_LOG"
SH
chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \
"$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send"
reset_entries() {
: >"$JOURNAL_ENTRIES"
}
# One core dump as systemd-coredump journals it. The UID must be this user's, or
# the watcher discards it as somebody else's crash before anything under test.
crash_entry() {
local comm="$1" exe="$2"
jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \
'{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242",
COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES"
}
# The stubbed journalctl ends after the entries, so the watcher's loop ends too.
# Its exit status is asserted rather than discarded: a watcher that dies on a
# muted crash notifies about nothing afterwards, which every assertion below
# that expects silence would otherwise read as success.
run_watch() {
local status=0
: >"$NOTIFY_LOG"
PATH="$watch_bin:$ROOT/bin:$PATH" \
JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \
NOTIFY_LOG="$NOTIFY_LOG" \
HOME="$watch_home" \
"$ROOT/bin/omarchy-crash-watch" || status=$?
(( status == 0 )) ||
fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it"
}
# Through the real command rather than writing the flag by hand: these assertions
# are then the guard that the thing the diagnosis runs and the thing the watcher
# reads have not drifted apart.
mute() {
HOME="$watch_home" PATH="$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null
}
announced() {
grep -Fq "Process crashed: $1" "$NOTIFY_LOG"
}
reset_entries
crash_entry hyprland /usr/bin/hyprland
run_watch
announced hyprland ||
fail "a crash nobody muted still announces itself"
pass "a crash nobody muted still announces itself"
mute hyprland on
run_watch
! announced hyprland ||
fail "muting a program stops the crash notifications the diagnosis offered to stop"
pass "muting a program stops its crash notifications"
reset_entries
crash_entry nautilus /usr/bin/nautilus
run_watch
announced nautilus ||
fail "muting one program silences every other program, which is the global toggle's job and not this one's"
pass "muting one program leaves every other program announcing"
mute hyprland off
reset_entries
crash_entry hyprland /usr/bin/hyprland
run_watch
announced hyprland ||
fail "un-muting a program brings its crash notifications back"
pass "un-muting a program brings its crash notifications back"
# The diagnosis tells the user to mute the name the toast showed them, so the
# toast has to show the name the watcher checks. COMM is truncated to 15
# characters and the executable's basename is not, and announcing the truncated
# one would leave a dutifully-followed mute matching nothing forever.
reset_entries
crash_entry chromium-browse /usr/lib/chromium/chromium-browser
run_watch
announced chromium-browser ||
fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing"
pass "the toast announces the name the mute is keyed on"
mute chromium-browser on
run_watch
! announced chromium-browser ||
fail "the mute is keyed on the name the notification announced, not on the truncated COMM"
pass "muting the announced name silences a program whose COMM was truncated"
# A muted crash must not end the watcher. Restart=always would paper over it
# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which
# never replays the crashes it missed while it was away.
reset_entries
crash_entry chromium-browse /usr/lib/chromium/chromium-browser
crash_entry nautilus /usr/bin/nautilus
run_watch
announced nautilus ||
fail "a muted crash stops the watcher reading the journal, losing every crash after it"
pass "a muted crash does not stop the watcher reading the next one"
# A process can set its own comm to anything prctl takes, slashes included, and
# a crash with no recorded executable falls back to it. A name that climbed out
# of crash-ignore/ would let a crashing program silence itself against an
# unrelated flag -- and have the diagnosis write one there on the user's behalf.
# The fixture carries two slashes so that dropping only the first is not mistaken
# for dropping all of them.
reset_entries
crash_entry a/../bar-off -
sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off"
touch "$sibling_flag"
run_watch
announced bar-off ||
fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification"
pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle"
rm -f "$sibling_flag"
# Stripping to the last component does not always leave a component. An empty
# name is no kind of array subscript and no kind of toast, and a dot component
# names a directory the mute would touch and then never match.
for empty_comm in / a/ . ..; do
reset_entries
crash_entry "$empty_comm" -
run_watch
announced unknown ||
fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on"
done
pass "a comm that strips down to nothing or a dot still announces under a name a mute can use"
# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field
# collapses and every field after it shifts along one -- the pid becomes a path,
# the crash reads as somebody else's, and it is dropped without a word.
reset_entries
crash_entry "" -
crash_entry nautilus /usr/bin/nautilus
run_watch
announced unknown ||
fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it"
announced nautilus ||
fail "an empty comm derails the rest of the journal entry"
pass "an empty comm is announced rather than parsed into the next field"
# Only "." and ".." are special. A leading dot is an ordinary filename, and
# folding those into the fallback would have one program's mute silence another.
for dotted_comm in .hidden ...; do
reset_entries
crash_entry "$dotted_comm" -
run_watch
announced "$dotted_comm" ||
fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes"
done
pass "a leading dot is an ordinary name rather than a special component"
# And the name it settles on is mutable like any other.
mute unknown on
reset_entries
crash_entry / -
run_watch
! announced unknown ||
fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced"
pass "the fallback name can be muted like any other"
mute unknown off
# What omarchy-crash-mute does on its own. That it agrees with the watcher is
# already covered above, which drives it for every mute it makes.
mute_home="$TMPDIR/mute-home"
mkdir -p "$mute_home"
crash_mute() {
HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@"
}
mute_flag() {
[[ $1 == "--" ]] && shift
printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1"
}
crash_mute | grep -Fq "No programs muted" ||
fail "an empty mute list prints nothing, so a user cannot tell it from a broken command"
pass "the command says so when nothing is muted"
crash_mute hyprland >/dev/null
crash_mute | grep -Fqx hyprland ||
fail "a muted program is missing from the list, so a mute cannot be found again to lift it"
pass "the command lists what it muted"
# The watcher keys on the basename, so the command has to take the path a crash
# recorded and land on the same flag the watcher will look for.
crash_mute /usr/lib/chromium/chromium-browser >/dev/null
[[ -f $(mute_flag chromium-browser) ]] ||
fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag"
pass "the command reduces a path to the name the watcher checks"
crash_mute hyprland off >/dev/null
[[ ! -f $(mute_flag hyprland) ]] ||
fail "off leaves the program muted, making the mute a one-way door"
pass "the command un-mutes"
# Muting is not flipping. The diagnosis offers this on a program the user may
# already have muted, and asking for a mute twice has to leave it muted.
crash_mute hyprland >/dev/null
crash_mute hyprland >/dev/null
[[ -f $(mute_flag hyprland) ]] ||
fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on"
pass "asking to mute twice leaves it muted"
# A program may legitimately be called .hidden, and a mute nobody can see is a
# mute nobody can lift.
crash_mute .hidden >/dev/null
crash_mute | grep -Fqx .hidden ||
fail "a mute on a dotted name is missing from the list, so it can never be found and lifted"
pass "the list shows a name that begins with a dot"
# It turns what it is given into a path, so it has to refuse whatever is not one
# component of one.
for bad_name in . .. /; do
! crash_mute "$bad_name" >/dev/null 2>&1 ||
fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read"
done
pass "the command refuses a name that is not a name"
! crash_mute hyprland sideways >/dev/null 2>&1 ||
fail "an action it does not know is treated as a mute, so a typo silences a program"
pass "the command refuses an action it does not know"
# And says what it refused, or the user retypes the same thing. Captured rather
# than piped: the command exits non-zero here, which pipefail would surface as
# the pipeline's status and read as a failed assertion.
refusal=$(crash_mute hyprland sideways 2>&1) || true
grep -Fq "Not an action" <<<"$refusal" ||
fail "an unknown action is refused without naming it, leaving the user nothing to correct"
pass "the command names the action it refused"
crash_mute ../bar-off >/dev/null
[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] ||
fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead"
pass "the command cannot be talked into writing outside crash-ignore/"
# A program may be called -h, and the router answers that with its own help
# before the command runs. A leading -- is the way through, so it has to be
# consumed rather than taken for the program name.
crash_mute -- -h >/dev/null 2>&1 ||
fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all"
[[ -f $(mute_flag -- -h) ]] ||
fail "a leading -- is taken for the program name, so muting -h mutes something else"
pass "a leading -- lets a program named like a flag be muted"
# toggle is advertised, so it has to flip both ways rather than quietly mute.
crash_mute toggler off >/dev/null
crash_mute toggler toggle >/dev/null
[[ -f $(mute_flag toggler) ]] ||
fail "toggle does not mute an un-muted program"
crash_mute toggler toggle >/dev/null
[[ ! -f $(mute_flag toggler) ]] ||
fail "toggle mutes but never un-mutes, so the advertised action only goes one way"
pass "toggle flips a mute both ways"
# The listing means what the watcher means, and the watcher honours a regular
# file. Anything else in there is not a mute, however much it looks like one.
mkdir -p "$(mute_flag notactuallymuted)"
! crash_mute | grep -Fqx notactuallymuted ||
fail "a directory is reported as muted while that program's crashes keep arriving"
pass "the listing counts only the flags the watcher honours"
rmdir "$(mute_flag notactuallymuted)"
# A mute that could not be written must not be reported as one. Without this the
# command can print success for a flag that was never created.
failing_bin="$TMPDIR/failing-bin"
mkdir -p "$failing_bin"
cat >"$failing_bin/omarchy-toggle" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$failing_bin/omarchy-toggle"
status=0
refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$?
(( status != 0 )) ||
fail "a mute that could not be written exits zero, so nothing downstream learns it failed"
! grep -Fq "Muted crash notifications" <<<"$refusal" ||
fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not"
pass "a mute that could not be written is not reported as one"
skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md"
grep -Fq 'omarchy-crash-mute' "$skill" ||
fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out"
pass "the diagnosis names the command that mutes"
grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" ||
fail "the crash group has no description, so the router lists a group it cannot describe"
pass "the crash group is described in the router"
run_node_test <<'JS'
const fs = require('fs')
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
+1 -1
View File
@@ -457,7 +457,7 @@ assert_bypass() {
assert_launch pi pi "Review this project"
assert_launch omp omp --auto-approve -- "Review this project"
assert_launch opencode opencode --auto --prompt "Review this project"
assert_launch ori ori code --prompt "Review this project"
assert_launch ori ori code --interactive --prompt "Review this project"
assert_launch claude claude --permission-mode auto -- "Review this project"
assert_launch codex codex --approve-for-me -- "Review this project"
assert_launch crush crush run "Review this project"
+45 -5
View File
@@ -61,11 +61,13 @@ if [[ $installer == "omarchy-install-browser" && ${OMARCHY_TEST_REAL_BROWSER_INS
fi
case $installer in
omarchy-pkg-add)
omarchy-pkg-add|omarchy-pkg-aur-add)
package=$1
printf 'pkg:%s\n' "$package" >>"$OMARCHY_TEST_INSTALL_LOG"
case $package in
chromium) command=chromium ;;
firefox) command=firefox ;;
zen-browser-bin) command=zen-browser ;;
cursor-bin) command=cursor ;;
sublime-text-4) command=sublime_text ;;
vim) command=vim ;;
@@ -107,6 +109,7 @@ SH
for installer in \
omarchy-pkg-add \
omarchy-pkg-aur-add \
omarchy-install-browser \
omarchy-install-terminal \
omarchy-install-editor-vscode \
@@ -205,10 +208,17 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu
[[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds"
cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" ||
fail "Chromium browser installer copies the default flags"
grep -Fxq 'sudo:mkdir -p /etc/chromium/policies/managed' "$setup_log" ||
fail "Chromium browser installer creates its policy directory"
grep -Fxq 'sudo:chmod a+rw /etc/chromium/policies/managed' "$setup_log" ||
fail "Chromium browser installer makes its policy directory writable"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" ||
fail "Chromium browser installer creates a root-owned Chromium policy parent"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" ||
fail "Chromium browser installer creates a root-owned Chromium policies parent"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies/managed' "$setup_log" ||
fail "Chromium browser installer creates a root-owned managed policy directory"
grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
fail "Chromium browser installer drops non-root files from its policy directory"
if grep -E 'groupadd|usermod|omarchy-browser-policy' "$setup_log" >/dev/null; then
fail "Chromium browser installer does not create a browser-policy group" "$(cat "$setup_log")"
fi
grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" ||
fail "Chromium browser installer registers the Copy URL host"
grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" ||
@@ -217,6 +227,36 @@ grep -Fxq 'omarchy-theme-set-browser:' "$setup_log" ||
fail "Chromium browser installer applies the current theme"
pass "Chromium browser installer restores the complete Omarchy setup"
: >"$install_log"
: >"$setup_log"
rm -f "$installed_dir/firefox"
OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install firefox >/dev/null
[[ $(<"$install_log") == "pkg:firefox" ]] || fail "Firefox browser installer installs the package"
[[ $(omarchy-default-browser) == "firefox" ]] || fail "Firefox becomes the default after its full installer succeeds"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /usr/lib/firefox/distribution' "$setup_log" ||
fail "Firefox browser installer creates its distribution directory"
grep -Fxq 'sudo:find /usr/lib/firefox/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
fail "Firefox browser installer drops non-root files from its distribution directory"
grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /usr/lib/firefox/distribution/policies.json" "$setup_log" ||
fail "Firefox browser installer copies policies.json without following a destination symlink"
[[ -e $installed_dir/firefox ]] || fail "Firefox browser installer marks firefox installed"
pass "Firefox browser installer restores the complete Omarchy setup"
: >"$install_log"
: >"$setup_log"
rm -f "$installed_dir/zen-browser"
OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install zen >/dev/null
[[ $(<"$install_log") == "pkg:zen-browser-bin" ]] || fail "Zen browser installer installs the package"
[[ $(omarchy-default-browser) == "zen" ]] || fail "Zen becomes the default after its full installer succeeds"
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /opt/zen-browser/distribution' "$setup_log" ||
fail "Zen browser installer creates its distribution directory"
grep -Fxq 'sudo:find /opt/zen-browser/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
fail "Zen browser installer drops non-root files from its distribution directory"
grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /opt/zen-browser/distribution/policies.json" "$setup_log" ||
fail "Zen browser installer copies policies.json without following a destination symlink"
[[ -e $installed_dir/zen-browser ]] || fail "Zen browser installer marks zen-browser installed"
pass "Zen browser installer restores the complete Omarchy setup"
omarchy-default-browser zen
rm -f "$installed_dir/chromium"
if OMARCHY_TEST_REAL_BROWSER_INSTALL=true OMARCHY_TEST_INSTALL_FAIL=true \
+54
View File
@@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null ||
pass "dns sudoers rule is scoped to the stock providers"
# The privileged half runs as root under sudo's secure_path, and a dev link
# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it.
# Every helper the script calls by bare name -- dirname, install, tee, nmcli,
# systemctl, awk -- is a system tool, so once it holds root the script pins PATH
# to trusted system directories and never resolves one of them out of the
# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why
# the pin is gated on EUID rather than set unconditionally.
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" ||
fail "omarchy-dns pins PATH to trusted system directories when it holds root"
# require_root carries its own `(( EUID == 0 ))`, so matching that text alone
# would pass with the pin deleted. Anchor on the unindented guard and require the
# pin to be the line it opens.
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true)
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
fail "omarchy-dns gates the trusted-PATH pin on holding root"
# The no-argument path only reads DNS config, so exercise the privileged phase
# directly when the suite is root and as namespaced root otherwise. This reaches
# tr while EUID is 0 without giving an ordinary test run any host privileges.
root_runner=()
if (( EUID != 0 )); then
root_runner=(unshare --user --map-root-user)
fi
# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and
# the non-graphical suites have to stay green on any machine -- a skip is a
# passing test. Only the runtime probe needs the namespace; the static checks
# above and the elevation checks below run either way.
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
poison_dir=$(mktemp -d)
poison_ran="$poison_dir/ran"
for helper in tr awk dirname install tee; do
cat >"$poison_dir/$helper" <<SH
#!/bin/bash
printf 'x' >"$poison_ran"
exec "/usr/bin/$helper" "\$@"
SH
chmod +x "$poison_dir/$helper"
done
if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" </dev/null >/dev/null 2>&1; then
rm -rf "$poison_dir"
fail "root omarchy-dns failed its read-only trusted-PATH probe"
fi
if [[ -e $poison_ran ]]; then
rm -rf "$poison_dir"
fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path"
fi
rm -rf "$poison_dir"
pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH"
else
pass "no unprivileged user namespace; skipping the root trusted-PATH probe"
fi
# require_root returns immediately for root, so the stubs below would not stand
# between the script and the host's real NetworkManager and resolved config.
if (( EUID == 0 )); then
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
set -euo pipefail
# omarchy-git-url-check decides which URLs omarchy-theme-install and
# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote
# helper -- a program it runs at clone time -- from exactly two URL shapes,
# `<helper>::<address>` and `<scheme>://<address>`, so those are the two shapes
# asserted here, alongside every legitimate form a user is likely to paste.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
check() {
"$ROOT/bin/omarchy-git-url-check" "$@" 2>&1
}
# `<helper>::<address>`, the shape that runs a program. `ext::` is the dangerous
# one: git runs the rest as a shell command once protocol.ext.allow permits it.
for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the transport helper '$url'" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "omarchy-git-url-check names the helper rejection for '$url'" "$output"
done
pass "a <helper>::<address> URL is refused"
# `<scheme>://<address>`, the shape #8067 left open: git looks up
# git-remote-<scheme> for any scheme it does not implement itself, so an
# allowlist is the only form of this check that holds.
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the '$url' transport" "$output"
grep -qF "which Omarchy does not clone from" <<<"$output" ||
fail "omarchy-git-url-check names the transport rejection for '$url'" "$output"
done
pass "a <scheme>://<address> URL outside git's own transports is refused"
# A leading dash is an option to git, not a URL.
for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the option '$url'" "$output"
done
pass "a URL shaped like a git option is refused"
output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output"
output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output"
pass "an empty URL is refused"
# Everything a user actually pastes. The scp-style forms carry a single colon,
# which git never reads as a helper, and the IPv6 host carries `::` inside
# brackets rather than at the start.
for url in \
"https://github.com/acme/omarchy-weather.git" \
"http://example.com/a/b.git" \
"https://user:token@github.com/acme/repo.git" \
"ssh://git@github.com/acme/repo.git" \
"ssh://git@[2001:db8::1]:22/org/repo.git" \
"git://example.com/repo.git" \
"git+ssh://git@example.com/acme/repo.git" \
"ssh+git://git@example.com/acme/repo.git" \
"ftp://example.com/repo.git" \
"ftps://example.com/repo.git" \
"file:///home/me/repo" \
"git@github.com:acme/repo.git" \
"git@[2001:db8::1]:org/repo.git" \
"host:-s/foo.git" \
"/home/me/repo" \
"./repo" \
"../repo" \
"repo"; do
output=$(check "$url") ||
fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output"
done
pass "the URL forms a user pastes are accepted"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command lua
run_paths() {
lua - <<'LUA'
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
local paths = require("default.hypr.paths")
assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home)
assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home)
LUA
}
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
XDG_CONFIG_HOME= XDG_STATE_HOME= \
EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \
run_paths
pass "empty XDG path variables fall back to their defaults"
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \
EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \
run_paths
pass "set XDG path variables are honored"
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
stub_dir="$tmpdir/bin"
home_dir="$tmpdir/home"
monitors_json="$tmpdir/monitors.json"
flag_dir="$home_dir/.local/state/omarchy/toggles/hypr"
mkdir -p "$stub_dir" "$flag_dir"
make_stub() {
local name=$1
local body=$2
printf '#!/bin/bash\n%s\n' "$body" >"$stub_dir/$name"
chmod +x "$stub_dir/$name"
}
make_stub omarchy-notification-send ':'
make_stub omarchy-hyprland-monitor-external-active 'exit 0'
make_stub omarchy-hyprland-toggle-disabled 'exit 0'
make_stub omarchy-hyprland-toggle ':'
make_stub omarchy-hyprland-monitor-internal ':'
make_stub omarchy-hyprland-monitor-internal-mirror ':'
make_stub omarchy-hw-clamshell 'exit 0'
make_stub omarchy-hyprland-monitor-laptop 'printf "%s\n" "$LAPTOP_NAME"'
make_stub hyprctl 'case "$1" in
monitors) cat "$MONITORS_JSON" ;;
eval) printf "%s\n" "$2" >>"$EVAL_LOG" ;;
esac'
eval_log="$tmpdir/eval.log"
run_monitor() {
local command=$1
shift
: >"$eval_log"
HOME="$home_dir" \
XDG_STATE_HOME="$home_dir/.local/state" \
LAPTOP_NAME="${LAPTOP_NAME:-eDP-1}" \
MONITORS_JSON="$monitors_json" \
EVAL_LOG="$eval_log" \
PATH="$stub_dir:$ROOT/bin:$PATH" \
"$ROOT/bin/$command" "$@"
}
printf '[{"name":"eDP-1"},{"name":"DP-3"}]\n' >"$monitors_json"
disable_flag="$flag_dir/internal-monitor-disable.lua"
run_monitor omarchy-hyprland-monitor-internal off
grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$disable_flag" >/dev/null ||
fail "internal off writes the connector name into the toggle flag"
pass "internal off accepts a plain connector name"
rm -f "$disable_flag"
set +e
LAPTOP_NAME='eDP-1", disabled = false })os.execute("calc")--' \
run_monitor omarchy-hyprland-monitor-internal off >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "internal off rejects a monitor name with Lua metacharacters"
[[ ! -e $disable_flag ]] || fail "an unsafe monitor name is not written as Lua"
pass "internal off refuses an unsafe monitor name"
mirror_flag="$flag_dir/internal-monitor-mirror.lua"
run_monitor omarchy-hyprland-monitor-internal-mirror on
grep -Fx 'hl.monitor({ output = "DP-3", mode = "preferred", position = "auto", scale = 1, mirror = "eDP-1" })' \
"$mirror_flag" >/dev/null ||
fail "mirror on writes the connector names into the toggle flag"
pass "mirror on accepts plain connector names"
rm -f "$mirror_flag"
printf '[{"name":"eDP-1"},{"name":"HEAD\\" })os.execute(\\"calc\\")--"}]\n' >"$monitors_json"
set +e
run_monitor omarchy-hyprland-monitor-internal-mirror on >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "mirror on rejects an external name with Lua metacharacters"
[[ ! -e $mirror_flag ]] || fail "an unsafe external monitor name is not written as Lua"
pass "mirror on refuses an unsafe headless output name"
# The clamshell sync writes the internal-monitor name into generated Lua too.
clamshell_flag="$flag_dir/internal-monitor-clamshell.lua"
printf '[{"name":"eDP-1"}]\n' >"$monitors_json"
rm -f "$clamshell_flag"
run_monitor omarchy-hyprland-monitor-clamshell
grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$clamshell_flag" >/dev/null ||
fail "clamshell disable writes the connector name into the toggle flag"
pass "clamshell disable accepts a plain connector name"
rm -f "$clamshell_flag"
set +e
LAPTOP_NAME='eDP-1", disabled = true })os.execute("calc")--' \
run_monitor omarchy-hyprland-monitor-clamshell >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "clamshell rejects a monitor name with Lua metacharacters"
[[ ! -e $clamshell_flag ]] || fail "an unsafe internal monitor name is not written as clamshell Lua"
pass "clamshell refuses an unsafe internal monitor name"
# The scaling command eval's the focused-monitor name into a Lua string.
printf '[{"name":"eDP-1","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \
>"$monitors_json"
run_monitor omarchy-hyprland-monitor-scaling 1.6
grep -F 'hl.monitor({ output = "eDP-1"' "$eval_log" >/dev/null ||
fail "scaling eval's the focused connector name"
pass "scaling accepts a plain connector name"
printf '[{"name":"eDP-1\\" })os.execute(\\"calc\\")--","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \
>"$monitors_json"
set +e
run_monitor omarchy-hyprland-monitor-scaling 1.6 >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "scaling rejects a focused monitor name with Lua metacharacters"
[[ ! -s $eval_log ]] || fail "an unsafe focused monitor name is not eval'd as Lua"
pass "scaling refuses an unsafe focused monitor name"
+145
View File
@@ -18,6 +18,151 @@ assertEqual(
'notifications strip inline image tags'
)
// The body renders as StyledText, which fetches <img src> over the network. The
// invariant that matters is not a particular output string but that no tag Qt
// would honour as an image survives, so assert that directly. Tags are bounded
// the conservative way the stripper bounds them: a `<` opens a tag that runs to
// the next `>`. Qt's own bound can be longer, since a `>` inside a quoted
// attribute value does not close a tag there — which only ever splits one Qt
// tag into several here, so a name this helper reads is a name Qt reads too.
function survivingTagNames(text) {
const names = []
let i = 0
while (i < text.length) {
const open = text.indexOf('<', i)
if (open === -1) break
const close = text.indexOf('>', open)
const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1)
// Read the name the way Qt does, skipping anything that is not part of it.
// Matching the separator with \s instead would give this helper the same
// blind spot as the code it is checking — Qt skips U+0085 and \s does not —
// and an assertion that shares the implementation's bug proves nothing.
const name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag)
if (name) names.push(name[1].toLowerCase())
i = close === -1 ? text.length : close + 1
}
return names
}
// Assert on styledBody, not sanitizeBody: styledBody is the string the card
// binds to the StyledText, so it is the only one Qt ever parses. Checking the
// sanitizer's output instead would pass a body whose surviving tag the newline
// rewrite later splits open.
function assertNoImageSurvives(body, description) {
const out = notifications.styledBody(body, 'Slack', '')
const names = survivingTagNames(out)
assert(
!names.includes('img'),
description,
`input: ${body}\noutput: ${out}\ntags: ${JSON.stringify(names)}`
)
}
assertNoImageSurvives(
'<img src="http://host/plain.png">',
'notifications leave no image tag for a plain payload'
)
// A payload spliced inside the literal "<img" prefix. Qt reads ONE malformed
// tag named `im` here and renders nothing; a stripper that deleted the inner
// match would close the halves up into a live <img> the input never had.
assertNoImageSurvives(
'<im<img src="http://host/decoy.png">g src="http://host/beacon.png">',
'notifications leave no image tag when a payload is spliced inside <img'
)
assertNoImageSurvives(
'<im<im<img src=a>g src=b>g src="http://host/deep.png">',
'notifications leave no image tag for a doubly nested payload'
)
assertNoImageSurvives(
'<img<img src="http://host/twin.png">',
'notifications leave no image tag when the outer tag is itself named img'
)
assertNoImageSurvives(
'< img src="http://host/spaced.png">',
'notifications leave no image tag when whitespace follows the angle bracket'
)
// Qt skips the separator between `<` and the tag name with QChar::isSpace(),
// which counts U+0085 NEL. JavaScript's \s does not. Reading the name with \s
// finds none here, keeps the tag, and Qt then reads `img` and fetches it —
// measured against Qt 6.11.2, where this exact body makes a StyledText Text
// issue an outbound GET. Asserted on the whole output rather than through
// assertNoImageSurvives so it holds even if that helper is ever loosened.
assertEqual(
notifications.sanitizeBody('<\u0085img src="http://host/nel.png">after', 'Slack', ''),
'after',
'notifications strip an image tag whose separator is U+0085, which Qt skips but \\s does not'
)
assertNoImageSurvives(
'<\u0085img src="http://host/nel2.png">',
'notifications leave no image tag when U+0085 follows the angle bracket'
)
// The card rewrites newlines to <br/> for the StyledText, which puts tag syntax
// inside a tag the stripper kept: `<x`, newline, `<img …>` is one tag named `x`
// to both the stripper and Qt, and the rewrite splits it into `<x<br/>` and a
// live image tag. Measured against Qt 6.11.2 — the rewritten form issues the GET
// and the original does not — so the strip has to run after the rewrite, which
// is what styledBody() does.
assertNoImageSurvives(
'<x\n<img src="http://host/split.png">',
'notifications leave no image tag when a newline rewrite splits a kept tag'
)
assertNoImageSurvives(
'<x\r\n<img src="http://host/split-crlf.png">',
'notifications leave no image tag when a CRLF rewrite splits a kept tag'
)
assertEqual(
notifications.styledBody('<x\n<img src="http://host/split.png">', 'Slack', ''),
'<x<br/>',
'notifications drop the image half of a tag the newline rewrite splits'
)
// The rewrite itself still happens, and body markup other than images survives it.
assertEqual(
notifications.styledBody('<b>bold</b>\nsecond line', 'Slack', ''),
'<b>bold</b><br/>second line',
'notifications keep body markup and the line break the card renders'
)
// The order above is only worth anything if the card actually renders it, and no
// JavaScript assertion can see a QML binding. Pin the binding itself: the rewrite
// belongs in the logic module, where the strip runs after it.
const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8')
assert(
/readonly property string styledBody: NotificationLogic\.styledBody\(body, app, appIcon\)/.test(cardQml),
'the notification card renders the body that was stripped after the newline rewrite'
)
assert(
!/<br\/>/.test(cardQml),
'the notification card does not rewrite newlines itself, which would leave tag syntax unchecked'
)
assertEqual(
notifications.sanitizeBody('trailing <img src="http://host/z.png"', 'Slack', ''),
'trailing ',
'notifications strip an unterminated image tag the renderer would close itself'
)
assertEqual(
notifications.sanitizeBody('<IMG SRC="http://host/u.png">shout', 'Slack', ''),
'shout',
'notifications strip image tags regardless of case'
)
assertEqual(
notifications.sanitizeBody('<b>bold</b> and <a href="http://host">link</a>', 'Slack', ''),
'<b>bold</b> and <a href="http://host">link</a>',
'notifications keep the body markup the body-markup capability advertises'
)
assertEqual(
notifications.sanitizeBody('<a href="https://example.com">example.com</a> Message body', 'Chromium', ''),
'Message body',
+119
View File
@@ -56,3 +56,122 @@ grep -qF "plugin id 'acme.same' is already used by" <<<"$output" ||
[[ ! -e $test_home/.config/omarchy/plugins/acme.same ]] ||
fail "plugin add leaves a target behind after refusing a duplicate id"
pass "plugin add refuses an installed manifest id regardless of directory name"
# --- URL transport-helper guard -------------------------------------------
#
# The guard refuses git transport helpers (`<name>::…`) and option-shaped URLs
# before `git clone` runs, matching omarchy-theme-install. A git stub records
# whether clone was reached, so the guard is exercised with no network: reaching
# the stub proves a URL passed the guard; not reaching it proves the guard
# rejected the URL first.
guard_stubs="$TMPDIR/guard-stubs"
mkdir -p "$guard_stubs"
cat >"$guard_stubs/omarchy-shell" <<'STUB'
#!/bin/bash
exit 0
STUB
chmod +x "$guard_stubs/omarchy-shell"
clone_marker="$TMPDIR/git-clone-reached"
cat >"$guard_stubs/git" <<STUB
#!/bin/bash
if [[ \$1 == "clone" ]]; then
touch "$clone_marker"
exit 1
fi
exit 0
STUB
chmod +x "$guard_stubs/git"
# A gum stub that answers `gum input` with a caller-chosen value, so a test can
# drive any URL through the interactive prompt path.
cat >"$guard_stubs/gum" <<'STUB'
#!/bin/bash
if [[ $1 == "input" ]]; then
printf '%s\n' "$GUM_INPUT_VALUE"
fi
STUB
chmod +x "$guard_stubs/gum"
add_url() {
HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$guard_stubs:$ROOT/bin:$PATH" \
omarchy-plugin-add "$1" --yes 2>&1
}
# Transport helpers reach the guard, are named as such, and never reach clone.
for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do
rm -f "$clone_marker"
output=$(add_url "$bad") &&
fail "plugin add rejects a transport-helper URL: $bad" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "plugin add names the transport-helper rejection: $bad" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for a transport-helper URL: $bad"
done
pass "plugin add rejects transport-helper URLs before cloning"
# The `://` spelling of the same thing: git resolves git-remote-<scheme> for any
# scheme it does not implement itself, so `ext::` and `ext://` reach the same
# helper and both have to be refused.
for bad in "ext://sh -c id" "gcrypt://example.com/x"; do
rm -f "$clone_marker"
output=$(add_url "$bad") &&
fail "plugin add rejects a transport-scheme URL: $bad" "$output"
grep -qF "which Omarchy does not clone from" <<<"$output" ||
fail "plugin add names the transport-scheme rejection: $bad" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for a transport-scheme URL: $bad"
done
pass "plugin add rejects transport-scheme URLs before cloning"
# Option-shaped URLs on argv are refused before clone — by the option parser
# (`-*` falls to "unknown add option"), not the guard. The guard's own
# leading-dash arm is only reachable through the interactive gum prompt and is
# exercised separately below.
for bad in "-oProxyCommand=x" "--upload-pack=x"; do
rm -f "$clone_marker"
output=$(add_url "$bad") &&
fail "plugin add rejects an option-shaped URL: $bad" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for an option-shaped URL: $bad"
done
pass "plugin add rejects option-shaped URLs before cloning"
# The guard's leading-dash arm is only reachable through `gum input`: argv
# dashes die in the option parser first. interactive() requires a TTY on stdin
# and stdout, so run this one case on a pty via util-linux `script -qec` (the
# suite's existing pty idiom); gum itself is stubbed, so no rendering happens.
# Probe script's util-linux syntax first and skip cleanly where it is missing.
if script -qec true /dev/null >/dev/null 2>&1; then
rm -f "$clone_marker"
status=0
raw=$(GUM_INPUT_VALUE="-oProxyCommand=x" HOME="$test_home" OMARCHY_PATH="$ROOT" \
PATH="$guard_stubs:$ROOT/bin:$PATH" \
script -qec "omarchy-plugin-add --yes" /dev/null) || status=$?
output=$(tr -d '\r' <<<"$raw")
(( status != 0 )) ||
fail "plugin add rejects an option-shaped URL from the gum prompt" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "plugin add names the guard rejection for the gum-prompt URL" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for an option-shaped gum-prompt URL"
pass "plugin add guard rejects an option-shaped URL from the interactive prompt"
else
pass "script -qec unavailable; skipping the interactive gum-prompt guard case"
fi
# Legitimate URL forms pass the guard and reach git clone (stubbed, no network).
for good in \
"https://github.com/acme/omarchy-weather.git" \
"git@github.com:acme/repo.git" \
"ssh://git@github.com/acme/repo.git" \
"git@[2001:db8::1]:org/repo.git"; do
rm -f "$clone_marker"
output=$(add_url "$good") || true
! grep -qF "names a git option or transport helper" <<<"$output" ||
fail "plugin add wrongly rejected a legitimate URL: $good" "$output"
[[ -e $clone_marker ]] ||
fail "plugin add did not reach git clone for a legitimate URL: $good" "$output"
done
pass "plugin add lets legitimate git URLs reach git clone"
+33 -3
View File
@@ -27,16 +27,41 @@ cat >"$TMPDIR/bin/usermod" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/usermod.calls"
STUB
chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod"
cat >"$TMPDIR/bin/groupadd" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/groupadd.calls"
STUB
cat >"$TMPDIR/bin/install" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/install.calls"
STUB
cat >"$TMPDIR/bin/find" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/find.calls"
STUB
cat >"$TMPDIR/bin/sudo" <<STUB
#!/bin/bash
echo "\$@" >>"$TMPDIR/sudo.calls"
exec "\$@"
STUB
chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo}
export PATH="$TMPDIR/bin:$PATH"
export OMARCHY_PATH="$ROOT"
# No install user (deferred-provisioning install): input recorded, usermod not called.
# No install user (deferred-provisioning install): groups recorded, usermod not called.
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh"
[[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user"
grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded"
! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" ||
fail "browser-policy group must not be recorded"
[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user"
[[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null ||
fail "browser-policy group is not created"
grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null ||
fail "browser-policy directory is created root-owned"
pass "deferred provisioning records groups without calling usermod"
# The docker group is root-equivalent and must never be granted automatically.
@@ -45,17 +70,22 @@ pass "docker group is not recorded at install"
# Missing user (defensive): no usermod either.
OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh"
[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user"
pass "missing install user defers group grants"
# Re-running never duplicates entries.
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh"
[[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh"
pass "group recording is idempotent"
# Existing user: usermod applies the recorded groups, and docker is never among them.
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh"
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh"
grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user"
! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" ||
fail "usermod must not grant browser-policy to the install user"
! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user"
pass "existing install user gets input but never docker"
pass "existing install user gets input but never docker or browser-policy"
+373
View File
@@ -0,0 +1,373 @@
"""Report every QML Text that renders a non-literal value without a textFormat.
Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per
violation, exits 1 on an unreadable tree). Lives in its own file rather than a
heredoc so the test can run it over fixtures and prove it still fails when it
should — a guard nothing can fail is a guard nobody should trust.
Two limits are deliberate, because a line scanner cannot close them. It reads
each Text element's own declaration, so text assigned from somewhere else —
`Binding { target: label; property: "text" }`, `PropertyChanges`, a
`Component.onCompleted` assignment, a `property alias` onto a child's text —
is invisible to it. And a regex literal containing a brace throws off the brace
depth. Neither shape exists in this tree; both would need a QML parser.
"""
import os
import re
import sys
from pathlib import Path
BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S)
def strip_block_comments(text):
"""Blank out /* */ comments, keeping every newline so line numbers hold.
strip_noise() only knows `//`, so before this a block comment between a
type name and its brace — `Text /* why */ {` — hid the element from
OPEN_ELEMENT and from the unscannable-form check alike, and the block
passed with no textFormat at all.
"""
out = []
i = 0
quote = None
while i < len(text):
c = text[i]
if quote:
if c == '\\':
out.append(text[i:i + 2])
i += 2
continue
if c == quote:
quote = None
out.append(c)
i += 1
continue
if c in '"\'':
quote = c
out.append(c)
i += 1
continue
if c == '/' and text.startswith('//', i):
end = text.find('\n', i)
if end == -1:
break
out.append(text[i:end])
i = end
continue
if c == '/' and text.startswith('/*', i):
end = text.find('*/', i + 2)
end = len(text) if end == -1 else end + 2
out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end]))
i = end
continue
out.append(c)
i += 1
return ''.join(out)
# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is
# the same element and was skipped, because the name compared unequal to `Text`.
TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text'
OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$')
INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$')
INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{')
PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:')
STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b')
# A binding that runs onto the next line: this line ends on an operator, or the
# next line opens with one.
TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$')
LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])')
def strip_noise(line, keep_strings=False):
out = []
i = 0
quote = None
while i < len(line):
c = line[i]
if quote:
if keep_strings:
out.append(c)
if c == '\\':
if keep_strings and i + 1 < len(line):
out.append(line[i + 1])
i += 2
continue
if c == quote:
quote = None
if not keep_strings:
out.append('S')
i += 1
continue
if c in '"\'':
quote = c
if keep_strings:
out.append(c)
i += 1
continue
if c == '/' and i + 1 < len(line) and line[i + 1] == '/':
break
out.append(c)
i += 1
return ''.join(out)
def is_pure_literal(expr):
residue = STRING_LITERAL.sub('', expr)
residue = re.sub(r'[\s+]', '', residue)
return residue == '' and STRING_LITERAL.search(expr) is not None
def binding_expression(lines, start):
"""The whole right-hand side of the binding beginning on line `start`.
The literal exemption has to be judged on the complete expression. Reading
only the physical `text:` line would exempt `text: "prefix"` while
`+ externalValue` sits underneath, letting a dynamic AutoText binding
through. Reading a wrapped concatenation of literals as dynamic would be
the opposite error, so follow the expression to its end either way.
"""
parts = []
parens = brackets = 0
i = start
while i < len(lines):
parts.append(strip_noise(lines[i], keep_strings=True))
counted = strip_noise(lines[i])
parens += counted.count('(') - counted.count(')')
brackets += counted.count('[') - counted.count(']')
# Look past blank and comment-only lines for the continuation. A
# comment or a blank line dropped into a wrapped expression does not
# end it, and stopping there would read `text: "prefix"` as the whole
# binding and exempt it as a literal while `+ externalValue` waits
# below — the exact misreading this function exists to prevent.
following = ''
for ahead in range(i + 1, len(lines)):
candidate = strip_noise(lines[ahead])
if candidate.strip():
following = candidate
break
continues = (parens > 0 or brackets > 0
or TRAILING_OPERATOR.search(counted.rstrip())
or LEADING_OPERATOR.match(following))
if not continues:
break
i += 1
chunk = ' '.join(parts)
return chunk.split(':', 1)[1] if ':' in chunk else chunk
def exempt_as_literal(lines, tline):
"""True when the binding is only string literals, however many lines."""
return is_pure_literal(binding_expression(lines, tline))
def blocks(lines):
stack = []
done = []
depth = 0
for idx, raw in enumerate(lines):
code = strip_noise(raw)
opened = OPEN_ELEMENT.search(code)
prop = PROP.match(code)
if (prop and stack and stack[-1]['depth'] == depth
and not opened and not PROPERTY_DECL.match(code)):
stack[-1]['props'].setdefault(prop.group(1), idx)
n_open = code.count('{')
n_close = code.count('}')
depth += n_open - n_close
if opened and n_open > 0:
# OPEN_ELEMENT anchors at the end of the line, so the element it
# matched is the innermost one opened here and its depth is the
# depth after every brace on the line.
stack.append({'name': opened.group(1), 'depth': depth,
'props': {}, 'start': idx})
while stack and depth < stack[-1]['depth']:
done.append(stack.pop())
done.extend(stack)
return done
INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}')
INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)')
# As a property of this block, not as a substring: `visible: root.textFormatEnabled`
# used to read as a declaration and exempt the element.
INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:')
def inline_violations(lines, rel):
"""Whole Text blocks written on one line.
OPEN_ELEMENT anchors at the end of the line, so the brace scanner never
sees these. A Repeater delegate is a plausible place for one.
"""
out = []
for idx, raw in enumerate(lines):
code = strip_noise(raw, keep_strings=True)
for match in INLINE_TEXT.finditer(code):
body = match.group(1)
if INLINE_TEXT_FORMAT.search(body):
continue
# A component root written on one line needs the default whether or
# not this line binds `text`, for the same reason the block form
# does: every caller supplies the binding.
if INLINE_COMPONENT_ONELINE.match(code):
out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat')
continue
binding = INLINE_BINDING.search(body)
if not binding or is_pure_literal(binding.group(1)):
continue
out.append(f'{rel}:{idx + 1}: inline Text block without textFormat')
return out
# `Text { text: someValue` with the block carrying on below is valid QML and is
# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the
# line so the brace tracker never opens the block, and INLINE_TEXT needs the
# closing brace on the same line. A dynamic AutoText binding written that way
# passes this file in silence, which is the one failure a test like this must
# not have.
#
# Rather than teach a line scanner to parse QML, require the two forms it can
# read: the whole block on one line, or nothing after the opening brace. Every
# Text in this tree is already written that way, so keeping to it costs nothing.
UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S')
BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$')
UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the '
'opening brace last on the line, or write the whole block on '
'one line with no nested braces')
COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$')
def opens_component(lines, start):
"""True when the Text block at `start` is a component root declared above it."""
for back in range(start - 1, -1, -1):
code = strip_noise(lines[back]).strip()
if not code:
continue
return bool(COMPONENT_OPENER.match(lines[back]))
return False
def unscannable_violations(lines, rel):
out = []
for idx, raw in enumerate(lines):
code = strip_noise(raw)
# `Text` with its brace on the next line. OPEN_ELEMENT needs both on
# one line, so the block is never opened and everything in it is
# attributed to the enclosing element instead.
if BARE_TEXT_OPENER.search(code):
following = ''
for ahead in range(idx + 1, len(lines)):
candidate = strip_noise(lines[ahead]).strip()
if candidate:
following = candidate
break
if following.startswith('{'):
out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}')
continue
for match in UNSCANNABLE_TEXT.finditer(code):
# A complete one-line block with no nested braces is fine —
# inline_violations reads those. Count rather than looking for a
# `}`, because `Text { text: ({ a: external }).a }` closes on this
# line yet INLINE_TEXT's brace-free body pattern cannot match it,
# so treating any `}` as "handled elsewhere" would drop it.
rest = code[match.end() - 1:]
depth = 1
closed = False
for char in rest:
if char == '{':
depth += 1
elif char == '}':
depth -= 1
if depth == 0:
closed = True
break
if closed and '{' not in rest:
continue
out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}')
return out
root = Path(sys.argv[1])
found = []
scanned = 0
def unreadable(error):
# rglob() swallows a directory it cannot enter, so a shell/ subtree with no
# read permission scanned as though it were empty and the run reported
# success. Same failure as an empty tree, and it fails the same way.
raise SystemExit(f'cannot read {error.filename}: {error.strerror}')
qml = []
for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable):
dirnames.sort()
qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml'))
for path in sorted(qml):
scanned += 1
lines = strip_block_comments(path.read_text()).splitlines()
rel = path.relative_to(root)
found.extend(inline_violations(lines, rel))
found.extend(unscannable_violations(lines, rel))
for b in blocks(lines):
if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']:
continue
# Read the block's own properties. A nested child declaring textFormat
# says nothing about its parent, so `Text { Text { textFormat: ... } }`
# must still report the outer element.
# The root element of a component takes its binding from callers, so it
# needs the default whether or not this file binds `text`. Require both
# depth 1 and column 0: the scanner attributes one element per line, so
# a `Row { Text {` line would report depth 1 for a nested block, and
# falling through to the binding check below is the safe reading.
# Indentation is not what makes it a root; depth 1 is. A `Row { Text {`
# line still reads as `Row` here, so leading whitespace can be ignored
# without letting a nested block be mistaken for the file's root.
if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'):
found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat')
continue
# A QML inline component is a root for the same reason, and the rule
# above cannot see one: `component InfoValue: Text {` sits inside
# another element, so its depth is not 1 and its line does not start
# with `Text`. Its `text` comes from every caller, so the file it lives
# in never binds it and the binding check below lets it through in
# silence. Only one file-level root Text exists in this tree, so
# without this the root rule is very nearly dead code.
# `component Info:` may also put its `Text {` on the following line,
# which INLINE_COMPONENT cannot match and which then reads as an
# ordinary nested block with no binding of its own — a caller's dynamic
# text passing in silence.
if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']):
found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat')
continue
if 'text' not in b['props']:
continue
tline = b['props']['text']
if exempt_as_literal(lines, tline):
continue
found.append(f'{rel}:{tline + 1}: text binding without textFormat')
# A scan that read nothing reports nothing, and an all-clear from a run that
# never opened a file is the one result this test must never give. Only a
# checkout with no shell/ QML at all reaches this.
if scanned == 0:
raise SystemExit('no .qml files found under shell/; the scan read nothing')
for line in found:
print(line)
+276
View File
@@ -0,0 +1,276 @@
#!/bin/bash
# A QML Text element with no textFormat uses Text.AutoText. Qt then runs
# mightBeRichText() over the string and promotes it to Text.RichText when it
# looks like markup, and RichText fetches <img src="http://..."> through
# QQuickPixmap. Any string that reaches such an element from outside the shell
# — a notification summary, an MPRIS track title, a window title, an SSID, a
# Bluetooth device name, clipboard content, a weather API response — can
# therefore make the shell issue an unauthenticated outbound GET with no user
# interaction.
#
# The promotion needs only that the attacker contribute the first `<` in the
# string, on the first line. A fixed label in front of the value does not
# protect it, and neither does .toUpperCase(), because the parser lowercases
# the tag before looking it up.
#
# So require an explicit textFormat on every Text whose text: binding is not a
# bare string literal. A literal carries no external data, so AutoText has
# nothing to promote; this test is what catches the edit that later turns such
# a literal into an expression.
#
# The scan itself lives in qml-text-format-scan.py. It is run twice: over the
# real tree, and over the fixtures below, which are the forms that have already
# slipped past it once. A guard nothing can fail is a guard nobody should trust,
# and every one of those fixtures passed silently before it was written down.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command python3
SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py"
violations=$(python3 "$SCAN" "$ROOT")
if [[ -n $violations ]]; then
count=$(printf '%s\n' "$violations" | wc -l)
fail "every Text with a dynamic text binding declares textFormat" \
"$violations
$count Text element(s) rely on Text.AutoText for a non-literal binding.
Add an explicit textFormat. Text.PlainText is right for anything that renders
data from outside the shell; use Text.StyledText only where markup is a
deliberate, documented feature, and strip <img> before it reaches the renderer."
fi
pass "every Text with a dynamic text binding declares textFormat"
# The scanner's own tests. Each fixture is a Text that renders external data
# with no textFormat, written in a form that once passed. `caught` asserts the
# scan reports something; `clean` asserts it does not, so the fixtures prove the
# scanner can fail rather than that it fails at everything.
fixture_root=$(mktemp -d)
trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT
function scan_fixture {
local name=$1
local dir="$fixture_root/$name"
mkdir -p "$dir/shell/Ui"
cat > "$dir/shell/Ui/Fixture.qml"
python3 "$SCAN" "$dir" 2>&1
}
function caught {
local name=$1 description=$2 output
output=$(scan_fixture "$name" || true)
if [[ -z $output ]]; then
fail "$description" "the scan reported nothing for fixture $name"
fi
pass "$description"
}
function clean {
local name=$1 description=$2 output
output=$(scan_fixture "$name" || true)
if [[ -n $output ]]; then
fail "$description" "the scan reported: $output"
fi
pass "$description"
}
caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
text: external
}
}
QML
clean literal "the scan leaves a string literal alone" <<'QML'
import QtQuick
Item {
Text {
text: "a literal"
}
}
QML
clean declared "the scan leaves a declared textFormat alone" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
textFormat: Text.PlainText
text: external
}
}
QML
# strip_noise() knew `//` and not `/* */`, so a block comment between the type
# name and its brace hid the whole element from every rule.
caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML'
import QtQuick
Item {
property string external: "x"
Text /* explanation */ {
text: external
}
}
QML
caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML'
import QtQuick
Item {
property string external: "x"
/*
* Text { text: "not this one" }
*/
Text {
text: external
}
}
QML
# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to
# `Text` and was skipped outright.
caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text {
text: external
}
}
QML
# textFormat was matched as a substring, so any property whose name merely
# started that way exempted the element.
caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text { text: external }
}
QML
caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML'
import QtQuick as QQ
QQ.Item {
property string external: "x"
QQ.Text { text: external
color: "red"
}
}
QML
caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML'
import QtQuick
Item {
property string external: "x"
property bool textFormatEnabled: true
Text { text: external; visible: textFormatEnabled }
}
QML
# A component root takes its text from every caller, so the file it lives in
# never binds it. The one-line form was covered; this one was not.
caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML'
import QtQuick
Item {
component Info:
Text {
}
}
QML
caught component-one-line "the scan reads a component root written on one line" <<'QML'
import QtQuick
Item {
component Info: Text { color: "red" }
}
QML
# Forms the scanner cannot read are reported rather than passed, which is the
# whole reason it can be a line scanner at all.
caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML'
import QtQuick
Item {
property string external: "x"
Text
{
text: external
}
}
QML
caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML'
import QtQuick
Item {
property string external: "x"
Text { text: external
color: "red"
}
}
QML
# A wrapped binding is judged whole: a literal first line says nothing about
# what is concatenated onto it below.
caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML'
import QtQuick
Item {
property string external: "x"
Text {
text: "prefix"
+ external
}
}
QML
clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML'
import QtQuick
Item {
Text {
text: "one"
+ "two"
}
}
QML
# A nested child's textFormat says nothing about its parent.
caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML'
import QtQuick
Text {
text: external.value
Text {
textFormat: Text.PlainText
text: "literal"
}
}
QML
# A scan that reads less than the tree holds must not report success. Both of
# these once did.
empty_root=$(mktemp -d)
mkdir -p "$empty_root/shell"
if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then
rm -rf "$empty_root"
fail "the scan fails when it reads no files" "an empty shell/ tree exited 0"
fi
rm -rf "$empty_root"
pass "the scan fails when it reads no files"
blind_root="$fixture_root/blind"
mkdir -p "$blind_root/shell/Ui/locked"
printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml"
printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml"
chmod 000 "$blind_root/shell/Ui/locked"
if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then
chmod 755 "$blind_root/shell/Ui/locked"
fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0"
fi
chmod 755 "$blind_root/shell/Ui/locked"
pass "the scan fails when a directory hides files from it"
+557
View File
@@ -0,0 +1,557 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
migration="$ROOT/migrations/1787494718.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
calls="$test_tmp/calls.log"
stages="$test_tmp/stages.log"
notifications="$test_tmp/notifications.log"
# A directory of its own, not $test_tmp: the migration derives the FIDO2
# directory from the authfile, and the case below where that directory is
# untraversable has to be able to take the permissions off it.
authdir="$test_tmp/etc-fido2"
authfile="$authdir/fido2"
migration_copy="$test_tmp/migration.sh"
mkdir -p "$stub_bin" "$authdir"
: >"$stages"
: >"$notifications"
# The migration repairs an absolute path no unprivileged suite can write, and an
# environment override in the shipped file would hand a root install and mv an
# operand the caller chooses. Retarget a scratch copy instead, and fail if the
# path is not named exactly once, so this seam cannot quietly stop standing for
# the file it copies.
occurrences=$(grep -Fo /etc/fido2/fido2 "$migration" | wc -l) || occurrences=0
(( occurrences == 1 )) ||
fail "the migration names its authfile exactly once, so the test can retarget a copy" \
"found $occurrences occurrences"
grep -Fxq 'authfile="/etc/fido2/fido2"' "$migration" ||
fail "the production authfile path is a fixed literal, not caller-controlled"
pass "migration names its authfile once, and the test drives a retargeted copy"
# Log every escalation, then execute only the expected bare sudo forms. Each
# operand is matched against the scratch authfile or a stage this stub created.
# This contains malformed calls made through that interface; arbitrary direct
# privileged commands in the migration are outside this harness.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
set -euo pipefail
reject() {
printf 'refusing unexpected sudo invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
exit 97
}
if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
safe_stage_path() {
local candidate=$1
local prefix="$TEST_AUTHFILE.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
recorded_stage() {
local candidate=$1
safe_stage_path "$candidate" || return 1
[[ -f $candidate && ! -L $candidate ]] || return 1
/usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES"
}
case "$1" in
mktemp)
if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then
reject "$@"
fi
case ${TEST_MKTEMP_MODE:-normal} in
normal)
stage=$(/usr/bin/mktemp -- "$2")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
reject "$@"
fi
printf '%s\n' "$stage" >>"$TEST_STAGES"
printf '%s\n' "$stage"
;;
malformed)
stage="$TEST_AUTHFILE.new.A/BCDE"
/usr/bin/mkdir -- "${stage%/*}"
: >"$stage"
printf '%s\n' "$stage"
;;
nonregular)
stage="$TEST_AUTHFILE.new.BAD123"
/usr/bin/mkdir -- "$stage"
printf '%s\n' "$stage"
;;
*)
reject "$@"
;;
esac
;;
install)
if (( $# != 10 )) || [[ $2 != "-T" || $3 != "-m" || $4 != "644" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHFILE" ]] || ! recorded_stage "${10}"; then
reject "$@"
fi
if [[ ${TEST_FAIL_INSTALL:-0} == "1" ]]; then
exit 71
fi
if (( EUID == 0 )); then
exec /usr/bin/install -T -m 644 -o root -g root "$9" "${10}"
else
exec /usr/bin/install -T -m 644 "$9" "${10}"
fi
;;
mv)
if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ ${TEST_FAIL_MV:-0} == "1" ]]; then
exit 72
fi
exec /usr/bin/mv -Tf -- "$3" "$4"
;;
chmod)
# Only ever the FIDO2 directory, and only back to the mode the setup
# installs. Nothing here may reopen the authfile itself.
if (( $# != 3 )) || [[ $2 != "755" || $3 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
exec /usr/bin/chmod 755 "$TEST_AUTHDIR"
;;
test)
# Looking behind an untraversable directory, never a write. This stub is not
# really root, so open the directory just long enough to answer the way root
# would and put its mode straight back -- the suite then still sees whether
# production left the mode alone.
if (( $# != 3 )) || [[ $2 != "-e" && $2 != "-L" ]] || [[ $3 != "$TEST_AUTHFILE" ]]; then
reject "$@"
fi
saved_mode=$(/usr/bin/stat -c %a "$TEST_AUTHDIR")
/usr/bin/chmod 755 "$TEST_AUTHDIR"
probe_status=0
/usr/bin/test "$2" "$3" || probe_status=$?
/usr/bin/chmod "$saved_mode" "$TEST_AUTHDIR"
exit "$probe_status"
;;
rm)
if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]]; then
reject "$@"
fi
if [[ ${TEST_MKTEMP_MODE:-normal} == "nonregular" && $4 == "$TEST_AUTHFILE.new.BAD123" && -d $4 && ! -L $4 ]]; then
exit 73
fi
recorded_stage "$4" || reject "$@"
exec /usr/bin/rm -f -- "$4"
;;
*)
reject "$@"
;;
esac
SH
chmod +x "$stub_bin/sudo"
cat >"$stub_bin/stat" <<'SH'
#!/bin/bash
set -euo pipefail
if [[ ${TEST_FAKE_STAT:-0} == "1" && ${TEST_AUTHFILE:-} == "${TEST_AUTHDIR:-}/fido2" ]] &&
(( $# == 3 )) && [[ $1 == "-c" && $3 == "$TEST_AUTHFILE" ]]; then
case "$2" in
%U) printf '%s\n' "$TEST_STAT_OWNER" ;;
%G) printf '%s\n' "$TEST_STAT_GROUP" ;;
%a) printf '%s\n' "$TEST_STAT_MODE" ;;
*) exec /usr/bin/stat "$@" ;;
esac
else
exec /usr/bin/stat "$@"
fi
SH
chmod +x "$stub_bin/stat"
# omarchy-migrate records this migration complete on any zero exit, so the
# states it cannot repair have to reach the user somewhere that outlives the
# update terminal's scrollback.
cat >"$stub_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf 'notify' >>"$TEST_NOTIFICATIONS"
printf '\t%s' "$@" >>"$TEST_NOTIFICATIONS"
printf '\n' >>"$TEST_NOTIFICATIONS"
exit "${TEST_NOTIFY_STATUS:-0}"
SH
chmod +x "$stub_bin/omarchy-notification-send"
run_migration() {
local fail_install="${1:-0}"
local fail_mv="${2:-0}"
local stat_owner="${3:-}"
local stat_group="${4:-}"
local stat_mode="${5:-}"
local mktemp_mode="${6:-normal}"
local notify_status="${7:-0}"
local fake_stat=0
if [[ -n $stat_owner || -n $stat_group || -n $stat_mode ]]; then
[[ -n $stat_owner && -n $stat_group && -n $stat_mode ]] ||
fail "a fake stat fixture supplies owner, group and mode together"
fake_stat=1
fi
: >"$calls"
: >"$notifications"
sed "s|/etc/fido2/fido2|$authfile|" "$migration" >"$migration_copy"
PATH="$stub_bin:$PATH" TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" \
TEST_FAIL_INSTALL="$fail_install" TEST_FAIL_MV="$fail_mv" TEST_FAKE_STAT="$fake_stat" \
TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_NOTIFICATIONS="$notifications" \
TEST_NOTIFY_STATUS="$notify_status" TEST_STAGES="$stages" TEST_STAT_GROUP="$stat_group" \
TEST_STAT_MODE="$stat_mode" TEST_STAT_OWNER="$stat_owner" TEST_TMP="$test_tmp" \
bash -euo pipefail "$migration_copy" >/dev/null
}
safe_fixture_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
# Every repair case is about an authfile its own user can still rewrite. The
# calls below give stat an explicit caller-owned state, so the same assertions
# work as an ordinary user, as real root, and in a namespace mapping only UID 0.
write_authfile() {
printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authfile"
chmod "$1" "$authfile"
}
# Almost every machine has never registered a key, and establishing that must
# not cost those users a password prompt.
rm -f "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a machine with no authfile escalates nothing" "$(cat "$calls")"
pass "migration skips a machine that never set FIDO2 up"
# What the old `sudo mv` left behind on every machine that did: the authfile PAM
# consults for sudo, owned by the account it authenticates, at the caller's umask.
write_authfile 644 || fail "the test can stage a non-root-owned authfile"
before_inode=$(stat -c %i "$authfile")
run_migration 0 0 caller caller 644
grep -Fq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" ||
fail "the repair asks root for a unique sibling stage" "$(cat "$calls")"
grep -Fq $'sudo\tinstall\t-T\t-m\t644\t-o\troot\t-g\troot\t'"$authfile"$'\t' "$calls" ||
fail "a user-owned authfile is reinstalled root:root and mode 644" "$(cat "$calls")"
grep -Fq $'sudo\tmv\t-Tf\t' "$calls" ||
fail "the staged authfile is atomically renamed over the live path" "$(cat "$calls")"
if grep -Fq $'sudo\tchown\t' "$calls"; then
fail "the repair replaces the authfile rather than chowning it" "$(cat "$calls")"
fi
if grep -Fq $'sudo\trm\t' "$calls"; then
fail "a successful repair disarms its EXIT cleanup" "$(cat "$calls")"
fi
pass "migration stages and atomically installs a root-owned authfile"
[[ $(stat -c %a "$authfile") == "644" ]] ||
fail "the repaired authfile is mode 644" "got: $(stat -c %a "$authfile")"
[[ $(cat "$authfile") == "tester:credential-handle,public-key,es256,+presence" ]] ||
fail "the repaired authfile keeps its credential" "got: $(cat "$authfile")"
if (( EUID == 0 )) && [[ $(stat -c %U:%G "$authfile") != "root:root" ]]; then
fail "the repaired authfile is root:root" "got: $(stat -c %U:%G "$authfile")"
fi
pass "migration preserves the credential with its PAM-readable mode"
# The whole point of replacing rather than chowning. Permission is checked at
# open(2), so a descriptor the registering user opened before the update stays
# writable on the old inode through any chmod or chown -- and pam_u2f resolving
# the authfile path would keep reading exactly that inode.
[[ $(stat -c %i "$authfile") != "$before_inode" ]] ||
fail "the repair lands on a new inode, orphaning any descriptor already open on the old one"
pass "migration replaces the inode a pre-existing writer would still hold"
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 1 )) ||
fail "the first repair creates exactly one stage" "got: ${staged_paths[*]}"
first_stage=${staged_paths[0]}
safe_fixture_stage_path "$first_stage" ||
fail "the stage is a unique sibling of the authfile" "got: $first_stage"
[[ ! -e $first_stage && ! -L $first_stage ]] ||
fail "the staged copy does not outlive the repair" "left behind: $first_stage"
pass "migration uses a unique sibling and leaves no staged copy behind"
# Treat mktemp's output as untrusted even though sudo normally resolves the
# system binary. This existing regular path has a six-character suffix only if
# `/` is accepted as one of the characters, as the old ?????? glob did. The
# strict shape check must reject it before any privileged write or cleanup.
write_authfile 644 || fail "the test can stage the malformed-output fixture"
before_inode=$(stat -c %i "$authfile")
malformed_parent="$authfile.new.A"
malformed_stage="$malformed_parent/BCDE"
if run_migration 0 0 caller caller 644 malformed; then
fail "malformed mktemp output fails the migration"
fi
grep -Fq $'sudo\tmktemp\t' "$calls" ||
fail "the malformed-output fixture reaches mktemp" "$(cat "$calls")"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls" || grep -Fq $'sudo\trm\t' "$calls"; then
fail "malformed mktemp output reaches no install, rename or cleanup" "$(cat "$calls")"
fi
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "malformed mktemp output leaves the live authfile inode alone"
[[ -f $malformed_stage && ! -L $malformed_stage ]] ||
fail "the malformed-output fixture remains a regular scratch file" "got: $malformed_stage"
/usr/bin/rm -- "$malformed_stage"
/usr/bin/rmdir -- "$malformed_parent"
pass "migration rejects malformed mktemp output before any privileged write"
# A name can have the right prefix and six-character suffix but still name an
# object mktemp would never return. Production must reject that object before
# install/mv; its cleanup may address only that validated scratch sibling and
# must not recursively remove the unexpected directory.
write_authfile 644 || fail "the test can stage the nonregular-output fixture"
before_inode=$(stat -c %i "$authfile")
nonregular_stage="$authfile.new.BAD123"
if run_migration 0 0 caller caller 644 nonregular; then
fail "nonregular mktemp output fails the migration"
fi
safe_fixture_stage_path "$nonregular_stage" ||
fail "the nonregular fixture uses a syntactically valid stage name" "got: $nonregular_stage"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then
fail "nonregular mktemp output is rejected before install or rename" "$(cat "$calls")"
fi
grep -Fq $'sudo\trm\t-f\t--\t'"$nonregular_stage" "$calls" ||
fail "cleanup addresses only the validated nonregular sibling" "$(cat "$calls")"
[[ -d $nonregular_stage && ! -L $nonregular_stage ]] ||
fail "cleanup does not recursively remove a nonregular stage" "got: $nonregular_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "nonregular mktemp output leaves the live authfile inode alone"
/usr/bin/rmdir -- "$nonregular_stage"
pass "migration rejects and safely handles nonregular mktemp output"
# A caller-owned file still needs a fresh inode and root ownership whatever its
# current mode.
write_authfile 600 || fail "the test can restage a non-root-owned authfile"
run_migration 0 0 caller caller 600
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a mode-600 authfile the user still owns is repaired" "$(cat "$calls")"
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 2 )) ||
fail "two repairs create two stages" "got: ${staged_paths[*]}"
second_stage=${staged_paths[1]}
[[ ! -e $second_stage && ! -L $second_stage ]] ||
fail "the second staged copy does not outlive the repair" "left behind: $second_stage"
pass "migration repairs a user-owned authfile whatever its mode and cleans its stage"
# A failure after mktemp must remove only the exact stage the stub created. The
# live authfile stays on its original inode because mv was never reached.
write_authfile 644 || fail "the test can stage the cleanup fixture"
before_inode=$(stat -c %i "$authfile")
if run_migration 1 0 caller caller 644; then
fail "an install failure propagates out of the migration"
fi
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 3 )) ||
fail "the failed repair creates one stage" "got: ${staged_paths[*]}"
failed_stage=${staged_paths[2]}
grep -Fq $'sudo\trm\t-f\t--\t'"$failed_stage" "$calls" ||
fail "the EXIT trap removes the failed repair's exact stage" "$(cat "$calls")"
[[ ! -e $failed_stage && ! -L $failed_stage ]] ||
fail "the failed stage is cleaned up" "left behind: $failed_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "a failed repair leaves the live authfile inode alone"
pass "migration cleans its unique stage after a failed repair"
# A failure after install has the same cleanup obligation. In particular, the
# EXIT trap must still be armed when mv fails.
write_authfile 644 || fail "the test can stage the mv-failure fixture"
before_inode=$(stat -c %i "$authfile")
if run_migration 0 1 caller caller 644; then
fail "an mv failure propagates out of the migration"
fi
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 4 )) ||
fail "the mv-failed repair creates one stage" "got: ${staged_paths[*]}"
failed_mv_stage=${staged_paths[3]}
grep -Fq $'sudo\tmv\t-Tf\t'"$failed_mv_stage"$'\t'"$authfile" "$calls" ||
fail "the injected mv failure occurs after install" "$(cat "$calls")"
grep -Fq $'sudo\trm\t-f\t--\t'"$failed_mv_stage" "$calls" ||
fail "the EXIT trap removes the mv-failed repair's exact stage" "$(cat "$calls")"
[[ ! -e $failed_mv_stage && ! -L $failed_mv_stage ]] ||
fail "the mv-failed stage is cleaned up" "left behind: $failed_mv_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "an mv failure leaves the live authfile inode alone"
pass "migration cleans its unique stage after a failed rename"
# The state a completed repair leaves, which is also where every machine that
# registers after this fix starts. A second account, and a second run for the
# same account, must find it done and escalate nothing. Fake only stat's view of
# the scratch authfile so this stays deterministic without borrowing a host
# file or requiring the suite itself to run as root.
write_authfile 644 || fail "the test can stage the settled-state fixture"
run_migration 0 0 root root 644
[[ ! -s $calls ]] ||
fail "an already root:root mode-644 authfile escalates nothing" "$(cat "$calls")"
pass "migration deterministically no-ops on its settled state"
# Owner, group and mode are independent parts of that state check. Hold two at
# their settled values while making each third value wrong, and require repair.
write_authfile 644 || fail "the test can stage the wrong-owner fixture"
run_migration 0 0 nobody root 644
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a non-root-owned authfile is repaired even when group and mode are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong owner"
write_authfile 644 || fail "the test can stage the wrong-group fixture"
run_migration 0 0 root nobody 644
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a non-root-group authfile is repaired even when owner and mode are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong group"
write_authfile 644 || fail "the test can stage the wrong-mode fixture"
run_migration 0 0 root root 600
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a mode-600 authfile is repaired even when owner and group are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong mode"
# Neither of these is ours to rewrite, and both must say so without escalating:
# chown follows a symlink and would take the target instead, while changing a
# directory's mode would alter an object the migration does not own.
rm -rf "$authfile"
ln -s "$test_tmp/elsewhere" "$authfile"
: >"$test_tmp/elsewhere"
run_migration
[[ ! -s $calls ]] || fail "a symlinked authfile escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] ||
fail "a symlinked authfile is raised where the update terminal cannot swallow it"
rm -f "$authfile"
ln -s "$test_tmp/missing" "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a dangling symlink escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] || fail "a dangling symlink is raised the same way"
pass "migration reports a symlinked authfile and repairs nothing"
rm -f "$authfile"
mkdir -p "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a directory at the authfile path escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] || fail "a non-regular authfile is raised the same way"
pass "migration reports a non-regular authfile and repairs nothing"
# omarchy-migrate writes this migration's completion marker on any zero exit, so
# a machine it cannot repair gets one shot at telling the user. The states above
# are exactly the ones where the authfile may already be under someone else's
# control, and a line in the update terminal scrolls past.
# Assert the argument shape rather than a substring. The glyph is a private-use
# codepoint that an edit can silently drop, and losing it shifts every argument
# left: -g swallows the headline, the body becomes the title, and the message
# goes out with no description. A substring match sees all of that as fine.
awk -F'\t' '
$1 == "notify" && NF == 7 && $2 == "-u" && $3 == "critical" && $4 == "-g" &&
$5 != "" && $6 == "FIDO2 authfile needs attention" && $7 != "" { found = 1 }
END { exit !found }
' "$notifications" ||
fail "the notification passes a glyph, headline and body as separate arguments" \
"$(cat -A "$notifications")"
pass "migration raises its unrepairable states as a desktop notification"
# The old setup created the FIDO2 directory with `sudo mkdir -p`, which took the
# caller's umask: registering under `umask 077` left it mode 0700 with the
# user-owned authfile still inside. Absence and "cannot look" are the same
# answer to an unprivileged test, so keying the early exit on the authfile
# recorded a repair on exactly the machines that still needed one.
rm -rf "$authfile"
write_authfile 644 || fail "the test can stage the untraversable-directory fixture"
before_inode=$(stat -c %i "$authfile")
chmod 000 "$authdir"
run_migration 0 0 caller caller 644
[[ $(stat -c %a "$authdir") == "755" ]] ||
fail "the migration reopens the directory the old umask closed" "got: $(stat -c %a "$authdir")"
grep -Fxq $'sudo\tchmod\t755\t'"$authdir" "$calls" ||
fail "the migration asks root to reopen the FIDO2 directory" "$(cat "$calls")"
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "an authfile hidden behind an untraversable directory is still repaired" "$(cat "$calls")"
[[ $(stat -c %i "$authfile") != "$before_inode" ]] ||
fail "the repair behind an untraversable directory still replaces the inode"
pass "migration repairs an authfile an unreadable directory hid from it"
# The narrow escalation above must not reach a machine that never registered a
# key, which is almost all of them.
rm -f "$authfile"
rm -rf "$authdir"
run_migration
[[ ! -s $calls ]] ||
fail "a machine with no FIDO2 directory still escalates nothing" "$(cat "$calls")"
mkdir -p "$authdir"
run_migration
[[ ! -s $calls ]] ||
fail "an empty readable FIDO2 directory escalates nothing" "$(cat "$calls")"
pass "migration still costs no password prompt on a machine that never set FIDO2 up"
# An aborted setup can leave the directory behind with nothing in it, and an
# administrator may keep one deliberately private. Looking costs a probe, but
# neither may have its mode widened, or its group and special bits discarded,
# for a repair that is not needed.
rm -f "$authfile"
chmod 000 "$authdir"
run_migration
[[ $(stat -c %a "$authdir") == "0" ]] ||
fail "an empty inaccessible FIDO2 directory keeps its mode" "got: $(stat -c %a "$authdir")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "an empty inaccessible FIDO2 directory is never reopened" "$(cat "$calls")"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then
fail "an empty inaccessible FIDO2 directory is never repaired" "$(cat "$calls")"
fi
chmod 755 "$authdir"
pass "migration looks behind an inaccessible FIDO2 directory without widening it"
# Notification delivery fails on a machine with no user bus or no notification
# server. That must not abort the migration under `bash -euo pipefail` and take
# every later migration with it.
rm -f "$authfile"
ln -s "$test_tmp/missing" "$authfile"
run_migration 0 0 "" "" "" normal 1
[[ -s $notifications ]] ||
fail "the failing notification was still attempted" "$(cat "$notifications")"
pass "migration survives a notification it could not deliver"
rm -f "$authfile"
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
remove="$ROOT/bin/omarchy-remove-security-fido2"
test_tmp=$(mktemp -d)
stub_bin="$test_tmp/bin"
calls="$test_tmp/calls.log"
authdir="$test_tmp/etc-fido2"
elsewhere="$test_tmp/elsewhere"
remove_copy="$test_tmp/remove.sh"
mkdir -p "$stub_bin"
cleanup() {
rm -rf "$test_tmp"
return 0
}
trap cleanup EXIT
# The same seam the setup and migration suites use: the removal deletes an
# absolute path no unprivileged suite can own, and an environment override in
# the shipped command would hand a privileged rm -rf an operand the caller
# chooses. Retarget a copy instead, and fail if the path is not named exactly
# once so this seam cannot quietly stop standing for the command it copies.
occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$remove") || occurrences=0
(( occurrences == 1 )) ||
fail "the removal names its FIDO2 directory exactly once" "found $occurrences occurrences"
pass "removal names its FIDO2 directory once, and the test drives a retargeted copy"
sed "s|^authdir=/etc/fido2$|authdir=$authdir|" "$remove" >"$remove_copy"
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
set -euo pipefail
reject() {
printf 'refusing unexpected sudo invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
exit 97
}
if [[ ${TEST_AUTHDIR:-} != /* || ${TEST_LOG:-} != /* ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
case "${1:-}" in
rm)
if (( $# != 3 )) || [[ $2 != "-rf" || $3 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
exec /usr/bin/rm -rf "$TEST_AUTHDIR"
;;
sed)
if (( $# != 4 )) || [[ $2 != "-i" ]]; then
reject "$@"
fi
;;
*)
reject "$@"
;;
esac
SH
cat >"$stub_bin/omarchy-pkg-drop" <<'SH'
#!/bin/bash
SH
chmod +x "$stub_bin/sudo" "$stub_bin/omarchy-pkg-drop"
invoke_remove() {
: >"$calls"
TEST_AUTHDIR="$authdir" TEST_LOG="$calls" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash "$remove_copy" </dev/null >/dev/null
}
# The ordinary case: a real directory holding a registration.
rm -rf "$authdir"
mkdir -p "$authdir"
printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authdir/fido2"
invoke_remove
grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" ||
fail "removal deletes the FIDO2 directory" "$(cat "$calls")"
[[ ! -e $authdir ]] || fail "the FIDO2 directory is gone"
pass "removal deletes a real FIDO2 directory"
# -d is false for a dangling link, so the guard it replaced left one sitting
# there for the next setup to install an authfile through.
rm -rf "$authdir"
ln -s "$test_tmp/missing" "$authdir"
invoke_remove
grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" ||
fail "removal deletes a dangling symlink at the FIDO2 directory" "$(cat "$calls")"
[[ ! -e $authdir && ! -L $authdir ]] ||
fail "the dangling symlink is gone"
pass "removal deletes a dangling symlink where -d would have skipped it"
# rm -rf on a symlink unlinks the link. Whatever it pointed at is not ours.
rm -rf "$authdir"
rm -rf "$elsewhere"
mkdir -p "$elsewhere"
printf 'keep me\n' >"$elsewhere/canary"
ln -s "$elsewhere" "$authdir"
invoke_remove
[[ ! -e $authdir && ! -L $authdir ]] ||
fail "the symlink at the FIDO2 directory is gone"
[[ -d $elsewhere && -f $elsewhere/canary ]] ||
fail "removal takes the symlink, never the directory it points at"
pass "removal takes a symlink itself and leaves its target intact"
# Nothing there at all: no escalation, so removing FIDO2 twice costs no prompt.
rm -rf "$authdir"
invoke_remove
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "removal escalates no rm when there is no FIDO2 directory" "$(cat "$calls")"
pass "removal escalates nothing when there is no FIDO2 directory"
+480
View File
@@ -0,0 +1,480 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
setup="$ROOT/bin/omarchy-setup-security-fido2"
test_tmp=$(mktemp -d)
stub_bin="$test_tmp/bin"
stages="$test_tmp/stages.log"
calls="$test_tmp/calls.log"
pamu_targets="$test_tmp/pamu-targets.log"
bare_mktemp="$test_tmp/bare-mktemp.log"
credential="tester:credential-handle,public-key,es256,+presence"
authdir="$test_tmp/etc-fido2"
authfile="$authdir/fido2"
setup_copy="$test_tmp/setup.sh"
mkdir -p "$stub_bin"
cleanup() {
rm -rf "$test_tmp"
return 0
}
trap cleanup EXIT
# The setup installs to an absolute path no unprivileged suite can write, and an
# environment override in the shipped command would hand its privileged install
# and mv an operand the caller chooses. Retarget a scratch copy instead, and
# fail if either path is not named exactly once, so this seam cannot quietly
# stop standing for the command it copies. Keying the suite on the host's own
# /etc/fido2 instead is what let the staging checks below pass without asserting
# anything on the machines that actually use FIDO2.
occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$setup") || occurrences=0
(( occurrences == 1 )) ||
fail "the setup names its FIDO2 directory exactly once" "found $occurrences occurrences"
occurrences=$(grep -Fxc 'authfile=/etc/fido2/fido2' "$setup") || occurrences=0
(( occurrences == 1 )) ||
fail "the setup names its authfile exactly once" "found $occurrences occurrences"
pass "setup names its FIDO2 paths once each, and the test drives a retargeted copy"
sed -e "s|^authdir=/etc/fido2$|authdir=$authdir|" \
-e "s|^authfile=/etc/fido2/fido2$|authfile=$authfile|" "$setup" >"$setup_copy"
# The setup must not create a caller-owned named file for pamu2fcfg. A bare
# mktemp is therefore a test failure; only the sudo stub below may invoke the
# real command, and it does so with an absolute scratch template.
cat >"$stub_bin/mktemp" <<'SH'
#!/bin/bash
printf 'mktemp' >>"$TEST_BARE_MKTEMP"
printf '\t%s' "$@" >>"$TEST_BARE_MKTEMP"
printf '\n' >>"$TEST_BARE_MKTEMP"
exit 98
SH
# Execute only the setup's expected bare-sudo protocol. The production mktemp
# template is logged exactly, but its root-created sibling is represented by a
# unique regular file inside the scratch directory. The whitelisted operations
# map every write into that directory; arbitrary direct commands are outside
# this harness.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
set -euo pipefail
reject() {
printf 'refusing unexpected sudo invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
exit 97
}
if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ! ${TEST_FAIL_CHMOD:-} =~ ^[01]$ || ! ${TEST_FAIL_MV:-} =~ ^[01]$ ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
safe_stage_path() {
local candidate=$1
local prefix="$TEST_AUTHFILE.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
recorded_stage() {
local candidate=$1
safe_stage_path "$candidate" || return 1
[[ -f $candidate && ! -L $candidate ]] || return 1
/usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES"
}
case "${1:-}" in
install)
if (( $# != 9 )) || [[ $2 != "-d" || $3 != "-m" || $4 != "755" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
if (( EUID == 0 )); then
exec /usr/bin/install -d -m 755 -o root -g root "$TEST_AUTHDIR"
else
exec /usr/bin/install -d -m 755 "$TEST_AUTHDIR"
fi
;;
mktemp)
if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then
reject "$@"
fi
case ${TEST_MKTEMP_MODE:-normal} in
normal)
stage=$(/usr/bin/mktemp -- "$2")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
reject "$@"
fi
printf '%s\n' "$stage" >>"$TEST_STAGES"
printf '%s\n' "$stage"
;;
malformed)
stage="$TEST_AUTHFILE.new.A/BCDE"
/usr/bin/mkdir -- "${stage%/*}"
: >"$stage"
printf '%s\n' "$stage"
;;
nonregular)
stage="$TEST_AUTHFILE.new.BAD123"
/usr/bin/mkdir -- "$stage"
printf '%s\n' "$stage"
;;
*)
reject "$@"
;;
esac
;;
tee)
if (( $# == 2 )) && recorded_stage "$2"; then
exec /usr/bin/tee "$2"
elif (( $# == 2 )) && [[ $2 == "/etc/pam.d/polkit-1" ]]; then
/usr/bin/cat >/dev/null
else
reject "$@"
fi
;;
test)
if (( $# != 3 )) || [[ $2 != "-s" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
/usr/bin/test -s "$3"
;;
chmod)
if (( $# != 3 )) || [[ $2 != "644" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ $TEST_FAIL_CHMOD == "1" ]]; then
exit 73
fi
exec /usr/bin/chmod 644 "$3"
;;
mv)
if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ $TEST_FAIL_MV == "1" ]]; then
exit 74
fi
exec /usr/bin/mv -Tf -- "$3" "$TEST_AUTHFILE"
;;
rm)
if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]] || ! recorded_stage "$4"; then
reject "$@"
fi
exec /usr/bin/rm -f -- "$4"
;;
sed)
if (( $# != 4 )) || [[ $2 != "-i" ]]; then
reject "$@"
fi
if [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/sudo" ]]; then
exit 0
elif [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/polkit-1" ]]; then
exit 0
else
reject "$@"
fi
;;
echo)
if (( $# != 2 )) || [[ $2 != "FIDO2 authentication test successful" ]]; then
reject "$@"
fi
;;
*)
reject "$@"
;;
esac
SH
cat >"$stub_bin/fido2-token" <<'SH'
#!/bin/bash
echo '/dev/hidraw0: vendor=0x1050, product=0x0407 (Yubico YubiKey)'
SH
cat >"$stub_bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
SH
# Record what pamu2fcfg's stdout actually targets. The fixed implementation
# gives it a pipe to privileged tee; refusing a regular-file descriptor keeps a
# regression from writing credential bytes into a caller-owned named file.
cat >"$stub_bin/pamu2fcfg" <<'SH'
#!/bin/bash
set -euo pipefail
target=$(readlink /proc/self/fd/1)
printf '%s\n' "$target" >>"$TEST_PAMU_TARGETS"
[[ $target == pipe:* ]] || exit 96
case "$TEST_PAMU_MODE" in
success)
printf '%s\n' "$TEST_CREDENTIAL"
;;
fail)
printf '%s\n' "$TEST_CREDENTIAL"
exit 23
;;
empty)
exit 0
;;
*)
exit 95
;;
esac
SH
chmod +x "$stub_bin/mktemp" "$stub_bin/sudo" "$stub_bin/fido2-token" \
"$stub_bin/omarchy-pkg-add" "$stub_bin/pamu2fcfg"
reset_run() {
: >"$calls"
: >"$stages"
: >"$pamu_targets"
: >"$bare_mktemp"
rm -rf "$authdir"
}
invoke_setup() {
local pamu_mode="${1:-success}"
local fail_chmod="${2:-0}"
local fail_mv="${3:-0}"
local mktemp_mode="${4:-normal}"
TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" TEST_BARE_MKTEMP="$bare_mktemp" \
TEST_CREDENTIAL="$credential" TEST_FAIL_CHMOD="$fail_chmod" TEST_FAIL_MV="$fail_mv" \
TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_PAMU_MODE="$pamu_mode" \
TEST_PAMU_TARGETS="$pamu_targets" TEST_STAGES="$stages" TEST_TMP="$test_tmp" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash "$setup_copy" </dev/null >/dev/null
}
run_setup() {
invoke_setup "${1:-success}" ||
fail "FIDO2 setup registers a device that answers fido2-token" "sudo calls:
$(cat "$calls")"
}
safe_fixture_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
single_stage() {
local count
count=$(wc -l <"$stages")
(( count == 1 )) || fail "setup creates exactly one privileged stage" "got $count stages"
head -n 1 "$stages"
}
assert_pipe_target() {
local count target
count=$(wc -l <"$pamu_targets")
(( count == 1 )) || fail "setup invokes pamu2fcfg exactly once" "got $count invocations"
target=$(head -n 1 "$pamu_targets")
[[ $target == pipe:* ]] ||
fail "pamu2fcfg writes only to a pipe, never a caller-owned named file" "got: $target"
}
assert_failed_stage_cleanup() {
local stage_path
stage_path=$(single_stage)
safe_fixture_stage_path "$stage_path" ||
fail "the failed setup stage is a unique scratch sibling" "got: $stage_path"
grep -Fxq $'sudo\trm\t-f\t--\t'"$stage_path" "$calls" ||
fail "failed setup removes its exact privileged stage" "$(cat "$calls")"
[[ ! -e $stage_path && ! -L $stage_path ]] ||
fail "the failed setup stage is gone" "left behind: $stage_path"
[[ ! -e $authfile ]] || fail "failed setup never publishes a credential"
}
# Each branch below is a fixture rather than whatever the host happens to have
# at /etc/fido2, so all of them run on every machine and the staging assertions
# that follow are reached even on one that already uses FIDO2.
reset_run
mkdir -p "$authdir"
printf '%s\n' "$credential" >"$authfile"
run_setup
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing when a registration already exists"
! grep -Fq $'sudo\tmktemp\t' "$calls" ||
fail "FIDO2 setup creates no stage over an existing registration" "$(cat "$calls")"
pass "FIDO2 setup leaves an existing registration alone"
reset_run
mkdir -p "$authdir"
ln -s /dev/null "$authfile"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a symlinked authfile"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing against a symlinked authfile"
[[ -L $authfile ]] || fail "FIDO2 setup leaves the symlinked authfile in place"
pass "FIDO2 setup refuses a symlinked authfile"
reset_run
mkdir -p "$authfile"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a directory where the authfile belongs"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing against a directory authfile"
pass "FIDO2 setup refuses a non-regular authfile"
# install -d follows a symlink at the directory and applies its mode and
# ownership to whatever it points at, so the credential would be staged and
# published inside the target and that directory reopened to root:root 755.
reset_run
mkdir -p "$test_tmp/elsewhere"
chmod 700 "$test_tmp/elsewhere"
ln -s "$test_tmp/elsewhere" "$authdir"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a symlinked FIDO2 directory"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing through a symlinked FIDO2 directory"
! grep -Fq $'sudo\tinstall\t' "$calls" ||
fail "FIDO2 setup never runs install -d through a symlink" "$(cat "$calls")"
[[ $(stat -c %a "$test_tmp/elsewhere") == "700" ]] ||
fail "FIDO2 setup leaves the symlink target's mode alone" "got: $(stat -c %a "$test_tmp/elsewhere")"
[[ ! -e $test_tmp/elsewhere/fido2 ]] ||
fail "FIDO2 setup publishes nothing inside the symlink target"
pass "FIDO2 setup refuses a symlinked FIDO2 directory and leaves its target alone"
reset_run
run_setup
stage_path=$(single_stage)
safe_fixture_stage_path "$stage_path" ||
fail "FIDO2 setup uses a unique sibling stage" "got: $stage_path"
assert_pipe_target
[[ ! -s $bare_mktemp ]] ||
fail "FIDO2 setup never creates a caller-owned temporary file" "$(cat "$bare_mktemp")"
grep -Fxq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" ||
fail "FIDO2 setup asks root to create a unique sibling stage" "$(cat "$calls")"
grep -Fxq $'sudo\ttee\t'"$stage_path" "$calls" ||
fail "pamu2fcfg is piped into the exact privileged stage" "$(cat "$calls")"
grep -Fxq $'sudo\tchmod\t644\t'"$stage_path" "$calls" ||
fail "FIDO2 setup makes the completed authfile PAM-readable" "$(cat "$calls")"
grep -Fxq $'sudo\tmv\t-Tf\t'"$stage_path"$'\t'"$authfile" "$calls" ||
fail "FIDO2 setup atomically publishes the exact privileged stage" "$(cat "$calls")"
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "successful setup leaves its cleanup trap inert" "$(cat "$calls")"
[[ ! -e $stage_path && ! -L $stage_path ]] ||
fail "the privileged stage path is gone after publication" "left behind: $stage_path"
[[ -f $authfile && $(<"$authfile") == "$credential" ]] ||
fail "the published authfile contains the generated credential"
[[ $(stat -c %a "$authfile") == "644" ]] ||
fail "the published authfile is mode 644" "got: $(stat -c %a "$authfile")"
pass "FIDO2 setup pipes the credential into a unique root-created stage and publishes it atomically"
# A chmod failure happens after a complete credential has been written but
# before publication. It must abort the setup and leave the EXIT trap armed.
reset_run
if invoke_setup success 1 >/dev/null 2>&1; then
fail "a failed chmod propagates out of FIDO2 setup"
fi
failed_stage=$(single_stage)
assert_pipe_target
grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" ||
fail "the injected chmod failure targets the exact privileged stage" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a stage whose chmod failed is never published" "$(cat "$calls")"
assert_failed_stage_cleanup
pass "FIDO2 setup propagates chmod failure and cleans its privileged stage"
# A failed atomic rename has the same cleanup obligation. The completed stage
# must not survive beside the live authfile when publication fails.
reset_run
if invoke_setup success 0 1 >/dev/null 2>&1; then
fail "a failed mv propagates out of FIDO2 setup"
fi
failed_stage=$(single_stage)
assert_pipe_target
grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" ||
fail "the mv-failure fixture reaches a completed mode-644 stage" "$(cat "$calls")"
grep -Fxq $'sudo\tmv\t-Tf\t'"$failed_stage"$'\t'"$authfile" "$calls" ||
fail "the injected mv failure targets the exact privileged stage" "$(cat "$calls")"
assert_failed_stage_cleanup
pass "FIDO2 setup propagates mv failure and cleans its privileged stage"
# Emit a valid credential and then fail. Without pipefail, tee's success masks
# pamu2fcfg's status and the nonempty file would be published.
reset_run
if invoke_setup fail >/dev/null 2>&1; then
fail "a failing pamu2fcfg pipeline fails setup"
fi
assert_pipe_target
assert_failed_stage_cleanup
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a failed pamu2fcfg result is never prepared for publication" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a failed pamu2fcfg result is never published" "$(cat "$calls")"
pass "FIDO2 setup propagates pamu2fcfg failure and cleans its privileged stage"
# A successful pipeline can still produce no credential. Reject that before
# chmod or rename, and clean the exact stage just as on command failure.
reset_run
if invoke_setup empty >/dev/null 2>&1; then
fail "an empty pamu2fcfg result fails setup"
fi
assert_pipe_target
assert_failed_stage_cleanup
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "an empty pamu2fcfg result is never prepared for publication" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "an empty pamu2fcfg result is never published" "$(cat "$calls")"
pass "FIDO2 setup rejects an empty credential and cleans its privileged stage"
# mktemp's output is an operand for a privileged tee, chmod, mv and rm. Take
# only the name this script asked for: a stage path outside that shape must stop
# the setup before any of them runs, exactly as the migration does.
reset_run
invoke_setup success 0 0 malformed >/dev/null 2>&1 &&
fail "a malformed mktemp result fails setup"
! grep -Fq $'sudo\ttee\t' "$calls" ||
fail "no credential is written to a malformed stage path" "$(cat "$calls")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a malformed stage path never reaches a privileged chmod" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a malformed stage path is never published" "$(cat "$calls")"
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "a malformed stage path never reaches a privileged rm" "$(cat "$calls")"
[[ ! -e $authfile ]] || fail "a malformed stage publishes no authfile"
pass "FIDO2 setup rejects malformed mktemp output before any privileged write"
reset_run
invoke_setup success 0 0 nonregular >/dev/null 2>&1 &&
fail "a nonregular mktemp result fails setup"
! grep -Fq $'sudo\ttee\t' "$calls" ||
fail "no credential is written into a nonregular stage" "$(cat "$calls")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a nonregular stage never reaches a privileged chmod" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a nonregular stage is never published" "$(cat "$calls")"
[[ ! -e $authfile ]] || fail "a nonregular stage publishes no authfile"
pass "FIDO2 setup rejects nonregular mktemp output before any privileged write"
+24 -1
View File
@@ -40,7 +40,7 @@ install_theme() {
: >"$git_calls"
: >"$theme_calls"
HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \
HOME="$test_tmp/home" PATH="${2-$mock_bin:$ROOT/bin:$PATH}" \
OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \
bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $?
}
@@ -58,6 +58,29 @@ done
pass "a URL that names a git option or a transport helper never reaches git"
# git resolves git-remote-<scheme> for any scheme it does not implement itself,
# so the `://` spelling of a helper has to be refused as well as the `::` one.
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x"; do
if install_theme "$url"; then
fail "omarchy-theme-install refuses the URL '$url'"
fi
[[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")"
done
pass "a URL naming a transport git does not implement never reaches git"
# The checker is a separate command, so its absence has to refuse the URL rather
# than wave it through to git.
if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then
fail "omarchy-theme-install refuses a URL it cannot check"
fi
[[ ! -s $git_calls ]] ||
fail "omarchy-theme-install refuses an unchecked URL before running git" "$(cat "$git_calls")"
pass "a missing url checker refuses the URL instead of cloning it"
# A URL whose derived name would escape the themes directory.
for url in "https://example.com/..git" "https://example.com/.git"; do
if install_theme "$url"; then
+4 -1
View File
@@ -7,9 +7,12 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
timezone_menu="$ROOT/bin/omarchy-menu-timezone"
sudoers_file="$ROOT/etc/sudoers.d/omarchy-tzupdate"
grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *' "$sudoers_file" >/dev/null ||
grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$' "$sudoers_file" >/dev/null ||
fail "timezone sudoers rule allows passwordless timedatectl timezone changes"
! grep -F 'set-timezone *' "$sudoers_file" >/dev/null ||
fail "timezone sudoers rule uses a bare wildcard that admits extra arguments like -H and -M"
! grep -F 'tzupdate' "$sudoers_file" >/dev/null ||
fail "timezone sudoers rule does not grant passwordless tzupdate"
+286
View File
@@ -0,0 +1,286 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command lua
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
stub_dir="$tmpdir/bin"
home_dir="$tmpdir/home"
xdg_decoy="$tmpdir/xdg-decoy"
log_file="$tmpdir/hyprctl.log"
marker="$tmpdir/marker"
mkdir -p "$stub_dir" "$home_dir" "$xdg_decoy"
state_dir="$home_dir/.local/state/omarchy/toggles/hypr"
name_file="$state_dir/touchpad-disabled-name"
state_lua="$state_dir/touchpad-disabled.lua"
cat >"$stub_dir/hyprctl" <<'EOF'
#!/bin/bash
case $1 in
eval) printf '%s\n' "$2" >>"$HYPRCTL_LOG" ;;
reload) printf 'reload\n' >>"$HYPRCTL_LOG" ;;
esac
EOF
chmod +x "$stub_dir/hyprctl"
cat >"$stub_dir/omarchy-osd" <<'EOF'
#!/bin/bash
:
EOF
chmod +x "$stub_dir/omarchy-osd"
stub_device() {
local kind=$1
local name=$2
cat >"$stub_dir/omarchy-hw-$kind" <<EOF
#!/bin/bash
printf '%s\n' '$name'
EOF
chmod +x "$stub_dir/omarchy-hw-$kind"
}
# XDG_STATE_HOME deliberately points away from HOME everywhere below: the
# input-device state is hardcoded to ~/.local/state like the sibling toggle
# tools and the pre-migration script, so nothing may read or write the XDG
# directory.
run_toggle() {
HOME="$home_dir" \
XDG_STATE_HOME="$xdg_decoy" \
HYPRCTL_LOG="$log_file" \
PATH="$stub_dir:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-toggle-input-device" "$@"
}
assert_decoy_untouched() {
[[ -z $(find "$xdg_decoy" -mindepth 1 -print -quit 2>/dev/null) ]] ||
fail "input-device state must ignore XDG_STATE_HOME"
}
: >"$log_file"
stub_device touchpad 'elan-touchpad'
run_toggle touchpad off
[[ $(<"$name_file") == "elan-touchpad" ]] || fail "touchpad disable stores the device name as data"
[[ ! -e $state_lua ]] || fail "touchpad disable writes no generated Lua"
grep -Fx 'hl.device({ name = "elan-touchpad", enabled = false })' "$log_file" >/dev/null ||
fail "touchpad disable applies a quoted Lua device name"
assert_decoy_untouched
pass "touchpad disable persists the device name as data"
: >"$log_file"
run_toggle touchpad on
[[ ! -e $name_file ]] || fail "touchpad enable clears the persisted device name"
grep -Fx 'hl.device({ name = "elan-touchpad", enabled = true })' "$log_file" >/dev/null ||
fail "touchpad enable applies a quoted Lua device name"
pass "touchpad enable clears persisted disable state"
run_toggle touchpad
[[ -f $name_file ]] || fail "default toggle action disables an enabled touchpad"
run_toggle touchpad
[[ ! -e $name_file ]] || fail "default toggle action enables a disabled touchpad"
pass "default toggle action flips the persisted state"
: >"$log_file"
stub_device touchscreen 'wacom-hid-52eb-finger'
ts_name_file="$state_dir/touchscreen-disabled-name"
run_toggle touchscreen off
[[ $(<"$ts_name_file") == "wacom-hid-52eb-finger" ]] ||
fail "touchscreen disable stores the device name as data"
grep -Fx 'hl.device({ name = "wacom-hid-52eb-finger", enabled = false })' "$log_file" >/dev/null ||
fail "touchscreen disable applies a quoted Lua device name"
run_toggle touchscreen on
[[ ! -e $ts_name_file ]] || fail "touchscreen enable clears the persisted device name"
pass "touchscreen routes through the same persisted-name state"
: >"$log_file"
rm -f "$marker"
stub_device touchpad 'touchpad"; touch '"$marker"'; echo "'
run_toggle touchpad off
[[ ! -e $marker ]] || fail "touchpad disable does not execute metacharacters in the device name"
[[ $(<"$name_file") == 'touchpad"; touch '"$marker"'; echo "' ]] ||
fail "a hostile device name is stored only as data"
[[ ! -e $state_lua ]] || fail "a hostile device name is not written as Lua"
grep -F 'hl.device({ name = "touchpad\"' "$log_file" >/dev/null ||
fail "hyprctl eval Lua-quotes quotes in the device name" "$(<"$log_file")"
pass "touchpad disable treats USB device names as data"
HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" MARKER="$marker" lua - <<'LUA'
local seen = {}
hl = {
device = function(opts)
table.insert(seen, opts)
end,
}
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.toggles")
assert(#seen == 1, "reload disables one device")
assert(seen[1].enabled == false)
assert(seen[1].name == 'touchpad"; touch ' .. os.getenv("MARKER") .. '; echo "', "device name is passed as a string")
LUA
pass "Hyprland reload loads the device name as a string"
# Public PoC device name: USB iProduct is interpolated into hl.device({ name = "..." }).
# os.execute is stubbed so the string is only checked as data.
poc_name='trackpad"})os.execute("~/calc&")--'
stub_device touchpad "$poc_name"
run_toggle touchpad on
: >"$log_file"
run_toggle touchpad off
[[ $(<"$name_file") == "$poc_name" ]] || fail "PoC device name is stored only as data"
[[ ! -e $state_lua ]] || fail "PoC device name is not written as Lua"
HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" \
POC_NAME="$poc_name" EVAL_SNIPPET="$(<"$log_file")" lua - <<'LUA'
local poc = os.getenv("POC_NAME")
local snippet = os.getenv("EVAL_SNIPPET")
local seen, executed = {}, false
hl = {
device = function(opts)
table.insert(seen, opts)
end,
}
os.execute = function()
executed = true
end
assert(load(snippet, "eval", "t"))()
assert(executed == false, "quoted hyprctl eval must not run os.execute")
assert(#seen == 1)
assert(seen[1].name == poc)
assert(seen[1].enabled == false)
seen, executed = {}, false
assert(load('hl.device({ name = "' .. poc .. '", enabled = false })', "unquoted", "t"))()
assert(executed == true, "unquoted interpolation is the Lua injection")
seen, executed = {}, false
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.toggles")
assert(executed == false, "reload must not run os.execute")
assert(#seen == 1)
assert(seen[1].name == poc)
LUA
pass "PoC device name cannot execute via eval or reload"
cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF'
#!/bin/bash
printf 'evil\nname\n'
EOF
chmod +x "$stub_dir/omarchy-hw-touchpad"
rm -f "$name_file"
set +e
run_toggle touchpad off >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "disable rejects a device name with a newline"
[[ ! -e $name_file ]] || fail "a rejected device name is not persisted"
pass "disable rejects control characters in a device name"
printf 'elan-touchpad\n' >"$name_file"
set +e
run_toggle touchpad on >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "enable still reports an invalid device name"
[[ ! -e $name_file ]] || fail "enable clears persisted state even with an invalid device name"
pass "a bad device name cannot wedge the persisted disable"
cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF'
#!/bin/bash
:
EOF
chmod +x "$stub_dir/omarchy-hw-touchpad"
set +e
run_toggle touchpad off >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "disable errors when no device is found"
[[ ! -e $name_file ]] || fail "no state is written when no device is found"
pass "disable errors when no device is found"
# The migration runs with the same XDG decoy: legacy files were written to
# ~/.local/state, so that is where it must look no matter what XDG says.
run_migration() {
HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" HYPRCTL_LOG="$log_file" \
PATH="$stub_dir:$ROOT/bin:$PATH" \
bash -euo pipefail "$ROOT/migrations/1787618700.sh" >/dev/null
}
mkdir -p "$state_dir"
rm -f "$state_dir"/*-disabled-name
printf 'hl.device({ name = "synps/2-synaptics-touchpad", enabled = false })\n' >"$state_lua"
printf 'hl.device({ name = "hostile\\"")", enabled = false })\n' >"$state_dir/touchscreen-disabled.lua"
: >"$log_file"
run_migration
[[ $(<"$name_file") == "synps/2-synaptics-touchpad" ]] ||
fail "migration recovers a device name containing a slash"
[[ ! -e $state_lua ]] || fail "migration deletes the generated touchpad Lua"
[[ ! -e $state_dir/touchscreen-disabled-name ]] ||
fail "migration does not copy a hostile name out of generated Lua"
[[ ! -e $state_dir/touchscreen-disabled.lua ]] ||
fail "migration deletes hostile generated Lua even when no name is recovered"
assert_decoy_untouched
# The package hook reloads Hyprland before migrations run, so the disable was
# already dropped for this session; the migration has to put it back.
grep -Fx 'reload' "$log_file" >/dev/null ||
fail "migration reloads so the recovered disable applies to this session"
pass "migration recovers plain names and discards hostile generated Lua"
printf 'kept-name\n' >"$name_file"
printf 'hl.device({ name = "other-touchpad", enabled = false })\n' >"$state_lua"
run_migration
[[ $(<"$name_file") == "kept-name" ]] || fail "migration keeps an existing device-name file"
[[ ! -e $state_lua ]] || fail "migration still deletes the generated Lua"
pass "migration is idempotent over an existing device-name file"
rm -f "$name_file"
printf 'garbage\n' >"$state_lua"
chmod 000 "$state_lua"
run_migration
[[ ! -e $state_lua ]] || fail "migration removes an unreadable generated Lua"
[[ ! -e $name_file ]] || fail "no name is recovered from an unreadable file"
pass "an unreadable state file does not wedge the migration"
: >"$log_file"
run_migration
[[ ! -s $log_file ]] || fail "migration with nothing to migrate does not reload"
pass "migration no-ops with nothing left to migrate"
# A compromised install carries a leftover generated touchpad-disabled.lua whose
# device name broke out into os.execute. Until the migration deletes it, a reload
# must not source it. toggles.lua excludes those two names from require_all, so the
# payload never runs, while a current name-file disable still applies.
reload_home="$tmpdir/reload-home"
reload_state="$reload_home/.local/state/omarchy/toggles/hypr"
mkdir -p "$reload_state"
reload_marker="$tmpdir/reload-executed"
rm -f "$reload_marker"
printf 'hl.device({ name = "trackpad"})os.execute("touch %s")--", enabled = false })\n' "$reload_marker" \
>"$reload_state/touchpad-disabled.lua"
printf 'elan-touchpad\n' >"$reload_state/touchpad-disabled-name"
HOME="$reload_home" XDG_STATE_HOME="$reload_home/.local/state" OMARCHY_PATH="$ROOT" lua - <<'LUA'
local disabled = {}
hl = { device = function(opts) table.insert(disabled, opts) end }
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.toggles")
assert(#disabled == 1, "only the current name-file disable is applied")
assert(disabled[1].name == "elan-touchpad", "disable uses the stored device name")
assert(disabled[1].enabled == false)
LUA
[[ ! -e $reload_marker ]] || fail "a leftover legacy generated toggle Lua must not execute on reload"
pass "reload excludes leftover legacy toggle Lua while applying the data disable"
+18
View File
@@ -67,6 +67,24 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null
grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null
pass "Omarchy 4 upgrade configures lock screen authentication for the target user"
grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade uses the shared browser-policy helper"
grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper"
if grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null; then
fail "Omarchy 4 upgrade does not create a browser-policy group"
fi
grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade creates a root-owned Chromium policy directory"
grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade hardens every Chromium-family policy directory"
grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null ||
fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set"
if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw|2775' "$upgrade_to_quattro" >/dev/null; then
fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory"
fi
pass "Omarchy 4 upgrade locks the Chromium policy directory to root"
grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null
grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null
grep -F 'systemd-networkd.socket' "$upgrade_to_quattro" >/dev/null
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
mkdir -p "$tmp_dir/bin" "$tmp_dir/home"
for stub in gtk-update-icon-cache update-desktop-database omarchy-notification-send; do
printf '#!/bin/bash\n:\n' >"$tmp_dir/bin/$stub"
chmod +x "$tmp_dir/bin/$stub"
done
run_install() {
HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" \
"$ROOT/bin/omarchy-webapp-install" "$@"
}
run_remove() {
HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \
"$ROOT/bin/omarchy-webapp-remove" "$@"
}
apps_dir="$tmp_dir/home/.local/share/applications"
icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps"
# A URL typed into the name field is the reported way in. Every slash used to
# become a directory level, leaving a launcher nothing could address. Assert on
# the message: creating the launcher directly in the applications directory
# already makes the redirect fail on its own, so a bare non-zero exit would pass
# just as well with no validation at all.
output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) &&
fail "webapp install rejects a name containing a slash"
[[ $output == *"App name cannot contain '/'"* ]] ||
fail "webapp install says why it refused a slashed name" "$output"
[[ -e "$apps_dir/http:" ]] &&
fail "webapp install does not create a directory from a slashed name"
pass "webapp install rejects a name that would nest the launcher"
# The name was a path fragment until something said otherwise, so ../ climbed
# out of the applications directory entirely and wrote wherever it landed.
if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then
fail "webapp install rejects a name that climbs out of the applications directory"
fi
[[ -e "$tmp_dir/escaped.desktop" ]] &&
fail "webapp install writes no launcher outside the applications directory"
pass "webapp install refuses a name that would escape the applications directory"
# The interactive prompt reads the name long before it is used as a path, and
# fetches the site icon in between. Rejecting only at the write leaves that icon
# behind in the user's icon theme, once per attempt.
mkdir -p "$tmp_dir/ibin"
cp "$tmp_dir/bin"/* "$tmp_dir/ibin/"
cat >"$tmp_dir/ibin/gum" <<'STUB'
#!/bin/bash
count_file="${GUM_STUB_COUNT:?}"
count=$(cat "$count_file" 2>/dev/null || echo 0)
count=$((count + 1))
echo "$count" >"$count_file"
if (( count == 1 )); then
echo "http://example.test/oops"
else
echo "https://example.com"
fi
STUB
cat >"$tmp_dir/ibin/curl" <<'STUB'
#!/bin/bash
# Answer any download with a real PNG so the icon fetch reports success.
out=""
prev=""
for arg in "$@"; do
[[ $prev == "-o" ]] && out="$arg"
prev="$arg"
done
if [[ -n $out ]]; then
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out"
fi
STUB
chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl"
if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \
GUM_STUB_COUNT="$tmp_dir/gum-count" \
"$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then
fail "interactive webapp install rejects a name containing a slash"
fi
if compgen -G "$icons_dir/*.png" >/dev/null; then
fail "interactive webapp install downloads no icon for a name it refuses" \
"$(ls "$icons_dir")"
fi
pass "webapp install refuses a slashed name before fetching its icon"
# A normal name still installs and removes.
run_install "Example App" "https://example.com" hey >/dev/null
[[ -f "$apps_dir/Example App.desktop" ]] ||
fail "webapp install writes the launcher for an ordinary name"
run_remove "Example App" >/dev/null
[[ -f "$apps_dir/Example App.desktop" ]] &&
fail "webapp remove deletes the launcher it installed"
pass "webapp install and remove round-trip an ordinary name"
# Anything installed by an older version can still be nested. Removal has to
# reach it, which a path rebuilt from the displayed name never could.
mkdir -p "$apps_dir/http:/127.0.0.1:4000"
cat >"$apps_dir/http:/127.0.0.1:4000/.desktop" <<'DESKTOP'
[Desktop Entry]
Name=http://127.0.0.1:4000
Exec=omarchy-launch-webapp https://127.0.0.1:4000
Type=Application
DESKTOP
# This is the name the picker shows for that file: the script strips .desktop
# from the path and then takes the basename, which lands on the directory.
run_remove "127.0.0.1:4000" >/dev/null
[[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] &&
fail "webapp remove deletes a launcher left nested by an older install"
pass "webapp remove reaches a nested legacy launcher"
# Removing by name on a machine with no applications directory yet must stay
# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr.
noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \
"$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null)
[[ -n $noise ]] &&
fail "webapp remove stays quiet with no applications directory" "$noise"
pass "webapp remove stays quiet when there is no applications directory"
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
detector="$ROOT/bin/omarchy-hw-dell-xps13-sidecar-amps"
leaf="$ROOT/install/hardware/dell-xps13-sidecar-amps.sh"
all="$ROOT/install/hardware/all.sh"
migration=$(grep -l "dell-xps13-sidecar-amps" "$ROOT"/migrations/*.sh | head -1)
grep -q 'run_logged .*hardware/dell-xps13-sidecar-amps.sh' "$all" ||
fail "the sidecar amplifier workaround runs during hardware setup"
pass "the sidecar amplifier workaround runs during hardware setup"
# The apply step rebuilds the boot image, so it has to see the Panther Lake
# kernel that ptl-kernel.sh swaps in rather than the stock one it replaces.
ptl_line=$(grep -n 'hardware/intel/ptl-kernel.sh' "$all" | cut -d: -f1)
amps_line=$(grep -n 'hardware/dell-xps13-sidecar-amps.sh' "$all" | cut -d: -f1)
((ptl_line < amps_line)) ||
fail "the sidecar amplifier workaround runs after the Panther Lake kernel swap"
pass "the sidecar amplifier workaround runs after the Panther Lake kernel swap"
[[ -n $migration ]] || fail "a migration enables the workaround on existing installs"
pass "a migration enables the workaround on existing installs"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mkdir -p "$test_tmp/bin"
cat >"$test_tmp/bin/omarchy-hw-match" <<'SH'
#!/bin/bash
[[ ${TEST_PRODUCT_NAME:-} == *"$1"* ]]
SH
cat >"$test_tmp/bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
printf 'pkg-add %s\n' "$*" >>"$CALL_LOG"
exit "${TEST_PKG_ADD_STATUS:-0}"
SH
cat >"$test_tmp/bin/sudo" <<'SH'
#!/bin/bash
exec "$@"
SH
cat >"$test_tmp/bin/dell-xps13-sidecar-amps-apply" <<'SH'
#!/bin/bash
printf 'apply\n' >>"$CALL_LOG"
exit "${TEST_APPLY_STATUS:-0}"
SH
cat >"$test_tmp/bin/omarchy-state" <<'SH'
#!/bin/bash
printf 'state %s\n' "$*" >>"$CALL_LOG"
SH
chmod +x "$test_tmp/bin"/*
sku_file="$test_tmp/product_sku"
call_log="$test_tmp/calls.log"
run_detector() {
printf '%s\n' "${2-0E53}" >"$sku_file"
PATH="$test_tmp/bin:$PATH" \
TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \
OMARCHY_DMI_PRODUCT_SKU="${3-$sku_file}" \
bash "$detector"
}
run_detector || fail "the detector matches the DX13260 with SKU 0E53"
pass "the detector matches the DX13260 with SKU 0E53"
run_detector "XPS 13 DX13261" && fail "the detector rejects another model"
pass "the detector rejects another model"
run_detector "XPS 13 DX13260" "0E54" && fail "the detector rejects another SKU"
pass "the detector rejects another SKU"
# An exact match must not be satisfied by a SKU that merely contains it.
run_detector "XPS 13 DX13260" "0E530" && fail "the detector rejects a longer SKU"
pass "the detector rejects a longer SKU"
run_detector "XPS 13 DX13260" "0E53" "$test_tmp/absent" &&
fail "the detector fails closed when the SKU attribute is missing"
pass "the detector fails closed when the SKU attribute is missing"
# Sourced the way run_logged runs it.
run_leaf() {
: >"$call_log"
printf '0E53\n' >"$sku_file"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" \
CALL_LOG="$call_log" \
TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \
TEST_PKG_ADD_STATUS="${2:-0}" \
TEST_APPLY_STATUS="${3:-0}" \
OMARCHY_DMI_PRODUCT_SKU="$sku_file" \
bash -c 'source "$1"' bash "$leaf"
}
run_leaf || fail "the leaf installs and applies on the target machine"
grep -q 'pkg-add dell-xps13-sidecar-amps' "$call_log" ||
fail "the leaf installs the package on the target machine"
grep -q '^apply$' "$call_log" ||
fail "the leaf applies the workaround on the target machine"
pass "the leaf installs and applies on the target machine"
run_leaf "ThinkPad X1" || fail "the leaf no-ops on other hardware"
[[ -s $call_log ]] && fail "the leaf no-ops on other hardware"
pass "the leaf no-ops on other hardware"
# Pacman registers a package even when its scriptlet fails, so a failing apply
# has to surface rather than be swallowed by a successful install.
run_leaf "XPS 13 DX13260" 0 1 && fail "a failing apply fails the leaf"
pass "a failing apply fails the leaf"
run_leaf "XPS 13 DX13260" 1 && fail "a failing package install fails the leaf"
grep -q '^apply$' "$call_log" && fail "a failing package install skips the apply"
pass "a failing package install fails the leaf without applying"
# The migration runner uses bash -euo pipefail and only records the migration
# when it exits clean, so a failed apply has to leave reboot-required unset.
run_migration() {
: >"$call_log"
printf '0E53\n' >"$sku_file"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" \
CALL_LOG="$call_log" \
OMARCHY_PATH="$ROOT" \
TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \
TEST_APPLY_STATUS="${2:-0}" \
OMARCHY_DMI_PRODUCT_SKU="$sku_file" \
bash -euo pipefail "$migration" >/dev/null
}
run_migration || fail "the migration applies the workaround and asks for a reboot"
grep -q 'state set reboot-required' "$call_log" ||
fail "the migration applies the workaround and asks for a reboot"
pass "the migration applies the workaround and asks for a reboot"
run_migration "XPS 13 DX13260" 1 && fail "a failing apply leaves the migration pending"
grep -q 'state set reboot-required' "$call_log" &&
fail "a failing apply does not mark reboot-required"
pass "a failing apply leaves the migration pending without marking reboot-required"
run_migration "ThinkPad X1" || fail "the migration no-ops on other hardware"
[[ -s $call_log ]] && fail "the migration no-ops on other hardware"
pass "the migration no-ops on other hardware"