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