diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 773e87d7..57f94396 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] [--exec [args...]] +# omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [--image ] [description] [--exec [args...]] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -11,46 +11,63 @@ description="" glyph= urgency="low" app_name="omarchy-action" +app_icon="" image= +expire_timeout=-1 exec_args=() exec_present=0 -args=() parsed_option_args=0 +usage() { + echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [-i ] [-t ] [--image ] [description] [--exec [args...]]" >&2 +} + +need_value() { + if (($1 < 2)); then + echo "Missing value for $2" >&2 + exit 1 + fi +} + parse_omarchy_option() { case $1 in -g | --glyph) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi + need_value $# "$1" glyph=$2 parsed_option_args=2 return 0 ;; -u | --urgency) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi + need_value $# "$1" urgency="$2" parsed_option_args=2 return 0 ;; --app-name) - if (($# < 2)); then - echo "Missing value for $1" >&2 - exit 1 - fi + need_value $# "$1" app_name=$2 parsed_option_args=2 return 0 ;; - --image) - if (($# < 2)); then - echo "Missing value for $1" >&2 + -i | --icon) + need_value $# "$1" + app_icon=$2 + parsed_option_args=2 + return 0 + ;; + -t | --expire-time) + need_value $# "$1" + if [[ $2 != *[!0-9-]* && $2 =~ ^-?[0-9]+$ ]]; then + expire_timeout=$2 + else + echo "Invalid $1 value (milliseconds expected): $2" >&2 exit 1 fi + parsed_option_args=2 + return 0 + ;; + --image) + need_value $# "$1" image=$2 parsed_option_args=2 return 0 @@ -69,7 +86,7 @@ while (($# > 0)); do done if (($# < 1)); then - echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] [--exec [args...]]" + usage exit 1 fi @@ -97,33 +114,35 @@ while (($# > 0)); do elif parse_omarchy_option "$@"; then shift "$parsed_option_args" else - # --exec is the only door to a click command. A relayed title or filename - # that lands here -- passthrough is the one position an untrusted value can - # still reach notify-send as an option -- must not be able to set the hint - # itself, which is the injection this transport exists to close. - if [[ $1 == *omarchy-exec-argv* ]]; then - echo "The click command hint can only be set with --exec, not passed through." >&2 - exit 1 - fi - args+=("$1") - shift + echo "Unknown option: $1" >&2 + usage + exit 1 fi done -# Tag as a user-action toast so it pops through DND. -args+=("-a" "$app_name" "-u" "$urgency") +case $urgency in +low) urgency_byte=0 ;; +normal) urgency_byte=1 ;; +critical) urgency_byte=2 ;; +*) + echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2 + exit 1 + ;; +esac + +# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte; +# the rest are strings. The click command rides here as omarchy-exec-argv, built +# only from --exec below. +hints=(urgency y "$urgency_byte") if [[ -n $glyph ]]; then - args+=("--hint=string:omarchy-glyph:$glyph") + hints+=(omarchy-glyph s "$glyph") fi if [[ -n $image ]]; then - args+=("--hint=string:image-path:$image") + hints+=(image-path s "$image") fi -# The click command travels with the popup as an argv hint the shell runs -# itself, so restored toasts stay clickable and senders don't block on a -# libnotify action (which dies when the shell restarts). if ((exec_present)); then if ((${#exec_args[@]} == 0)); then echo "--exec needs a command: --exec [args...]" >&2 @@ -141,14 +160,25 @@ if ((exec_present)); then # NUL-delimit into jq so every byte survives as data: jq's own --args would eat # a bare "--", and a newline in an arg must not split the vector. exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') - args+=("--hint=string:omarchy-exec-argv:$exec_argv_json") + hints+=(omarchy-exec-argv s "$exec_argv_json") fi -# `--` so notify-send reads the headline and description as text. Without it a -# headline beginning with a dash is parsed as options ("-rf x" becomes -r), and -# one shaped like `--hint=string:...` sets a hint of its own. -if [[ -n $description ]]; then - notify-send "${args[@]}" -- "$headline" "$description" -else - notify-send "${args[@]}" -- "$headline" -fi +hint_count=$((${#hints[@]} / 3)) + +# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its +# argv parsing is the surface that reinterprets a relayed headline like +# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed +# D-Bus parameter instead, and the leading `--` keeps a dash-leading value +# (headline, description, a negative timeout) positional rather than a busctl +# option. So the summary and body are strings that can never become a hint, and +# omarchy-exec-argv is set only from --exec. +# +# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body, +# actions (empty), hints, expire_timeout. +busctl --user -- call \ + org.freedesktop.Notifications /org/freedesktop/Notifications \ + org.freedesktop.Notifications Notify susssasa{sv}i \ + "$app_name" 0 "$app_icon" "$headline" "$description" \ + 0 \ + "$hint_count" "${hints[@]}" \ + "$expire_timeout" >/dev/null diff --git a/config/omarchy/hooks/font-set.d/show-font-notification.sample b/config/omarchy/hooks/font-set.d/show-font-notification.sample index a75f2fa8..c1f50ee5 100644 --- a/config/omarchy/hooks/font-set.d/show-font-notification.sample +++ b/config/omarchy/hooks/font-set.d/show-font-notification.sample @@ -4,4 +4,4 @@ # To put it into use, remove .sample from this file name. # Example: Show the name of the font that was just set. -# notify-send -u low "New font" "Your new font is $1" +# omarchy-notification-send -u low "New font" "Your new font is $1" diff --git a/config/omarchy/hooks/post-boot.d/weather.sample b/config/omarchy/hooks/post-boot.d/weather.sample index 60a1c88f..01857cb7 100644 --- a/config/omarchy/hooks/post-boot.d/weather.sample +++ b/config/omarchy/hooks/post-boot.d/weather.sample @@ -6,5 +6,5 @@ weather=$(omarchy-weather-status 2>/dev/null) || true if [[ -n $weather && $weather != "Weather unavailable" ]]; then - notify-send -u low "$weather" + omarchy-notification-send -u low "$weather" fi diff --git a/config/omarchy/hooks/post-update.d/show-update-notification.sample b/config/omarchy/hooks/post-update.d/show-update-notification.sample index f6ba07b0..929e1e2a 100644 --- a/config/omarchy/hooks/post-update.d/show-update-notification.sample +++ b/config/omarchy/hooks/post-update.d/show-update-notification.sample @@ -4,4 +4,4 @@ # To put it into use, remove .sample from this file name. # Example: Show notification after the system has been updated. -# notify-send -u low "Update Performed" "Your system is now up to date" +# omarchy-notification-send -u low "Update Performed" "Your system is now up to date" diff --git a/config/omarchy/hooks/theme-set.d/show-theme-notification.sample b/config/omarchy/hooks/theme-set.d/show-theme-notification.sample index db19b298..d224db7c 100644 --- a/config/omarchy/hooks/theme-set.d/show-theme-notification.sample +++ b/config/omarchy/hooks/theme-set.d/show-theme-notification.sample @@ -4,4 +4,4 @@ # To put it into use, remove .sample from this file name. # Example: Show the name of the theme that was just set. -# notify-send -u low "New theme" "Your new theme is $1" +# omarchy-notification-send -u low "New theme" "Your new theme is $1" diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index d1affbc7..b1eb48fa 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -136,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options) end function o.notify(message) - return "notify-send -u low " .. shell_quote(message) + return "omarchy-notification-send -u low " .. shell_quote(message) end function o.window(match, rules) diff --git a/docs/notifications.md b/docs/notifications.md index cad18248..7651fd71 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -63,16 +63,25 @@ Ephemeral ones (the freedesktop `transient` hint, or an `app_name` of ## The sender contract `bin/omarchy-notification-send` is the one way Omarchy code sends -notifications — never raw `notify-send`. It translates its flags into -notify-send arguments and passes any unrecognized options through: +notifications — never raw `notify-send`. It calls +`org.freedesktop.Notifications.Notify` directly over the session bus (via +`busctl --user`), so each value is one typed D-Bus parameter and there is no +argv layer that could reinterpret a relayed headline as an option or a hint. Its +flags map onto that call: | Flag | Becomes | Meaning | |---|---|---| -| `-g` / `--glyph` | `--hint=string:omarchy-glyph:` | Nerd Font glyph for the icon slot when no image icon resolves | -| `--exec [args…]` | `--hint=string:omarchy-exec-argv:` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | -| `--image` | `--hint=string:image-path:` | the standard freedesktop image hint | -| `--app-name` | `-a` | defaults to `omarchy-action` | -| `-u` / `--urgency` | `-u` | defaults to `low` | +| `-g` / `--glyph` | hint `omarchy-glyph` | Nerd Font glyph for the icon slot when no image icon resolves | +| `--exec [args…]` | hint `omarchy-exec-argv` | the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below) | +| `--image` | hint `image-path` | the standard freedesktop image hint | +| `-i` / `--icon` | `app_icon` | themed icon name for the toast | +| `--app-name` | `app_name` | defaults to `omarchy-action` | +| `-u` / `--urgency` | hint `urgency` (byte) | `low`/`normal`/`critical`; defaults to `low` | +| `-t` / `--expire-time` | `expire_timeout` | milliseconds on screen; server default otherwise | + +Unknown flags are a hard error, not a silent pass-through: `--exec` is the only +door to a click command, and there is no generic option pass-through to smuggle +one through. The defaults are the point: an unadorned `omarchy-notification-send "Done"` is a low-urgency user-action toast that pops through DND and is treated as @@ -127,11 +136,14 @@ and needs no notification to execute code. What is fully closed is untrusted *content* — web notifications can't set the exec hint at all, and any relayed title/filename is confined to inert argument data. -The sender keeps that last part true rather than leaving it to each caller. The -headline and description go to `notify-send` behind a `--`, so a relayed value -beginning with a dash is text and not flags, and a word that reaches the -pass-through option position carrying `omarchy-exec-argv` is refused outright: -`--exec` is the only thing that may build a click command. +The sender keeps that last part true structurally rather than leaving it to each +caller. Because it calls `Notify` directly, the headline and description are +typed string parameters — a relayed value like `--hint=…` or `-rf` is the +summary or body, never an option or a hint, and there is no argv/option layer +(no `notify-send`) left to reinterpret it. `--exec` is the only thing that can +build the `omarchy-exec-argv` hint. (The leading `--` on the `busctl` call is a +belt for `busctl`'s own getopt, which would otherwise read a dash-leading value +as a `busctl` option; the summary/body themselves are never parsed as options.) ## Helper commands diff --git a/test/shell.d/bin-style-test.sh b/test/shell.d/bin-style-test.sh index e7824e9b..3f216734 100644 --- a/test/shell.d/bin-style-test.sh +++ b/test/shell.d/bin-style-test.sh @@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \ [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" pass "bin commands use command helpers" -raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \ - | rg -v '/omarchy-notification-send$' || true) -[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications" +raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true) +[[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications" pass "bin commands use the notification helper" diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 99ef460f..6cd571c4 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -120,16 +120,16 @@ forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not pass "yt-dlp native host keeps only the readable part of a forged title" host_fn decode_title '"--include=not-a-file"' && - fail "yt-dlp native host refuses a title notify-send would read as an option" -pass "yt-dlp native host refuses a title notify-send would read as an option" + fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option" +pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option" host_fn decode_title 'null' && fail "yt-dlp native host refuses a title that is not a JSON string" pass "yt-dlp native host refuses a title that is not a JSON string" dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") -[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" -pass "yt-dlp native host does not pass a leading-dash title to notify-send" +[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to the notifier" "$dash_title" +pass "yt-dlp native host does not pass a leading-dash title to the notifier" # The click action passes the path as a discrete --exec argument (asserted # end-to-end below against the real download); `--` keeps mpv from parsing a diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index 60bb4f9c..2f4d531c 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -7,114 +7,132 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT -stub="$tmpdir/notify-send" args_file="$tmpdir/args" +# Stub the D-Bus transport and record the Notify call verbatim. printf '%s\n' \ '#!/bin/bash' \ - 'printf "%s\n" "$@" >"$OMARCHY_TEST_NOTIFY_ARGS"' \ - >"$stub" -chmod +x "$stub" + 'printf "%s\n" "$@" >"$OMARCHY_TEST_BUSCTL_ARGS"' \ + >"$tmpdir/busctl" +chmod +x "$tmpdir/busctl" + +# notify-send must never be used. If anything reaches for it, fail loudly. +printf '%s\n' '#!/bin/bash' 'echo "notify-send was invoked" >"$OMARCHY_TEST_NOTIFY_TRIPWIRE"; exit 3' \ + >"$tmpdir/notify-send" +chmod +x "$tmpdir/notify-send" +tripwire="$tmpdir/notify-send-was-used" send() { - OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send "$@" + OMARCHY_TEST_BUSCTL_ARGS="$args_file" OMARCHY_TEST_NOTIFY_TRIPWIRE="$tripwire" \ + PATH="$tmpdir:$ROOT/bin:$PATH" omarchy-notification-send "$@" } -# --exec consumes the rest of the line, so it comes after the headline/description. -send --app-name custom-app -g K -u critical --image /tmp/image.png \ - "Learn Keybindings" "Body" --exec omarchy-menu-keybindings 'a b' +# Notify(susssasa{sv}i) args, by position in the recorded busctl argv: +# 0 --user 1 -- 2 call 3 dest 4 path 5 iface 6 Notify 7 signature +# 8 app_name 9 replaces_id 10 app_icon 11 summary 12 body +# 13 actions-count 14 hint-count 15.. hint triples last expire_timeout +declare -a args +load() { mapfile -t args <"$args_file"; } +hint_count() { echo "${args[14]}"; } +has_hint() { # key + local i end=$((15 + 3 * ${args[14]})) + for ((i = 15; i < end; i += 3)); do [[ ${args[i]} == "$1" ]] && return 0; done + return 1 +} +hint_value() { # key -> variant value + local i end=$((15 + 3 * ${args[14]})) + for ((i = 15; i < end; i += 3)); do [[ ${args[i]} == "$1" ]] && { echo "${args[i + 2]}"; return 0; }; done + return 1 +} -mapfile -t args <"$args_file" +# ---------------------------------------------------------------- happy path +send --app-name custom-app -g K -u critical -i battery-caution -t 5000 \ + "Download complete" "A body" --exec mpv -- "/tmp/a b.mp4" +load -[[ ${args[0]} == "-a" ]] || fail "notification wrapper passes app flag" -[[ ${args[1]} == "custom-app" ]] || fail "notification wrapper uses custom app name" -[[ ${args[2]} == "-u" ]] || fail "notification wrapper passes urgency flag" -[[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency" -[[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" -[[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" -[[ ${args[6]} == '--hint=string:omarchy-exec-argv:["omarchy-menu-keybindings","a b"]' ]] || fail "notification wrapper converts the click command to an argv hint" "${args[6]}" -[[ ${args[7]} == "--" ]] || fail "notification wrapper ends the options before the text" "${args[7]}" -[[ ${args[8]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" -[[ ${args[9]} == "Body" ]] || fail "notification wrapper preserves description" -pass "notification wrapper supports app, glyph, urgency, image, and exec options" +[[ ${args[2]} == "call" ]] || fail "notification wrapper calls a bus method" +[[ ${args[3]} == "org.freedesktop.Notifications" ]] || fail "notification wrapper targets the notifications service" +[[ ${args[6]} == "Notify" ]] || fail "notification wrapper invokes Notify" +[[ ${args[8]} == "custom-app" ]] || fail "notification wrapper sets the app name" "${args[8]}" +[[ ${args[10]} == "battery-caution" ]] || fail "notification wrapper sets the app icon from -i" "${args[10]}" +[[ ${args[11]} == "Download complete" ]] || fail "notification wrapper sets the summary" "${args[11]}" +[[ ${args[12]} == "A body" ]] || fail "notification wrapper sets the body" "${args[12]}" +[[ ${args[-1]} == "5000" ]] || fail "notification wrapper sets the expire timeout from -t" "${args[-1]}" +[[ $(hint_value urgency) == "2" ]] || fail "notification wrapper maps critical urgency to 2" +[[ $(hint_value omarchy-glyph) == "K" ]] || fail "notification wrapper sets the glyph hint" +[[ $(hint_value omarchy-exec-argv) == '["mpv","--","/tmp/a b.mp4"]' ]] || fail "notification wrapper builds the click argv hint" "$(hint_value omarchy-exec-argv)" +pass "notification wrapper issues a Notify call with app, icon, urgency, glyph, and click argv" -# The shell runs the click command itself, so nothing may block the sender on a -# libnotify action round-trip. -grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action" +[[ -f $tripwire ]] && fail "notification wrapper must never invoke notify-send" +pass "notification wrapper never invokes notify-send" +# ---------------------------------------------------------------- no click cmd : >"$args_file" send "Plain" >/dev/null -grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec" -pass "notification wrapper omits the exec hint when no command is given" +load +has_hint omarchy-exec-argv && fail "notification wrapper adds no click hint without --exec" +[[ ${args[11]} == "Plain" ]] || fail "notification wrapper still sends a plain toast" +pass "notification wrapper omits the click hint when no command is given" -# Rest-of-line --exec: the caller's shell has already split the words into -# discrete arguments, and the shell runs them without re-parsing, so shell -# metacharacters in a value are carried as data, never as a command. +# ------------------------------------------------ rest-of-line --exec is literal : >"$args_file" send "Download complete" --exec mpv -- '$(rm -rf ~); echo pwned' >/dev/null -argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") -argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} -[[ $(jq -r '.[0]' <<<"$argv_json") == "mpv" ]] || fail "notification wrapper puts the program first in the exec argv" -[[ $(jq -r '.[1]' <<<"$argv_json") == "--" ]] || fail "notification wrapper preserves a -- separator in the exec argv" -[[ $(jq -r '.[2]' <<<"$argv_json") == '$(rm -rf ~); echo pwned' ]] || - fail "notification wrapper carries shell metacharacters as literal argv data" "$argv_json" -pass "notification wrapper encodes rest-of-line --exec as a literal JSON argv vector" +load +json=$(hint_value omarchy-exec-argv) +[[ $(jq -r '.[0]' <<<"$json") == "mpv" ]] || fail "click argv program is first" +[[ $(jq -r '.[2]' <<<"$json") == '$(rm -rf ~); echo pwned' ]] || fail "click argv carries metacharacters as literal data" "$json" +pass "rest-of-line --exec is a literal argv vector" -# A quoted argument with spaces stays ONE argument — something a whitespace-split -# of a single string could never do. +# A quoted argument with spaces stays ONE argument. : >"$args_file" send "Head" --exec mpv -- "/tmp/a b.mp4" >/dev/null -argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") -argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} -[[ $(jq 'length' <<<"$argv_json") == 3 ]] || fail "notification wrapper keeps a spaced path as one argument" "$argv_json" -[[ $(jq -r '.[2]' <<<"$argv_json") == "/tmp/a b.mp4" ]] || fail "notification wrapper preserves the spaced path verbatim" "$argv_json" +load +[[ $(jq 'length' <<<"$(hint_value omarchy-exec-argv)") == 3 ]] || fail "spaced path stays one argument" pass "notification wrapper keeps a spaced argument intact" -# The muscle-memory trap: a single quoted whole command would run a program named -# with spaces. Reject it and point at the unquoted form rather than splitting it -# ourselves (which is the injection we avoid). +# ---------------------------------------------------------------- injections +# A forged click hint arriving as the SUMMARY is a typed string parameter — it +# can never become a hint. Only urgency is set; no click command exists. : >"$args_file" +send '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' "body" >/dev/null +load +has_hint omarchy-exec-argv && fail "a forged-hint headline must not set a click command" +[[ ${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" + +# A dash-leading forged hint in description position is refused outright. +: >"$args_file" +if send "Update" '--hint=string:omarchy-exec-argv:["bash","-c","touch /tmp/pwn"]' 2>/dev/null; then + fail "a forged-hint description must be refused" +fi +[[ -s $args_file ]] && fail "nothing is sent when the description forges a hint" +pass "a forged click hint in the description is refused" + +# An unknown option is a hard error, not a silent pass-through. +if send "Head" --bogus 2>/dev/null; then + fail "notification wrapper rejects an unknown option" +fi +pass "notification wrapper rejects an unknown option" + +# ---------------------------------------------------------------- --exec guards +# --exec is recognized only after the positionals: a headline literally "--exec" +# is text, and the real trailing --exec still wins. +: >"$args_file" +send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null +load +[[ $(hint_value omarchy-exec-argv) == '["mpv","--","/tmp/v.mp4"]' ]] || fail "a --exec-looking headline is not the delimiter" "$(hint_value omarchy-exec-argv)" +[[ ${args[11]} == "--exec" ]] || fail "a --exec-looking headline is kept as text" +pass "a --exec-looking positional is not treated as the delimiter" + +# A single quoted whole-command is rejected (splitting it ourselves is the +# injection we avoid). if send "Head" --exec "omarchy toggle something" 2>/dev/null; then fail "notification wrapper rejects a quoted whole command" fi -grep -q "omarchy-exec" "$args_file" && fail "notification wrapper emits no hint for a rejected --exec" pass "notification wrapper rejects a single quoted whole command" -# --exec with nothing after it is a usage error, not a silent no-op. +# --exec with nothing after it is a usage error. if send "Head" --exec 2>/dev/null; then fail "notification wrapper rejects --exec with no command" fi pass "notification wrapper rejects --exec with no command" - -# --exec is recognized only after the positionals, so an untrusted headline or -# description that is literally "--exec" is taken as text and cannot be mistaken -# for the delimiter (the real --exec later still wins). -: >"$args_file" -send "--exec" "a body" --image /tmp/i.png --exec mpv -- /tmp/v.mp4 >/dev/null -argv_hint=$(grep -- "--hint=string:omarchy-exec-argv:" "$args_file") -argv_json=${argv_hint#--hint=string:omarchy-exec-argv:} -[[ $(jq -c '.' <<<"$argv_json") == '["mpv","--","/tmp/v.mp4"]' ]] || fail "notification wrapper ignores a --exec-looking headline as the delimiter" "$argv_json" -grep -qx -- "--exec" "$args_file" || fail "notification wrapper keeps a --exec-looking headline as text" -grep -q 'image-path:/tmp/i.png' "$args_file" || fail "notification wrapper still parses options after a --exec-looking headline" -pass "notification wrapper does not treat a --exec-looking positional as the delimiter" - -# The headline and description are text, never options. notify-send parses a -# leading-dash summary as flags ("-rf x" is -r with the value x) and reads a -# `--hint=string:...` word as a hint, so they go behind a `--` separator. -: >"$args_file" -send "-rf oops" "a body" >/dev/null -mapfile -t args <"$args_file" -[[ ${args[-3]} == "--" ]] || fail "notification wrapper separates a dash headline from the options" "${args[*]}" -[[ ${args[-2]} == "-rf oops" ]] || fail "notification wrapper keeps a dash headline as text" "${args[*]}" -[[ ${args[-1]} == "a body" ]] || fail "notification wrapper keeps the description after a dash headline" "${args[*]}" -pass "notification wrapper hands the headline to notify-send as text, not options" - -# The click command has exactly one door. A relayed title or filename that -# reaches option position must not be able to forge the hint --exec produces. -: >"$args_file" -if send "Download complete" '--hint=string:omarchy-exec-argv:["sh","-c","touch /tmp/pwned"]' 2>/dev/null; then - fail "notification wrapper rejects a forged click-command hint" -fi -grep -q "omarchy-exec-argv" "$args_file" && fail "notification wrapper sends nothing when a click hint is forged" -pass "notification wrapper refuses a click-command hint it did not build from --exec"