Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae1694830 | ||
|
|
77305ed3b9 | ||
|
|
9301092404 | ||
|
|
4cd8a081cb | ||
|
|
23dab9ec4d | ||
|
|
9285b19d6a | ||
|
|
4637735aa2 | ||
|
|
68ab12f77d | ||
|
|
30471bf35a |
@@ -528,7 +528,7 @@ def fetch_codex_rpc():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[codex, "-s", "read-only", "-a", "untrusted", "app-server"],
|
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Whenever this runs as root — invoked directly through the passwordless
|
||||||
|
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
|
||||||
|
# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path)
|
||||||
|
# prepends a user-writable checkout bin/ to it. Every helper this script calls
|
||||||
|
# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system
|
||||||
|
# tool, never an omarchy-* command, so pin PATH to trusted system directories
|
||||||
|
# and keep root from resolving one out of that checkout. The unprivileged
|
||||||
|
# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec.
|
||||||
|
if (( EUID == 0 )); then
|
||||||
|
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
|
||||||
|
fi
|
||||||
|
|
||||||
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
|
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
|
||||||
|
|
||||||
provider_from_arg() {
|
provider_from_arg() {
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Check that a git URL names a repository, not a transport helper
|
||||||
|
# omarchy:args=<git-url>
|
||||||
|
# omarchy:hidden=true
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# git picks a remote helper -- an executable it runs at clone time -- out of a URL
|
||||||
|
# in exactly two shapes, and no others: `<helper>::<address>`, and
|
||||||
|
# `<scheme>://<address>` for any scheme git does not handle itself. A single
|
||||||
|
# colon is always scp-style ssh, and a bare path is always a path; neither can
|
||||||
|
# reach a helper. So constraining those two shapes covers the whole surface.
|
||||||
|
#
|
||||||
|
# The `::` shape is refused outright, because no helper reachable that way is one
|
||||||
|
# a theme or plugin URL has business naming, and `ext::` runs a shell command.
|
||||||
|
# The `://` shape cannot be refused the same way, since it is also how every
|
||||||
|
# legitimate URL arrives -- so it is allowlisted instead. The list is the
|
||||||
|
# transports git still connects itself, `git+ssh` and `ssh+git` included: those
|
||||||
|
# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are
|
||||||
|
# left out deliberately -- git ships a helper for each, and `ext` runs whatever
|
||||||
|
# command the URL carries.
|
||||||
|
TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file)
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "omarchy-git-url-check: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
url="${1-}"
|
||||||
|
|
||||||
|
if [[ -z $url ]]; then
|
||||||
|
fail "a git URL is required"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
|
||||||
|
fail "'$url' names a git option or transport helper, not a repository."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then
|
||||||
|
scheme="${BASH_REMATCH[1]}"
|
||||||
|
|
||||||
|
for transport in "${TRANSPORTS[@]}"; do
|
||||||
|
if [[ $scheme == "$transport" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
fail "'$url' names the '$scheme' transport, which Omarchy does not clone from."
|
||||||
|
fi
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround.
|
||||||
|
|
||||||
|
product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}"
|
||||||
|
|
||||||
|
omarchy-hw-match "DX13260" &&
|
||||||
|
grep -qix "0E53" "$product_sku" 2>/dev/null
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
|
|||||||
[[ -n $url ]] || fail "a git URL is required"
|
[[ -n $url ]] || fail "a git URL is required"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Refuse a URL that names a git option or a transport helper before cloning, so
|
||||||
|
# an untrusted URL cannot run a command before the plugin is validated or
|
||||||
|
# enabled. The check is shared with omarchy-theme-install and explains itself; a
|
||||||
|
# missing checker leaves this non-zero, which refuses the URL rather than
|
||||||
|
# cloning it.
|
||||||
|
omarchy-git-url-check "$url" || exit 1
|
||||||
|
|
||||||
if (( ! ASSUME_YES )); then
|
if (( ! ASSUME_YES )); then
|
||||||
cat >&2 <<WARN
|
cat >&2 <<WARN
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
|
|||||||
|
|
||||||
remove_pam_config
|
remove_pam_config
|
||||||
|
|
||||||
if [[ -d /etc/fido2 ]]; then
|
authdir=/etc/fido2
|
||||||
|
|
||||||
|
# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and
|
||||||
|
# a later setup would install the authfile through it. rm -rf on a symlink
|
||||||
|
# removes the link itself, never the directory it points at.
|
||||||
|
if [[ -e $authdir || -L $authdir ]]; then
|
||||||
echo "Removing FIDO2 configuration..."
|
echo "Removing FIDO2 configuration..."
|
||||||
sudo rm -rf /etc/fido2
|
sudo rm -rf "$authdir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Removing FIDO2 packages..."
|
echo "Removing FIDO2 packages..."
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# omarchy:requires-sudo=true
|
# omarchy:requires-sudo=true
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
|
||||||
check_fido2_hardware() {
|
check_fido2_hardware() {
|
||||||
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the pamu2fcfg file
|
# Create the pamu2fcfg file
|
||||||
if [[ ! -f /etc/fido2/fido2 ]]; then
|
authdir=/etc/fido2
|
||||||
sudo mkdir -p /etc/fido2
|
authfile=/etc/fido2/fido2
|
||||||
|
|
||||||
|
# install -d follows a symlink here and applies the mode and ownership to
|
||||||
|
# whatever it points at, so the credential would be staged and published inside
|
||||||
|
# the link target and that directory reopened to root:root 755. This is the
|
||||||
|
# threat omarchy-remove-security-fido2 already names on its side.
|
||||||
|
if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then
|
||||||
|
echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m"
|
||||||
|
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -f follows symlinks, so the already-registered check below reads a symlinked
|
||||||
|
# authfile as a registration and leaves it in place, and is false for a
|
||||||
|
# directory, so it tries to register over one. Only a regular file is a valid
|
||||||
|
# pam_u2f authfile.
|
||||||
|
if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then
|
||||||
|
echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m"
|
||||||
|
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f $authfile ]]; then
|
||||||
|
sudo install -d -m 755 -o root -g root "$authdir"
|
||||||
echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m"
|
echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m"
|
||||||
echo -e "Touch your FIDO2 key when it lights up...\n"
|
echo -e "Touch your FIDO2 key when it lights up...\n"
|
||||||
|
|
||||||
if pamu2fcfg >/tmp/fido2; then
|
# A unique sibling created by root cannot be replaced by another process
|
||||||
sudo mv /tmp/fido2 /etc/fido2/fido2
|
# running as this user. Stream pamu2fcfg into it instead of asking root to
|
||||||
|
# reopen a caller-owned path: an observed temporary name could otherwise be
|
||||||
|
# replaced with a symlink before the privileged copy. The final rename is
|
||||||
|
# atomic, and -T refuses a directory at the destination. Mode 644 keeps the
|
||||||
|
# root-owned global authfile readable when pam_u2f uses openasuser; only root
|
||||||
|
# can still rewrite it.
|
||||||
|
stage=""
|
||||||
|
|
||||||
|
# mktemp's output is an operand for four privileged commands below, one of
|
||||||
|
# them an rm. Take only the name this script asked for rather than whatever
|
||||||
|
# came back on stdout.
|
||||||
|
safe_stage_path() {
|
||||||
|
local candidate=$1
|
||||||
|
local prefix="$authfile.new."
|
||||||
|
local suffix
|
||||||
|
|
||||||
|
[[ $candidate == "$prefix"* ]] || return 1
|
||||||
|
suffix=${candidate#"$prefix"}
|
||||||
|
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_stage() {
|
||||||
|
local status=$?
|
||||||
|
|
||||||
|
if safe_stage_path "$stage"; then
|
||||||
|
sudo rm -f -- "$stage" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_stage EXIT
|
||||||
|
stage=$(sudo mktemp "$authfile.new.XXXXXX")
|
||||||
|
|
||||||
|
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
|
||||||
|
echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then
|
||||||
|
sudo chmod 644 "$stage"
|
||||||
|
sudo mv -Tf "$stage" "$authfile"
|
||||||
|
stage=""
|
||||||
|
trap - EXIT
|
||||||
echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
|
echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
|
||||||
else
|
else
|
||||||
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
|
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
|
||||||
|
|||||||
@@ -16,14 +16,10 @@ if [[ -z $REPO_URL ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# git reads a leading dash as an option, and `<helper>::<address>` as a remote
|
# Refuse a URL that names a git option or a transport helper before cloning. The
|
||||||
# helper to run. The helper name is a bare word at the very start, which is what
|
# check is shared with omarchy-plugin-add and explains itself; a missing checker
|
||||||
# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git
|
# leaves this non-zero, which refuses the URL rather than cloning it.
|
||||||
# carries `::` too and must still clone.
|
omarchy-git-url-check "$REPO_URL" || exit 1
|
||||||
if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
|
|
||||||
echo "Error: '$REPO_URL' names a git option or transport helper, not a repository."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
THEMES_DIR="$HOME/.config/omarchy/themes"
|
THEMES_DIR="$HOME/.config/omarchy/themes"
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *
|
%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_+.-]*)*$
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh"
|
|||||||
run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh"
|
run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh"
|
run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh"
|
||||||
|
|
||||||
|
# Rebuilds the boot image, so it has to follow the Panther Lake kernel swap
|
||||||
|
# above rather than sit with the other Dell leaf at the top of this file.
|
||||||
|
run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh"
|
||||||
|
|
||||||
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh"
|
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-display.sh"
|
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-display.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-touchpad.sh"
|
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-touchpad.sh"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Enable the temporary sidecar amplifier workaround on the exact Dell XPS 13 model that needs it.
|
||||||
|
#
|
||||||
|
# Pacman registers a package even when its post_install scriptlet fails, so the
|
||||||
|
# apply command runs explicitly here: a failed cleanup or boot-image rebuild has
|
||||||
|
# to reach the caller rather than hide behind a successfully registered package.
|
||||||
|
|
||||||
|
if omarchy-hw-dell-xps13-sidecar-amps; then
|
||||||
|
omarchy-pkg-add dell-xps13-sidecar-amps &&
|
||||||
|
sudo dell-xps13-sidecar-amps-apply
|
||||||
|
fi
|
||||||
@@ -61,6 +61,7 @@ linux-firmware-marvell
|
|||||||
|
|
||||||
# Dell laptop support packages
|
# Dell laptop support packages
|
||||||
dell-xps-touchpad-haptics
|
dell-xps-touchpad-haptics
|
||||||
|
dell-xps13-sidecar-amps
|
||||||
|
|
||||||
# Speaker tunings (LV2 limiter every tuning ends in)
|
# Speaker tunings (LV2 limiter every tuning ends in)
|
||||||
lsp-plugins-lv2
|
lsp-plugins-lv2
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
### Apple M1/M2 chips
|
### Apple M1/M2 chips
|
||||||
|
|
||||||
[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://codeberg.org/malik-na/omarchy-mac).
|
[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://github.com/omarchy-mac/omarchy-mac).
|
||||||
|
|
||||||
### Apple Virtual Machine
|
### Apple Virtual Machine
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root"
|
||||||
|
|
||||||
|
authfile="/etc/fido2/fido2"
|
||||||
|
|
||||||
|
# omarchy-migrate records this migration as complete whenever it exits zero, so
|
||||||
|
# a line printed here scrolls past once in the update terminal and is never
|
||||||
|
# shown again. The states below cannot be repaired without deciding what to do
|
||||||
|
# with a file we do not own, and they are exactly the ones where the authfile
|
||||||
|
# may already be under someone else's control, so say so where it outlives the
|
||||||
|
# scrollback as well.
|
||||||
|
report_unrepairable() {
|
||||||
|
echo " $1"
|
||||||
|
echo " $2"
|
||||||
|
omarchy-notification-send -u critical -g "FIDO2 authfile needs attention" "$1 $2" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nothing to repair on any machine that never set FIDO2 up, which is almost all
|
||||||
|
# of them. Checked before any sudo so those machines never see a password
|
||||||
|
# prompt. -L as well as -e: a dangling symlink is invisible to -e.
|
||||||
|
if [[ ! -L $authfile && ! -e $authfile ]]; then
|
||||||
|
# Absence and "cannot look" are the same answer to the tests above. The old
|
||||||
|
# setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the
|
||||||
|
# caller's umask and sudoers' 0022, so anyone registering under `umask 077`
|
||||||
|
# left it mode 0700 with the user-owned authfile still inside. Escalate for
|
||||||
|
# that case alone -- a machine that never set FIDO2 up has no directory here
|
||||||
|
# and still reaches exit 0 without a password prompt. Not through a symlink:
|
||||||
|
# chmod would act on whatever it points at.
|
||||||
|
authdir=${authfile%/*}
|
||||||
|
|
||||||
|
if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask root whether a registration is behind it before touching the directory
|
||||||
|
# itself. An aborted setup that left an empty 0700 directory, or one an
|
||||||
|
# administrator deliberately keeps private, must not have its mode widened
|
||||||
|
# and its group and special bits discarded for a repair it does not need.
|
||||||
|
if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo chmod 755 "$authdir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The old privileged move could install a symlink here if its fixed staging path
|
||||||
|
# was redirected. Reported, not repaired: chown follows symlinks and would take
|
||||||
|
# ownership of the target instead, and removing it would strip sudo and polkit
|
||||||
|
# from anyone whose only credential is the token.
|
||||||
|
if [[ -L $authfile ]]; then
|
||||||
|
report_unrepairable "$authfile is a symlink, not a regular file." \
|
||||||
|
"Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A directory or a device here is no more ours to rewrite than a symlink is,
|
||||||
|
# and changing a directory's mode would alter an object we do not own.
|
||||||
|
if [[ ! -f $authfile ]]; then
|
||||||
|
report_unrepairable "$authfile is not a regular file." \
|
||||||
|
"Leaving it alone. Remove it and re-run Setup > Security > Fido2."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Migration state is per-user, so every account re-runs this. The file's own
|
||||||
|
# ownership is the state check: the second account finds the repair already
|
||||||
|
# done and exits without escalating.
|
||||||
|
owner=$(stat -c %U "$authfile" 2>/dev/null) || owner=""
|
||||||
|
group=$(stat -c %G "$authfile" 2>/dev/null) || group=""
|
||||||
|
mode=$(stat -c %a "$authfile" 2>/dev/null) || mode=""
|
||||||
|
if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Setup used to `mv` this in from /tmp, which carried the invoking user's
|
||||||
|
# ownership into /etc. Root ownership stops that user from rewriting their own
|
||||||
|
# PAM credential without root. Mode 644 keeps the public credential mapping
|
||||||
|
# readable when pam_u2f opens an absolute authfile as the authenticating user.
|
||||||
|
#
|
||||||
|
# Rename a fresh copy over the path rather than chowning in place. A descriptor
|
||||||
|
# opened while the file was still the user's own stays writable on that inode
|
||||||
|
# through any later chmod or chown, since permission is checked at open(2), and
|
||||||
|
# pam_u2f resolving the path would keep landing on it. Replacing the inode
|
||||||
|
# leaves that descriptor writing to a file nothing reads.
|
||||||
|
stage=""
|
||||||
|
|
||||||
|
safe_stage_path() {
|
||||||
|
local candidate=$1
|
||||||
|
local prefix="$authfile.new."
|
||||||
|
local suffix
|
||||||
|
|
||||||
|
[[ $candidate == "$prefix"* ]] || return 1
|
||||||
|
suffix=${candidate#"$prefix"}
|
||||||
|
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_stage() {
|
||||||
|
local status=$?
|
||||||
|
|
||||||
|
if safe_stage_path "$stage"; then
|
||||||
|
sudo rm -f -- "$stage" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_stage EXIT
|
||||||
|
stage=$(sudo mktemp "$authfile.new.XXXXXX")
|
||||||
|
|
||||||
|
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
|
||||||
|
echo " Could not create a safe staging file beside $authfile."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo install -T -m 644 -o root -g root "$authfile" "$stage"
|
||||||
|
sudo mv -Tf "$stage" "$authfile"
|
||||||
|
stage=""
|
||||||
|
trap - EXIT
|
||||||
@@ -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,6 @@
|
|||||||
|
echo "Enable Dell XPS 13 sidecar speaker amplifiers"
|
||||||
|
|
||||||
|
if omarchy-hw-dell-xps13-sidecar-amps; then
|
||||||
|
source "$OMARCHY_PATH/install/hardware/dell-xps13-sidecar-amps.sh"
|
||||||
|
omarchy-state set reboot-required
|
||||||
|
fi
|
||||||
@@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin"
|
|||||||
cat >"$TEST_HOME/bin/codex" <<'EOF'
|
cat >"$TEST_HOME/bin/codex" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [[ -n ${CODEX_ARGS_FILE:-} ]]; then
|
||||||
|
printf '%s\0' "$@" >"$CODEX_ARGS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
while read -r request; do
|
while read -r request; do
|
||||||
id=$(jq -r '.id // empty' <<<"$request")
|
id=$(jq -r '.id // empty' <<<"$request")
|
||||||
method=$(jq -r '.method // 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}}}}
|
{"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
|
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")
|
"$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" ]] ||
|
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "210" ]] ||
|
||||||
fail "Codex collector counts each turn once" "$result"
|
fail "Codex collector counts each turn once" "$result"
|
||||||
pass "Codex collector counts each turn once"
|
pass "Codex collector counts each turn once"
|
||||||
|
|||||||
@@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null ||
|
|||||||
|
|
||||||
pass "dns sudoers rule is scoped to the stock providers"
|
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
|
# 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.
|
# between the script and the host's real NetworkManager and resolved config.
|
||||||
if (( EUID == 0 )); then
|
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"
|
||||||
@@ -56,3 +56,122 @@ grep -qF "plugin id 'acme.same' is already used by" <<<"$output" ||
|
|||||||
[[ ! -e $test_home/.config/omarchy/plugins/acme.same ]] ||
|
[[ ! -e $test_home/.config/omarchy/plugins/acme.same ]] ||
|
||||||
fail "plugin add leaves a target behind after refusing a duplicate id"
|
fail "plugin add leaves a target behind after refusing a duplicate id"
|
||||||
pass "plugin add refuses an installed manifest id regardless of directory name"
|
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"
|
||||||
|
|||||||
+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"
|
: >"$git_calls"
|
||||||
: >"$theme_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" \
|
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 $?
|
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"
|
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.
|
# A URL whose derived name would escape the themes directory.
|
||||||
for url in "https://example.com/..git" "https://example.com/.git"; do
|
for url in "https://example.com/..git" "https://example.com/.git"; do
|
||||||
if install_theme "$url"; then
|
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"
|
timezone_menu="$ROOT/bin/omarchy-menu-timezone"
|
||||||
sudoers_file="$ROOT/etc/sudoers.d/omarchy-tzupdate"
|
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"
|
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 ||
|
! grep -F 'tzupdate' "$sudoers_file" >/dev/null ||
|
||||||
fail "timezone sudoers rule does not grant passwordless tzupdate"
|
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"
|
||||||
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