[Security] Stop USB device names from being executed as Hyprland Lua (backport of #8129)
Backport of the input-device name fix (PR #8129 by @acrogenesis, merged to
quattro as 9285b19d) onto the v4-0-1 release branch.
Hyprland input-device and monitor names come from USB descriptors and hyprctl
output, so they are attacker-influenceable, yet the toggle and monitor commands
interpolated them straight into hyprctl eval and into generated Lua that
Hyprland re-executes on every reload. XF86TouchpadToggle is bound with
locked = true, so a malicious USB name reached Lua execution from the lock
screen as the logged-in user, and a persisted disable made it run on every
start. Publicly reported by Jorrit Jongma / Chainfire.
The disable is no longer executable Lua anywhere. The device name is stored as
plain-text data in a *-disabled-name sidecar and read back by a packaged module,
default/hypr/disabled-input-device.lua, on every reload; the live hyprctl eval
Lua-quotes the name and rejects control characters outright. The reload loader
excludes the two legacy filenames, so a leftover generated *-disabled.lua on a
not-yet-migrated install can never be sourced as code again, and a migration
recovers the device name from it and deletes it, sanitizing installs that ran
the vulnerable version. All four monitor scripts validate an output name against
a plain-connector-name pattern before writing it as Lua, closing the same latent
pattern in the siblings, and paths.lua treats a set-but-empty XDG_STATE_HOME as
unset to match the bash side.
Clean cherry-pick: all fourteen files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. This branch ships no leftover
*-disabled.lua template of its own -- the tracked "disabled" files are the same
two quattro has -- so the migration is the only path that has to sanitize
anything here.
test/shell passes: 192 files, including the three this adds. The toggle suite's
public-PoC case passes here, as do the monitor scripts' accept/reject cases and
the XDG path cases. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
This commit is contained in:
1 parent
c6d676f23c
commit
2e989e35e5
14 files changed
+612
-27
No files matched your search
@@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
|
|||||||
|
|
||||||
INTERNAL=$(omarchy-hyprland-monitor-laptop)
|
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() {
|
valid_scale() {
|
||||||
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
|
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ off() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if ! omarchy-hyprland-monitor-external-active; then
|
||||||
omarchy-notification-send -g "Can't disable the only active display"
|
omarchy-notification-send -g "Can't disable the only active display"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ on() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
omarchy-hyprland-toggle $DISABLE_TOGGLE off
|
||||||
|
|
||||||
if omarchy-hyprland-toggle-disabled $TOGGLE; then
|
if omarchy-hyprland-toggle-disabled $TOGGLE; then
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ set_scale() {
|
|||||||
local width="$(echo "$monitor_info" | jq -r '.width')"
|
local width="$(echo "$monitor_info" | jq -r '.width')"
|
||||||
local height="$(echo "$monitor_info" | jq -r '.height')"
|
local height="$(echo "$monitor_info" | jq -r '.height')"
|
||||||
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
|
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")"
|
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
|
||||||
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
|
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
|
||||||
# factor even when the monitor scale itself is fractional.
|
# factor even when the monitor scale itself is fractional.
|
||||||
|
|||||||
@@ -7,44 +7,70 @@
|
|||||||
KIND="${1:-}"
|
KIND="${1:-}"
|
||||||
ACTION="${2:-toggle}"
|
ACTION="${2:-toggle}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
case "$KIND" in
|
case "$KIND" in
|
||||||
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
|
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
|
||||||
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
|
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Hyprland sources this directory on reload, so the disabled state survives restarts
|
# The persisted disable is the device name stored as plain data; on every
|
||||||
STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua"
|
# 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")"
|
device="$("omarchy-hw-$KIND")"
|
||||||
|
|
||||||
if [[ -z $device ]]; then
|
require_device() {
|
||||||
echo "No $KIND device found" >&2
|
if [[ -z $device ]]; then
|
||||||
exit 1
|
echo "No $KIND device found" >&2
|
||||||
fi
|
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() {
|
enable() {
|
||||||
hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null
|
# Clear the persisted state before requiring a usable device, so a device
|
||||||
rm -f "$STATE_FILE"
|
# 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"
|
omarchy-osd -i "$ICON" -m "$LABEL enabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null
|
require_device
|
||||||
mkdir -p "$(dirname "$STATE_FILE")"
|
apply_device false
|
||||||
printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE"
|
mkdir -p "$(dirname "$NAME_FILE")"
|
||||||
|
printf '%s\n' "$device" >"$NAME_FILE"
|
||||||
omarchy-osd -i "$ICON" -m "$LABEL disabled"
|
omarchy-osd -i "$ICON" -m "$LABEL disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
on) enable ;;
|
on) enable ;;
|
||||||
off) disable ;;
|
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 <touchpad|touchscreen> [on|off|toggle]" >&2
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -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
|
||||||
+13
-3
@@ -4,9 +4,19 @@
|
|||||||
|
|
||||||
local home = os.getenv("HOME")
|
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 {
|
return {
|
||||||
home = home,
|
home = home,
|
||||||
config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"),
|
config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"),
|
||||||
state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"),
|
state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"),
|
||||||
omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy",
|
omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"),
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
-- Pass a module prefix for normal package.path modules, e.g.
|
-- Pass a module prefix for normal package.path modules, e.g.
|
||||||
-- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps")
|
-- 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 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 = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -12,19 +14,23 @@ local function shell_quote(path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function M.files(dir, module_prefix, options)
|
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")
|
local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort")
|
||||||
if handle then
|
if handle then
|
||||||
for filename in handle:lines() do
|
for filename in handle:lines() do
|
||||||
local module = filename:gsub("%.lua$", "")
|
local name = filename:gsub("%.lua$", "")
|
||||||
if module_prefix then
|
if not exclude[name] then
|
||||||
module = module_prefix .. "." .. module
|
local module = name
|
||||||
end
|
if module_prefix then
|
||||||
|
module = module_prefix .. "." .. module
|
||||||
|
end
|
||||||
|
|
||||||
if options and options.reload then
|
if options and options.reload then
|
||||||
package.loaded[module] = nil
|
package.loaded[module] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
require(module)
|
require(module)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
handle:close()
|
handle:close()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all")
|
|||||||
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
|
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
|
||||||
package.path = toggles_dir .. "/?.lua;" .. package.path
|
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")
|
require("default.hypr.workspace-layouts")
|
||||||
@@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle <thing>`. Run `omarchy
|
|||||||
| Suspend | — | `omarchy toggle suspend` |
|
| Suspend | — | `omarchy toggle suspend` |
|
||||||
| Hybrid GPU | — | `omarchy toggle hybrid gpu` |
|
| 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`).
|
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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
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"
|
||||||
Reference in new issue
Block a user