diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index cbd1b33f..f33f23af 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua" INTERNAL=$(omarchy-hyprland-monitor-laptop) +# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a +# name that is not a plain connector string could execute on the next reload. +# Names come from hyprctl; a user-created headless output can carry anything. +if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe internal monitor name" >&2 + exit 1 +fi + valid_scale() { [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] } diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 836073ee..c0be60fa 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -28,6 +28,13 @@ off() { exit 1 fi + # The name is written into generated Lua below, so only a plain connector + # name may pass; anything else could execute on the next reload. + if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + if ! omarchy-hyprland-monitor-external-active; then omarchy-notification-send -g 󰍹 "Can't disable the only active display" exit 1 diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index d5213d4c..ec48c740 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -22,6 +22,15 @@ on() { exit 1 fi + # Both names are written into generated Lua below, so only plain connector + # names may pass; a user-created headless output can carry any name. + for output in "$INTERNAL" "$EXTERNAL"; do + if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + done + omarchy-hyprland-toggle $DISABLE_TOGGLE off if omarchy-hyprland-toggle-disabled $TOGGLE; then diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index 240a1ea5..8de107f4 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -80,6 +80,14 @@ set_scale() { local width="$(echo "$monitor_info" | jq -r '.width')" local height="$(echo "$monitor_info" | jq -r '.height')" local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')" + + # active_monitor is written into the Lua string eval'd below, so only a plain + # connector name may pass; a hostile output name could execute otherwise. + if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe monitor name" >&2 + exit 1 + fi + local new_scale="$(clean_scale "$requested_scale" "$width" "$height")" # GTK only honors integer GDK_SCALE values, so persist the nearest whole # factor even when the monitor scale itself is fractional. diff --git a/bin/omarchy-toggle-input-device b/bin/omarchy-toggle-input-device index aea0ca11..77ff084a 100755 --- a/bin/omarchy-toggle-input-device +++ b/bin/omarchy-toggle-input-device @@ -7,44 +7,70 @@ KIND="${1:-}" ACTION="${2:-toggle}" +usage() { + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 +} + case "$KIND" in touchpad) LABEL="Touchpad" ICON="touchpad" ;; touchscreen) LABEL="Touchscreen" ICON="touch" ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac -# Hyprland sources this directory on reload, so the disabled state survives restarts -STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua" +# The persisted disable is the device name stored as plain data; on every +# reload default/hypr/disabled-input-device.lua reads it back and disables the +# device. Names come from USB descriptors and must not be interpolated into +# shell or Lua. The path is hardcoded to ~/.local/state like the sibling +# toggle tools, so it keeps working when XDG_STATE_HOME diverges. +NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name" device="$("omarchy-hw-$KIND")" -if [[ -z $device ]]; then - echo "No $KIND device found" >&2 - exit 1 -fi +require_device() { + if [[ -z $device ]]; then + echo "No $KIND device found" >&2 + exit 1 + fi + + if [[ $device == *[[:cntrl:]]* ]]; then + echo "Invalid $KIND device name" >&2 + exit 1 + fi +} + +apply_device() { + local enabled=$1 + local quoted=${device//\\/\\\\} + quoted=${quoted//\"/\\\"} + hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null +} enable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null - rm -f "$STATE_FILE" + # Clear the persisted state before requiring a usable device, so a device + # that stops reporting a valid name can never wedge the disable in place. + rm -f "$NAME_FILE" + require_device + apply_device true omarchy-osd -i "$ICON" -m "$LABEL enabled" } disable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null - mkdir -p "$(dirname "$STATE_FILE")" - printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" + require_device + apply_device false + mkdir -p "$(dirname "$NAME_FILE")" + printf '%s\n' "$device" >"$NAME_FILE" omarchy-osd -i "$ICON" -m "$LABEL disabled" } case "$ACTION" in on) enable ;; off) disable ;; - toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; + toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac diff --git a/default/hypr/disabled-input-device.lua b/default/hypr/disabled-input-device.lua new file mode 100644 index 00000000..c9c20943 --- /dev/null +++ b/default/hypr/disabled-input-device.lua @@ -0,0 +1,21 @@ +-- Disable a Hyprland input device whose name was stored as data, not Lua. +-- Device names come from USB descriptors and must never be loaded as code. + +local paths = require("default.hypr.paths") + +return function(kind) + -- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the + -- sibling bash toggle tools, which all write there regardless of + -- XDG_STATE_HOME. + local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r") + if not file then + return + end + + local name = file:read("*l") + file:close() + + if name and name ~= "" then + hl.device({ name = name, enabled = false }) + end +end diff --git a/default/hypr/paths.lua b/default/hypr/paths.lua index dd3fc348..c20489f6 100644 --- a/default/hypr/paths.lua +++ b/default/hypr/paths.lua @@ -4,9 +4,19 @@ local home = os.getenv("HOME") +-- A variable that is set but empty means "unset" (XDG Base Directory spec); +-- bash's ${VAR:-fallback} in the sibling tools treats it the same way. +local function env_or(name, fallback) + local value = os.getenv(name) + if value == nil or value == "" then + return fallback + end + return value +end + return { home = home, - config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"), - state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"), - omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy", + config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"), + state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"), + omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"), } diff --git a/default/hypr/require_all.lua b/default/hypr/require_all.lua index 56153a00..b77a55cd 100644 --- a/default/hypr/require_all.lua +++ b/default/hypr/require_all.lua @@ -4,6 +4,8 @@ -- Pass a module prefix for normal package.path modules, e.g. -- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps") -- Pass nil as the prefix when the directory itself has been added to package.path. +-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy +-- file that must never be loaded as code stays on disk for a migration to remove. local M = {} @@ -12,19 +14,23 @@ local function shell_quote(path) end function M.files(dir, module_prefix, options) + local exclude = options and options.exclude or {} local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort") if handle then for filename in handle:lines() do - local module = filename:gsub("%.lua$", "") - if module_prefix then - module = module_prefix .. "." .. module - end + local name = filename:gsub("%.lua$", "") + if not exclude[name] then + local module = name + if module_prefix then + module = module_prefix .. "." .. module + end - if options and options.reload then - package.loaded[module] = nil - end + if options and options.reload then + package.loaded[module] = nil + end - require(module) + require(module) + end end handle:close() end diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index 1ca62205..bb9f28d4 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all") local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" package.path = toggles_dir .. "/?.lua;" .. package.path -require_all.files(toggles_dir, nil, { reload = true }) +-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older +-- versions and could carry an injected USB device name. They must never be loaded +-- as code again: exclude them so a not-yet-migrated install cannot execute a +-- leftover payload on reload. The migration recovers the name and deletes them. +require_all.files(toggles_dir, nil, { + reload = true, + exclude = { + ["touchpad-disabled"] = true, + ["touchscreen-disabled"] = true, + }, +}) + +local disabled_input_device = require("default.hypr.disabled-input-device") +disabled_input_device("touchpad") +disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 6c01398c..2effdac1 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy | Suspend | — | `omarchy toggle suspend` | | Hybrid GPU | — | `omarchy toggle hybrid gpu` | -The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup. +The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again. The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). diff --git a/migrations/1787618700.sh b/migrations/1787618700.sh new file mode 100644 index 00000000..a5e8e6a0 --- /dev/null +++ b/migrations/1787618700.sh @@ -0,0 +1,39 @@ +echo "Store Hyprland input-device names as data instead of generated Lua" + +# omarchy-toggle-input-device used to interpolate hyprctl device names into +# hyprctl eval and a generated Lua file. Those names come from USB descriptors, +# so recover the plain device name as data and delete the generated Lua. A name +# that could have broken out of the old Lua string literal is discarded, not +# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME. +toggles_dir="$HOME/.local/state/omarchy/toggles/hypr" + +reapply=0 + +for kind in touchpad touchscreen; do + state_file="$toggles_dir/$kind-disabled.lua" + name_file="$toggles_dir/$kind-disabled-name" + + [[ -f $state_file ]] || continue + + if [[ ! -f $name_file && -r $state_file ]]; then + old=$(<"$state_file") + pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$' + if [[ $old =~ $pattern ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file" + fi + fi + + rm -f "$state_file" + + if [[ -f $name_file ]]; then + reapply=1 + fi +done + +# The package hook reloads Hyprland before migrations run, so this session has +# already dropped the disable: the generated Lua is no longer loaded and the +# name file did not exist yet to replace it. Reload once more now that it does, +# or the device the user switched off stays on until their next login. +if (( reapply )); then + hyprctl reload >/dev/null 2>&1 || true +fi diff --git a/test/shell.d/hyprland-paths-test.sh b/test/shell.d/hyprland-paths-test.sh new file mode 100644 index 00000000..4d880754 --- /dev/null +++ b/test/shell.d/hyprland-paths-test.sh @@ -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" diff --git a/test/shell.d/monitor-output-name-test.sh b/test/shell.d/monitor-output-name-test.sh new file mode 100644 index 00000000..14cd8852 --- /dev/null +++ b/test/shell.d/monitor-output-name-test.sh @@ -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" diff --git a/test/shell.d/toggle-input-device-test.sh b/test/shell.d/toggle-input-device-test.sh new file mode 100755 index 00000000..2b0c10d8 --- /dev/null +++ b/test/shell.d/toggle-input-device-test.sh @@ -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" </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"