From ec9da050eef10a0d9f1dc5a8dbab41f78e74a1fa Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:47:31 -0400 Subject: [PATCH 01/73] Stop Resolve dialogs from recapturing pointer focus --- default/hypr/apps/davinci-resolve.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 6f6abda3..1af83aa1 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,9 +3,10 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager)$" }, { stay_focused = false }) +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory)$" }, { stay_focused = false }) From 94a7b70ed4ed514cd120918ee39a965c299dfd24 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:50:12 -0400 Subject: [PATCH 02/73] Explain Resolve pointer focus override --- default/hypr/apps/davinci-resolve.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 1af83aa1..a2eeef40 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,6 +3,7 @@ o.window(".*[Rr]esolve.*", { float = true, stay_focused = true, + -- Prevent modal dialog pointer warps when focus follows the mouse. no_follow_mouse = true, tag = "-default-opacity", opacity = "1 1", From 7c896d3521f4fe0a22d49226caef69b184b2d222 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 24 Aug 2026 07:16:53 +0530 Subject: [PATCH 03/73] Keep a web app name out of the launcher's directory structure The app name becomes a filename, and omarchy-webapp-install ran `mkdir -p "$(dirname "$DESKTOP_FILE")"` over it, so every slash turned into a directory level. Typing a URL into the Name field -- the reported way in -- wrote the launcher to `~/.local/share/applications/http:/127.0.0.1:4000/.desktop`. Removal could then never reach it. The picker lists the file but displays a name derived from the path, and the removal rebuilt a flat `$DESKTOP_DIR/$APP_NAME.desktop` from that name, so `rm -f` deleted nothing and the app stayed in the launcher with no error. Refuse a name containing a slash rather than silently renaming what the user typed, and delete the file the scan actually found instead of a path rebuilt from its display name. The second half also clears up whatever earlier versions nested, which a reconstructed path cannot address. --- bin/omarchy-webapp-install | 14 ++++++-- bin/omarchy-webapp-remove | 34 +++++++++++++----- test/shell.d/webapp-name-test.sh | 61 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 test/shell.d/webapp-name-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..b7985968 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -104,6 +104,15 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +# The name becomes a filename. A slash would turn it into directory levels, so +# the launcher lands somewhere omarchy-webapp-remove cannot address and the app +# is stuck in the launcher. Refuse rather than silently renaming what the user +# typed -- most often it is a URL entered in the name field. +if [[ $APP_NAME == */* ]]; then + echo "App name cannot contain '/': $APP_NAME" + exit 1 +fi + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" @@ -132,8 +141,9 @@ fi EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" # Create application .desktop file -DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" -mkdir -p "$(dirname "$DESKTOP_FILE")" +DESKTOP_DIR="$HOME/.local/share/applications" +DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" +mkdir -p "$DESKTOP_DIR" cat >"$DESKTOP_FILE" <"$tmp_dir/bin/$stub" + chmod +x "$tmp_dir/bin/$stub" +done + +run_install() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" \ + "$ROOT/bin/omarchy-webapp-install" "$@" +} + +run_remove() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "$@" +} + +apps_dir="$tmp_dir/home/.local/share/applications" + +# A URL typed into the name field is the reported way in. Every slash used to +# become a directory level, leaving a launcher nothing could address. +if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name containing a slash" +fi +[[ -e "$apps_dir/http:" ]] && + fail "webapp install does not create a directory from a slashed name" +pass "webapp install rejects a name that would nest the launcher" + +# A normal name still installs and removes. +run_install "Example App" "https://example.com" hey >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] || + fail "webapp install writes the launcher for an ordinary name" +run_remove "Example App" >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] && + fail "webapp remove deletes the launcher it installed" +pass "webapp install and remove round-trip an ordinary name" + +# Anything installed by an older version can still be nested. Removal has to +# reach it, which a path rebuilt from the displayed name never could. +mkdir -p "$apps_dir/http:/127.0.0.1:4000" +cat >"$apps_dir/http:/127.0.0.1:4000/.desktop" <<'DESKTOP' +[Desktop Entry] +Name=http://127.0.0.1:4000 +Exec=omarchy-launch-webapp https://127.0.0.1:4000 +Type=Application +DESKTOP + +# This is the name the picker shows for that file: the script strips .desktop +# from the path and then takes the basename, which lands on the directory. +run_remove "127.0.0.1:4000" >/dev/null +[[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && + fail "webapp remove deletes a launcher left nested by an older install" +pass "webapp remove reaches a nested legacy launcher" From 44b00a4e80c0d3bf19464d18bcf9ca2f35e02a78 Mon Sep 17 00:00:00 2001 From: David Helmus Date: Mon, 24 Aug 2026 08:52:25 +0200 Subject: [PATCH 04/73] test: avoid mise shim recursion --- test/shell.d/copy-url-shortcut-migration-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index f8cf3497..dd25420e 100644 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ b/test/shell.d/copy-url-shortcut-migration-test.sh @@ -28,7 +28,7 @@ write_stale_preferences() { stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -REAL_PYTHON=$(command -v python3) +REAL_PYTHON=$(command -p -v python3) export REAL_PYTHON run_migration() { From e66c27f1e7ed395fce4e6910300b2d7476413640 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 24 Aug 2026 12:37:46 -0400 Subject: [PATCH 05/73] Require signed packages from the Omarchy repository --- default/pacman/pacman-edge.conf | 1 - default/pacman/pacman-rc.conf | 1 - default/pacman/pacman-stable.conf | 1 - migrations/1787589206.sh | 20 ++++++++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 migrations/1787589206.sh diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index d83befca..a968d26d 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -26,7 +26,6 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch # Repositories for debug symbol packages. diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index 50d2e498..cf8ca40a 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/edge/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 5dafbc84..7e4b5538 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -26,5 +26,4 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [omarchy] -SigLevel = Optional TrustAll Server = https://pkgs.omarchy.org/stable/$arch diff --git a/migrations/1787589206.sh b/migrations/1787589206.sh new file mode 100644 index 00000000..928905a9 --- /dev/null +++ b/migrations/1787589206.sh @@ -0,0 +1,20 @@ +echo "Require signed packages from the Omarchy repository" + +# The [omarchy] repo predates the Omarchy packaging key, so existing installs +# carry a SigLevel override that also accepts unsigned packages. Packages are +# signed now, so drop the override and let the repo inherit the global +# SigLevel = Required DatabaseOptional like every other repo. Machine-wide and +# self-detecting, so another user's rerun no-ops. +omarchy_sig_override='SigLevel = Optional TrustAll' + +if [[ -f /etc/pacman.conf ]] && + sed -n '/^\[omarchy\]/,/^\[/p' /etc/pacman.conf | grep -qxF "$omarchy_sig_override"; then + # Requiring signatures with an untrusted packaging key would fail every + # omarchy transaction, including the one that could repair it. + if omarchy-pkg-missing omarchy-keyring || + ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then + omarchy-update-keyring + fi + + sudo sed -i "/^\[omarchy\]/,/^\[/{/^$omarchy_sig_override$/d}" /etc/pacman.conf +fi From 597f57a198f664aaa8604a2625b934c55876278d Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Mon, 24 Aug 2026 22:00:42 -0400 Subject: [PATCH 06/73] Allow Resolve Voiceover to release focus --- default/hypr/apps/davinci-resolve.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index a2eeef40..734d2b8f 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -10,4 +10,5 @@ o.window(".*[Rr]esolve.*", { }) o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true }) -o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory)$" }, { stay_focused = false }) +-- Resolve exposes the Voiceover panel under the generic "Dialog" title. +o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager|Preferences|Find Directory|Dialog)$" }, { stay_focused = false }) From 8bee78bc639388931fec76ccabe16d72decc0637 Mon Sep 17 00:00:00 2001 From: Jason Wall Date: Mon, 24 Aug 2026 22:11:16 -0700 Subject: [PATCH 07/73] Keep the Windows VM display fully opaque --- default/hypr/apps/windows-vm.lua | 5 +++++ test/shell.d/windows-vm-test.sh | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 default/hypr/apps/windows-vm.lua diff --git a/default/hypr/apps/windows-vm.lua b/default/hypr/apps/windows-vm.lua new file mode 100644 index 00000000..a31bf882 --- /dev/null +++ b/default/hypr/apps/windows-vm.lua @@ -0,0 +1,5 @@ +-- Keep the Windows VM display opaque instead of applying the default window opacity. +o.window({ class = "^xfreerdp$", title = "^Windows VM - Omarchy$" }, { + tag = "-default-opacity", + opacity = "1 1", +}) diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 2e887672..181a0b02 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" windows_vm_command="$ROOT/bin/omarchy-windows-vm" +windows_vm_rules="$ROOT/default/hypr/apps/windows-vm.lua" rg -q '^ restart: "no"$' "$windows_vm_command" || fail "Windows VM uses manual startup by default" @@ -14,3 +15,13 @@ if rg -q '^ restart: unless-stopped$' "$windows_vm_command"; then fail "Windows VM does not restart automatically at boot" fi pass "Windows VM does not restart automatically at boot" + +rg -q '/title:"Windows VM - Omarchy"' "$windows_vm_command" || + fail "Windows VM launches FreeRDP with its expected title" +rg -q 'class = "\^xfreerdp\$", title = "\^Windows VM - Omarchy\$"' "$windows_vm_rules" || + fail "Windows VM opacity rule targets its FreeRDP window" +rg -q 'tag = "-default-opacity"' "$windows_vm_rules" || + fail "Windows VM opts out of default opacity" +rg -q 'opacity = "1 1"' "$windows_vm_rules" || + fail "Windows VM stays fully opaque" +pass "Windows VM stays fully opaque" From 12c350e404acaa8149bd5cb4c783ba7f52cb78ef Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 25 Aug 2026 08:45:50 +0200 Subject: [PATCH 08/73] Match the Windows VM title regardless of the launcher's shell quoting The new assertion pinned /title:"Windows VM - Omarchy" with the quote sitting immediately after the colon. That quote is incidental shell syntax, not the title the Hyprland rule matches on. Open PR #7902 moves the RDP arguments into an array, where the same flag reads "/title:Windows VM - Omarchy", so the assertion would fail for whichever of the two pull requests merged second. Allow an optional quote after the colon. The assertion still fails if the title itself drifts, in either direction, which is the coupling it exists to protect. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/windows-vm-test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 181a0b02..e2a04dae 100644 --- a/test/shell.d/windows-vm-test.sh +++ b/test/shell.d/windows-vm-test.sh @@ -16,7 +16,9 @@ if rg -q '^ restart: unless-stopped$' "$windows_vm_command"; then fi pass "Windows VM does not restart automatically at boot" -rg -q '/title:"Windows VM - Omarchy"' "$windows_vm_command" || +# Tolerate either shell quoting of the argument -- what must not drift is the +# title itself, since the Hyprland rule below matches on it. +rg -q 'title:"?Windows VM - Omarchy"' "$windows_vm_command" || fail "Windows VM launches FreeRDP with its expected title" rg -q 'class = "\^xfreerdp\$", title = "\^Windows VM - Omarchy\$"' "$windows_vm_rules" || fail "Windows VM opacity rule targets its FreeRDP window" From fe56d68e905c8dc63a4c6a43727fef40833360d7 Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:00 +0200 Subject: [PATCH 09/73] Validate the cached Apple-display device path before use The cached device path was trusted for merely existing, not for being a hiddev node, and fell back to a predictable /tmp path when XDG_RUNTIME_DIR was unset. Validate the cache shape (hiddev char device) and cache only under the user-private runtime dir; asdcontrol already gates non-Apple devices downstream, so this is defense-in-depth in the layer Omarchy owns. --- bin/omarchy-brightness-display-apple | 21 ++- .../brightness-display-apple-cache-test.sh | 120 ++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100755 test/shell.d/brightness-display-apple-cache-test.sh diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 81202b87..1027686d 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -4,7 +4,13 @@ # omarchy:args=[--no-osd] [+N%|N%-|N%] # omarchy:examples=omarchy brightness display apple | omarchy brightness display apple +5% | omarchy brightness display apple --no-osd 50% -device_cache="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-apple.device" +# Only cache under the user-private runtime dir. With no XDG_RUNTIME_DIR we skip +# caching (detect every run) rather than fall back to a predictable, world-writable +# /tmp path another user could pre-create. +device_cache="" +if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then + device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device" +fi no_osd=0 if [[ ${1:-} == "--no-osd" ]]; then no_osd=1 @@ -28,9 +34,14 @@ find_apple_display_device() { local cached="" local device="" - if [[ -r $device_cache ]]; then + if [[ -n "$device_cache" && -r $device_cache ]]; then read -r cached <"$device_cache" || true - if [[ -n $cached && -e $cached ]]; then + # Trust a cached value only if it still names a hiddev character device. A + # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and + # we re-detect instead of handing an arbitrary path to asdcontrol. The globs + # are left unquoted on purpose: [[ ]] pattern-matches an unquoted right side, + # and quoting them would turn the match into a literal string comparison. + if [[ ( $cached == /dev/hiddev* || $cached == /dev/usb/hiddev* ) && -c $cached ]]; then printf '%s\n' "$cached" return 0 fi @@ -39,7 +50,9 @@ find_apple_display_device() { device="$(detect_apple_display_device)" || return 1 [[ -n $device ]] || return 1 - printf '%s\n' "$device" >"$device_cache" + if [[ -n "$device_cache" ]]; then + printf '%s\n' "$device" >"$device_cache" + fi printf '%s\n' "$device" } diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh new file mode 100755 index 00000000..2808f93f --- /dev/null +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol +# invocation, make detection deterministic by having --detect report no device, +# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's +# detect_apple_display_device returns before it ever runs asdcontrol, so the +# reject cases assert on the negative: a refused cache value is never handed to +# `asdcontrol -- `. Blind-trust validation would hand it over and be +# caught here. +stub_dir="$TMPDIR/stubs" +mkdir -p "$stub_dir" + +asd_log="$TMPDIR/asdcontrol.log" + +cat >"$stub_dir/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$stub_dir/sudo" + +cat >"$stub_dir/asdcontrol" <>"$asd_log" +# --detect reports nothing, so detection never yields a device. +if [[ \$1 == "--detect" ]]; then + exit 0 +fi +# A brightness read (a lone device arg) returns a plausible value; a set +# ( -- ) just succeeds. +if [[ \$# -eq 1 ]]; then + printf '%s: BRIGHTNESS=30000\n' "\$1" +fi +exit 0 +STUB +chmod +x "$stub_dir/asdcontrol" + +cat >"$stub_dir/omarchy-osd" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$stub_dir/omarchy-osd" + +run_wrapper() { + # $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper. + local xdg="$1" + shift + : >"$asd_log" + if [[ -n $xdg ]]; then + XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + else + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + omarchy-brightness-display-apple "$@" 2>&1 || true + fi +} + +# --- A cache value that is not a hiddev character device is rejected ---------- +xdg_dir="$TMPDIR/xdg" +mkdir -p "$xdg_dir" +cache_file="$xdg_dir/omarchy-brightness-display-apple.device" + +regular_file="$TMPDIR/not-a-device" +: >"$regular_file" + +for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do + printf '%s\n' "$poison" >"$cache_file" + output=$(run_wrapper "$xdg_dir" "+5%") + if grep -qF -- "$poison -- +5%" "$asd_log"; then + fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output" + fi +done +pass "wrapper rejects a cached path that is not a hiddev character device" + +# NOTE: the complementary arm (a cache value that DOES match /dev/hiddev* but is +# not a character device) cannot be built without root -- only real device nodes +# live under /dev. It is covered by the -c test and exercised below only when a +# real hiddev node happens to be present. + +# --- A legitimate cached hiddev node is trusted (only where HW is present) ---- +real_hiddev="" +for candidate in /dev/usb/hiddev* /dev/hiddev*; do + if [[ -c $candidate ]]; then + real_hiddev="$candidate" + break + fi +done +if [[ -n $real_hiddev ]]; then + printf '%s\n' "$real_hiddev" >"$cache_file" + run_wrapper "$xdg_dir" "+5%" >/dev/null + grep -qF -- "$real_hiddev -- +5%" "$asd_log" || + fail "wrapper did not trust a valid cached hiddev node: $real_hiddev" + pass "wrapper trusts a cached hiddev character device without re-detecting" +else + pass "no /dev/hiddev* character device present; skipping the valid-cache case" +fi + +# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ +# Guard on the real path not pre-existing so we never clobber a live cache, and +# remove what we create. Old code read /tmp and would hand /dev/null to +# asdcontrol; new code has no cache path at all when XDG_RUNTIME_DIR is unset. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +if [[ -e $tmp_cache ]]; then + pass "$tmp_cache already exists on this host; skipping the /tmp-fallback case" +else + printf '%s\n' "/dev/null" >"$tmp_cache" + output=$(run_wrapper "" "+5%") + used=1 + grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 + rm -f "$tmp_cache" + (( used == 0 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" + pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +fi From e53548fae28ecd6b08dc2f7f5facf7f71f05621b Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:51:39 +0200 Subject: [PATCH 10/73] Harden the test's temp-file handling against a symlink race The /tmp-fallback case did check-then-create on a fixed /tmp name, a TOCTOU/symlink race, and the EXIT trap only cleaned $TMPDIR. Create the decoy atomically with noclobber (O_EXCL) so it refuses to overwrite an existing file or follow a symlink at that path, and remove it on exit only when this test created it. The fixed path is required (it is exactly the path the old code would form), so a random mktemp name cannot replace it. Addresses the Copilot review on #8198; the wrapper fix is unchanged. --- .../brightness-display-apple-cache-test.sh | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh index 2808f93f..c3ac8d53 100755 --- a/test/shell.d/brightness-display-apple-cache-test.sh +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -5,7 +5,20 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the +# old wrapper would have formed, so it cannot use a random mktemp name. Track whether +# we created it and remove it on exit only then -- never touch a path we did not create. +tmp_cache="/tmp/omarchy-brightness-display-apple.device" +created_tmp_cache=0 + +cleanup() { + rm -rf "$TMPDIR" + # Remove the /tmp decoy only if this test is the one that created it. + if (( created_tmp_cache )); then + rm -f "$tmp_cache" + fi +} +trap cleanup EXIT # Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol # invocation, make detection deterministic by having --detect report no device, @@ -102,14 +115,14 @@ else fi # --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ -# Guard on the real path not pre-existing so we never clobber a live cache, and -# remove what we create. Old code read /tmp and would hand /dev/null to -# asdcontrol; new code has no cache path at all when XDG_RUNTIME_DIR is unset. -tmp_cache="/tmp/omarchy-brightness-display-apple.device" -if [[ -e $tmp_cache ]]; then - pass "$tmp_cache already exists on this host; skipping the /tmp-fallback case" -else - printf '%s\n' "/dev/null" >"$tmp_cache" +# Create the decoy atomically with noclobber (O_EXCL) instead of check-then-create: +# this refuses to overwrite an existing file or follow a symlink at the fixed path, +# closing the TOCTOU/symlink race. The fixed path is required -- it is exactly the +# path the old code would have formed, so a decoy anywhere else would prove nothing. +# If the path is already taken, skip rather than touch it; the EXIT trap removes the +# decoy only when this test created it. +if ( set -C; printf '%s\n' "/dev/null" >"$tmp_cache" ) 2>/dev/null; then + created_tmp_cache=1 output=$(run_wrapper "" "+5%") used=1 grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 @@ -117,4 +130,6 @@ else (( used == 0 )) || fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" +else + pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" fi From b91180a80875740e486016f3625545ac53fe256d Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Tue, 25 Aug 2026 14:48:44 +0200 Subject: [PATCH 11/73] Skip Chromium EULA on first run --- bin/omarchy-upgrade-to-quattro | 2 +- install/config/theme-system.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index e28da697..fd8151ba 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1314,7 +1314,7 @@ apply_system_transition() { as_root install -d -m 0777 /etc/chromium/policies/managed as_root install -d -m 0755 /usr/lib/chromium - printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ + printf '%s\n' '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null # Deliberately do NOT add the user to the docker group. That group is diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..c8272486 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -10,7 +10,7 @@ gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true mkdir -p /etc/chromium/policies/managed chmod a+rw /etc/chromium/policies/managed -# Default Chromium to follow system appearance ("device") instead of dark +# Set Chromium first-run defaults mkdir -p /usr/lib/chromium -echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ +echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ /usr/lib/chromium/initial_preferences From 2c93e66b0cd0b8138e16dc580fa26878f31f4e50 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 25 Aug 2026 16:05:39 +0100 Subject: [PATCH 12/73] Run ONCE with sudo when installing The install user is no longer in the docker group by default, so a bare `once` cannot reach the Docker socket. Run with `sudo` instead. The script already requires sudo to install the command and enable the service, so we can safely use it for the initial command launch as well. --- bin/omarchy-install-service-once | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-install-service-once b/bin/omarchy-install-service-once index 9b5cf446..b721a738 100755 --- a/bin/omarchy-install-service-once +++ b/bin/omarchy-install-service-once @@ -10,4 +10,4 @@ echo "Enabling ONCE background service..." sudo systemctl enable --now once-background.service echo -e "\nLaunching ONCE..." -once +sudo once From c34d20ca14faa75b90580124790956f5c061db82 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 25 Aug 2026 18:39:00 +0100 Subject: [PATCH 13/73] [Security] Pin Windows VM mounts behind a root boundary --- bin/omarchy-windows-vm | 360 +++++++++++++++++++----- manual/28-windows-vm.md | 4 +- test/shell.d/windows-vm-compose-test.sh | 82 +++--- 3 files changed, 343 insertions(+), 103 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 1487cb21..c22c4815 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -67,9 +67,14 @@ priv_target() { priv() { local action="$1" shift - if [[ $action != write_compose ]] && ! docker_needs_sudo; then - "__priv_$action" "$@" - return + if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then + # Existing installs used bind sources in $HOME. One privileged run is + # needed to atomically move those directories below the root-owned mount + # boundary, even when the caller can otherwise reach Docker directly. + if [[ $action != up && $action != up_wait ]] || ! compose_needs_mount_migration; then + "__priv_$action" "$@" + return + fi fi local target target=$(priv_target) || { @@ -88,16 +93,6 @@ valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } -valid_path() { - [[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 - # Reject non-normalized paths: a . or .. component canonicalizes at mount time - # (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory — - # host / included — into the guest. Volumes must be given already-normalized. - case "$1" in - *//* | */./* | */../* | */. | */..) return 1 ;; - esac - return 0 -} valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } # The only privileged sub-actions __priv may dispatch. A bash command name @@ -113,13 +108,207 @@ valid_priv_action() { # --- privileged actions (run as root via pkexec, or directly when sudoless) --- +# Resolve the account that authorized pkexec. Never trust HOME or a caller- +# supplied mount path in the privileged process: pkexec can reset HOME, and the +# old path arguments were the source of an arbitrary host bind-mount primitive. +resolve_caller() { + local entry canonical parent owner mode + + if ((EUID == 0)); then + [[ ${PKEXEC_UID:-} =~ ^[0-9]+$ ]] && ((10#$PKEXEC_UID > 0)) || { + echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2 + return 1 + } + CALLER_UID=$((10#$PKEXEC_UID)) + else + CALLER_UID=$(id -u) + fi + + entry=$(getent passwd "$CALLER_UID") || { + echo "omarchy-windows-vm: no account exists for uid $CALLER_UID" >&2 + return 1 + } + IFS=: read -r _ _ _ CALLER_GID _ CALLER_HOME _ <<<"$entry" + [[ $CALLER_GID =~ ^[0-9]+$ && $CALLER_HOME == /* && -d $CALLER_HOME ]] || { + echo "omarchy-windows-vm: invalid home directory for uid $CALLER_UID" >&2 + return 1 + } + + # A direct, non-root development invocation with a non-standard runtime has + # no privilege boundary and may use its current HOME (which also keeps these + # functions testable). Production always uses the account database value. + if ((EUID != 0)) && [[ $RUNTIME_DIR != /var/lib/omarchy/windows ]]; then + CALLER_HOME=${HOME:-$CALLER_HOME} + fi + canonical=$(realpath -e -- "$CALLER_HOME" 2>/dev/null) || return 1 + [[ $canonical == "$CALLER_HOME" ]] || { + echo "omarchy-windows-vm: refusing a home directory reached through a symlink" >&2 + return 1 + } + + if ((EUID == 0)); then + owner=$(stat -Lc '%u' "$CALLER_HOME") || return 1 + [[ $owner == "$CALLER_UID" ]] || { + echo "omarchy-windows-vm: caller does not own $CALLER_HOME" >&2 + return 1 + } + # The user must not be able to rename or replace their home while root is + # moving the legacy data entry out of it. + parent=$(dirname -- "$CALLER_HOME") + while :; do + owner=$(stat -Lc '%u' "$parent") || return 1 + mode=$(stat -Lc '%a' "$parent") || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || { + echo "omarchy-windows-vm: unsafe writable parent in home path: $parent" >&2 + return 1 + } + [[ $parent == / ]] && break + parent=$(dirname -- "$parent") + done + fi + + # Keep the potentially large disk on the same filesystem as the user's home, + # but outside that user-writable directory. The already-validated home parent + # is root-owned, so this sibling tree provides a stable rename boundary. + MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + USERS_DIR="$MOUNT_ROOT/users" + CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID" + EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage" + EXPECTED_SHARED="$CALLER_DATA_ROOT/shared" + LEGACY_STORAGE="$CALLER_HOME/.windows" + LEGACY_SHARED="$CALLER_HOME/Windows" +} + +boundary_owner() { + # Production boundaries remain root-owned even when a docker-group user runs + # the read-only bring-up checks directly. A non-standard runtime is supported + # only for unprivileged tests/development and is owned by that caller. + if ((EUID == 0)) || [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]]; then + printf '0' + else + printf '%s' "$CALLER_UID" + fi +} + +assert_boundary_dir() { + local path="$1" expected_owner="$2" owner mode canonical + [[ -d $path && ! -L $path ]] || return 1 + canonical=$(realpath -e -- "$path" 2>/dev/null) || return 1 + [[ $canonical == "$path" ]] || return 1 + owner=$(stat -Lc '%u' "$path") || return 1 + mode=$(stat -Lc '%a' "$path") || return 1 + [[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022)) +} + +prepare_runtime_tree() { + local owner probe + owner=$(boundary_owner) + if ((EUID == 0)); then + [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || { + echo "omarchy-windows-vm: refusing a non-standard privileged runtime path" >&2 + return 1 + } + # Check the nearest existing ancestor before mkdir can follow anything. + # Every new component is then created by root and checked again below. + probe=$RUNTIME_DIR + while [[ ! -e $probe && ! -L $probe ]]; do probe=$(dirname -- "$probe"); done + while :; do + assert_boundary_dir "$probe" 0 || { + echo "omarchy-windows-vm: unsafe runtime parent: $probe" >&2 + return 1 + } + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") + done + if [[ -e $MOUNT_ROOT || -L $MOUNT_ROOT ]]; then + assert_boundary_dir "$MOUNT_ROOT" 0 || { + echo "omarchy-windows-vm: unsafe mount root: $MOUNT_ROOT" >&2 + return 1 + } + fi + fi + mkdir -p -- "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + chmod 0755 "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + if ((EUID == 0)); then + chown root:root "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + fi + assert_boundary_dir "$RUNTIME_DIR" "$owner" && + assert_boundary_dir "$MOUNT_ROOT" "$owner" && + assert_boundary_dir "$USERS_DIR" "$owner" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" || { + echo "omarchy-windows-vm: unsafe VM mount boundary" >&2 + return 1 + } +} + + # Move an existing home entry first, then inspect the pinned object below the +# root-owned parent. This closes the check/use gap where an attacker could swap +# a checked home directory for a symlink before Docker resolved it. +prepare_mount_leaf() { + local legacy="$1" stable="$2" rejected source_dev target_dev + + if [[ ! -e $stable && ! -L $stable ]]; then + if [[ -e $legacy || -L $legacy ]]; then + # rename(2) pins the exact directory entry the caller presented. GNU mv + # falls back to a privileged recursive copy across filesystems, which + # would reopen the source path and reintroduce the race, so fail closed in + # that uncommon layout instead of copying as root. + source_dev=$(stat -c '%d' -- "$legacy") || return 1 + target_dev=$(stat -Lc '%d' -- "$CALLER_DATA_ROOT") || return 1 + [[ $source_dev == "$target_dev" ]] || { + echo "omarchy-windows-vm: cannot safely migrate $legacy across filesystems" >&2 + echo "Move it onto the filesystem containing $MOUNT_ROOT, then retry." >&2 + return 1 + } + mv --no-copy -T -- "$legacy" "$stable" || return 1 + if [[ ! -d $stable || -L $stable ]]; then + rejected="$CALLER_DATA_ROOT/rejected-$(basename -- "$stable")-$$" + mv --no-copy -T -- "$stable" "$rejected" 2>/dev/null || true + echo "omarchy-windows-vm: refusing non-directory VM data entry at $legacy" >&2 + return 1 + fi + else + install -d -m 0700 "$stable" + fi + fi + + [[ -d $stable && ! -L $stable ]] && [[ $(realpath -e -- "$stable" 2>/dev/null) == "$stable" ]] || { + echo "omarchy-windows-vm: unsafe VM data directory: $stable" >&2 + return 1 + } + if ((EUID == 0)); then + chown "$CALLER_UID:$CALLER_GID" "$stable" + fi + + if [[ -L $legacy ]]; then + [[ $(realpath -e -- "$legacy" 2>/dev/null) == "$stable" ]] || { + echo "omarchy-windows-vm: $legacy does not point to its protected mount anchor" >&2 + return 1 + } + elif [[ -e $legacy ]]; then + echo "omarchy-windows-vm: refusing to replace existing data at $legacy" >&2 + return 1 + else + ln -s -- "$stable" "$legacy" || return 1 + if ((EUID == 0)); then + chown -h "$CALLER_UID:$CALLER_GID" "$legacy" + fi + fi +} + +prepare_caller_mounts() { + resolve_caller && prepare_runtime_tree && + prepare_mount_leaf "$LEGACY_STORAGE" "$EXPECTED_STORAGE" && + prepare_mount_leaf "$LEGACY_SHARED" "$EXPECTED_SHARED" +} + # Reads KEY=VALUE lines on stdin, re-validates every field, and writes the # compose atomically as root. Re-validation here is the security boundary: the # writer refuses rather than emit a compose an attacker could have influenced. # Only these fixed keys are honored; image, container name, devices, caps, and # port bindings are hard-coded and never taken from input. __priv_write_compose() { - local ram cores disk username password tz storage shared key value + local ram cores disk username password tz key value while IFS='=' read -r key value; do case "$key" in @@ -129,8 +318,6 @@ __priv_write_compose() { USERNAME) username="$value" ;; PASSWORD) password="$value" ;; TZ) tz="$value" ;; - STORAGE) storage="$value" ;; - SHARED) shared="$value" ;; esac done @@ -140,8 +327,7 @@ __priv_write_compose() { valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } valid_password "$password" || { echo "invalid password" >&2; exit 2; } valid_tz "$tz" || tz="UTC" - valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } - valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; } + prepare_caller_mounts || exit 2 # Neutralize anything in the password that could be misread when the compose # is parsed. Two layers apply, in this order at parse time: docker compose @@ -154,10 +340,6 @@ __priv_write_compose() { esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\$/\$\$} - mkdir -p "$RUNTIME_DIR" - chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true - chown root:root "$RUNTIME_DIR" 2>/dev/null || true - local tmp tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") cat >"$tmp" <"$tmp" || { rm -f "$tmp"; return 1; } + chmod 0640 "$tmp" + if ((EUID == 0)); then + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" + fi + mv -f -- "$tmp" "$COMPOSE_FILE" +} + +assert_compose_trusted() { + local owner expected mode + [[ -f $COMPOSE_FILE && ! -L $COMPOSE_FILE ]] || return 1 + owner=$(stat -Lc '%u' "$COMPOSE_FILE") || return 1 + mode=$(stat -Lc '%a' "$COMPOSE_FILE") || return 1 + expected=$(boundary_owner) + [[ $owner == "$expected" ]] && ! ((8#$mode & 022)) +} + assert_mounts_safe() { - local mnt src real - for mnt in /storage /shared; do - src=$(get_mount_source "$mnt") - [[ -n $src ]] || { - echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2 + local storage shared owner + resolve_caller || return 1 + assert_compose_trusted || { + echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 + return 1 + } + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + + if [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]]; then + ((EUID == 0)) || { + echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2 return 1 } - if [[ -L $src ]]; then - echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2 - return 1 - fi - if [[ -e $src ]]; then - [[ -d $src ]] || { - echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2 - return 1 - } - real=$(realpath "$src" 2>/dev/null) - [[ $real == "$src" ]] || { - echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2 - return 1 - } - fi - done + prepare_caller_mounts || return 1 + rewrite_compose_mounts || return 1 + storage=$EXPECTED_STORAGE + shared=$EXPECTED_SHARED + fi + + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] || { + echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2 + return 1 + } + owner=$(boundary_owner) + assert_boundary_dir "$RUNTIME_DIR" "$owner" && + assert_boundary_dir "$MOUNT_ROOT" "$owner" && + assert_boundary_dir "$USERS_DIR" "$owner" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" && + [[ -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]] && + [[ -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] && + [[ $(realpath -e -- "$EXPECTED_STORAGE" 2>/dev/null) == "$EXPECTED_STORAGE" ]] && + [[ $(realpath -e -- "$EXPECTED_SHARED" 2>/dev/null) == "$EXPECTED_SHARED" ]] || { + echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2 + return 1 + } } __priv_up() { assert_mounts_safe && dc up -d; } @@ -273,19 +495,24 @@ __priv_up_wait() { __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_remove() { + resolve_caller || return 1 dc down 2>/dev/null || true docker rmi "$IMAGE" 2>/dev/null || true rm -f "$COMPOSE_FILE" - rmdir "$RUNTIME_DIR" 2>/dev/null || true + # Shared files intentionally survive removal. The storage leaf cannot be + # swapped by the user because its parent is the protected boundary. + if [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" && -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]]; then + rm -rf --one-file-system -- "$EXPECTED_STORAGE" + fi } # --- config helpers ---------------------------------------------------------- # Feed the collected settings to the elevated writer. write_compose() { - local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8" - printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ - "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" | + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ + "$ram" "$cores" "$disk" "$username" "$password" "$tz" | priv write_compose } @@ -340,22 +567,17 @@ migrate_legacy_compose() { [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." - local ram cores disk username password tz storage shared + local ram cores disk username password tz ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") - # The VM's data always lived in the user's own ~/.windows and ~/Windows; the - # old compose only ever recorded those. Reconstruct them from $HOME (trusted — - # this runs as the user) rather than reading host paths back from a file a - # rogue process could have rewritten to bind-mount, say, / into the guest. - storage="$HOME/.windows" - shared="$HOME/Windows" - [[ -z $tz ]] && tz="UTC" - if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then + # The elevated writer derives both mount anchors from the authenticated uid; + # it never consumes volume paths from this user-owned legacy file. + if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then echo "Could not migrate the existing configuration automatically." >&2 echo "Re-run: omarchy-windows-vm install" >&2 return 1 @@ -404,7 +626,6 @@ install_windows() { omarchy-pkg-add freerdp openbsd-netcat gum - mkdir -p "$HOME/.windows" mkdir -p "$HOME/.local/share/applications" cat </dev/null @@ -540,15 +761,14 @@ EOF exit 1 fi - mkdir -p "$HOME/Windows" - local tz tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) # Write the root-owned compose from the validated settings (one prompt if - # sudoless Docker is off), then bring the stack up. + # sudoless Docker is off). The writer creates protected storage/shared mount + # anchors and leaves the familiar home entries as symlinks to them. write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ - "$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || { + "$USERNAME" "$PASSWORD" "$tz" || { echo "❌ Failed to write the Windows VM configuration." exit 1 } diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 9da7786f..e12a74d8 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -26,7 +26,9 @@ omarchy windows vm launch # start and connect ## Sharing files -The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk lives in `~/.windows`. +The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`. + +Those familiar home paths are links to per-user mount anchors in a root-owned `.omarchy-windows` directory beside your home directory. Keeping the anchors on the home filesystem preserves the expected disk location, while their protected parent prevents another process running as you from swapping a checked directory for a symlink while the privileged VM is starting. The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 0aa353f2..953f3403 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -13,39 +13,47 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT export OMARCHY_WINDOWS_DIR="$TMPDIR/win" +export HOME="$TMPDIR/home" +mkdir -p "$HOME" # Source the command's functions; the dispatcher just prints usage for "help". set -- help source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" -write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED - printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ +write() { # RAM CORES DISK USER PASS TZ + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ "$@" | __priv_write_compose } # --- valid compose, with the dangerous bits pinned and unreachable by input --- rm -f "$COMPOSE" -write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows +write 4G 2 64G alice 's3cret' Europe/Copenhagen +resolve_caller [[ -f $COMPOSE ]] || fail "writer produced a compose file" grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" -grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the per-uid protected anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared files use the per-uid protected anchor" +[[ -L $HOME/.windows && $(realpath "$HOME/.windows") == "$EXPECTED_STORAGE" ]] || fail "home storage link targets the protected anchor" +[[ -L $HOME/Windows && $(realpath "$HOME/Windows") == "$EXPECTED_SHARED" ]] || fail "home shared link targets the protected anchor" grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" -pass "writer emits a pinned compose with no host-root mount" +pass "writer derives protected per-uid anchors and emits no host-root mount" # --- injection attempts are rejected, no file written --- rm -f "$COMPOSE" -write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted" +write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username was accepted" [[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" -write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted" -write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted" -pass "injection attempts in username, path, and RAM are rejected" +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller-supplied storage path affected the compose" +grep -q -- '- /:/storage' "$COMPOSE" && fail "host root was accepted as storage" +write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM was accepted" +pass "injection attempts are rejected and caller-supplied paths are ignored" # --- password survives YAML (" \) and compose interpolation ($) --- rm -f "$COMPOSE" tricky='p@$$w:rd$HOME"x\y' -write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows +write 8G 4 64G bob "$tricky" UTC grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") [[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" @@ -65,8 +73,10 @@ pass "privileged action whitelist accepts known actions and rejects the rest" # mount host / into the guest, so migration must ignore its volume paths and # reconstruct them from the current user's $HOME. rm -rf "$OMARCHY_WINDOWS_DIR" -export HOME="$TMPDIR/home" -mkdir -p "$HOME/.config/windows" +rm -rf "$MOUNT_ROOT" +rm -f "$HOME/.windows" "$HOME/Windows" +mkdir -p "$HOME/.config/windows" "$HOME/.windows" "$HOME/Windows" +touch "$HOME/.windows/existing-disk" "$HOME/Windows/existing-shared-file" LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" COMPOSE_FILE="$COMPOSE" cat >"$LEGACY_COMPOSE_FILE" <<'LEG' @@ -88,32 +98,40 @@ priv() { local a=$1; shift; "__priv_$a" "$@"; } migrate_legacy_compose [[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" -grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume" +resolve_caller +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "migration uses the protected storage anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "migration uses the protected shared anchor" +[[ -f $EXPECTED_STORAGE/existing-disk ]] || fail "migration preserves the existing disk data" +[[ -f $EXPECTED_SHARED/existing-shared-file ]] || fail "migration preserves existing shared files" +[[ -L $HOME/.windows && -L $HOME/Windows ]] || fail "migration replaces home entries with compatibility links" grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path" [[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose" -pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes" +pass "legacy migration pins existing data and ignores tampered legacy volumes" -# --- bring-up refuses a symlinked mount source (a symlink redirects the -# privileged bind mount the same way traversal would; the string check on -# the stored path cannot see it) --- -rm -f "$COMPOSE" -mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare" -write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare" +# --- bring-up accepts only the derived pair in a trusted compose --- assert_mounts_safe || fail "real directory mount sources are accepted" -ln -sfn / "$TMPDIR/evilshare" -write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare" -assert_mounts_safe && fail "a symlinked mount source must be refused" -pass "bring-up refuses a symlinked mount source" +sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "a tampered host path must be refused" +sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" +chmod 0666 "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "a user-writable compose must be refused" +chmod 0640 "$COMPOSE" +pass "bring-up rejects unexpected mounts and a writable compose" -# --- valid_path rejects traversal and non-normalized paths --- -for p in /home/u/.windows /var/lib/omarchy/windows; do - valid_path "$p" || fail "valid_path rejected a normal path: $p" -done -for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do - valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p" -done -pass "valid_path accepts normalized paths and rejects traversal" +# --- a symlink supplied as legacy data is renamed below the protected parent +# before inspection, then quarantined rather than followed --- +rm -rf "$OMARCHY_WINDOWS_DIR" +rm -rf "$MOUNT_ROOT" +rm -f "$HOME/.windows" "$HOME/Windows" +ln -s / "$HOME/.windows" +mkdir -p "$HOME/Windows" +rm -f "$COMPOSE" +write 4G 2 64G dave pw UTC 2>/dev/null && fail "a symlinked legacy data entry was accepted" +[[ ! -f $COMPOSE ]] || fail "no compose is written for a symlinked legacy entry" +[[ ! -L $MOUNT_ROOT/users/$(id -u)/storage ]] || fail "the mount anchor must not remain a symlink" +find "$MOUNT_ROOT/users/$(id -u)" -maxdepth 1 -type l -name 'rejected-storage-*' | grep -q . || fail "the rejected symlink was not quarantined" +pass "migration pins and rejects a symlinked legacy data entry" # --- credentials are stored privately and round-trip (incl. = in password) --- export CREDENTIALS_FILE="$TMPDIR/creds" From 95b791af16dc7cdb1a5291c94c7301f3972c0926 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 01:20:40 -0600 Subject: [PATCH 14/73] Stop world-writable browser policy directories Chromium managed policy is mandatory for every profile. World-writable dirs let any local uid plant policy, including force-installed extensions. Write goes through the omarchy-browser-policy group at 2775 so theme colour still works without other-write. --- bin/omarchy-install-browser | 30 ++- bin/omarchy-provision-owner | 9 + bin/omarchy-theme-set-browser | 18 +- bin/omarchy-upgrade-to-quattro | 22 +- install/config/all.sh | 1 + install/config/browser-policy.sh | 3 + install/config/theme-system.sh | 4 - install/helpers/as-root.sh | 7 + install/helpers/browser-policy.sh | 171 ++++++++++++++++ migrations/1787515927.sh | 24 +++ test/shell.d/browser-policy-dir-test.sh | 244 +++++++++++++++++++++++ test/shell.d/default-apps-test.sh | 47 ++++- test/shell.d/provisioning-groups-test.sh | 37 +++- test/shell.d/upgrade-to-quattro-test.sh | 17 ++ 14 files changed, 596 insertions(+), 38 deletions(-) create mode 100644 install/config/browser-policy.sh create mode 100644 install/helpers/as-root.sh create mode 100644 install/helpers/browser-policy.sh create mode 100644 migrations/1787515927.sh create mode 100755 test/shell.d/browser-policy-dir-test.sh diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index f71c7c98..d808c805 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -6,9 +6,12 @@ set -e -setup_policy_directory() { - sudo mkdir -p "$1" - sudo chmod a+rw "$1" +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +setup_chromium_policy_directory() { + browser_policy_setup_group + browser_policy_grant_user "${USER:-$(id -un)}" + browser_policy_setup_dir "$1" } announce_browser_installed() { @@ -23,13 +26,6 @@ copy_chromium_flags() { omarchy-install-chromium-ytdlp } -setup_firefox_preferences() { - local distribution_dir="$1" - - setup_policy_directory "$distribution_dir" - sudo cp -f "$OMARCHY_PATH/default/firefox/policies.json" "$distribution_dir/policies.json" -} - setup_firefox_wayland() { mkdir -p ~/.config/environment.d echo "MOZ_ENABLE_WAYLAND=1" > ~/.config/environment.d/omarchy-firefox-wayland.conf @@ -40,7 +36,7 @@ chromium) echo "Installing Chromium..." omarchy-pkg-add chromium - setup_policy_directory /etc/chromium/policies/managed + setup_chromium_policy_directory /etc/chromium/policies/managed copy_chromium_flags ~/.config/chromium-flags.conf omarchy-theme-set-browser announce_browser_installed "Chromium" @@ -49,7 +45,7 @@ chrome) echo "Installing Chrome..." omarchy-pkg-aur-add google-chrome || exit 1 - setup_policy_directory /etc/opt/chrome/policies/managed + setup_chromium_policy_directory /etc/opt/chrome/policies/managed copy_chromium_flags ~/.config/chrome-flags.conf omarchy-theme-set-browser announce_browser_installed "Chrome" @@ -58,7 +54,7 @@ edge) echo "Installing Edge..." omarchy-pkg-aur-add microsoft-edge-stable-bin || exit 1 - setup_policy_directory /etc/opt/edge/policies/managed + setup_chromium_policy_directory /etc/opt/edge/policies/managed copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf omarchy-theme-set-browser announce_browser_installed "Edge" @@ -67,7 +63,7 @@ brave) echo "Installing Brave..." omarchy-pkg-aur-add brave-bin || exit 1 - setup_policy_directory /etc/brave/policies/managed + setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave" @@ -76,7 +72,7 @@ brave-origin) echo "Installing Brave Origin..." omarchy-pkg-aur-add brave-origin-bin || exit 1 - setup_policy_directory /etc/brave/policies/managed + setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-origin-flags.conf omarchy-theme-set-browser announce_browser_installed "Brave Origin" @@ -85,7 +81,7 @@ firefox) echo "Installing Firefox..." omarchy-pkg-add firefox || exit 1 - setup_firefox_preferences /usr/lib/firefox/distribution + browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution setup_firefox_wayland announce_browser_installed "Firefox" ;; @@ -93,7 +89,7 @@ zen) echo "Installing Zen..." omarchy-pkg-aur-add zen-browser-bin || exit 1 - setup_firefox_preferences /opt/zen-browser/distribution + browser_policy_setup_firefox_distribution /opt/zen-browser/distribution setup_firefox_wayland announce_browser_installed "Zen" ;; diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index c27de22c..7c208eb9 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -742,6 +742,15 @@ create_user() { # for specific commands), and a duplicate grant is harmless. echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel + + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + OMARCHY_INSTALL_USER=$username + OMARCHY_PROVISIONING_DIR=$PROVISIONING_DIR + browser_policy_setup_group + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_setup_dir "$dir" + done } install_authorized_keys() { diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 4dd7592f..a22cb0d6 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -13,11 +13,10 @@ else THEME_HEX_COLOR="#1c2027" fi -set_browser_policy() { - local policy_dir="$1" +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - [[ -d $policy_dir ]] || return - echo "{\"BrowserThemeColor\": \"$THEME_HEX_COLOR\", \"BrowserColorScheme\": \"device\"}" | tee "$policy_dir/color.json" >/dev/null +set_browser_policy() { + browser_policy_write_color "$1" "$THEME_HEX_COLOR" } refresh_running_browser() { @@ -30,17 +29,20 @@ refresh_running_browser() { fi } -set_browser_policy /etc/chromium/policies/managed +failed=0 +set_browser_policy /etc/chromium/policies/managed || failed=1 refresh_running_browser chromium chromium -set_browser_policy /etc/opt/chrome/policies/managed +set_browser_policy /etc/opt/chrome/policies/managed || failed=1 refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome -set_browser_policy /etc/opt/edge/policies/managed +set_browser_policy /etc/opt/edge/policies/managed || failed=1 refresh_running_browser msedge microsoft-edge-stable -set_browser_policy /etc/brave/policies/managed +set_browser_policy /etc/brave/policies/managed || failed=1 refresh_running_browser brave brave # Match on the binary path: the running process is named plain "brave", and a # bare -f brave-origin pattern would also match the installer's own terminal. refresh_running_browser /opt/brave-origin-bin/ brave-origin -f + +exit "$failed" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index e28da697..62ef21b9 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1312,7 +1312,22 @@ apply_system_transition() { /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg as_root gtk-update-icon-cache /usr/share/icons/Yaru >/dev/null 2>&1 || true - as_root install -d -m 0777 /etc/chromium/policies/managed + local browser_policy_helper=/usr/share/omarchy/install/helpers/browser-policy.sh + if ! as_root test -f "$browser_policy_helper"; then + warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened." + else + as_root env OMARCHY_PATH=/usr/share/omarchy OMARCHY_INSTALL_USER="$target_user" \ + bash -euo pipefail -c ' + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + browser_policy_setup_group + browser_policy_setup_dir /etc/chromium/policies/managed + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ $dir == "/etc/chromium/policies/managed" ]] && continue + [[ -d $dir ]] || continue + browser_policy_setup_dir "$dir" + done + ' + fi as_root install -d -m 0755 /usr/lib/chromium printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null @@ -2306,6 +2321,11 @@ refresh_current_theme_after_upgrade() { # hooks because one of them runs `hyprctl reload`. Still poke terminal # emulators so the active upgrade terminal picks up generated theme files. run_as_user_omarchy omarchy-restart-terminal >/dev/null 2>&1 || true + + # apply_system_transition purged user-owned color.json. Headless theme-set + # skipped omarchy-theme-set-browser, so rewrite the colour here. + run_as_user_omarchy omarchy-theme-set-browser >/dev/null 2>&1 || + warn "Could not apply browser theme colour. Run 'omarchy theme set \"$theme_name\"' after reboot if Chromium's theme looks stale." } # Everything below mutates the system, so a non-zero exit from here on leaves a diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..d8c7d9bb 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -1,4 +1,5 @@ run_logged "$OMARCHY_INSTALL/config/theme-system.sh" +run_logged "$OMARCHY_INSTALL/config/browser-policy.sh" run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh" run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh new file mode 100644 index 00000000..a02de2f3 --- /dev/null +++ b/install/config/browser-policy.sh @@ -0,0 +1,3 @@ +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +browser_policy_setup_group +browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..83db0e15 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -6,10 +6,6 @@ ln -snf /usr/share/icons/Adwaita/symbolic/actions/go-next-symbolic.svg \ /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true -# Chromium policy directory for theme -mkdir -p /etc/chromium/policies/managed -chmod a+rw /etc/chromium/policies/managed - # Default Chromium to follow system appearance ("device") instead of dark mkdir -p /usr/lib/chromium echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ diff --git a/install/helpers/as-root.sh b/install/helpers/as-root.sh new file mode 100644 index 00000000..005ae351 --- /dev/null +++ b/install/helpers/as-root.sh @@ -0,0 +1,7 @@ +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh new file mode 100644 index 00000000..803df2c6 --- /dev/null +++ b/install/helpers/browser-policy.sh @@ -0,0 +1,171 @@ +# Chromium-family machine policy is mandatory for every profile. A dedicated +# group at 2775 lets every Omarchy user write color.json and every other uid +# read; other-write stays off. Setgid so new files inherit the group. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" + +BROWSER_POLICY_GROUP=omarchy-browser-policy + +BROWSER_POLICY_MANAGED_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +BROWSER_POLICY_FIREFOX_DIRS=( + /usr/lib/firefox/distribution + /opt/zen-browser/distribution +) + +browser_policy_setup_group() { + local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" + + as_root groupadd --system --force "$BROWSER_POLICY_GROUP" + as_root mkdir -p "$provisioning_dir" + if ! grep -qxF "$BROWSER_POLICY_GROUP" "$provisioning_dir/groups" 2>/dev/null; then + printf '%s\n' "$BROWSER_POLICY_GROUP" | as_root tee -a "$provisioning_dir/groups" >/dev/null + fi + + if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then + as_root usermod -aG "$BROWSER_POLICY_GROUP" "$OMARCHY_INSTALL_USER" + fi +} + +browser_policy_grant_user() { + local user=${1:-} + + if [[ -z $user || $user == "root" ]]; then + user=${SUDO_USER:-} + fi + + [[ -n $user && $user != "root" ]] || return 0 + getent passwd "$user" >/dev/null || return 0 + as_root usermod -aG "$BROWSER_POLICY_GROUP" "$user" +} + +browser_policy_purge_dir() { + local dir=$1 + + as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + +} + +browser_policy_dir_hardened() { + local dir=$1 + + [[ -d $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 +} + +browser_policy_setup_dir() { + local dir=$1 + + as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" + browser_policy_purge_dir "$dir" +} + +browser_policy_file_owner() { + local user + + if [[ -n ${OMARCHY_INSTALL_USER:-} && $OMARCHY_INSTALL_USER != "root" ]]; then + printf '%s\n' "$OMARCHY_INSTALL_USER" + return + fi + if [[ -n ${SUDO_USER:-} && $SUDO_USER != "root" ]]; then + printf '%s\n' "$SUDO_USER" + return + fi + if [[ -n ${PKEXEC_UID:-} ]]; then + user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) + if [[ -n $user && $user != "root" ]]; then + printf '%s\n' "$user" + return + fi + fi + user=${USER:-$(id -un)} + if [[ $user != "root" ]]; then + printf '%s\n' "$user" + fi +} + +# sudo when this process has a controlling terminal (fd 0 is /dev/null under +# `bash -lc cmd &`, but /dev/tty still works). pkexec when it does not. +browser_policy_elevate() { + if (( EUID == 0 )); then + "$@" + elif { exec 3/dev/null; then + exec 3<&- + sudo "$@" + else + pkexec "$@" + fi +} + +browser_policy_write_color() { + local policy_dir=$1 + local hex=$2 + local dest=$policy_dir/color.json + local payload + local tmp + local owner + + [[ -d $policy_dir ]] || return 0 + + payload=$(printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex") + tmp=$(mktemp) || return 1 + printf '%s' "$payload" >"$tmp" + + # A planted symlink or directory must not be written through or into. + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest" 2>/dev/null; then + if ! browser_policy_elevate rm -rf -- "$dest"; then + rm -f "$tmp" + echo "omarchy-theme-set-browser: cannot replace $dest (need group $BROWSER_POLICY_GROUP)" >&2 + return 1 + fi + fi + fi + + if install -m 664 -T "$tmp" "$dest" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + + owner=$(browser_policy_file_owner) + [[ -n $owner ]] || owner=root + if browser_policy_elevate install -m 664 -o "$owner" -g "$BROWSER_POLICY_GROUP" -T "$tmp" "$dest"; then + rm -f "$tmp" + return 0 + fi + + rm -f "$tmp" + echo "omarchy-theme-set-browser: cannot write $dest (need group $BROWSER_POLICY_GROUP)" >&2 + return 1 +} + +browser_policy_firefox_hardened() { + local dir=$1 + + [[ -d $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 + [[ -f $dir/policies.json && ! -L $dir/policies.json ]] || return 1 +} + +browser_policy_install_firefox_policies() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -m 644 -o root -g root -T "$policies" "$distribution_dir/policies.json" +} + +browser_policy_setup_firefox_distribution() { + local distribution_dir=$1 + local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} + + as_root install -d -m 0755 -o root -g root "$distribution_dir" + browser_policy_purge_dir "$distribution_dir" + browser_policy_install_firefox_policies "$distribution_dir" "$policies" +} diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh new file mode 100644 index 00000000..f15298bd --- /dev/null +++ b/migrations/1787515927.sh @@ -0,0 +1,24 @@ +echo "Stop world-writable Chromium and Firefox policy directories" + +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +browser_policy_setup_group +browser_policy_grant_user "${USER:-$(id -un)}" + +repaired=0 +for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_dir_hardened "$dir" && continue + browser_policy_setup_dir "$dir" + repaired=1 +done + +if (( repaired )); then + omarchy-theme-set-browser +fi + +for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do + [[ -d $dir ]] || continue + browser_policy_firefox_hardened "$dir" && continue + browser_policy_setup_firefox_distribution "$dir" +done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh new file mode 100755 index 00000000..34a0c3cd --- /dev/null +++ b/test/shell.d/browser-policy-dir-test.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin=$test_tmp/bin +mkdir -p "$mock_bin" +elev_log=$test_tmp/elev.log +cat >"$mock_bin/sudo" <>"$elev_log" +[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 +exit 0 +SH +cat >"$mock_bin/pkexec" <>"$elev_log" +[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/sudo" "$mock_bin/pkexec" +export PATH="$mock_bin:$PATH" +: >"$elev_log" +export OMARCHY_PATH="$ROOT" +export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" + +source "$ROOT/install/helpers/browser-policy.sh" + +# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged. +unprivileged_as_root() { + if [[ $1 == "install" ]]; then + shift + local args=() + local skip=0 + local arg + for arg in "$@"; do + if (( skip )); then + skip=0 + continue + fi + case $arg in + -o|-g) skip=1 ;; + *) args+=("$arg") ;; + esac + done + command install "${args[@]}" + else + "$@" + fi +} + +write_dir=$test_tmp/writable +mkdir -p "$write_dir" +browser_policy_write_color "$write_dir" "#aabbcc" || + fail "theme colour writes into a writable policy directory" +grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "theme colour writes BrowserThemeColor" +mode=$(stat -c '%a' "$write_dir/color.json") +[[ $mode == "664" ]] || fail "theme colour creates a group-writable policy file" "mode=$mode" +pass "theme colour writes a group-writable color.json" + +if (( EUID == 0 )); then + pass "running as root; skipping the mktemp-failure check" +else + chmod u+w "$write_dir" + export TMPDIR=$test_tmp/missing-tmp + if browser_policy_write_color "$write_dir" "#dead00" 2>/dev/null; then + fail "theme colour fails when mktemp cannot create a file" + fi + unset TMPDIR + grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || + fail "a failed mktemp leaves an existing color.json intact" + pass "a failed mktemp does not truncate color.json" +fi + +printf 'original\n' >"$test_tmp/pwn" +rm -f "$write_dir/color.json" +ln -s "$test_tmp/pwn" "$write_dir/color.json" +browser_policy_write_color "$write_dir" "#aabbcc" || + fail "theme colour replaces a planted color.json symlink" +[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || + fail "theme colour unlinks a planted color.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged" +pass "theme colour does not follow a planted color.json symlink" + +plant_write=$test_tmp/plant-dir +mkdir -p "$plant_write/color.json/nested" +printf 'inside\n' >"$plant_write/color.json/nested/x" +browser_policy_write_color "$plant_write" "#aabbcc" || + fail "theme colour replaces a planted color.json directory" +[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || + fail "theme colour does not write into a planted color.json directory" +pass "theme colour does not write into a planted color.json directory" + +missing_dir=$test_tmp/missing +browser_policy_write_color "$missing_dir" "#aabbcc" || + fail "theme colour skips a policy directory that does not exist" +[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" +pass "theme colour skips a missing policy directory" + +if (( EUID == 0 )); then + pass "running as root; skipping elevation checks" +else + denied_dir=$test_tmp/denied + mkdir -p "$denied_dir" + chmod a-w "$denied_dir" + owner=${USER:-$(id -un)} + : >"$elev_log" + browser_policy_write_color "$denied_dir" "#aabbcc" || + fail "elevated install reports success from pkexec" + grep -E "^PKEXEC install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || + fail "without a controlling tty, colour write elevates through pkexec as the owner" "$(cat "$elev_log")" + if grep -E '^SUDO ' "$elev_log" >/dev/null; then + fail "without a controlling tty, colour write does not call sudo" "$(cat "$elev_log")" + fi + pass "without a controlling tty, colour write elevates through pkexec" + + : >"$elev_log" + export OMARCHY_TEST_SUDO_FAIL=1 + if browser_policy_write_color "$denied_dir" "#aabbcc" 2>"$test_tmp/write.err"; then + fail "theme colour fails when the policy directory is not writable" + fi + unset OMARCHY_TEST_SUDO_FAIL + grep -F 'omarchy-browser-policy' "$test_tmp/write.err" >/dev/null || + fail "theme colour names the group when the write is denied" + pass "theme colour reports a denied policy write" + + if command -v script >/dev/null; then + : >"$elev_log" + cat >"$test_tmp/tty-write.sh" </dev/null + grep -E "^SUDO install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || + fail "with a controlling tty, colour write elevates through sudo" "$(cat "$elev_log")" + if grep -E '^PKEXEC ' "$elev_log" >/dev/null; then + fail "with a controlling tty, colour write does not call pkexec" "$(cat "$elev_log")" + fi + pass "with a controlling tty, colour write elevates through sudo" + else + pass "script(1) unavailable; skipping the controlling-tty elevation check" + fi +fi + +planted_dir=$test_tmp/planted +mkdir -p "$planted_dir/evil" +printf 'evil\n' >"$planted_dir/evil/f" +printf 'old\n' >"$planted_dir/color.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$planted_dir" +[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" +[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" +[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +pass "policy setup drops non-root files and non-empty subdirectories" + +owned=$test_tmp/not-root +mkdir -p "$owned" +chmod 2775 "$owned" +BROWSER_POLICY_GROUP=$(id -gn) +if browser_policy_dir_hardened "$owned"; then + fail "a user-owned 2775 directory is not treated as hardened" +fi +BROWSER_POLICY_GROUP=omarchy-browser-policy +pass "a hardened directory must be root-owned" + +dist=$test_tmp/distribution +mkdir -p "$dist" +printf 'original\n' >"$test_tmp/firefox-pwn" +ln -s "$test_tmp/firefox-pwn" "$dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +browser_policy_install_firefox_policies "$dist" || + fail "Firefox policy install replaces a planted policies.json symlink" +[[ -f $dist/policies.json && ! -L $dist/policies.json ]] || + fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it" +grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged" +grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies" +pass "Firefox policy install does not follow a planted policies.json symlink" + +dir_dist=$test_tmp/distribution-dir +mkdir -p "$dir_dist" +mkdir "$dir_dist/policies.json" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then + fail "Firefox policy install refuses a planted policies.json directory" +fi +[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" +pass "Firefox policy install does not write into a planted policies.json directory" + +grant_log=$test_tmp/usermod.calls +as_root() { + if [[ $1 == "usermod" ]]; then + printf '%s\n' "$*" >>"$grant_log" + return 0 + fi + unprivileged_as_root "$@" +} +invoker=${USER:-$(id -un)} +: >"$grant_log" +SUDO_USER=$invoker +browser_policy_grant_user root +unset SUDO_USER +grep -qx -- "usermod -aG omarchy-browser-policy $invoker" "$grant_log" || + fail "granting as root uses SUDO_USER" "$(cat "$grant_log")" +: >"$grant_log" +OMARCHY_INSTALL_USER="" +browser_policy_grant_user "" +[[ ! -s $grant_log ]] || fail "an empty grant does not usermod" +pass "sudo install browser grants the invoking user, not root" + +grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser exits non-zero when a policy write fails" +pass "omarchy-theme-set-browser exits non-zero when a policy write fails" + +policy_files=( + "$ROOT/bin/omarchy-install-browser" + "$ROOT/bin/omarchy-provision-owner" + "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-upgrade-to-quattro" + "$ROOT/install/config/theme-system.sh" + "$ROOT/install/config/browser-policy.sh" + "$ROOT/install/helpers/browser-policy.sh" + "$ROOT/migrations/1787515927.sh" +) +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable" +fi +pass "browser policy setup is not world-writable" + +mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") +(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" +grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration no-ops a machine already repaired" +grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration still grants the current user the group" +grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration covers Firefox and Zen" +grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || + fail "the shared helper names the Zen distribution directory" +pass "a migration locks existing policy directories" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index aa07659d..00a9af55 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -61,11 +61,13 @@ if [[ $installer == "omarchy-install-browser" && ${OMARCHY_TEST_REAL_BROWSER_INS fi case $installer in -omarchy-pkg-add) +omarchy-pkg-add|omarchy-pkg-aur-add) package=$1 printf 'pkg:%s\n' "$package" >>"$OMARCHY_TEST_INSTALL_LOG" case $package in chromium) command=chromium ;; + firefox) command=firefox ;; + zen-browser-bin) command=zen-browser ;; cursor-bin) command=cursor ;; sublime-text-4) command=sublime_text ;; vim) command=vim ;; @@ -107,6 +109,7 @@ SH for installer in \ omarchy-pkg-add \ + omarchy-pkg-aur-add \ omarchy-install-browser \ omarchy-install-terminal \ omarchy-install-editor-vscode \ @@ -205,10 +208,14 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds" cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" || fail "Chromium browser installer copies the default flags" -grep -Fxq 'sudo:mkdir -p /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer creates its policy directory" -grep -Fxq 'sudo:chmod a+rw /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer makes its policy directory writable" +grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || + fail "Chromium browser installer creates the browser-policy group" +grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || + fail "Chromium browser installer creates a group-writable managed policy directory" +grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Chromium browser installer drops non-root files from its policy directory" +grep -Fxq "sudo:usermod -aG omarchy-browser-policy ${USER:-$(id -un)}" "$setup_log" || + fail "Chromium browser installer grants the installing user the browser-policy group" grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" || fail "Chromium browser installer registers the Copy URL host" grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" || @@ -217,6 +224,36 @@ grep -Fxq 'omarchy-theme-set-browser:' "$setup_log" || fail "Chromium browser installer applies the current theme" pass "Chromium browser installer restores the complete Omarchy setup" +: >"$install_log" +: >"$setup_log" +rm -f "$installed_dir/firefox" +OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install firefox >/dev/null +[[ $(<"$install_log") == "pkg:firefox" ]] || fail "Firefox browser installer installs the package" +[[ $(omarchy-default-browser) == "firefox" ]] || fail "Firefox becomes the default after its full installer succeeds" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /usr/lib/firefox/distribution' "$setup_log" || + fail "Firefox browser installer creates its distribution directory" +grep -Fxq 'sudo:find /usr/lib/firefox/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Firefox browser installer drops non-root files from its distribution directory" +grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /usr/lib/firefox/distribution/policies.json" "$setup_log" || + fail "Firefox browser installer copies policies.json without following a destination symlink" +[[ -e $installed_dir/firefox ]] || fail "Firefox browser installer marks firefox installed" +pass "Firefox browser installer restores the complete Omarchy setup" + +: >"$install_log" +: >"$setup_log" +rm -f "$installed_dir/zen-browser" +OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install zen >/dev/null +[[ $(<"$install_log") == "pkg:zen-browser-bin" ]] || fail "Zen browser installer installs the package" +[[ $(omarchy-default-browser) == "zen" ]] || fail "Zen becomes the default after its full installer succeeds" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /opt/zen-browser/distribution' "$setup_log" || + fail "Zen browser installer creates its distribution directory" +grep -Fxq 'sudo:find /opt/zen-browser/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || + fail "Zen browser installer drops non-root files from its distribution directory" +grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /opt/zen-browser/distribution/policies.json" "$setup_log" || + fail "Zen browser installer copies policies.json without following a destination symlink" +[[ -e $installed_dir/zen-browser ]] || fail "Zen browser installer marks zen-browser installed" +pass "Zen browser installer restores the complete Omarchy setup" + omarchy-default-browser zen rm -f "$installed_dir/chromium" if OMARCHY_TEST_REAL_BROWSER_INSTALL=true OMARCHY_TEST_INSTALL_FAIL=true \ diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index d0eac226..7b5c6964 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -27,16 +27,40 @@ cat >"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" STUB -chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod" +cat >"$TMPDIR/bin/groupadd" <>"$TMPDIR/groupadd.calls" +STUB +cat >"$TMPDIR/bin/install" <>"$TMPDIR/install.calls" +STUB +cat >"$TMPDIR/bin/find" <>"$TMPDIR/find.calls" +STUB +cat >"$TMPDIR/bin/sudo" <>"$TMPDIR/sudo.calls" +exec "\$@" +STUB +chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo} export PATH="$TMPDIR/bin:$PATH" +export OMARCHY_PATH="$ROOT" -# No install user (deferred-provisioning install): input recorded, usermod not called. +# No install user (deferred-provisioning install): groups recorded, usermod not called. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" +grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "browser-policy group recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" +grep -F -- '--system --force omarchy-browser-policy' "$TMPDIR/groupadd.calls" >/dev/null || + fail "browser-policy group is created as a system group" +grep -F -- '-d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || + fail "browser-policy directory is created group-writable" pass "deferred provisioning records groups without calling usermod" # The docker group is root-equivalent and must never be granted automatically. @@ -45,17 +69,24 @@ pass "docker group is not recorded at install" # Missing user (defensive): no usermod either. OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" pass "missing install user defers group grants" # Re-running never duplicates entries. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" +[[ $(grep -cxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || + fail "browser-policy group recorded once" pass "group recording is idempotent" # Existing user: usermod applies the recorded groups, and docker is never among them. OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" +OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" +grep -qx -- "-aG omarchy-browser-policy existing" "$TMPDIR/usermod.calls" || + fail "usermod grants browser-policy to the install user" ! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input but never docker" +pass "existing install user gets input and browser-policy but never docker" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index bf94605f..c38c17f7 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -67,6 +67,23 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade configures lock screen authentication for the target user" +grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade uses the shared browser-policy helper" +grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper" +grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade creates the browser-policy group" +grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade creates a group-writable Chromium policy directory" +grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade hardens every Chromium-family policy directory" +grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set" +if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory" +fi +pass "Omarchy 4 upgrade locks the Chromium policy directory to the browser-policy group" + grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.socket' "$upgrade_to_quattro" >/dev/null From b0e6611c704e617a238d250ab62109047a8239ac Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 02:01:24 -0600 Subject: [PATCH 15/73] Require root-owned Firefox policies before skipping repair The hardened gate only looked at the distribution directory. A regular policies.json planted under the old 777 mode would then be left in place if the directory later looked 755/root. --- install/helpers/browser-policy.sh | 16 +++++++++++++++- test/shell.d/browser-policy-dir-test.sh | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 803df2c6..b383e325 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -145,13 +145,27 @@ browser_policy_write_color() { return 1 } +browser_policy_firefox_policy_file_ok() { + local file=$1 + local mode + local group_write + local other_write + + [[ -f $file && ! -L $file ]] || return 1 + [[ $(stat -c '%U' "$file") == "root" ]] || return 1 + mode=$(stat -c '%a' "$file") + group_write=$((8#${mode: -2:1})) + other_write=$((8#${mode: -1})) + (( (group_write & 2) == 0 && (other_write & 2) == 0 )) +} + browser_policy_firefox_hardened() { local dir=$1 [[ -d $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 - [[ -f $dir/policies.json && ! -L $dir/policies.json ]] || return 1 + browser_policy_firefox_policy_file_ok "$dir/policies.json" } browser_policy_install_firefox_policies() { diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 34a0c3cd..c7b795fc 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -168,6 +168,18 @@ fi BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" +fx_policy=$test_tmp/policies.json +printf '%s\n' '{"policies":{}}' >"$fx_policy" +chmod 644 "$fx_policy" +if browser_policy_firefox_policy_file_ok "$fx_policy"; then + fail "a user-owned policies.json is not treated as hardened" +fi +ln -sf "$fx_policy" "$test_tmp/policies-link.json" +if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then + fail "a policies.json symlink is not treated as hardened" +fi +pass "Firefox policy files must be root-owned regular files without group or other write" + dist=$test_tmp/distribution mkdir -p "$dist" printf 'original\n' >"$test_tmp/firefox-pwn" From 87dfa14c5645d37bc9df454450f91680ffe587dc Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 24 Aug 2026 10:58:33 -0600 Subject: [PATCH 16/73] Keep a trusted Firefox policies.json when repairing the directory A world-writable distribution dir failed the hardened check even when policies.json was already root-owned, and setup then overwrote it. --- migrations/1787515927.sh | 6 +++++- test/shell.d/browser-policy-dir-test.sh | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index f15298bd..e16efd20 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -20,5 +20,9 @@ fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do [[ -d $dir ]] || continue browser_policy_firefox_hardened "$dir" && continue - browser_policy_setup_firefox_distribution "$dir" + as_root install -d -m 0755 -o root -g root "$dir" + browser_policy_purge_dir "$dir" + if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then + browser_policy_install_firefox_policies "$dir" + fi done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index c7b795fc..63591de1 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -251,6 +251,8 @@ grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || fail "the policy-directory migration still grants the current user the group" grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || fail "the policy-directory migration covers Firefox and Zen" +grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration keeps a trusted Firefox policies.json" grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null || fail "the shared helper names the Zen distribution directory" pass "a migration locks existing policy directories" From bebe19bc70696c916d4fa1d0fd39d480bfff99a0 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 12:10:08 -0600 Subject: [PATCH 17/73] Harden browser policy parent directories and validate theme RGB install -d follows a planted ancestor symlink, and a writable parent can rename the managed leaf aside. chromium.theme is user-installed, so only a 0-255 RGB triple becomes a colour. --- bin/omarchy-theme-set-browser | 11 ++-- install/helpers/browser-policy.sh | 67 ++++++++++++++++++++ migrations/1787515927.sh | 4 +- test/shell.d/browser-policy-dir-test.sh | 83 +++++++++++++++++++++++++ test/shell.d/default-apps-test.sh | 4 ++ 5 files changed, 161 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index a22cb0d6..60fa3211 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -3,18 +3,15 @@ # omarchy:summary=Apply the current theme color to Chromium, Chrome, Edge, and Brave # omarchy:hidden=true +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + CHROMIUM_THEME=$HOME/.local/state/omarchy/current/theme/chromium.theme +THEME_HEX_COLOR=$BROWSER_POLICY_DEFAULT_COLOR if [[ -f $CHROMIUM_THEME ]]; then - THEME_RGB_COLOR=$(<$CHROMIUM_THEME) - THEME_HEX_COLOR=$(printf '#%02x%02x%02x' ${THEME_RGB_COLOR//,/ }) -else - # Use a default, neutral grey if theme doesn't have a color - THEME_HEX_COLOR="#1c2027" + THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)") fi -source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - set_browser_policy() { browser_policy_write_color "$1" "$THEME_HEX_COLOR" } diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index b383e325..8826243b 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -13,11 +13,26 @@ BROWSER_POLICY_MANAGED_DIRS=( /etc/brave/policies/managed ) +# Ancestors of the managed dirs, shortest first. A writable or attacker-owned +# parent can rename the leaf aside; install -d follows a planted symlink. +BROWSER_POLICY_PARENT_DIRS=( + /etc/chromium + /etc/chromium/policies + /etc/opt/chrome + /etc/opt/chrome/policies + /etc/opt/edge + /etc/opt/edge/policies + /etc/brave + /etc/brave/policies +) + BROWSER_POLICY_FIREFOX_DIRS=( /usr/lib/firefox/distribution /opt/zen-browser/distribution ) +BROWSER_POLICY_DEFAULT_COLOR="#1c2027" + browser_policy_setup_group() { local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" @@ -59,13 +74,65 @@ browser_policy_dir_hardened() { [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 } +browser_policy_parent_hardened() { + local dir=$1 + + [[ -d $dir && ! -L $dir ]] || return 1 + [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 + [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 +} + +browser_policy_parents_hardened() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + [[ -e $parent || -L $parent ]] || continue + browser_policy_parent_hardened "$parent" || return 1 + done +} + +browser_policy_setup_parent() { + local dir=$1 + + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi + as_root install -d -m 0755 -o root -g root "$dir" +} + +browser_policy_setup_parents_for() { + local dir=$1 + local parent + + for parent in "${BROWSER_POLICY_PARENT_DIRS[@]}"; do + [[ $dir == "$parent"/* ]] || continue + browser_policy_setup_parent "$parent" + done +} + browser_policy_setup_dir() { local dir=$1 + browser_policy_setup_parents_for "$dir" as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" browser_policy_purge_dir "$dir" } +# Themes are user-installed. Accept only three 0-255 components. +browser_policy_theme_hex() { + local theme_rgb=$1 + + if [[ $theme_rgb =~ ^[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*,[[:space:]]*([0-9]{1,3})[[:space:]]*$ ]] && + (( 10#${BASH_REMATCH[1]} < 256 && 10#${BASH_REMATCH[2]} < 256 && 10#${BASH_REMATCH[3]} < 256 )); then + printf '#%02x%02x%02x' "$((10#${BASH_REMATCH[1]}))" "$((10#${BASH_REMATCH[2]}))" "$((10#${BASH_REMATCH[3]}))" + return + fi + + printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" +} + browser_policy_file_owner() { local user diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index e16efd20..787418e7 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -8,7 +8,9 @@ browser_policy_grant_user "${USER:-$(id -un)}" repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir ]] || continue - browser_policy_dir_hardened "$dir" && continue + if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then + continue + fi browser_policy_setup_dir "$dir" repaired=1 done diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 63591de1..7092a515 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -168,6 +168,87 @@ fi BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" +saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") +parent_root=$test_tmp/parents +mkdir -p "$parent_root/etc/chromium/policies/managed/keep" +printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" +chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" +chmod 2775 "$parent_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$parent_root/etc/chromium" + "$parent_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then + fail "a world-writable policy parent is not treated as hardened" +fi +browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed" +mode=$(stat -c '%a' "$parent_root/etc/chromium") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode" +mode=$(stat -c '%a' "$parent_root/etc/chromium/policies") +[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode" +[[ -d $parent_root/etc/chromium/policies/managed/keep ]] || + fail "parent repair does not purge the managed directory" +pass "policy parent directories are tightened to 0755 without purging the leaf" + +symlink_root=$test_tmp/symlink-parents +mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed" +printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json" +ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium" +BROWSER_POLICY_PARENT_DIRS=( + "$symlink_root/etc/chromium" + "$symlink_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed" +[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink" +[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] || + fail "setup recreates /etc/chromium as a real directory" +[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] || + fail "setup recreates /etc/chromium/policies as a real directory" +[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted parent symlink" +grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || + fail "replacing a parent symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted parent symlink" + +[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || + fail "theme colour converts an RGB triple to hex" +[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || + fail "theme colour accepts a trailing newline" +[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] || + fail "theme colour pads single-digit components" +[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] || + fail "theme colour tolerates surrounding whitespace" +[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] || + fail "theme colour treats leading zeros as decimal" +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || + fail "theme colour falls back to the neutral grey for '$malformed'" +done +pass "theme colour is six hex digits or the stock grey" + +for theme in "$ROOT"/themes/*/chromium.theme; do + [[ -f $theme ]] || continue + rgb=$(<$theme) + hex=$(browser_policy_theme_hex "$rgb") + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || + fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")" + if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then + fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")" + fi +done +pass "shipped chromium.theme files parse as RGB triples" + +grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then + fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" +fi +pass "omarchy-theme-set-browser validates the theme colour" + fx_policy=$test_tmp/policies.json printf '%s\n' '{"policies":{}}' >"$fx_policy" chmod 644 "$fx_policy" @@ -247,6 +328,8 @@ mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy (( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || fail "the policy-directory migration no-ops a machine already repaired" +grep -F 'browser_policy_parents_hardened' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs a world-writable parent of a hardened leaf" grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || fail "the policy-directory migration still grants the current user the group" grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index 00a9af55..c88c2253 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -210,6 +210,10 @@ cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.con fail "Chromium browser installer copies the default flags" grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || fail "Chromium browser installer creates the browser-policy group" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" || + fail "Chromium browser installer creates a root-owned Chromium policy parent" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" || + fail "Chromium browser installer creates a root-owned Chromium policies parent" grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || fail "Chromium browser installer creates a group-writable managed policy directory" grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || From 44a186afe48891f5304cd14fafa3462093cfede9 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 12:14:52 -0600 Subject: [PATCH 18/73] Replace planted policy directory symlinks instead of following them install -d follows a managed or distribution symlink and would chmod the target. Unlink those paths first, and treat a dangling symlink as a directory the migration still has to repair. --- install/helpers/browser-policy.sh | 9 +++-- migrations/1787515927.sh | 6 ++-- test/shell.d/browser-policy-dir-test.sh | 44 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 8826243b..687edce2 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -68,7 +68,7 @@ browser_policy_purge_dir() { browser_policy_dir_hardened() { local dir=$1 - [[ -d $dir ]] || return 1 + [[ -d $dir && ! -L $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 @@ -116,6 +116,9 @@ browser_policy_setup_dir() { local dir=$1 browser_policy_setup_parents_for "$dir" + if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then + as_root rm -rf -- "$dir" + fi as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" browser_policy_purge_dir "$dir" } @@ -229,7 +232,7 @@ browser_policy_firefox_policy_file_ok() { browser_policy_firefox_hardened() { local dir=$1 - [[ -d $dir ]] || return 1 + [[ -d $dir && ! -L $dir ]] || return 1 [[ $(stat -c '%a' "$dir") == "755" ]] || return 1 [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 browser_policy_firefox_policy_file_ok "$dir/policies.json" @@ -246,7 +249,7 @@ browser_policy_setup_firefox_distribution() { local distribution_dir=$1 local policies=${2:-$OMARCHY_PATH/default/firefox/policies.json} - as_root install -d -m 0755 -o root -g root "$distribution_dir" + browser_policy_setup_parent "$distribution_dir" browser_policy_purge_dir "$distribution_dir" browser_policy_install_firefox_policies "$distribution_dir" "$policies" } diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index 787418e7..113daf34 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -7,7 +7,7 @@ browser_policy_grant_user "${USER:-$(id -un)}" repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then continue fi @@ -20,9 +20,9 @@ if (( repaired )); then fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_firefox_hardened "$dir" && continue - as_root install -d -m 0755 -o root -g root "$dir" + browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then browser_policy_install_firefox_policies "$dir" diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 7092a515..b06f174f 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -213,6 +213,50 @@ grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" || BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") pass "policy setup does not follow a planted parent symlink" +leaf_link_root=$test_tmp/leaf-link +mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker" +printf 'planted\n' >"$leaf_link_root/attacker/evil.json" +chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies" +ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed" +BROWSER_POLICY_PARENT_DIRS=( + "$leaf_link_root/etc/chromium" + "$leaf_link_root/etc/chromium/policies" +) +as_root() { unprivileged_as_root "$@"; } +if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then + fail "a planted managed symlink is not treated as hardened" +fi +browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed" +[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup replaces a planted managed symlink" +[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] || + fail "setup recreates managed as a real directory" +[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] || + fail "setup does not keep policy that lived behind a planted managed symlink" +grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" || + fail "replacing a managed symlink does not delete the symlink target" +BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}") +pass "policy setup does not follow a planted managed symlink" + +fx_link_root=$test_tmp/fx-link +mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt" +printf 'planted\n' >"$fx_link_root/attacker/policies.json" +ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen" +as_root() { unprivileged_as_root "$@"; } +if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then + fail "a planted Firefox distribution symlink is not treated as hardened" +fi +browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" || + fail "Firefox setup replaces a planted distribution symlink" +[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink" +[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] || + fail "Firefox setup recreates the distribution directory" +[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] || + fail "Firefox setup writes policies.json into the recreated directory" +grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" || + fail "replacing a Firefox distribution symlink does not delete the symlink target" +pass "Firefox setup does not follow a planted distribution symlink" + [[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] || fail "theme colour converts an RGB triple to hex" [[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] || From bafc9a1000b503856e6fd642b87791cc3ddae5cf Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 25 Aug 2026 13:01:01 -0600 Subject: [PATCH 19/73] Write browser theme colour through a passwordless helper Managed policy dirs are enterprise trust roots, so they stay 0755 root:root. The menu path takes root for that one write through a sudoers glob of six hex digits, the same shape as omarchy-dns, and falls back to pkexec where the grant is not installed. Drop omarchy-browser-policy; a group member could plant any JSON, not just a colour. --- bin/omarchy-install-browser | 2 - bin/omarchy-provision-owner | 5 +- bin/omarchy-theme-set-browser | 13 +- bin/omarchy-theme-set-browser-policy | 118 +++++++++++++ bin/omarchy-upgrade-to-quattro | 5 +- etc/sudoers.d/omarchy-theme-browser | 8 + install/config/browser-policy.sh | 1 - install/helpers/browser-policy.sh | 117 ++----------- migrations/1787515927.sh | 11 +- test/shell.d/browser-policy-dir-test.sh | 132 +++----------- test/shell.d/browser-policy-sudoers-test.sh | 184 ++++++++++++++++++++ test/shell.d/default-apps-test.sh | 11 +- test/shell.d/provisioning-groups-test.sh | 19 +- test/shell.d/upgrade-to-quattro-test.sh | 11 +- 14 files changed, 383 insertions(+), 254 deletions(-) create mode 100755 bin/omarchy-theme-set-browser-policy create mode 100644 etc/sudoers.d/omarchy-theme-browser create mode 100755 test/shell.d/browser-policy-sudoers-test.sh diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index d808c805..4593bc72 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -9,8 +9,6 @@ set -e source "$OMARCHY_PATH/install/helpers/browser-policy.sh" setup_chromium_policy_directory() { - browser_policy_setup_group - browser_policy_grant_user "${USER:-$(id -un)}" browser_policy_setup_dir "$1" } diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 7c208eb9..0db99f2e 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -744,11 +744,8 @@ create_user() { chmod 440 /etc/sudoers.d/00-omarchy-wheel source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - OMARCHY_INSTALL_USER=$username - OMARCHY_PROVISIONING_DIR=$PROVISIONING_DIR - browser_policy_setup_group for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done } diff --git a/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 60fa3211..612e586f 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -12,10 +12,6 @@ if [[ -f $CHROMIUM_THEME ]]; then THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)") fi -set_browser_policy() { - browser_policy_write_color "$1" "$THEME_HEX_COLOR" -} - refresh_running_browser() { local process="$1" local command="$2" @@ -27,16 +23,11 @@ refresh_running_browser() { } failed=0 -set_browser_policy /etc/chromium/policies/managed || failed=1 +omarchy-theme-set-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1 + refresh_running_browser chromium chromium - -set_browser_policy /etc/opt/chrome/policies/managed || failed=1 refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome - -set_browser_policy /etc/opt/edge/policies/managed || failed=1 refresh_running_browser msedge microsoft-edge-stable - -set_browser_policy /etc/brave/policies/managed || failed=1 refresh_running_browser brave brave # Match on the binary path: the running process is named plain "brave", and a # bare -f brave-origin pattern would also match the installer's own terminal. diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy new file mode 100755 index 00000000..6f628f9b --- /dev/null +++ b/bin/omarchy-theme-set-browser-policy @@ -0,0 +1,118 @@ +#!/bin/bash + +# omarchy:summary=Write the current theme color into the browser policy directories +# omarchy:args= +# omarchy:hidden=true + +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 (printf's builtin aside: install, mktemp, rm) 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 + +# Enterprise policy trust roots. The list is fixed here rather than taken from +# the caller: the caller chooses a color, never a path. +POLICY_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /etc/brave/policies/managed +) + +# The path etc/sudoers.d/omarchy-theme-browser names. The privileged half always +# runs from there rather than from whichever copy was invoked, so the rule +# matches even where $OMARCHY_PATH points at a checkout. +PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy + +usage() { + echo "Usage: omarchy-theme-set-browser-policy " >&2 +} + +if (( $# != 1 )); then + usage + exit 1 +fi + +color="$1" + +# Six lowercase hex digits is the whole of what this accepts. The leading "#" +# is added when the JSON is written rather than passed in: "#" opens a comment +# in sudoers, and keeping it out of argv lets the sudoers rule spell the +# argument as a plain six-character glob. +if [[ ! $color =~ ^[0-9a-f]{6}$ ]]; then + echo "omarchy-theme-set-browser-policy: expected six lowercase hex digits, got '$color'" >&2 + exit 1 +fi + +# True when sudo would run this exact command without stopping for a password. +# `sudo -l` on its own reports whether a command is permitted, which the blanket +# %wheel rule answers yes to for everything; the long listing prints the matched +# entry's tags, so !authenticate is the grant in +# etc/sudoers.d/omarchy-theme-browser and nothing else. Listing runs nothing +# and, under -n, prompts for nothing. +sudo_grants_passwordless() { + sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate' +} + +require_root() { + if (( EUID == 0 )); then + return + elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then + exec sudo "$PACKAGED_PATH" "$@" + else + exec pkexec "$PACKAGED_PATH" "$@" + fi +} + +require_root "$color" + +failed=0 +staged="" +cleanup() { + [[ -n $staged ]] && rm -f "$staged" +} +trap cleanup EXIT + +for policy_dir in "${POLICY_DIRS[@]}"; do + # Only browsers Omarchy has installed have a policy directory. Creating one + # here would hand a browser a managed-policy root it does not otherwise have. + [[ -d $policy_dir && ! -L $policy_dir ]] || continue + + dest=$policy_dir/color.json + staged=$(mktemp) || { + failed=1 + continue + } + printf '{"BrowserThemeColor": "#%s", "BrowserColorScheme": "device"}\n' "$color" >"$staged" + + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest"; then + rm -f "$staged" + staged="" + echo "omarchy-theme-set-browser-policy: cannot replace $dest" >&2 + failed=1 + continue + fi + fi + + if ! install -m 0644 -o root -g root -T "$staged" "$dest"; then + rm -f "$staged" + staged="" + echo "omarchy-theme-set-browser-policy: cannot write $dest" >&2 + failed=1 + continue + fi + + rm -f "$staged" + staged="" +done + +exit "$failed" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 62ef21b9..32ea6465 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1316,14 +1316,13 @@ apply_system_transition() { if ! as_root test -f "$browser_policy_helper"; then warn "$browser_policy_helper is unavailable; Chromium policy directories were not hardened." else - as_root env OMARCHY_PATH=/usr/share/omarchy OMARCHY_INSTALL_USER="$target_user" \ + as_root env OMARCHY_PATH=/usr/share/omarchy \ bash -euo pipefail -c ' source "$OMARCHY_PATH/install/helpers/browser-policy.sh" - browser_policy_setup_group browser_policy_setup_dir /etc/chromium/policies/managed for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ $dir == "/etc/chromium/policies/managed" ]] && continue - [[ -d $dir ]] || continue + [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done ' diff --git a/etc/sudoers.d/omarchy-theme-browser b/etc/sudoers.d/omarchy-theme-browser new file mode 100644 index 00000000..853d2412 --- /dev/null +++ b/etc/sudoers.d/omarchy-theme-browser @@ -0,0 +1,8 @@ +# Theme switching is a menu action with no terminal to carry a password prompt, +# and it repaints the browser accent on every switch, so this one write must not +# stop for a password. The argument is spelled out as six hex digits rather than +# a wildcard: the grant covers a color and nothing else, and sudoers matches a +# command's arguments exactly, so it cannot be stretched into extra ones. The +# helper revalidates the same shape, since the terminal path does not come +# through this rule. +%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f] diff --git a/install/config/browser-policy.sh b/install/config/browser-policy.sh index a02de2f3..fd802c66 100644 --- a/install/config/browser-policy.sh +++ b/install/config/browser-policy.sh @@ -1,3 +1,2 @@ source "$OMARCHY_PATH/install/helpers/browser-policy.sh" -browser_policy_setup_group browser_policy_setup_dir /etc/chromium/policies/managed diff --git a/install/helpers/browser-policy.sh b/install/helpers/browser-policy.sh index 687edce2..c2c93d8e 100644 --- a/install/helpers/browser-policy.sh +++ b/install/helpers/browser-policy.sh @@ -1,11 +1,9 @@ -# Chromium-family machine policy is mandatory for every profile. A dedicated -# group at 2775 lets every Omarchy user write color.json and every other uid -# read; other-write stays off. Setgid so new files inherit the group. +# Chromium-family machine policy is mandatory for every profile. Directories +# stay 0755 root:root; omarchy-theme-set-browser-policy is the privileged +# write for color.json. source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" -BROWSER_POLICY_GROUP=omarchy-browser-policy - BROWSER_POLICY_MANAGED_DIRS=( /etc/chromium/policies/managed /etc/opt/chrome/policies/managed @@ -33,47 +31,12 @@ BROWSER_POLICY_FIREFOX_DIRS=( BROWSER_POLICY_DEFAULT_COLOR="#1c2027" -browser_policy_setup_group() { - local provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" - - as_root groupadd --system --force "$BROWSER_POLICY_GROUP" - as_root mkdir -p "$provisioning_dir" - if ! grep -qxF "$BROWSER_POLICY_GROUP" "$provisioning_dir/groups" 2>/dev/null; then - printf '%s\n' "$BROWSER_POLICY_GROUP" | as_root tee -a "$provisioning_dir/groups" >/dev/null - fi - - if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - as_root usermod -aG "$BROWSER_POLICY_GROUP" "$OMARCHY_INSTALL_USER" - fi -} - -browser_policy_grant_user() { - local user=${1:-} - - if [[ -z $user || $user == "root" ]]; then - user=${SUDO_USER:-} - fi - - [[ -n $user && $user != "root" ]] || return 0 - getent passwd "$user" >/dev/null || return 0 - as_root usermod -aG "$BROWSER_POLICY_GROUP" "$user" -} - browser_policy_purge_dir() { local dir=$1 as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + } -browser_policy_dir_hardened() { - local dir=$1 - - [[ -d $dir && ! -L $dir ]] || return 1 - [[ $(stat -c '%a' "$dir") == "2775" ]] || return 1 - [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 - [[ $(stat -c '%G' "$dir") == $BROWSER_POLICY_GROUP ]] || return 1 -} - browser_policy_parent_hardened() { local dir=$1 @@ -82,6 +45,10 @@ browser_policy_parent_hardened() { [[ $(stat -c '%U' "$dir") == "root" ]] || return 1 } +browser_policy_dir_hardened() { + browser_policy_parent_hardened "$1" +} + browser_policy_parents_hardened() { local dir=$1 local parent @@ -116,10 +83,7 @@ browser_policy_setup_dir() { local dir=$1 browser_policy_setup_parents_for "$dir" - if [[ -L $dir || ( -e $dir && ! -d $dir ) ]]; then - as_root rm -rf -- "$dir" - fi - as_root install -d -m 2775 -o root -g "$BROWSER_POLICY_GROUP" "$dir" + browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" } @@ -136,82 +100,31 @@ browser_policy_theme_hex() { printf '%s' "$BROWSER_POLICY_DEFAULT_COLOR" } -browser_policy_file_owner() { - local user - - if [[ -n ${OMARCHY_INSTALL_USER:-} && $OMARCHY_INSTALL_USER != "root" ]]; then - printf '%s\n' "$OMARCHY_INSTALL_USER" - return - fi - if [[ -n ${SUDO_USER:-} && $SUDO_USER != "root" ]]; then - printf '%s\n' "$SUDO_USER" - return - fi - if [[ -n ${PKEXEC_UID:-} ]]; then - user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) - if [[ -n $user && $user != "root" ]]; then - printf '%s\n' "$user" - return - fi - fi - user=${USER:-$(id -un)} - if [[ $user != "root" ]]; then - printf '%s\n' "$user" - fi -} - -# sudo when this process has a controlling terminal (fd 0 is /dev/null under -# `bash -lc cmd &`, but /dev/tty still works). pkexec when it does not. -browser_policy_elevate() { - if (( EUID == 0 )); then - "$@" - elif { exec 3/dev/null; then - exec 3<&- - sudo "$@" - else - pkexec "$@" - fi -} - -browser_policy_write_color() { +browser_policy_install_color() { local policy_dir=$1 local hex=$2 local dest=$policy_dir/color.json - local payload local tmp - local owner - [[ -d $policy_dir ]] || return 0 + [[ -d $policy_dir && ! -L $policy_dir ]] || return 0 + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || return 1 - payload=$(printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex") tmp=$(mktemp) || return 1 - printf '%s' "$payload" >"$tmp" + printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex" >"$tmp" - # A planted symlink or directory must not be written through or into. if [[ -L $dest || -d $dest ]]; then if ! rm -rf -- "$dest" 2>/dev/null; then - if ! browser_policy_elevate rm -rf -- "$dest"; then - rm -f "$tmp" - echo "omarchy-theme-set-browser: cannot replace $dest (need group $BROWSER_POLICY_GROUP)" >&2 - return 1 - fi + rm -f "$tmp" + return 1 fi fi - if install -m 664 -T "$tmp" "$dest" 2>/dev/null; then - rm -f "$tmp" - return 0 - fi - - owner=$(browser_policy_file_owner) - [[ -n $owner ]] || owner=root - if browser_policy_elevate install -m 664 -o "$owner" -g "$BROWSER_POLICY_GROUP" -T "$tmp" "$dest"; then + if install -m 0644 -T "$tmp" "$dest" 2>/dev/null; then rm -f "$tmp" return 0 fi rm -f "$tmp" - echo "omarchy-theme-set-browser: cannot write $dest (need group $BROWSER_POLICY_GROUP)" >&2 return 1 } diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index 113daf34..f1f33869 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -2,15 +2,9 @@ echo "Stop world-writable Chromium and Firefox policy directories" source "$OMARCHY_PATH/install/helpers/browser-policy.sh" -browser_policy_setup_group -browser_policy_grant_user "${USER:-$(id -un)}" - repaired=0 for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue - if browser_policy_dir_hardened "$dir" && browser_policy_parents_hardened "$dir"; then - continue - fi browser_policy_setup_dir "$dir" repaired=1 done @@ -21,7 +15,10 @@ fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue - browser_policy_firefox_hardened "$dir" && continue + if browser_policy_firefox_hardened "$dir"; then + browser_policy_purge_dir "$dir" + continue + fi browser_policy_setup_parent "$dir" browser_policy_purge_dir "$dir" if ! browser_policy_firefox_policy_file_ok "$dir/policies.json"; then diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index b06f174f..20b8067c 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -7,24 +7,6 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT -mock_bin=$test_tmp/bin -mkdir -p "$mock_bin" -elev_log=$test_tmp/elev.log -cat >"$mock_bin/sudo" <>"$elev_log" -[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 -exit 0 -SH -cat >"$mock_bin/pkexec" <>"$elev_log" -[[ \${OMARCHY_TEST_SUDO_FAIL:-} == 1 ]] && exit 1 -exit 0 -SH -chmod +x "$mock_bin/sudo" "$mock_bin/pkexec" -export PATH="$mock_bin:$PATH" -: >"$elev_log" export OMARCHY_PATH="$ROOT" export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning" @@ -55,20 +37,20 @@ unprivileged_as_root() { write_dir=$test_tmp/writable mkdir -p "$write_dir" -browser_policy_write_color "$write_dir" "#aabbcc" || +browser_policy_install_color "$write_dir" "#aabbcc" || fail "theme colour writes into a writable policy directory" grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null || fail "theme colour writes BrowserThemeColor" mode=$(stat -c '%a' "$write_dir/color.json") -[[ $mode == "664" ]] || fail "theme colour creates a group-writable policy file" "mode=$mode" -pass "theme colour writes a group-writable color.json" +[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode" +pass "theme colour writes a 0644 color.json" if (( EUID == 0 )); then pass "running as root; skipping the mktemp-failure check" else chmod u+w "$write_dir" export TMPDIR=$test_tmp/missing-tmp - if browser_policy_write_color "$write_dir" "#dead00" 2>/dev/null; then + if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then fail "theme colour fails when mktemp cannot create a file" fi unset TMPDIR @@ -80,7 +62,7 @@ fi printf 'original\n' >"$test_tmp/pwn" rm -f "$write_dir/color.json" ln -s "$test_tmp/pwn" "$write_dir/color.json" -browser_policy_write_color "$write_dir" "#aabbcc" || +browser_policy_install_color "$write_dir" "#aabbcc" || fail "theme colour replaces a planted color.json symlink" [[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] || fail "theme colour unlinks a planted color.json symlink instead of writing through it" @@ -90,62 +72,25 @@ pass "theme colour does not follow a planted color.json symlink" plant_write=$test_tmp/plant-dir mkdir -p "$plant_write/color.json/nested" printf 'inside\n' >"$plant_write/color.json/nested/x" -browser_policy_write_color "$plant_write" "#aabbcc" || +browser_policy_install_color "$plant_write" "#aabbcc" || fail "theme colour replaces a planted color.json directory" [[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] || fail "theme colour does not write into a planted color.json directory" pass "theme colour does not write into a planted color.json directory" missing_dir=$test_tmp/missing -browser_policy_write_color "$missing_dir" "#aabbcc" || +browser_policy_install_color "$missing_dir" "#aabbcc" || fail "theme colour skips a policy directory that does not exist" [[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory" pass "theme colour skips a missing policy directory" -if (( EUID == 0 )); then - pass "running as root; skipping elevation checks" -else - denied_dir=$test_tmp/denied - mkdir -p "$denied_dir" - chmod a-w "$denied_dir" - owner=${USER:-$(id -un)} - : >"$elev_log" - browser_policy_write_color "$denied_dir" "#aabbcc" || - fail "elevated install reports success from pkexec" - grep -E "^PKEXEC install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || - fail "without a controlling tty, colour write elevates through pkexec as the owner" "$(cat "$elev_log")" - if grep -E '^SUDO ' "$elev_log" >/dev/null; then - fail "without a controlling tty, colour write does not call sudo" "$(cat "$elev_log")" - fi - pass "without a controlling tty, colour write elevates through pkexec" - - : >"$elev_log" - export OMARCHY_TEST_SUDO_FAIL=1 - if browser_policy_write_color "$denied_dir" "#aabbcc" 2>"$test_tmp/write.err"; then - fail "theme colour fails when the policy directory is not writable" - fi - unset OMARCHY_TEST_SUDO_FAIL - grep -F 'omarchy-browser-policy' "$test_tmp/write.err" >/dev/null || - fail "theme colour names the group when the write is denied" - pass "theme colour reports a denied policy write" - - if command -v script >/dev/null; then - : >"$elev_log" - cat >"$test_tmp/tty-write.sh" </dev/null - grep -E "^SUDO install -m 664 -o $owner -g omarchy-browser-policy -T .+ $denied_dir/color.json$" "$elev_log" >/dev/null || - fail "with a controlling tty, colour write elevates through sudo" "$(cat "$elev_log")" - if grep -E '^PKEXEC ' "$elev_log" >/dev/null; then - fail "with a controlling tty, colour write does not call pkexec" "$(cat "$elev_log")" - fi - pass "with a controlling tty, colour write elevates through sudo" - else - pass "script(1) unavailable; skipping the controlling-tty elevation check" - fi +if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then + fail "theme colour rejects hex without a leading #" fi +if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then + fail "theme colour rejects uppercase hex" +fi +pass "theme colour accepts only # plus six lowercase hex digits" planted_dir=$test_tmp/planted mkdir -p "$planted_dir/evil" @@ -156,16 +101,16 @@ browser_policy_setup_dir "$planted_dir" [[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory" [[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json" [[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place" +mode=$(stat -c '%a' "$planted_dir") +[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode" pass "policy setup drops non-root files and non-empty subdirectories" owned=$test_tmp/not-root mkdir -p "$owned" -chmod 2775 "$owned" -BROWSER_POLICY_GROUP=$(id -gn) +chmod 755 "$owned" if browser_policy_dir_hardened "$owned"; then - fail "a user-owned 2775 directory is not treated as hardened" + fail "a user-owned 0755 directory is not treated as hardened" fi -BROWSER_POLICY_GROUP=omarchy-browser-policy pass "a hardened directory must be root-owned" saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}") @@ -173,7 +118,7 @@ parent_root=$test_tmp/parents mkdir -p "$parent_root/etc/chromium/policies/managed/keep" printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x" chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" -chmod 2775 "$parent_root/etc/chromium/policies/managed" +chmod 755 "$parent_root/etc/chromium/policies/managed" BROWSER_POLICY_PARENT_DIRS=( "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies" @@ -270,7 +215,7 @@ pass "Firefox setup does not follow a planted distribution symlink" for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do [[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] || - fail "theme colour falls back to the neutral grey for '$malformed'" + fail "theme colour falls back to the stock grey for '$malformed'" done pass "theme colour is six hex digits or the stock grey" @@ -288,6 +233,8 @@ pass "shipped chromium.theme files parse as RGB triples" grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex" +grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || + fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy" if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then fail "omarchy-theme-set-browser does not hand unvetted theme words to printf" fi @@ -328,27 +275,6 @@ fi [[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place" pass "Firefox policy install does not write into a planted policies.json directory" -grant_log=$test_tmp/usermod.calls -as_root() { - if [[ $1 == "usermod" ]]; then - printf '%s\n' "$*" >>"$grant_log" - return 0 - fi - unprivileged_as_root "$@" -} -invoker=${USER:-$(id -un)} -: >"$grant_log" -SUDO_USER=$invoker -browser_policy_grant_user root -unset SUDO_USER -grep -qx -- "usermod -aG omarchy-browser-policy $invoker" "$grant_log" || - fail "granting as root uses SUDO_USER" "$(cat "$grant_log")" -: >"$grant_log" -OMARCHY_INSTALL_USER="" -browser_policy_grant_user "" -[[ ! -s $grant_log ]] || fail "an empty grant does not usermod" -pass "sudo install browser grants the invoking user, not root" - grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser exits non-zero when a policy write fails" pass "omarchy-theme-set-browser exits non-zero when a policy write fails" @@ -357,25 +283,25 @@ policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" "$ROOT/bin/omarchy-theme-set-browser" + "$ROOT/bin/omarchy-theme-set-browser-policy" "$ROOT/bin/omarchy-upgrade-to-quattro" "$ROOT/install/config/theme-system.sh" "$ROOT/install/config/browser-policy.sh" "$ROOT/install/helpers/browser-policy.sh" "$ROOT/migrations/1787515927.sh" ) -if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777' "${policy_files[@]}" >/dev/null; then - fail "browser policy setup is not world-writable" +if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then + fail "browser policy setup is not world-writable and does not use omarchy-browser-policy" fi pass "browser policy setup is not world-writable" mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations") (( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}" -grep -F 'browser_policy_dir_hardened' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration no-ops a machine already repaired" -grep -F 'browser_policy_parents_hardened' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration repairs a world-writable parent of a hardened leaf" -grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null || - fail "the policy-directory migration still grants the current user the group" +grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null || + fail "the policy-directory migration repairs managed directories" +if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then + fail "the policy-directory migration does not grant a browser-policy group" +fi grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null || fail "the policy-directory migration covers Firefox and Zen" grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null || diff --git a/test/shell.d/browser-policy-sudoers-test.sh b/test/shell.d/browser-policy-sudoers-test.sh new file mode 100755 index 00000000..2af8ea2b --- /dev/null +++ b/test/shell.d/browser-policy-sudoers-test.sh @@ -0,0 +1,184 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +helper="$ROOT/bin/omarchy-theme-set-browser-policy" +setter="$ROOT/bin/omarchy-theme-set-browser" +sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser" +rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' + +# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads +# as "any arguments" -- or widening the glob to `*` would let the grant carry +# something other than a color while leaving this line looking right. +rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file") +[[ $rules == "$rule" ]] || + fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules" + +if command -v visudo >/dev/null; then + visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses" +fi + +grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names" + +grep -E 'sudo -n -l -l' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing" + +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" || + fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root" +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root" + +pass "browser policy sudoers rule is scoped to a single color argument" + +for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \ + /etc/opt/edge/policies/managed /etc/brave/policies/managed; do + grep -Fx " $dir" "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list" +done + +policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /') +((policy_dir_count == 4)) || + fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \ + "got: $policy_dir_count" + +grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null || + fail "omarchy-theme-set-browser-policy installs color.json with install -T" +if grep -E 'mv -f' "$helper" >/dev/null; then + fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory" +fi + +pass "browser policy helper writes a fixed set of policy directories" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/pkexec" <<'SH' +#!/bin/bash +printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/pkexec" + +# STUB_GRANTED empty stands for an install whose omarchy-settings predates the +# sudoers file. The default is granted, matching a current Omarchy. +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +if [[ $1 == -n && $2 == -l ]]; then + if [[ ${STUB_GRANTED-granted} == "granted" ]]; then + echo " Options: !authenticate" + else + echo " Matched: ${!#}" + fi + exit 0 +fi +printf 'sudo %s\n' "$*" >"$ELEVATION_LOG" +SH +chmod +x "$stub_bin/sudo" + +if ((EUID == 0)); then + pass "running as root; skipping the elevation checks, which would rewrite this machine's browser policy" +else + elevation_for() { + : >"$test_tmp/elevation" + ELEVATION_LOG="$test_tmp/elevation" \ + PATH="$stub_bin:$PATH" \ + bash "$helper" "$@" /dev/null 2>&1 || true + cat "$test_tmp/elevation" + } + + elevation=$(elevation_for 1c2027) + [[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \ + "got: $elevation" + + dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027) + [[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \ + "got: $dev_linked" + + pass "browser policy helper elevates a valid color through the sudo grant" + + ungranted=$(STUB_GRANTED="" elevation_for 1c2027) + [[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] || + fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \ + "got: $ungranted" + + pass "browser policy helper falls back to polkit wherever the grant does not reach" + + for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \ + '$(id)' "1c2027;id" "#1c2027"; do + if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \ + bash "$helper" "$bad" /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects '$bad'" + fi + + rejected=$(elevation_for "$bad") + [[ -z $rejected ]] || + fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected" + done + + if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff /dev/null 2>&1; then + fail "omarchy-theme-set-browser-policy rejects more than one argument" + fi + + pass "browser policy helper accepts nothing but six lowercase hex digits" +fi + +setter_bin="$test_tmp/setter-bin" +mkdir -p "$setter_bin" + +cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >"$COLOR_LOG" +SH +chmod +x "$setter_bin/omarchy-theme-set-browser-policy" + +cat >"$setter_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$setter_bin/omarchy-cmd-present" + +setter_home="$test_tmp/home" +theme_dir="$setter_home/.local/state/omarchy/current/theme" +mkdir -p "$theme_dir" + +color_for_theme() { + : >"$test_tmp/color" + if [[ $# -gt 0 ]]; then + printf '%s' "$1" >"$theme_dir/chromium.theme" + else + rm -f "$theme_dir/chromium.theme" + fi + + HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \ + OMARCHY_PATH="$ROOT" bash "$setter" /dev/null 2>&1 || true + cat "$test_tmp/color" +} + +[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] || + fail "omarchy-theme-set-browser converts an RGB triple to six hex digits" +[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] || + fail "omarchy-theme-set-browser accepts a trailing newline" +[[ $(color_for_theme "0,0,0") == "000000" ]] || + fail "omarchy-theme-set-browser pads single-digit components" +[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] || + fail "omarchy-theme-set-browser tolerates surrounding whitespace" + +for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \ + "1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do + color=$(color_for_theme "$malformed") + [[ $color == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color" +done + +[[ $(color_for_theme) == "1c2027" ]] || + fail "omarchy-theme-set-browser falls back to the stock colour with no theme file" + +pass "browser theme color is derived as six hex digits or falls back to the stock grey" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index c88c2253..8151c250 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -208,18 +208,17 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds" cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" || fail "Chromium browser installer copies the default flags" -grep -Fxq 'sudo:groupadd --system --force omarchy-browser-policy' "$setup_log" || - fail "Chromium browser installer creates the browser-policy group" grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" || fail "Chromium browser installer creates a root-owned Chromium policy parent" grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" || fail "Chromium browser installer creates a root-owned Chromium policies parent" -grep -Fxq 'sudo:install -d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$setup_log" || - fail "Chromium browser installer creates a group-writable managed policy directory" +grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies/managed' "$setup_log" || + fail "Chromium browser installer creates a root-owned managed policy directory" grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" || fail "Chromium browser installer drops non-root files from its policy directory" -grep -Fxq "sudo:usermod -aG omarchy-browser-policy ${USER:-$(id -un)}" "$setup_log" || - fail "Chromium browser installer grants the installing user the browser-policy group" +if grep -E 'groupadd|usermod|omarchy-browser-policy' "$setup_log" >/dev/null; then + fail "Chromium browser installer does not create a browser-policy group" "$(cat "$setup_log")" +fi grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" || fail "Chromium browser installer registers the Copy URL host" grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" || diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index 7b5c6964..5a5fc516 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -55,12 +55,13 @@ OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" -grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "browser-policy group recorded" +! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || + fail "browser-policy group must not be recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" -grep -F -- '--system --force omarchy-browser-policy' "$TMPDIR/groupadd.calls" >/dev/null || - fail "browser-policy group is created as a system group" -grep -F -- '-d -m 2775 -o root -g omarchy-browser-policy /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || - fail "browser-policy directory is created group-writable" +[[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null || + fail "browser-policy group is not created" +grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || + fail "browser-policy directory is created root-owned" pass "deferred provisioning records groups without calling usermod" # The docker group is root-equivalent and must never be granted automatically. @@ -77,8 +78,6 @@ pass "missing install user defers group grants" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" -[[ $(grep -cxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || - fail "browser-policy group recorded once" pass "group recording is idempotent" # Existing user: usermod applies the recorded groups, and docker is never among them. @@ -86,7 +85,7 @@ OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" -grep -qx -- "-aG omarchy-browser-policy existing" "$TMPDIR/usermod.calls" || - fail "usermod grants browser-policy to the install user" +! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" || + fail "usermod must not grant browser-policy to the install user" ! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" -pass "existing install user gets input and browser-policy but never docker" +pass "existing install user gets input but never docker or browser-policy" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index c38c17f7..60e8abff 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -71,18 +71,19 @@ grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade uses the shared browser-policy helper" grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade survives a packaged tree without the browser-policy helper" -grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null || - fail "Omarchy 4 upgrade creates the browser-policy group" +if grep -F 'browser_policy_setup_group' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not create a browser-policy group" +fi grep -F 'browser_policy_setup_dir /etc/chromium/policies/managed' "$upgrade_to_quattro" >/dev/null || - fail "Omarchy 4 upgrade creates a group-writable Chromium policy directory" + fail "Omarchy 4 upgrade creates a root-owned Chromium policy directory" grep -F 'BROWSER_POLICY_MANAGED_DIRS' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade hardens every Chromium-family policy directory" grep -F 'run_as_user_omarchy omarchy-theme-set-browser' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade rewrites browser theme colour after a headless theme-set" -if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw' "$upgrade_to_quattro" >/dev/null; then +if grep -E 'install -d -m 0?[27]?777 /etc/.*/policies|chmod a\+rw|2775' "$upgrade_to_quattro" >/dev/null; then fail "Omarchy 4 upgrade does not create a world-writable Chromium policy directory" fi -pass "Omarchy 4 upgrade locks the Chromium policy directory to the browser-policy group" +pass "Omarchy 4 upgrade locks the Chromium policy directory to root" grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1' "$upgrade_to_quattro" >/dev/null grep -F 'systemd-networkd.service' "$upgrade_to_quattro" >/dev/null From 45749c5b68cbe3ef41eac75baf5e8afc729896c8 Mon Sep 17 00:00:00 2001 From: David Helmus Date: Tue, 25 Aug 2026 21:09:45 +0200 Subject: [PATCH 20/73] test: cover Python shim bypass Place a synthetic python3 shim first in PATH so CI catches any regression that resolves REAL_PYTHON through user-managed shims. --- test/shell.d/copy-url-shortcut-migration-test.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index dd25420e..934a57ee 100644 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ b/test/shell.d/copy-url-shortcut-migration-test.sh @@ -28,8 +28,18 @@ write_stale_preferences() { stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -REAL_PYTHON=$(command -p -v python3) +cat >"$stub_bin/python3" <<'STUB' +#!/bin/bash +exit 127 +STUB +chmod +x "$stub_bin/python3" + +# Test stubs must delegate to the system interpreter, not a user shim that can +# route python3 back through the stubs and recurse. +REAL_PYTHON=$(PATH="$stub_bin:$PATH" command -p -v python3) +[[ $REAL_PYTHON != "$stub_bin/python3" ]] || fail "real Python resolution bypasses user shims" export REAL_PYTHON +rm -f "$stub_bin/python3" run_migration() { HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 From 3af7675a10fdfc5a49789ea4723e454aac17704b Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Wed, 26 Aug 2026 16:14:51 +0200 Subject: [PATCH 21/73] Require `textFormat` declaration for all `Text` elements. --- shell/Ui/Button.qml | 3 + shell/Ui/ConfirmDialog.qml | 2 + shell/Ui/Dropdown.qml | 3 + shell/Ui/MultiSelect.qml | 6 + shell/Ui/NumberField.qml | 1 + shell/Ui/OpticalGlyph.qml | 1 + shell/Ui/PanelActionButton.qml | 1 + shell/Ui/PanelHero.qml | 3 + shell/Ui/PanelSectionHeader.qml | 4 + shell/Ui/PanelToolTip.qml | 1 + shell/Ui/SearchableDropdown.qml | 5 + shell/Ui/SpeedTestOverlay.qml | 5 + shell/Ui/Toggle.qml | 2 + shell/Ui/WidgetButton.qml | 1 + shell/plugins/agents/Panel.qml | 12 ++ shell/plugins/bar/Bar.qml | 1 + shell/plugins/bar/widgets/ActiveWindow.qml | 1 + shell/plugins/bar/widgets/Tray.qml | 4 + shell/plugins/clipboard/Clipboard.qml | 4 + shell/plugins/dev-gallery/GalleryPanel.qml | 12 ++ shell/plugins/emojis/Emojis.qml | 3 + shell/plugins/image-picker/ImagePicker.qml | 2 + shell/plugins/lock/LockView.qml | 1 + shell/plugins/menu/Menu.qml | 7 + .../notifications/NotificationLogic.js | 28 +++- .../components/NotificationCard.qml | 7 + shell/plugins/osd/Osd.qml | 2 + shell/plugins/panels/audio/Panel.qml | 11 ++ shell/plugins/panels/bluetooth/Panel.qml | 6 + shell/plugins/panels/clock/Panel.qml | 8 + shell/plugins/panels/dropbox/Panel.qml | 6 + shell/plugins/panels/monitor/Panel.qml | 7 + shell/plugins/panels/network/Panel.qml | 8 + shell/plugins/panels/power/Panel.qml | 3 + shell/plugins/panels/tailscale/Panel.qml | 10 ++ shell/plugins/panels/weather/Panel.qml | 14 ++ shell/plugins/panels/wifiqr/Panel.qml | 3 + shell/plugins/polkit/PolkitAgent.qml | 2 + shell/plugins/reminders/ReminderFlow.qml | 1 + shell/plugins/services/media/BarWidget.qml | 8 + test/shell.d/notifications-test.sh | 34 +++++ test/shell.d/qml-text-format-test.sh | 144 ++++++++++++++++++ 42 files changed, 386 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/qml-text-format-test.sh diff --git a/shell/Ui/Button.qml b/shell/Ui/Button.qml index 2c093b4b..2b84577a 100644 --- a/shell/Ui/Button.qml +++ b/shell/Ui/Button.qml @@ -138,6 +138,7 @@ BorderSurface { radius: 0 } contentItem: Text { + textFormat: Text.PlainText text: root.tooltipText color: root.tooltipForeground font.family: root.fontFamily @@ -158,6 +159,7 @@ BorderSurface { spacing: Style.spacing.controlGap Text { + textFormat: Text.PlainText visible: root.iconText !== "" text: root.iconText color: root.selected ? root._selectedColor : root.foreground @@ -177,6 +179,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.text !== "" text: root.text color: root.selected ? root._selectedColor : root.foreground diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index ed4f8c98..bc108d97 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -69,6 +69,7 @@ Item { Text { id: messageText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -105,6 +106,7 @@ Item { radius: 0 Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) diff --git a/shell/Ui/Dropdown.qml b/shell/Ui/Dropdown.qml index 214a7fe4..58386c9b 100644 --- a/shell/Ui/Dropdown.qml +++ b/shell/Ui/Dropdown.qml @@ -71,6 +71,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -110,6 +111,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -214,6 +216,7 @@ Item { : "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index f759b66d..85705779 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -259,6 +259,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -298,6 +299,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -451,6 +453,7 @@ Item { : Border.controlSpec("normal", root.foreground, root.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.loadingOptions ? "󰦖" : "󰑐" color: root.foreground @@ -486,6 +489,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText) @@ -581,6 +585,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: modelData.label color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -589,6 +594,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/NumberField.qml b/shell/Ui/NumberField.qml index 24b70f5b..985c9f7e 100644 --- a/shell/Ui/NumberField.qml +++ b/shell/Ui/NumberField.qml @@ -25,6 +25,7 @@ Column { spacing: Style.spacing.md Text { + textFormat: Text.PlainText visible: root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index d446a52c..a8881d49 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -25,6 +25,7 @@ Item { Text { id: glyph + textFormat: Text.PlainText // Keep the shared line box and baseline intact. Correcting only the // horizontal painted bounds avoids per-glyph vertical drift. anchors.centerIn: parent diff --git a/shell/Ui/PanelActionButton.qml b/shell/Ui/PanelActionButton.qml index 8a1b10bc..05f7d6be 100644 --- a/shell/Ui/PanelActionButton.qml +++ b/shell/Ui/PanelActionButton.qml @@ -69,6 +69,7 @@ BorderSurface { Behavior on color { ColorAnimation { duration: 60 } } Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.iconText color: root.enabled diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml index 7d663f37..4d13cf14 100644 --- a/shell/Ui/PanelHero.qml +++ b/shell/Ui/PanelHero.qml @@ -48,6 +48,7 @@ Item { width: parent.width Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0))) @@ -75,6 +76,7 @@ Item { Text { id: detailText + textFormat: Text.PlainText anchors.centerIn: parent text: root.detail color: root.dim @@ -87,6 +89,7 @@ Item { Text { id: metaText + textFormat: Text.PlainText width: parent.width text: root.meta.toUpperCase() visible: text !== "" diff --git a/shell/Ui/PanelSectionHeader.qml b/shell/Ui/PanelSectionHeader.qml index 5559248e..f0d54fb9 100644 --- a/shell/Ui/PanelSectionHeader.qml +++ b/shell/Ui/PanelSectionHeader.qml @@ -11,6 +11,10 @@ Text { property string fontFamily: Style.font.family property real fontSize: Style.font.caption + // Callers bind `text` from outside this file, so the default has to be set + // here. AutoText would let a section title that happens to carry a device or + // network name promote itself to rich text. + textFormat: Text.PlainText color: Qt.darker(foreground, 1.4) font.family: fontFamily font.pixelSize: fontSize diff --git a/shell/Ui/PanelToolTip.qml b/shell/Ui/PanelToolTip.qml index 139b6cf0..90d3133a 100644 --- a/shell/Ui/PanelToolTip.qml +++ b/shell/Ui/PanelToolTip.qml @@ -36,6 +36,7 @@ ToolTip { } contentItem: Text { + textFormat: Text.PlainText text: root.text color: root.panelForeground font.family: root.fontFamily diff --git a/shell/Ui/SearchableDropdown.qml b/shell/Ui/SearchableDropdown.qml index 9cf0aa49..7728d86b 100644 --- a/shell/Ui/SearchableDropdown.qml +++ b/shell/Ui/SearchableDropdown.qml @@ -93,6 +93,7 @@ Item { spacing: Style.spacing.labelGap Text { + textFormat: Text.PlainText visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) @@ -132,6 +133,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter @@ -246,6 +248,7 @@ Item { height: popup.height - searchHeader.height - Style.spacing.xxs - 1 Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: resultList.count === 0 text: root.emptyText @@ -313,6 +316,7 @@ Item { spacing: Style.spacing.xxs Text { + textFormat: Text.PlainText text: root.optionLabel(modelData) color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily @@ -321,6 +325,7 @@ Item { width: parent.width } Text { + textFormat: Text.PlainText visible: text !== "" text: root.optionDescription(modelData) color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml index 1216f348..a8f84c7b 100644 --- a/shell/Ui/SpeedTestOverlay.qml +++ b/shell/Ui/SpeedTestOverlay.qml @@ -130,6 +130,7 @@ PanelWindow { spacing: Style.space(16) Text { + textFormat: Text.PlainText visible: root.title !== "" text: root.title.toUpperCase() color: root.onScrimDim @@ -182,6 +183,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText visible: root.failed text: root.error color: root.onScrimUrgent @@ -368,6 +370,7 @@ PanelWindow { spacing: 0 Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter // Both branches go through the locale: a reading is a measurement, so // its separators follow the system's number conventions rather than the @@ -383,6 +386,7 @@ PanelWindow { } Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter text: root.unit color: root.onScrimDim @@ -394,6 +398,7 @@ PanelWindow { // The 90° gap at the bottom of the scale is where a cluster prints its // unit; here it names the direction. Text { + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom text: dial.label diff --git a/shell/Ui/Toggle.qml b/shell/Ui/Toggle.qml index 26a5cf96..b861fec7 100644 --- a/shell/Ui/Toggle.qml +++ b/shell/Ui/Toggle.qml @@ -69,6 +69,7 @@ BorderSurface { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.label color: root.foreground font.family: root.fontFamily @@ -79,6 +80,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText visible: root.description !== "" text: root.description color: Qt.darker(root.foreground, 1.5) diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 02d843ab..87d18050 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -74,6 +74,7 @@ Item { Text { id: label + textFormat: Text.PlainText visible: root.labelVisible anchors.centerIn: parent text: root.text diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 6637531a..f4ecdd9a 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -434,6 +434,7 @@ Panel { } Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: heroMarkImage.status !== Image.Ready text: button.text @@ -504,6 +505,7 @@ Panel { Text { id: statusText + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -558,6 +560,7 @@ Panel { Text { id: balanceValue + textFormat: Text.PlainText text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : "" color: root.balanceAlarming ? root.urgent : root.foreground font.family: root.fontFamily @@ -575,6 +578,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width text: root.balanceDetailText(root.balance) @@ -680,6 +684,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: text !== "" width: parent.width topPadding: Style.space(2) @@ -710,6 +715,7 @@ Panel { Text { id: limitLabel + textFormat: Text.PlainText // A model-scoped window is titled after its model, and those names run // long enough to reach the percentage, so the title gives way first. text: limitRow.window ? limitRow.window.title : "" @@ -725,6 +731,7 @@ Panel { Text { id: limitValue + textFormat: Text.PlainText text: limitRow.window && limitRow.window.percent >= 0 ? Math.round(limitRow.window.percent * 100) + "%" : "—" @@ -744,6 +751,7 @@ Panel { Text { id: resetText + textFormat: Text.PlainText width: parent.width text: { var remainingMs = root.resetMsFor(limitRow.window) @@ -798,6 +806,7 @@ Panel { Text { id: dayLabel + textFormat: Text.PlainText text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -835,6 +844,7 @@ Panel { Text { id: dayValue + textFormat: Text.PlainText text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0) color: dayRow.today ? root.foreground : root.dim font.family: root.fontFamily @@ -890,6 +900,7 @@ Panel { Text { id: modelName + textFormat: Text.PlainText text: modelRow.row ? modelRow.row.name : "" color: root.foreground font.family: root.fontFamily @@ -904,6 +915,7 @@ Panel { Text { id: modelTokens + textFormat: Text.PlainText text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : "" color: root.dim font.family: root.fontFamily diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 5dcd205f..9e736b3f 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -1090,6 +1090,7 @@ Item { Text { id: tooltipLabel + textFormat: Text.PlainText anchors.centerIn: parent text: root.tooltipText color: Color.tooltip.text diff --git a/shell/plugins/bar/widgets/ActiveWindow.qml b/shell/plugins/bar/widgets/ActiveWindow.qml index 97ccce8d..ff7e83d8 100644 --- a/shell/plugins/bar/widgets/ActiveWindow.qml +++ b/shell/plugins/bar/widgets/ActiveWindow.qml @@ -29,6 +29,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left width: parent.width diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index d0d07f57..650358c1 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -467,6 +467,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: rowIcon.right anchors.leftMargin: Style.space(10) @@ -577,6 +578,7 @@ BarWidget { } Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Style.space(28) @@ -681,6 +683,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left @@ -709,6 +712,7 @@ BarWidget { } Text { + textFormat: Text.PlainText visible: !menuRow.modelData.isSeparator anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index d819f949..da969e65 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -432,6 +432,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -500,6 +501,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0) height: parent.height text: parent.parent.previewText @@ -546,6 +548,7 @@ Item { } Text { + textFormat: Text.PlainText visible: parent.activeRow && !parent.activeRow.previewImage anchors.fill: parent anchors.leftMargin: root.contentMargin @@ -593,6 +596,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index 945b6252..680bdbef 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -519,12 +519,14 @@ Item { width: Style.space(140) spacing: Style.space(1) Text { + textFormat: Text.PlainText text: "Style.font." + modelData.key color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: modelData.size + " px" color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily @@ -534,6 +536,7 @@ Item { Text { id: sampleText + textFormat: Text.PlainText anchors.left: metaCol.right anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -574,6 +577,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.family color: root.foreground font.family: root.fontFamily @@ -587,6 +591,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.resolvedFamily color: root.foreground font.family: root.fontFamily @@ -600,6 +605,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.font.baseSize + " px" color: root.foreground font.family: root.fontFamily @@ -613,6 +619,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeHorizontal + " px" color: root.foreground font.family: root.fontFamily @@ -626,6 +633,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.bar.sizeVertical + " px" color: root.foreground font.family: root.fontFamily @@ -639,6 +647,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.scale.toFixed(2) color: root.foreground font.family: root.fontFamily @@ -652,6 +661,7 @@ Item { font.pixelSize: Style.font.bodySmall } Text { + textFormat: Text.PlainText text: Style.spacing.panelPadding + " px" color: root.foreground font.family: root.fontFamily @@ -818,6 +828,7 @@ Item { Text { id: csLabel + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1273,6 +1284,7 @@ Item { } Text { + textFormat: Text.PlainText text: Math.round((demoSlider.dragging ? demoSlider.liveValue : sliderRow.demoVolume) * 100) + "%" color: root.foreground font.family: root.fontFamily diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index cbdf541d..376c382e 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -247,6 +247,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -284,6 +285,7 @@ Item { color: hasCursor ? root.selectedBackground : "transparent" Text { + textFormat: Text.PlainText text: parent.emoji font.family: root.fontFamily font.pixelSize: Style.font.display @@ -326,6 +328,7 @@ Item { } Text { + textFormat: Text.PlainText text: "No matches for “" + root.filterText + "”" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 672a5d16..5c002402 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -545,6 +545,7 @@ Item { Text { id: selectedLabel + textFormat: Text.PlainText visible: root.showLabels anchors.top: carousel.bottom anchors.topMargin: Style.space(16) @@ -561,6 +562,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.filterable && root.filterText anchors.top: selectedLabel.bottom anchors.topMargin: Style.space(8) diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 7b0b0ae0..c2deae0f 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -184,6 +184,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.fill: passwordInput text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) visible: passwordInput.text.length === 0 diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index eeaf2e25..aa879c18 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1199,6 +1199,7 @@ Item { color: "transparent" Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -1287,6 +1288,7 @@ Item { Text { id: iconText + textFormat: Text.PlainText visible: row.hasIcon && !row.isApp text: row.icon color: row.hasCursor ? root.selectedText : root.foreground @@ -1328,6 +1330,7 @@ Item { Text { id: labelText + textFormat: Text.PlainText width: parent.width text: row.label color: row.hasCursor ? root.selectedText : root.foreground @@ -1338,6 +1341,7 @@ Item { } Text { + textFormat: Text.PlainText width: parent.width text: row.detail visible: (root.filterText || row.kind === "dmenu") && row.detail.length > 0 @@ -1358,6 +1362,7 @@ Item { spacing: 0 Text { + textFormat: Text.PlainText visible: false text: row.childCount color: root.foreground @@ -1368,6 +1373,7 @@ Item { } Text { + textFormat: Text.PlainText text: row.kind === "menu" || row.kind === "link" ? "›" : "" color: row.hasCursor ? root.selectedText : root.foreground opacity: row.kind === "menu" || row.kind === "link" ? 0.36 : 0 @@ -1452,6 +1458,7 @@ Item { } Text { + textFormat: Text.PlainText text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet" color: root.foreground opacity: 0.7 diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 9bad602d..fc71a824 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,8 +5,34 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// The body renders as StyledText so notifications can use the markup the +// body-markup capability advertises (see Service.qml). StyledText honours +// , and a remote src makes the shell issue an unauthenticated GET +// with no user action, so image tags go before the renderer sees them. +// +// One replace() pass is not enough. String.replace scans left to right once, +// so a payload spliced inside the literal "g src="http://a/beacon.png"> +// -> +// +// Repeat to a fixed point. Each pass can only shorten the string, so this +// terminates. +function stripImageTags(text) { + var current = text + var previous + do { + previous = current + // The `$` alternative catches a tag left unterminated at the end of the + // string, which the renderer closes for itself. + current = current.replace(/]*(?:>|$)/gi, "") + } while (current !== previous) + return current +} + function sanitizeBody(body, app, appIcon) { - var text = String(body || "").replace(/]*>/gi, "") + var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text return text diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index cf88f23e..1171ddc4 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -133,6 +133,7 @@ BorderSurface { // Glyph fallback (Nerd Font character) when no image icon is // available. Used by omarchy-notification-send's `-g` flag. Text { + textFormat: Text.PlainText anchors.centerIn: parent visible: root.hasGlyph && smallIconImage.status !== Image.Ready text: root.glyph @@ -143,6 +144,7 @@ BorderSurface { } Text { + textFormat: Text.PlainText Layout.alignment: Qt.AlignVCenter visible: root.compactGlyph text: root.glyph @@ -159,6 +161,11 @@ BorderSurface { spacing: Style.space(2) Text { + // The spec defines the summary as a single line of plain text, so + // AutoText could only ever promote a hostile string to rich text. + // The body below is StyledText on purpose — see Service.qml's + // bodyMarkupSupported — and is stripped in NotificationLogic. + textFormat: Text.PlainText Layout.fillWidth: true visible: root.summary.length > 0 text: root.summary diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index abf53e22..581bfa3c 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -159,6 +159,7 @@ Item { width: root.iconWidth height: parent.height Text { + textFormat: Text.PlainText // Sit the glyph's ink flush in the column, centered when the // column is wider than this particular glyph. x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x) @@ -186,6 +187,7 @@ Item { } } Text { + textFormat: Text.PlainText visible: root.message !== "" width: root.hasProgress ? root.valueWidth : root.messageWidth // The readout hugs the card edge so a short percentage doesn't leave diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index f8a86c6f..26d0c58d 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -711,6 +711,7 @@ Panel { // Status only — the switch owns muting, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.outputIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -761,6 +762,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: root.outputVolumeName( outputSlider.dragging ? outputSlider.liveValue : root.outputVolume, root.outputMuted @@ -800,6 +802,7 @@ Panel { Text { id: outputPercent + textFormat: Text.PlainText text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -886,6 +889,7 @@ Panel { Text { id: microphonePercent + textFormat: Text.PlainText text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -1030,6 +1034,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sinkGlyph(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1040,6 +1045,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sinkRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1089,6 +1095,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: root.sourceGlyph(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1099,6 +1106,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.nodeLabel(sourceRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1159,6 +1167,7 @@ Panel { Text { id: streamMuteIcon + textFormat: Text.PlainText text: streamRow.streamMuted ? "󰝟" : "󰕾" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1179,6 +1188,7 @@ Panel { } Text { + textFormat: Text.PlainText text: root.streamLabel(streamRow.node) color: root.bar.foreground font.family: root.bar.fontFamily @@ -1191,6 +1201,7 @@ Panel { Text { id: streamPct + textFormat: Text.PlainText text: Math.round(streamRow.streamVolume * 100) + "%" color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 343357b4..b0078f84 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -698,6 +698,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.icon @@ -748,6 +749,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -863,6 +865,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0 text: !root.adapter ? "No Bluetooth adapter" : !root.adapter.enabled ? "Turn Bluetooth on to scan" @@ -971,6 +974,7 @@ Panel { Text { id: deviceIcon + textFormat: Text.PlainText text: row.isConnected ? "󰂱" : "󰂯" color: row.statusColor font.family: root.bar.fontFamily @@ -989,6 +993,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: root.deviceLabel(row.dev) || "Device" color: root.bar.foreground font.family: root.bar.fontFamily @@ -997,6 +1002,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText visible: row.statusText !== "" text: row.statusText color: row.statusColor diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index f0dff3ae..be5d08a0 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -311,6 +311,7 @@ Panel { Text { id: heroDate + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse @@ -413,6 +414,7 @@ Panel { Text { id: yearLabel + textFormat: Text.PlainText visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter @@ -425,6 +427,7 @@ Panel { Text { id: yearPercent + textFormat: Text.PlainText visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -485,6 +488,7 @@ Panel { Text { id: lifePercent + textFormat: Text.PlainText anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.lifeDonePercent + "%" @@ -608,6 +612,7 @@ Panel { model: root.weekdays Text { + textFormat: Text.PlainText required property var modelData width: root.cellWidth height: Style.space(16) @@ -631,6 +636,7 @@ Panel { spacing: root.cellSpacing Text { + textFormat: Text.PlainText width: root.weekColumnWidth height: root.cellHeight horizontalAlignment: Text.AlignHCenter @@ -662,6 +668,7 @@ Panel { border.color: Style.normalBorderFor(root.contentForeground, Color.accent) Text { + textFormat: Text.PlainText anchors.centerIn: parent text: modelData.day color: modelData.inMonth @@ -707,6 +714,7 @@ Panel { Text { id: monthLabel + textFormat: Text.PlainText anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter // Fixed width so the chevrons hold still between a diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index f1dc2301..b470cee2 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -281,6 +281,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: dropbox.actionStatus !== "" || dropbox.lastError !== "" width: parent.width text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError @@ -421,6 +422,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Login to Dropbox" : "Dropbox CLI is not installed" color: root.foreground @@ -430,6 +432,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: dropbox.installed ? "Start the authentication flow" : "Install Dropbox from the service menu" color: root.dim @@ -478,6 +481,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: Model.fileGlyph(fileRow.fileName) color: root.foreground font.family: root.fontFamily @@ -491,6 +495,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: fileRow.fileName color: root.foreground @@ -500,6 +505,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: Model.fileMeta(fileRow.file) color: root.dim diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 1753906e..bec38820 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.displays.length > 1 ? "󰍺" : "󰍹" color: root.bar.foreground font.family: root.bar.fontFamily @@ -559,6 +560,7 @@ Panel { Text { id: heroLabel + textFormat: Text.PlainText text: { if (root.brightnessAvailable) { return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() @@ -602,6 +604,7 @@ Panel { Text { id: brightnessPercent + textFormat: Text.PlainText text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -674,6 +677,7 @@ Panel { Text { id: textSizePx + textFormat: Text.PlainText text: (textSizeSlider.dragging ? root.textSizeStops[Math.round(textSizeSlider.liveValue)] : root.displayedTextPx()) + "px" @@ -747,6 +751,7 @@ Panel { // focused one. Text { id: scaleMonitor + textFormat: Text.PlainText text: root.focusedMonitor // Only worth naming when more than one display is in play. visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 @@ -887,6 +892,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "") color: root.bar.foreground font.family: root.bar.fontFamily @@ -897,6 +903,7 @@ Panel { } Text { + textFormat: Text.PlainText text: monitorRow.display.enabled ? "󰄬" : "" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index dea1d280..c4da0afe 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -1090,6 +1090,7 @@ Panel { // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon + textFormat: Text.PlainText text: root.icon color: root.bar.foreground font.family: root.bar.fontFamily @@ -1170,6 +1171,7 @@ Panel { // rather than in a pill, which crowded the on/off switch. Text { id: heroSsid + textFormat: Text.PlainText width: parent.width readonly property string title: { @@ -1189,6 +1191,7 @@ Panel { Text { id: heroMeta + textFormat: Text.PlainText width: parent.width text: { if (root.info.type === "wifi") { @@ -1711,6 +1714,7 @@ Panel { Text { id: networkIcon + textFormat: Text.PlainText text: row.net ? root.wifiIconFor(row.net.signal) : "" color: row.statusColor font.family: root.bar.fontFamily @@ -1732,6 +1736,7 @@ Panel { Text { id: lockIndicator + textFormat: Text.PlainText visible: row.requiresCredentials || row.forgetVisible width: parent.width anchors.verticalCenter: parent.verticalCenter @@ -1779,6 +1784,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: row.net ? (row.net.ssid || "Hidden") : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -1787,6 +1793,7 @@ Panel { width: parent.width } Text { + textFormat: Text.PlainText // Signal strength is conveyed by the wifi-bars icon and the // right-edge glyph/buttons carry protection or forget affordances, // so the second line only carries action status (Connecting…, @@ -1893,6 +1900,7 @@ Panel { radius: Style.cornerRadius Text { + textFormat: Text.PlainText anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 871ee2f6..7733bb37 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -325,6 +325,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText text: root.batteryIcon() color: root.bar.foreground font.family: root.bar.fontFamily @@ -356,6 +357,7 @@ Panel { Text { id: heroStatus + textFormat: Text.PlainText text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -369,6 +371,7 @@ Panel { Text { id: heroPercent + textFormat: Text.PlainText text: root.batteryInfo.percentage || "—" color: root.bar.foreground font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 34278eda..6a976307 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -498,6 +498,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError @@ -841,6 +842,7 @@ Panel { } Text { + textFormat: Text.PlainText text: accountRow.accountText color: root.foreground font.family: root.fontFamily @@ -933,6 +935,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: tailscale.osIcon(peer ? peer.OS : "") color: root.foreground font.family: root.fontFamily @@ -946,6 +949,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: peerRow.peerName color: root.foreground @@ -955,6 +959,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: { var parts = [] @@ -1087,6 +1092,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: copyChoice.label color: root.foreground @@ -1134,6 +1140,7 @@ Panel { Text { id: exitNodeGlyph + textFormat: Text.PlainText text: exitNodeRow.addMullvad ? "+" : (peer && peer.Mullvad === true ? "󰖂" : "󱇢") color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode || exitNodeRow.addMullvad ? root.foreground : root.dim font.family: root.fontFamily @@ -1154,6 +1161,7 @@ Panel { } Text { + textFormat: Text.PlainText text: exitNodeRow.peerName color: root.foreground font.family: root.fontFamily @@ -1224,6 +1232,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionName color: root.foreground @@ -1234,6 +1243,7 @@ Panel { } Text { + textFormat: Text.PlainText width: parent.width text: regionRow.regionDetail visible: text !== "" diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index dacb4ac9..edb12777 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -531,6 +531,7 @@ Panel { Text { id: heroIcon + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 text: root.label || "—" @@ -547,6 +548,7 @@ Panel { Text { id: tempBig + textFormat: Text.PlainText text: root.reportTempNum || "—" color: root.bar.foreground font.family: root.bar.fontFamily @@ -556,6 +558,7 @@ Panel { font.bold: true } Text { + textFormat: Text.PlainText text: root.current ? root.tempUnit : "" color: root.bar.foreground font.family: root.bar.fontFamily @@ -593,6 +596,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter } Text { + textFormat: Text.PlainText text: (root.reportLocation || "").toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -643,6 +647,7 @@ Panel { color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" Text { + textFormat: Text.PlainText anchors.centerIn: parent text: root.savingLocation ? "󰦖" : "✕" font.family: root.bar.fontFamily @@ -683,6 +688,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportFeels color: root.bar.foreground font.family: root.bar.fontFamily @@ -700,6 +706,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportWind color: root.bar.foreground font.family: root.bar.fontFamily @@ -717,6 +724,7 @@ Panel { font.letterSpacing: 1 } Text { + textFormat: Text.PlainText text: root.reportHumidity color: root.bar.foreground font.family: root.bar.fontFamily @@ -752,12 +760,14 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: modelData.name color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText visible: text !== "" text: modelData.description color: Qt.darker(root.bar.foreground, 1.5) @@ -817,6 +827,7 @@ Panel { spacing: Style.space(10) Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.dayIcon(modelData) color: root.bar.foreground @@ -829,6 +840,7 @@ Panel { spacing: Style.space(2) Text { + textFormat: Text.PlainText text: root.dayName(modelData.date).toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -840,12 +852,14 @@ Panel { spacing: Style.space(6) Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "max") color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } Text { + textFormat: Text.PlainText text: root.bareTempForDay(modelData, "min") color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml index 276434a8..1426b5fd 100644 --- a/shell/plugins/panels/wifiqr/Panel.qml +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -257,6 +257,7 @@ Item { spacing: Style.space(16) Text { + textFormat: Text.PlainText text: (root.ssid || "Wi-Fi").toUpperCase() color: root.onScrimDim font.family: root.fontFamily @@ -318,6 +319,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.error !== "" text: root.error color: root.onScrimUrgent @@ -340,6 +342,7 @@ Item { } Text { + textFormat: Text.PlainText visible: root.showingQr && root.secured text: root.passwordError !== "" ? root.passwordError : root.passwordVisible ? root.password diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index 8ce95973..8786eeeb 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -332,6 +332,7 @@ Item { } Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -374,6 +375,7 @@ Item { Text { id: justificationText + textFormat: Text.PlainText anchors.fill: parent anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index bc6616db..fef95cf0 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -156,6 +156,7 @@ Item { anchors.leftMargin: card.contentLeftInset Text { + textFormat: Text.PlainText anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/shell/plugins/services/media/BarWidget.qml b/shell/plugins/services/media/BarWidget.qml index 52793c16..02650efc 100644 --- a/shell/plugins/services/media/BarWidget.qml +++ b/shell/plugins/services/media/BarWidget.qml @@ -32,6 +32,7 @@ BarWidget { Text { id: glyph + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: root.playIcon color: activePlayer && activePlayer.isPlaying ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5) @@ -53,6 +54,7 @@ BarWidget { Text { id: labelText + textFormat: Text.PlainText text: root.title + (root.artist ? " · " + root.artist : "") color: root.bar.barForeground font.family: root.bar.fontFamily @@ -148,6 +150,7 @@ BarWidget { width: parent.width - Style.space(74) Text { + textFormat: Text.PlainText text: root.title || "Nothing playing" color: root.bar.foreground font.family: root.bar.fontFamily @@ -158,6 +161,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.artist color: Qt.darker(root.bar.foreground, 1.3) font.family: root.bar.fontFamily @@ -168,6 +172,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : "" color: Qt.darker(root.bar.foreground, 1.6) font.family: root.bar.fontFamily @@ -255,6 +260,7 @@ BarWidget { spacing: Style.space(8) Text { + textFormat: Text.PlainText text: sourceRow.player && sourceRow.player.isPlaying ? "󰏤" : "󰐊" color: root.bar.foreground font.family: root.bar.fontFamily @@ -270,6 +276,7 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter Text { + textFormat: Text.PlainText text: sourceRow.sourceTitle color: root.bar.foreground font.family: root.bar.fontFamily @@ -280,6 +287,7 @@ BarWidget { } Text { + textFormat: Text.PlainText text: sourceRow.sourceDetail color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 58a4e32a..754dd29e 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,6 +18,40 @@ assertEqual( 'notifications strip inline image tags' ) +// The body renders as StyledText, which fetches over the network, so +// the strip has to survive a payload built to outlive one replace() pass. A +// single left-to-right pass consumes the inner tag and lets the outer halves +// close up into a live tag: . +assertEqual( + notifications.sanitizeBody('g src="http://host/beacon.png">', 'Slack', ''), + '', + 'notifications strip image tags that reassemble after one substitution' +) + +assertEqual( + notifications.sanitizeBody('g src=b>g src="http://host/deep.png">', 'Slack', ''), + '', + 'notifications strip nested image tags to a fixed point' +) + +assertEqual( + notifications.sanitizeBody('trailing shout', 'Slack', ''), + 'shout', + 'notifications strip image tags regardless of case' +) + +assertEqual( + notifications.sanitizeBody('bold and link', 'Slack', ''), + 'bold and link', + 'notifications keep the body markup the body-markup capability advertises' +) + assertEqual( notifications.sanitizeBody('example.com Message body', 'Chromium', ''), 'Message body', diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh new file mode 100755 index 00000000..ff088969 --- /dev/null +++ b/test/shell.d/qml-text-format-test.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# A QML Text element with no textFormat uses Text.AutoText. Qt then runs +# mightBeRichText() over the string and promotes it to Text.RichText when it +# looks like markup, and RichText fetches through +# QQuickPixmap. Any string that reaches such an element from outside the shell +# — a notification summary, an MPRIS track title, a window title, an SSID, a +# Bluetooth device name, clipboard content, a weather API response — can +# therefore make the shell issue an unauthenticated outbound GET with no user +# interaction. +# +# The promotion needs only that the attacker contribute the first `<` in the +# string, on the first line. A fixed label in front of the value does not +# protect it, and neither does .toUpperCase(), because the parser lowercases +# the tag before looking it up. +# +# So require an explicit textFormat on every Text whose text: binding is not a +# bare string literal. A literal carries no external data, so AutoText has +# nothing to promote; this test is what catches the edit that later turns such +# a literal into an expression. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command python3 + +violations=$(ROOT="$ROOT" python3 <<'PY' +import os +import re +from pathlib import Path + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +ROOT_TEXT = re.compile(r'^Text\s*\{\s*$') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + if opened and n_open > 0: + depth += 1 + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + depth += n_open - 1 - n_close + else: + depth += n_open - n_close + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +root = Path(os.environ['ROOT']) +found = [] +for path in sorted((root / 'shell').rglob('*.qml')): + lines = path.read_text().splitlines() + rel = path.relative_to(root) + + # A component whose root element is a Text takes its binding from callers, + # so the default has to be declared in the component itself. + if lines and any(ROOT_TEXT.match(l) for l in lines[:40]): + if not any(re.match(r'\s*textFormat\s*:', l) for l in lines): + found.append(f'{rel}: root Text element declares no textFormat') + + for b in blocks(lines): + if b['name'] != 'Text' or 'textFormat' in b['props']: + continue + if 'text' not in b['props']: + continue + tline = b['props']['text'] + expr = strip_noise(lines[tline], keep_strings=True).split(':', 1)[1] + if is_pure_literal(expr): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +for line in found: + print(line) +PY +) + +if [[ -n $violations ]]; then + count=$(printf '%s\n' "$violations" | wc -l) + fail "every Text with a dynamic text binding declares textFormat" \ + "$violations + +$count Text element(s) rely on Text.AutoText for a non-literal binding. +Add an explicit textFormat. Text.PlainText is right for anything that renders +data from outside the shell; use Text.StyledText only where markup is a +deliberate, documented feature, and strip before it reaches the renderer." +fi + +pass "every Text with a dynamic text binding declares textFormat" From 6e962b4466d245bc468352c83bafc9f60986653f Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Wed, 26 Aug 2026 16:36:12 +0200 Subject: [PATCH 22/73] Address review comments: Enforce stricter tag handling and image sanitation in notifications. --- .../notifications/NotificationLogic.js | 58 +++++++++--- test/shell.d/notifications-test.sh | 65 ++++++++++--- test/shell.d/qml-text-format-test.sh | 94 ++++++++++++++++--- 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index fc71a824..67b7bcca 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,30 +5,58 @@ function isChromiumDerived(app, appIcon) { source.indexOf("opera") >= 0 } +// True when a `<...>` run is an image tag, so the name is read the way Qt's +// parser reads it: after the `<` and an optional `/`, the leading run of +// letters and digits. +function isImageTag(tag) { + var name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + return !!name && name[1].toLowerCase() === "img" +} + // The body renders as StyledText so notifications can use the markup the // body-markup capability advertises (see Service.qml). StyledText honours // , and a remote src makes the shell issue an unauthenticated GET // with no user action, so image tags go before the renderer sees them. // -// One replace() pass is not enough. String.replace scans left to right once, -// so a payload spliced inside the literal "`, nested `<` and all — that is how Qt's parser bounds it — +// and only a tag whose own name is `img` is dropped. +// +// Deleting a substring is what makes a naive `/]*>/g` unsafe. Given // // g src="http://a/beacon.png"> -// -> // -// Repeat to a fixed point. Each pass can only shorten the string, so this -// terminates. +// Qt reads ONE malformed tag named `im` and renders nothing, but removing the +// inner match closes the surviving halves up into `` +// — a live tag the input never contained. The stripper would be manufacturing +// the very thing it exists to remove. +// +// Because every `<` opens a tag, the text between tags never contains one, so +// dropping a tag cannot splice its neighbours into a new one. That makes a +// single pass sufficient, with no re-scanning and no input bound to police. function stripImageTags(text) { - var current = text - var previous - do { - previous = current - // The `$` alternative catches a tag left unterminated at the end of the - // string, which the renderer closes for itself. - current = current.replace(/]*(?:>|$)/gi, "") - } while (current !== previous) - return current + var out = "" + var i = 0 + + while (i < text.length) { + var open = text.indexOf("<", i) + if (open === -1) { + out += text.slice(i) + break + } + + out += text.slice(i, open) + + // An unterminated tag at the end of the string still reaches the renderer, + // which closes it itself, so treat the remainder as one tag. + var close = text.indexOf(">", open) + var tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + + if (!isImageTag(tag)) out += tag + i = close === -1 ? text.length : close + 1 + } + + return out } function sanitizeBody(body, app, appIcon) { diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 754dd29e..370598f6 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,20 +18,61 @@ assertEqual( 'notifications strip inline image tags' ) -// The body renders as StyledText, which fetches over the network, so -// the strip has to survive a payload built to outlive one replace() pass. A -// single left-to-right pass consumes the inner tag and lets the outer halves -// close up into a live tag: . -assertEqual( - notifications.sanitizeBody('g src="http://host/beacon.png">', 'Slack', ''), - '', - 'notifications strip image tags that reassemble after one substitution' +// The body renders as StyledText, which fetches over the network. The +// invariant that matters is not a particular output string but that no tag Qt +// would honour as an image survives, so assert that directly. Tags are bounded +// the way Qt bounds them: a `<` opens a tag that runs to the next `>`. +function survivingTagNames(text) { + const names = [] + let i = 0 + while (i < text.length) { + const open = text.indexOf('<', i) + if (open === -1) break + const close = text.indexOf('>', open) + const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) + const name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + if (name) names.push(name[1].toLowerCase()) + i = close === -1 ? text.length : close + 1 + } + return names +} + +function assertNoImageSurvives(body, description) { + const out = notifications.sanitizeBody(body, 'Slack', '') + const names = survivingTagNames(out) + assert( + !names.includes('img'), + description, + `input: ${body}\noutput: ${out}\ntags: ${JSON.stringify(names)}` + ) +} + +assertNoImageSurvives( + '', + 'notifications leave no image tag for a plain payload' ) -assertEqual( - notifications.sanitizeBody('g src=b>g src="http://host/deep.png">', 'Slack', ''), - '', - 'notifications strip nested image tags to a fixed point' +// A payload spliced inside the literal " the input never had. +assertNoImageSurvives( + 'g src="http://host/beacon.png">', + 'notifications leave no image tag when a payload is spliced inside g src=b>g src="http://host/deep.png">', + 'notifications leave no image tag for a doubly nested payload' +) + +assertNoImageSurvives( + '', + 'notifications leave no image tag when the outer tag is itself named img' +) + +assertNoImageSurvives( + '< img src="http://host/spaced.png">', + 'notifications leave no image tag when whitespace follows the angle bracket' ) assertEqual( diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index ff088969..2d7774f0 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -34,7 +34,10 @@ OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -ROOT_TEXT = re.compile(r'^Text\s*\{\s*$') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') def strip_noise(line, keep_strings=False): @@ -76,6 +79,40 @@ def is_pure_literal(expr): return residue == '' and STRING_LITERAL.search(expr) is not None +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + following = strip_noise(lines[i + 1]) if i + 1 < len(lines) else '' + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + def blocks(lines): stack = [] done = [] @@ -89,39 +126,70 @@ def blocks(lines): stack[-1]['props'].setdefault(prop.group(1), idx) n_open = code.count('{') n_close = code.count('}') + depth += n_open - n_close if opened and n_open > 0: - depth += 1 + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. stack.append({'name': opened.group(1), 'depth': depth, 'props': {}, 'start': idx}) - depth += n_open - 1 - n_close - else: - depth += n_open - n_close while stack and depth < stack[-1]['depth']: done.append(stack.pop()) done.extend(stack) return done +INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if 'textFormat' in body: + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + root = Path(os.environ['ROOT']) found = [] for path in sorted((root / 'shell').rglob('*.qml')): lines = path.read_text().splitlines() rel = path.relative_to(root) - - # A component whose root element is a Text takes its binding from callers, - # so the default has to be declared in the component itself. - if lines and any(ROOT_TEXT.match(l) for l in lines[:40]): - if not any(re.match(r'\s*textFormat\s*:', l) for l in lines): - found.append(f'{rel}: root Text element declares no textFormat') + found.extend(inline_violations(lines, rel)) for b in blocks(lines): if b['name'] != 'Text' or 'textFormat' in b['props']: continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + if b['depth'] == 1 and lines[b['start']].startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + if 'text' not in b['props']: continue tline = b['props']['text'] - expr = strip_noise(lines[tline], keep_strings=True).split(':', 1)[1] - if is_pure_literal(expr): + if exempt_as_literal(lines, tline): continue found.append(f'{rel}:{tline + 1}: text binding without textFormat') From e428dc26278d529f7754cda57918227d655027fa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 26 Aug 2026 17:10:17 +0200 Subject: [PATCH 23/73] Strip image tags whose separator Qt skips but `\s` does not QQuickStyledText skips the characters between `<` and the tag name with QChar::isSpace(), which counts U+0085 NEL. JavaScript's `\s` does not, so isImageTag() read no name at all from a tag written as `<`, U+0085, `img`, kept it, and Qt then read `img` and issued the GET the stripper exists to prevent. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server. Read the name by skipping everything that is not part of it rather than by matching the separator, so the two definitions cannot drift apart again. Over-skipping is the safe direction: it can only classify more runs as images, and dropping a run never manufactures a tag. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications/NotificationLogic.js | 28 +++++++++++++++---- test/shell.d/notifications-test.sh | 28 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 67b7bcca..b3f7be09 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -6,10 +6,21 @@ function isChromiumDerived(app, appIcon) { } // True when a `<...>` run is an image tag, so the name is read the way Qt's -// parser reads it: after the `<` and an optional `/`, the leading run of -// letters and digits. +// parser reads it: after the `<`, the leading run of letters and digits. +// +// Skip everything up to that run rather than matching the separator, because +// there is no JavaScript expression for what Qt skips. QQuickStyledText calls +// skipSpace(), which is QChar::isSpace(), and that set is not `\s`: Qt counts +// U+0085 NEL and `\s` does not, while `\s` counts U+FEFF and Qt does not. A +// name read with `\s` therefore misses a tag written as `<`, U+0085, `img`: +// Qt skips the NEL, reads `img` and issues the GET, while the regex finds no +// name at all and the tag is kept. Measured against Qt 6.11.2. +// +// Over-skipping is the safe direction. It can only classify more runs as +// images, and dropping a run never manufactures a tag: a dropped run joins two +// stretches of text that each contain no `<`. function isImageTag(tag) { - var name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + var name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) return !!name && name[1].toLowerCase() === "img" } @@ -19,8 +30,15 @@ function isImageTag(tag) { // with no user action, so image tags go before the renderer sees them. // // Work in whole tags, never in substrings of one. A `<` opens a tag that runs -// to the next `>`, nested `<` and all — that is how Qt's parser bounds it — -// and only a tag whose own name is `img` is dropped. +// to the next `>`, nested `<` and all, and only a tag whose own name is `img` +// is dropped. +// +// That is the conservative bound, not Qt's exact one: Qt lets a `>` inside a +// quoted attribute value pass without closing the tag, so a Qt tag can be +// longer than the run taken here. Do not "correct" this to match Qt. Taking +// the shorter run only ever splits one Qt tag into several, and a split can +// only expose an `` through. // // Deleting a substring is what makes a naive `/]*>/g` unsafe. Given // diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 370598f6..6dfc54a8 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -21,7 +21,10 @@ assertEqual( // The body renders as StyledText, which fetches over the network. The // invariant that matters is not a particular output string but that no tag Qt // would honour as an image survives, so assert that directly. Tags are bounded -// the way Qt bounds them: a `<` opens a tag that runs to the next `>`. +// the conservative way the stripper bounds them: a `<` opens a tag that runs to +// the next `>`. Qt's own bound can be longer, since a `>` inside a quoted +// attribute value does not close a tag there — which only ever splits one Qt +// tag into several here, so a name this helper reads is a name Qt reads too. function survivingTagNames(text) { const names = [] let i = 0 @@ -30,7 +33,11 @@ function survivingTagNames(text) { if (open === -1) break const close = text.indexOf('>', open) const tag = close === -1 ? text.slice(open) : text.slice(open, close + 1) - const name = /^<\/?\s*([A-Za-z0-9]+)/.exec(tag) + // Read the name the way Qt does, skipping anything that is not part of it. + // Matching the separator with \s instead would give this helper the same + // blind spot as the code it is checking — Qt skips U+0085 and \s does not — + // and an assertion that shares the implementation's bug proves nothing. + const name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag) if (name) names.push(name[1].toLowerCase()) i = close === -1 ? text.length : close + 1 } @@ -75,6 +82,23 @@ assertNoImageSurvives( 'notifications leave no image tag when whitespace follows the angle bracket' ) +// Qt skips the separator between `<` and the tag name with QChar::isSpace(), +// which counts U+0085 NEL. JavaScript's \s does not. Reading the name with \s +// finds none here, keeps the tag, and Qt then reads `img` and fetches it — +// measured against Qt 6.11.2, where this exact body makes a StyledText Text +// issue an outbound GET. Asserted on the whole output rather than through +// assertNoImageSurvives so it holds even if that helper is ever loosened. +assertEqual( + notifications.sanitizeBody('<\u0085img src="http://host/nel.png">after', 'Slack', ''), + 'after', + 'notifications strip an image tag whose separator is U+0085, which Qt skips but \\s does not' +) + +assertNoImageSurvives( + '<\u0085img src="http://host/nel2.png">', + 'notifications leave no image tag when U+0085 follows the angle bracket' +) + assertEqual( notifications.sanitizeBody('trailing Date: Wed, 26 Aug 2026 16:22:07 +0100 Subject: [PATCH 24/73] Refuse a theme name that is shell syntax, and quote the one the unlock picker returns A theme installed from a git repo is named after the repo URL, and that name becomes its directory name under ~/.config/omarchy/themes. Style > Unlock built a command line out of the name the picker returned and handed it to omarchy-launch-floating-terminal-with-presentation, which runs its argument as a shell string -- so a theme directory called `a';id;'b` ran `id`. Themes are already held to contributing colour and nothing that executes, which is why omarchy-theme-set stages no .lua, terminal config, or vscode.json from one. Hold the derived name to the characters a theme name needs, which stops it from being dangerous at every place it lands rather than at the one found, and quote it with printf %q on the way into the action for the names already on disk. omarchy-theme-remove keeps its existing path-climb guard: its name reaches only a quoted rm, and the same charset would strand a theme installed before this. Reported-by: Luis Alvarez (lalvarezt) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011WFcUm5HWFyxaVYdwAeWPP --- bin/omarchy-theme-install | 13 ++- default/omarchy/omarchy-menu.jsonc | 2 +- test/shell.d/plymouth-set-test.sh | 100 ++++++++++++++++++++++ test/shell.d/theme-install-guards-test.sh | 28 ++++++ 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index 4bc1d11e..fb5acdc2 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -29,10 +29,15 @@ REPO_PATH="$REPO_URL" THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_PATH="$THEMES_DIR/$THEME_NAME" -# The name comes from the URL and is joined into a path that is about to be -# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading -# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`. -if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then +# The name comes from the URL, is joined into a path that is about to be +# removed, and then names a directory the rest of Omarchy passes around by +# name: Style > Unlock builds a command line out of the one the picker +# returned. So it is held to the characters a theme name needs rather than +# screened for the harm of the day -- a repo called `..` would take +# ~/.config/omarchy with it, and one called `a';'id` would carry its own +# command into that picker. The leading character is kept out of `.` and `-`, +# which also covers `host:-s/foo.git` leaving basename with `.git`. +if [[ ! $THEME_NAME =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then echo "Error: '$REPO_URL' does not give a usable theme name." exit 1 fi diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 59582db7..10a045af 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -103,7 +103,7 @@ // Style "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, "style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""}, - "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"}, + "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme $(printf %q \"$unlock\")\"; fi"}, "style.font": {"icon":"","label":"Font","provider":"fonts"}, "style.bar": {"icon":"󰍜","label":"Menu Bar"}, "style.bar.position": {"icon":"","label":"Position"}, diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 2cbc95be..eb8b120b 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -41,3 +41,103 @@ grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarc fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" pass "a themed logo cannot republish a file it merely points at" + +# Style > Unlock picks a theme by name and hands the answer to +# omarchy-launch-floating-terminal-with-presentation, which joins its arguments +# into a script and runs that with `bash -c`. So the name is shell source +# unless the action quotes it -- and the name is a directory name under +# ~/.config/omarchy/themes, which a theme installed from a git repo gets from +# the repo URL. `a';id;'b` is a legal directory name. +require_command node + +unlock_action=$(node -e ' + const fs = require("fs") + const path = require("path") + const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js")) + const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8")) + process.stdout.write(items.find(item => item.id === "style.unlock").action) +') + +[[ -n $unlock_action ]] || fail "the shipped menu still carries a style.unlock action" + +stub_dir="$test_tmp/stubs" +mkdir -p "$stub_dir" + +canary="$test_tmp/canary" +set_args="$test_tmp/set-args" +reset_marker="$test_tmp/reset-ran" + +# What a name that got reparsed would reach. It is a command rather than a +# `touch` so that no quoting of the test's own paths is involved. +cat >"$stub_dir/omarchy-test-canary" <"$canary" +STUB + +cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB' +#!/bin/bash +printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME" +STUB + +# Stands in for the real wrapper, which is a shell-string API: it interpolates +# "$*" into a script and hands that to `bash -c`. The grep below is what keeps +# this stub honest if the wrapper ever stops working that way. +cat >"$stub_dir/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +#!/bin/bash +exec bash -c "omarchy-show-logo; $*; omarchy-show-done" +STUB + +grep -Fq 'bash -c "$presentation_script"' "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" || + fail "the presentation wrapper still runs its argument as a shell string, as the stub above assumes" + +# Records what actually arrived, so a name that survived as data is told apart +# from one that arrived split or partly eaten. +cat >"$stub_dir/omarchy-plymouth-set-by-theme" <<'STUB' +#!/bin/bash +printf '%s\n' "$#" "$@" >"$OMARCHY_TEST_SET_ARGS" +STUB + +cat >"$stub_dir/omarchy-plymouth-reset" <<'STUB' +#!/bin/bash +printf 'ran\n' >"$OMARCHY_TEST_RESET_MARKER" +STUB + +for command in omarchy-show-logo omarchy-show-done; do + printf '#!/bin/bash\nexit 0\n' >"$stub_dir/$command" +done + +chmod +x "$stub_dir"/* + +run_unlock_action() { + rm -f "$canary" "$set_args" "$reset_marker" + + PATH="$stub_dir:$PATH" \ + OMARCHY_TEST_UNLOCK_NAME="$1" \ + OMARCHY_TEST_SET_ARGS="$set_args" \ + OMARCHY_TEST_RESET_MARKER="$reset_marker" \ + bash -c "$unlock_action" >/dev/null 2>&1 +} + +# A directory name cannot hold a slash or a NUL, and everything else is fair +# game -- these are the shapes that would run on the way to the picker. +for name in "a';omarchy-test-canary;'b" 'a$(omarchy-test-canary)b' 'a`omarchy-test-canary`b' 'a b' '-a'; do + run_unlock_action "$name" + + [[ ! -e $canary ]] || fail "a theme name reaches the unlock screen as data, not as shell" "ran for: $name" + [[ $(cat "$set_args" 2>/dev/null) == $'1\n'"$name" ]] || + fail "the unlock screen gets the theme name whole" "$name: $(cat "$set_args" 2>/dev/null)" +done + +pass "a theme name cannot carry a command into the unlock screen" + +# The two ordinary paths still work: a named theme is applied, and `default` +# resets rather than being looked up as a theme. +run_unlock_action "tokyo-night" +[[ $(cat "$set_args" 2>/dev/null) == $'1\ntokyo-night' ]] || + fail "an ordinary theme name still reaches omarchy-plymouth-set-by-theme" "$(cat "$set_args" 2>/dev/null)" + +run_unlock_action "default" +[[ -e $reset_marker ]] || fail "picking default still resets the unlock screen" +[[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")" + +pass "the unlock picker still applies a theme and still resets on default" diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index bc29338e..43cf1f3e 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -92,6 +92,34 @@ done pass "a URL whose name would climb out of the themes directory never reaches git" +# The derived name outlives the clone: it is the theme's directory name, and +# Style > Unlock builds a command line out of the name the picker returned. A +# repo whose name carries shell syntax would hand that picker its own command, +# so the name is refused here rather than quoted at each place it lands. +for url in \ + "https://example.com/omarchy-a';id;'b-theme.git" \ + 'https://example.com/a$(id).git' \ + 'https://example.com/a`id`.git' \ + "https://example.com/a b.git" \ + "https://example.com/-a.git"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the derived name from '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL whose name would be shell syntax never reaches git" + +# And the check is an allowlist, so the punctuation a real theme name uses has +# to keep working. +install_theme "https://github.com/example/omarchy-tokyo_night.2-theme.git" || + fail "omarchy-theme-install accepts the punctuation a theme name uses" +grep -Fq "/themes/tokyo_night.2" "$git_calls" || + fail "omarchy-theme-install derives a name carrying an underscore and a dot" "$(cat "$git_calls")" + +pass "a theme name may still hold an underscore, a dot, and a dash" + # basename reads a leading dash as an option once the scp-style prefix is gone. install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" From 0260d2accbc047dda144b4738fdd8a15b9a9e69d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 26 Aug 2026 17:10:30 +0200 Subject: [PATCH 25/73] Stop the textFormat test from passing when it has not checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root rule matched only a file-level root Text, of which this tree has exactly one. QML inline components are roots for the same reason — the `text` of `component InfoValue: Text {` comes from every caller, so the file it lives in never binds it — but they sit inside another element, so the depth-1 test never saw them. Six went uncovered while the test reported green, among them the network panel's InfoValue, which callers bind to the IP address and gateway. Six more ways to write a Text were read as clean rather than as unreadable: an opening brace that is not last on its line, a brace on the line after `Text`, a one-line block containing nested braces, a wrapped binding split by a comment or a blank line before its `+` (which exempted a dynamic binding as a literal), and a root Text indented from column zero. Require the forms a line scanner can read instead of parsing QML; the tree already writes every Text that way. Last, a run that read no files reported success. A checkout with no shell/ QML now fails instead, since an all-clear from a scan that opened nothing is the one answer this test must never give. Each case is covered by a fixture that fails without its fix. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: OpenAI Codex (gpt-5, xhigh) --- shell/plugins/panels/dropbox/Panel.qml | 2 + shell/plugins/panels/network/Panel.qml | 2 + shell/plugins/panels/power/Panel.qml | 2 + test/shell.d/qml-text-format-test.sh | 105 ++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index b470cee2..515b43ce 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -530,6 +530,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.foreground opacity: 0.6 font.family: root.fontFamily @@ -537,6 +538,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index c4da0afe..d1e41149 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -1954,6 +1954,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -1961,6 +1962,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 7733bb37..b1c34da2 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -520,6 +520,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -527,6 +528,7 @@ Panel { } component InfoValue: Text { + textFormat: Text.PlainText color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 2d7774f0..0b286a76 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -31,6 +31,8 @@ import re from pathlib import Path OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') @@ -96,7 +98,17 @@ def binding_expression(lines, start): counted = strip_noise(lines[i]) parens += counted.count('(') - counted.count(')') brackets += counted.count('[') - counted.count(']') - following = strip_noise(lines[i + 1]) if i + 1 < len(lines) else '' + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break continues = (parens > 0 or brackets > 0 or TRAILING_OPERATOR.search(counted.rstrip()) or LEADING_OPERATOR.match(following)) @@ -156,6 +168,12 @@ def inline_violations(lines, rel): body = match.group(1) if 'textFormat' in body: continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue binding = INLINE_BINDING.search(body) if not binding or is_pure_literal(binding.group(1)): continue @@ -163,12 +181,75 @@ def inline_violations(lines, rel): return out +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + root = Path(os.environ['ROOT']) found = [] +scanned = 0 for path in sorted((root / 'shell').rglob('*.qml')): + scanned += 1 lines = path.read_text().splitlines() rel = path.relative_to(root) found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) for b in blocks(lines): if b['name'] != 'Text' or 'textFormat' in b['props']: @@ -182,10 +263,24 @@ for path in sorted((root / 'shell').rglob('*.qml')): # depth 1 and column 0: the scanner attributes one element per line, so # a `Row { Text {` line would report depth 1 for a nested block, and # falling through to the binding check below is the safe reading. - if b['depth'] == 1 and lines[b['start']].startswith('Text'): + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') continue + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + if INLINE_COMPONENT.match(lines[b['start']]): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + if 'text' not in b['props']: continue tline = b['props']['text'] @@ -193,6 +288,12 @@ for path in sorted((root / 'shell').rglob('*.qml')): continue found.append(f'{rel}:{tline + 1}: text binding without textFormat') +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + for line in found: print(line) PY From 187c268d68b8ff1a0599236fb989d0f3e0446348 Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:55:47 +0200 Subject: [PATCH 26/73] Escape webapp .desktop values per freedesktop spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - omarchy-webapp-install wrote all substituted values raw into the generated .desktop (Name/Comment/Icon/MimeType/Exec), with no escaping. - Adds two spec-level escapers: Desktop Entry string escaping on every field (a raw newline could inject a second key line / second Exec=), and Exec-argument quoting for the default Exec's URL (spaces, %, reserved chars). $CUSTOM_EXEC stays file-syntax-only — it is a full command line by design, not a single value. - No known exploit path: untrusted input reaches these values today only via Omarchy literals, interactive gum, or direct CLI. This is defense-in-depth for a latent sink. - Verified end-to-end: generated .desktop has one escaped key per field; gio launch passes the URL to omarchy-launch-webapp as a single unchanged argument (Sunshine literal and normal gum path both byte-identical). --- bin/omarchy-webapp-install | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..e4f0039d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -65,6 +65,26 @@ fetch_site_icon() { download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest" } +desktop_string_escape() { + # Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"): + # a raw newline would start a new key line and let a value inject a second + # Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value + # written into the .desktop file passes through here. + printf '%s' "$1" \ + | sed -e ':a;N;$!ba' \ + -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\n/\\n/g' -e 's/^ /\\s/' +} + +desktop_exec_arg() { + # One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes + # " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's + # URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only). + local escaped + escaped=$(printf '%s' "$1" \ + | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g') + printf '"%s"' "$escaped" +} + if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") @@ -128,28 +148,38 @@ else ICON_VALUE=$(icon_name_from_ref "$ICON_REF") fi -# Use custom exec if provided, otherwise default behavior -EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" +# Default Exec quotes the URL as one Exec-spec argument; the whole line then gets +# the file-syntax escaping below (unescaped first at read time per spec, so the +# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only. +if [[ -n $CUSTOM_EXEC ]]; then + EXEC_COMMAND=$CUSTOM_EXEC +else + EXEC_COMMAND="omarchy-launch-webapp $(desktop_exec_arg "$APP_URL")" +fi # Create application .desktop file DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" mkdir -p "$(dirname "$DESKTOP_FILE")" +name_field=$(desktop_string_escape "$APP_NAME") +exec_field=$(desktop_string_escape "$EXEC_COMMAND") +icon_field=$(desktop_string_escape "$ICON_VALUE") + cat >"$DESKTOP_FILE" <>"$DESKTOP_FILE" + printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE" fi chmod +x "$DESKTOP_FILE" From 3b0e89902993b129c6c97dfd45446894331b6c77 Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Wed, 26 Aug 2026 23:37:44 +0100 Subject: [PATCH 27/73] Let a theme name hold a plus or lead with an underscore, and document the set The name a theme installs under is derived from its repo URL, and holding it to an allowlist made that allowlist a naming convention nobody had written down. It was also tighter than the harm it exists to stop: `+` is not shell syntax and a leading `_` is neither the `..` climb nor the dash basename reads as an option, so `omarchy-c++-theme` was refused for nothing. Widen the set to those two and say what it is where a theme author is already picking a name. The leading character stays out of `.` and `-`, which is the part that does the work. Reported-by: Luis Alvarez (lalvarezt) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fd3RCHxwjEbMXoSYB9Aiso --- bin/omarchy-theme-install | 2 +- manual/43-making-your-own-theme.md | 2 ++ test/shell.d/theme-install-guards-test.sh | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index fb5acdc2..6b442678 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -37,7 +37,7 @@ THEME_PATH="$THEMES_DIR/$THEME_NAME" # ~/.config/omarchy with it, and one called `a';'id` would carry its own # command into that picker. The leading character is kept out of `.` and `-`, # which also covers `host:-s/foo.git` leaving basename with `.git`. -if [[ ! $THEME_NAME =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then +if [[ ! $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]; then echo "Error: '$REPO_URL' does not give a usable theme name." exit 1 fi diff --git a/manual/43-making-your-own-theme.md b/manual/43-making-your-own-theme.md index ed6aee82..c6818a4b 100644 --- a/manual/43-making-your-own-theme.md +++ b/manual/43-making-your-own-theme.md @@ -38,6 +38,8 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. +That leftover `[themename]` becomes the theme's directory name, so it has to be one Omarchy can hand around safely: it must start with a letter, a digit, or an underscore, and the rest may hold letters, digits, `.`, `_`, `+`, and `-`. Capitals are lowercased for you, but anything else — a space, a quote, a non-English character — is refused at install time rather than turned into a directory name. So `omarchy-tokyo-night-theme`, `omarchy-flexoki_light-theme`, and `omarchy-c++-theme` all install fine. + Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those. You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index 43cf1f3e..2b29bba8 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -120,6 +120,21 @@ grep -Fq "/themes/tokyo_night.2" "$git_calls" || pass "a theme name may still hold an underscore, a dot, and a dash" +# A plus is neither path-climb nor shell syntax, and a leading underscore is +# neither the `..` climb nor the dash that reads as an option, so the allowlist +# keeps both rather than stranding a repo that names itself with them. +install_theme "https://github.com/example/omarchy-c++-theme.git" || + fail "omarchy-theme-install accepts a name holding a plus" +grep -Fq "/themes/c++" "$git_calls" || + fail "omarchy-theme-install derives a name carrying a plus" "$(cat "$git_calls")" + +install_theme "https://github.com/example/_private.git" || + fail "omarchy-theme-install accepts a name starting with an underscore" +grep -Fq "/themes/_private" "$git_calls" || + fail "omarchy-theme-install derives a name starting with an underscore" "$(cat "$git_calls")" + +pass "a plus and a leading underscore are still usable theme names" + # basename reads a leading dash as an option once the scp-style prefix is gone. install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" From 877f1e96ef1af8d821a00047c3b38328eefb3b0a Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 27 Aug 2026 07:17:44 +0530 Subject: [PATCH 28/73] Keep web app launchers on http(s) Chromium --app= will run javascript:, file:, and data: URLs. Prefix schemeless input with https as before, then refuse anything else. --- bin/omarchy-webapp-install | 32 +++++++++++---- test/shell.d/webapp-install-test.sh | 60 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 test/shell.d/webapp-install-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..308f8841 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -42,6 +42,27 @@ download_icon() { [[ -s $2 && $(file -b --mime-type "$2") == image/* ]] } +# Chromium --app= treats javascript:, file:, and data: as a document to +# run. Prefix schemeless input with https as before, then refuse anything +# that is not http(s). Desktop-file value escaping is a separate concern +# (see open work on the freedesktop string/Exec rules). +normalize_webapp_url() { + local url=$1 + if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then + url="https://$url" + fi + printf '%s' "$url" +} + +require_http_url() { + local url=$1 + if [[ $url =~ ^https?:// ]]; then + return 0 + fi + echo "Error: web app URL must be http or https." >&2 + exit 1 +} + fetch_site_icon() { local site_url="$1" dest="$2" local origin page icon_url @@ -69,9 +90,7 @@ if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") - if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then - APP_URL="https://$APP_URL" - fi + APP_URL=$(normalize_webapp_url "$APP_URL") # Try to fetch the site's icon automatically first. mkdir -p "$ICON_DIR" @@ -88,10 +107,7 @@ if (( $# < 3 )); then INTERACTIVE_MODE=true else APP_NAME="$1" - APP_URL="$2" - if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then - APP_URL="https://$APP_URL" - fi + APP_URL=$(normalize_webapp_url "$2") ICON_REF="$3" CUSTOM_EXEC="$4" # Optional custom exec command MIME_TYPES="$5" # Optional mime types @@ -104,6 +120,8 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +require_http_url "$APP_URL" + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" diff --git a/test/shell.d/webapp-install-test.sh b/test/shell.d/webapp-install-test.sh new file mode 100644 index 00000000..f436a2d3 --- /dev/null +++ b/test/shell.d/webapp-install-test.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +home="$tmpdir/home" +mkdir -p "$home/.local/share/applications" + +install_webapp() { + HOME="$home" "$ROOT/bin/omarchy-webapp-install" "$@" +} + +desktop_for() { + printf '%s' "$home/.local/share/applications/$1.desktop" +} + +if install_webapp "Example" "https://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install accepts an https URL" "$(cat "$tmpdir/err")" +fi + +desktop=$(desktop_for Example) +[[ -f $desktop ]] || fail "webapp install writes a desktop file" +grep -Fxq 'Name=Example' "$desktop" || fail "webapp install writes the app name" +grep -Fxq 'Exec=omarchy-launch-webapp https://example.com' "$desktop" || + fail "webapp install launches the https URL" "$(cat "$desktop")" +pass "webapp install writes an https desktop entry" + +if install_webapp "Plain" "example.org/app" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install prefixes a schemeless URL with https" "$(cat "$tmpdir/err")" +fi +grep -Fxq 'Exec=omarchy-launch-webapp https://example.org/app' "$(desktop_for Plain)" || + fail "webapp install stores the prefixed https URL" "$(cat "$(desktop_for Plain)")" +pass "webapp install prefixes a schemeless URL with https" + +if install_webapp "Local" "https://localhost:47990" "webapp" "omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install keeps a custom https exec" "$(cat "$tmpdir/err")" +fi +grep -Fxq 'Exec=omarchy-launch-webapp https://localhost:47990 --ignore-certificate-errors' "$(desktop_for Local)" || + fail "webapp install writes the custom exec" "$(cat "$(desktop_for Local)")" +pass "webapp install keeps a custom https exec" + +for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp://example.com" "ext://x"; do + if install_webapp "Bad" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "webapp install refuses '$url'" + fi + grep -Fq 'must be http or https' "$tmpdir/err" || + fail "webapp install names the scheme refusal for '$url'" "$(cat "$tmpdir/err")" + [[ ! -e $(desktop_for Bad) ]] || fail "webapp install does not write a desktop file for '$url'" +done +pass "webapp install refuses non-http(s) URLs" From eb7ecd13f30a0455373725f45042f20c142a0c73 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:21:07 -0500 Subject: [PATCH 29/73] Keep Ori interactive when launched with a prompt (#8455) --- bin/omarchy-agent | 4 +++- test/shell.d/default-agent-test.sh | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 3c009460..3e3b7a5d 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -92,8 +92,10 @@ omp) ;; ori) # Ori is a harness launcher, and `ori code` is the agent it runs itself. + # A prompt alone means one headless turn there, printed after the turn ends, + # so --interactive is what seeds the session with it and keeps the window. command=(ori code) - [[ -n ${prompt:-} ]] && command+=(--prompt "$prompt") + [[ -n ${prompt:-} ]] && command+=(--interactive --prompt "$prompt") ;; pi) command=(pi) diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 5b4512f1..db964d90 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -457,7 +457,7 @@ assert_bypass() { assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" -assert_launch ori ori code --prompt "Review this project" +assert_launch ori ori code --interactive --prompt "Review this project" assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch crush crush run "Review this project" From 8d14869689ae4772b1181d1012a37c7b0712cfc9 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:28:11 +0200 Subject: [PATCH 30/73] Let a crash diagnosis mute that program's notifications A crash that is understood is not a crash that stops: an upstream bug waiting on a release, a program that dumps core every time it exits. The diagnosis explains it once and the toast keeps arriving, and the only answer Omarchy had was Crash Capture, which turns off every program's notifications in order to silence one. The watcher already resolves a name to dedupe on and announces that same name in the toast, so the mute is keyed on it: a flag file under toggles/crash-ignore/, written by the existing omarchy-toggle and read by the existing omarchy-toggle-enabled. One flag per name rather than one list, so `on` mutes, `off` un-mutes, and `ls -A` shows what is muted, with no new file format and nothing to parse. It is the executable's basename wherever one was recorded, falling back to the process name, which the kernel truncates to fifteen characters -- muting the truncated form would match nothing, forever, while looking like it worked. The name is not always a name, though, and the mute turns it into a path. A program picks its own comm and prctl takes anything, including slashes, and the watcher falls back to comm whenever a crash carries no absolute executable. So it is stripped to its last component first: without that, `a/../bar-off` is a legal comm aimed at an unrelated Omarchy flag, letting a crashing program suppress its own notification and letting a user who accepted the offered mute hide their bar instead. Stripping does not always leave a component either -- `/` leaves an empty string, which is no kind of array subscript and no kind of toast, and `.` or `..` names a directory that omarchy-toggle would touch and report success on, leaving a mute that never matches. Both fall back to `unknown`, the word omarchy-agent-crash already uses for a name it does not have, and which mutes like any other. The skill offers this at the end of a diagnosis and never runs it unprompted, which makes it the single change a diagnosis may make to a system it otherwise only reads. It tells the agent to use the name it was handed rather than re-derive one, since the watcher resolved that name already and the two agree for ordinary names and not for strange ones; a diagnosis started by hand from `omarchy agent crash ` is given no name and gets the derivation instead. It also says to treat the name as hostile text rather than as a word -- it is whatever the crashed program's author called a file, so a single quote inside one closes the quotes around it and the rest runs as the shell -- and to check the flag arrived rather than assume it. Co-Authored-By: Codex XHigh --- bin/omarchy-crash-watch | 17 ++ default/agents/skills/diagnose-crash/SKILL.md | 55 ++++- manual/17-ai.md | 2 + test/shell.d/crash-capture-test.sh | 188 ++++++++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index ee1ed82d..09dd32d3 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -71,11 +71,28 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=$comm [[ $exe == /* ]] && name=${exe##*/} + # A process can set its own comm to anything prctl takes, slashes included, + # and a crash with no recorded executable falls back to it. The mute below + # turns this name into a path, so keep it one component: a crash must not + # reach a flag outside crash-ignore/, nor have a diagnosis write one there. + name=${name##*/} + + # What that leaves is not always a name. "/" leaves nothing, which is no + # kind of array subscript and no kind of toast, and a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match. + [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue # Never announce our own machinery, or it notifies about itself. [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + # Muted at the end of a diagnosis, when the user was offered it and said + # yes. A flag per program rather than one list, so it un-mutes with + # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..7faadb1f 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,60 @@ ambiguous, say so rather than assembling confidence out of guesswork. **Leave the system as you found it.** Diagnosis reads; it does not fix, tidy, or reconfigure. The one thing to clean up is your own: delete the core you extracted -above, which is a copy of the crashed process's memory. +above, which is a copy of the crashed process's memory. The single change a +diagnosis may make is the mute below, and only when the user asks for it. + +## Offer to stop the notifications for this program + +A crash that is now understood keeps announcing itself, and understanding it +rarely stops it happening: an upstream bug waiting on a release, a program that +dumps core every time it exits, a driver that misbehaves on this hardware. Finish +by offering to silence crash notifications for **that one program**: + +```bash +omarchy-toggle 'crash-ignore/' on +``` + +`` is the `process:` name in the crash facts, verbatim. The watcher works +that name out and then announces it, so what you were handed is already the exact +string the mute is keyed on — do not re-derive it from `coredumpctl` when you were +given it, because the two agree for ordinary names and not for strange ones. + +A diagnosis started by hand from `omarchy agent crash ` is given no name, so +there you do have to work it out the way the watcher does: the executable's +basename when an absolute `Executable:` was recorded, otherwise the process name +with everything up to the last `/` dropped, and `unknown` when that leaves +nothing, `.` or `..`. Prefer the executable — the kernel truncates the process +name to 15 characters and does not truncate the basename, so a mute on the +truncated one matches nothing, forever, while looking like it worked. + +The name is whatever the crashed program's author chose to call a file, so handle +it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, +but a name containing a single quote closes them and the rest of it runs as your +shell — escape it, or the program that just crashed chooses the command. Then +check the flag actually arrived, which is also how you learn a name was too long +for the filesystem to keep: + +```bash +omarchy-toggle-enabled 'crash-ignore/' && echo muted +``` + +Offer it; never run it unprompted. The user may well want to keep being told. + +Say how to undo it in the same breath, so it is not a one-way door: the same +command with `off` un-mutes, and each mute is one file in +`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the +directory appears with the first mute, so before that there is nothing to list. + +The key is a bare name, so programs sharing one share a mute, and anything run +through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` +silences every other Python or Node program on the machine, which is rarely what +the user means: say so rather than quietly doing it. + +This silences one program. Every other crash still notifies, and the muted +program still crashes — nothing here fixes anything, and a mute offered instead +of a fix that was within reach is the wrong answer. If the user wants crash +notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. ## If it is an Omarchy bug diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..5588ef9b 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,6 +39,8 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. + ### Desktop apps The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index d4119cd8..2f705310 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,194 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user- fail "crash capture is no longer on by default for new installs" pass "crash capture is on by default" +require_command jq + +# The per-program mute, driven through the real watcher with a stubbed journal: +# these prove what a person sees -- a toast arriving or not -- where asserting +# that a flag file was read would prove only that a flag file was read. +watch_bin="$TMPDIR/watch-bin" +watch_home="$TMPDIR/watch-home" +NOTIFY_LOG="$TMPDIR/notify-log" +JOURNAL_ENTRIES="$TMPDIR/journal-entries" + +mkdir -p "$watch_bin" "$watch_home" + +cat >"$watch_bin/journalctl" <<'SH' +#!/bin/bash +cat "$JOURNAL_ENTRIES" +SH + +cat >"$watch_bin/omarchy-default-agent" <<'SH' +#!/bin/bash +echo claude +SH + +cat >"$watch_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$watch_bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_LOG" +SH + +chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \ + "$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send" + +reset_entries() { + : >"$JOURNAL_ENTRIES" +} + +# One core dump as systemd-coredump journals it. The UID must be this user's, or +# the watcher discards it as somebody else's crash before anything under test. +crash_entry() { + local comm="$1" exe="$2" + + jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \ + '{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242", + COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES" +} + +# The stubbed journalctl ends after the entries, so the watcher's loop ends too. +# Its exit status is asserted rather than discarded: a watcher that dies on a +# muted crash notifies about nothing afterwards, which every assertion below +# that expects silence would otherwise read as success. +run_watch() { + local status=0 + + : >"$NOTIFY_LOG" + + PATH="$watch_bin:$ROOT/bin:$PATH" \ + JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \ + NOTIFY_LOG="$NOTIFY_LOG" \ + HOME="$watch_home" \ + "$ROOT/bin/omarchy-crash-watch" || status=$? + + (( status == 0 )) || + fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" +} + +mute() { + HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" +} + +announced() { + grep -Fq "Process crashed: $1" "$NOTIFY_LOG" +} + +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "a crash nobody muted still announces itself" +pass "a crash nobody muted still announces itself" + +mute hyprland on +run_watch +! announced hyprland || + fail "muting a program stops the crash notifications the diagnosis offered to stop" +pass "muting a program stops its crash notifications" + +reset_entries +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "muting one program silences every other program, which is the global toggle's job and not this one's" +pass "muting one program leaves every other program announcing" + +mute hyprland off +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "un-muting a program brings its crash notifications back" +pass "un-muting a program brings its crash notifications back" + +# The diagnosis tells the user to mute the name the toast showed them, so the +# toast has to show the name the watcher checks. COMM is truncated to 15 +# characters and the executable's basename is not, and announcing the truncated +# one would leave a dutifully-followed mute matching nothing forever. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +run_watch +announced chromium-browser || + fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing" +pass "the toast announces the name the mute is keyed on" + +mute chromium-browser on +run_watch +! announced chromium-browser || + fail "the mute is keyed on the name the notification announced, not on the truncated COMM" +pass "muting the announced name silences a program whose COMM was truncated" + +# A muted crash must not end the watcher. Restart=always would paper over it +# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which +# never replays the crashes it missed while it was away. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "a muted crash stops the watcher reading the journal, losing every crash after it" +pass "a muted crash does not stop the watcher reading the next one" + +# A process can set its own comm to anything prctl takes, slashes included, and +# a crash with no recorded executable falls back to it. A name that climbed out +# of crash-ignore/ would let a crashing program silence itself against an +# unrelated flag -- and have the diagnosis write one there on the user's behalf. +# The fixture carries two slashes so that dropping only the first is not mistaken +# for dropping all of them. +reset_entries +crash_entry a/../bar-off - +sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off" +touch "$sibling_flag" +run_watch +announced bar-off || + fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification" +pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle" +rm -f "$sibling_flag" + +# Stripping to the last component does not always leave a component. An empty +# name is no kind of array subscript and no kind of toast, and a dot component +# names a directory the mute would touch and then never match. +for empty_comm in / a/ . ..; do + reset_entries + crash_entry "$empty_comm" - + run_watch + announced unknown || + fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on" +done +pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" + +# Only "." and ".." are special. A leading dot is an ordinary filename, and +# folding those into the fallback would have one program's mute silence another. +for dotted_comm in .hidden ...; do + reset_entries + crash_entry "$dotted_comm" - + run_watch + announced "$dotted_comm" || + fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes" +done +pass "a leading dot is an ordinary name rather than a special component" + +# And the name it settles on is mutable like any other. +mute unknown on +reset_entries +crash_entry / - +run_watch +! announced unknown || + fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced" +pass "the fallback name can be muted like any other" +mute unknown off + +skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" +grep -Fq 'crash-ignore/' "$skill" || + fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" +grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || + fail "the watcher no longer reads the flag the diagnosis offers to write" +pass "the diagnosis and the watcher name the same flag" + run_node_test <<'JS' const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') From eeb4206c7b4a665e714fe8f5060c1647b63dad43 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:38:18 +0200 Subject: [PATCH 31/73] Say in the manual what the skill already says about quoting The manual had single quotes covering "punctuation your shell would otherwise read as its own", which is more than they do: a name containing a single quote closes them, and the rest of it is read as shell. The skill states that correctly and the manual did not, so the one document a person reads before typing the command was the one making the claim that does not hold. --- manual/17-ai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index 5588ef9b..86c99354 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,7 +39,7 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. -Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in single quotes, which is what carries a name with a space in it; a name containing a quote character closes those quotes, so that one has to be escaped as well. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. ### Desktop apps From ea6ee9440ab4be75b2c3b05527d61ea206a01ce1 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 11:26:28 +0200 Subject: [PATCH 32/73] Add omarchy-crash-mute to mute and unmute one program The mute was reachable only as `omarchy-toggle crash-ignore/`, which asks whoever runs it to know the flag layout, to reduce a binary's path to the name the watcher keys on, and to have read the rule that a name climbing out of that directory writes an unrelated toggle. All of that was carried in the skill's prose, which is the wrong place for a rule that has to hold: prose is advice, and the thing being advised about is a name the crashed program chose. So it is a command now. `omarchy crash mute hyprland` silences that program, `off` lifts it, `toggle` flips it, and no argument lists what is muted. It takes the binary's path as readily as the name and reduces it the way the watcher does, so the `Executable:` line from `coredumpctl` can be handed straight to it; it refuses what is not one component of a name, so it cannot be talked into writing outside its own directory whatever it is given; and it re-reads the flag afterwards and reports what is now true rather than what was asked for. The listing counts only regular files, because that is all the watcher honours -- anything else in there would read as muted while the crashes kept arriving. A leading `--` is consumed so a program named `-h`, which the router would otherwise answer with its own help, can still be muted. The watcher gained an unrelated fix that this uncovered. Its fields are read with `IFS=$'\t'`, and tab is IFS whitespace, so an empty field collapsed into the next delimiter and shifted every field after it along one: a crash whose comm was empty had a path read as its pid and was discarded as somebody else's. A process can set its comm to nothing, so that was reachable. Empty fields now arrive as a dash like missing ones, and a dash joins the empty and dot cases that fall back to `unknown`. Co-Authored-By: Codex XHigh --- bin/omarchy | 1 + bin/omarchy-crash-mute | 73 +++++++++ bin/omarchy-crash-watch | 26 +-- default/agents/skills/diagnose-crash/SKILL.md | 53 +++--- manual/17-ai.md | 2 +- test/shell.d/crash-capture-test.sh | 152 +++++++++++++++++- 6 files changed, 264 insertions(+), 43 deletions(-) create mode 100755 bin/omarchy-crash-mute diff --git a/bin/omarchy b/bin/omarchy index 111d214a..4219109b 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -40,6 +40,7 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management" GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers" GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers" GROUP_DESCRIPTIONS[config]="System configuration helpers" +GROUP_DESCRIPTIONS[crash]="Crash notification controls" GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" diff --git a/bin/omarchy-crash-mute b/bin/omarchy-crash-mute new file mode 100755 index 00000000..88229fea --- /dev/null +++ b/bin/omarchy-crash-mute @@ -0,0 +1,73 @@ +#!/bin/bash + +# omarchy:summary=Silence crash notifications for one program, or list what is silenced +# omarchy:args=[--] [] [on|off|toggle] +# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off + +# The flag omarchy-crash-watch reads before announcing a crash. Muting is per +# program; Trigger > Toggle > Crash Capture is the switch for all of them. + +set -uo pipefail + +readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore" + +usage() { + echo "Usage: omarchy crash mute [--] [] [on|off|toggle]" >&2 +} + +# Only regular files, because that is all the watcher honours: anything else in +# there would be reported as muted while the crashes kept arriving. The dotted +# glob is for a program legitimately called .hidden, and `.` and `..` fail the +# same -f test that keeps them out. +list() { + local entry found=0 + + for entry in "$MUTES"/* "$MUTES"/.*; do + [[ -f $entry ]] || continue + printf '%s\n' "${entry##*/}" + found=1 + done + + ((found)) || echo "No programs muted. Crashes all notify." +} + +# A program may be named -h, and the router answers that with its own help +# before this ever runs. `omarchy crash mute -- -h` is the way through. +[[ ${1:-} == "--" ]] && shift + +if (($# == 0)); then + list + exit 0 +fi + +program=$1 +action=${2:-on} + +# The watcher keys the mute on the executable's basename, so accept the path it +# reports as readily as the name, and reduce either the same way it does. +program=${program##*/} + +if [[ -z $program || $program == "." || $program == ".." ]]; then + echo "Not a program name: $1" >&2 + usage + exit 1 +fi + +case "$action" in + on|off|toggle) ;; + *) + echo "Not an action: $action" >&2 + usage + exit 1 + ;; +esac + +omarchy-toggle "crash-ignore/$program" "$action" || exit 1 + +# Report what is now true rather than what was asked for: the flag is what the +# watcher reads, and a toggle does not say which way it went. +if omarchy-toggle-enabled "crash-ignore/$program"; then + echo "Muted crash notifications for $program." +else + echo "Crash notifications for $program are back on." +fi diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index 09dd32d3..d3e78d16 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -48,12 +48,17 @@ announce() { # -n 0 so a restart does not re-announce crashes already dealt with. journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | while IFS= read -r entry; do + # A dash for a field that is empty as well as one that is missing: tab is + # IFS whitespace, so an empty field collapses into the next delimiter and + # every field after it shifts along one. A process can set its own comm to + # nothing, and that crash used to be read as somebody else's and dropped. IFS=$'\t' read -r uid comm pid exe signal < <( - jq -r '[(._UID // "-"), - (.COREDUMP_COMM // "-"), - (.COREDUMP_PID // "-"), - (.COREDUMP_EXE // "-"), - (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + jq -r 'def field: if . == null or . == "" then "-" else . end; + [(._UID | field), + (.COREDUMP_COMM | field), + (.COREDUMP_PID | field), + (.COREDUMP_EXE | field), + (.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null ) [[ $pid =~ ^[0-9]+$ ]] || continue @@ -78,10 +83,11 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=${name##*/} # What that leaves is not always a name. "/" leaves nothing, which is no - # kind of array subscript and no kind of toast, and a dot component names a + # kind of array subscript and no kind of toast; a dot component names a # directory rather than a flag, so a mute on it would touch that directory - # and then never match. - [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + # and then never match; and a dash is what the read above puts there when + # the crash recorded no name at all. + [[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue @@ -89,8 +95,8 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue # Muted at the end of a diagnosis, when the user was offered it and said - # yes. A flag per program rather than one list, so it un-mutes with - # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + # yes. A flag per program rather than one list, so omarchy-crash-mute can + # lift one without reading, rewriting and re-parsing the rest. omarchy-toggle-enabled "crash-ignore/$name" && continue now=$EPOCHSECONDS diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7faadb1f..3ae58493 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -98,39 +98,40 @@ dumps core every time it exits, a driver that misbehaves on this hardware. Finis by offering to silence crash notifications for **that one program**: ```bash -omarchy-toggle 'crash-ignore/' on +omarchy-crash-mute '' ``` -`` is the `process:` name in the crash facts, verbatim. The watcher works -that name out and then announces it, so what you were handed is already the exact -string the mute is keyed on — do not re-derive it from `coredumpctl` when you were -given it, because the two agree for ordinary names and not for strange ones. +`` is the `process:` name in the crash facts, or the `binary:` path — +the command reduces a path to the same name the watcher keys on, so passing +`/usr/lib/chromium/chromium-browser` and passing `chromium-browser` land on the +same flag. Prefer the binary's path wherever the crash recorded one: the kernel +truncates the process name to 15 characters and does not truncate a basename, and +a mute on the truncated form matches nothing, forever, while looking like it +worked. -A diagnosis started by hand from `omarchy agent crash ` is given no name, so -there you do have to work it out the way the watcher does: the executable's -basename when an absolute `Executable:` was recorded, otherwise the process name -with everything up to the last `/` dropped, and `unknown` when that leaves -nothing, `.` or `..`. Prefer the executable — the kernel truncates the process -name to 15 characters and does not truncate the basename, so a mute on the -truncated one matches nothing, forever, while looking like it worked. +That is also the answer for a diagnosis started by hand from `omarchy agent crash +`, which is handed no name at all: give the command the `Executable:` line +from `coredumpctl info` and let it do the reducing. Some crashes record no +executable — pass the process name then, and `unknown` where the crash has +neither, which is the name such a crash is announced under. -The name is whatever the crashed program's author chose to call a file, so handle -it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, -but a name containing a single quote closes them and the rest of it runs as your -shell — escape it, or the program that just crashed chooses the command. Then -check the flag actually arrived, which is also how you learn a name was too long -for the filesystem to keep: - -```bash -omarchy-toggle-enabled 'crash-ignore/' && echo muted -``` +The name is still whatever the crashed program's author chose to call a file, so +handle it as hostile text rather than as a word. The command refuses a name that +is not one — it cannot be talked into writing a flag outside its own directory — +but that is no help if the name reaches a shell unescaped first: single quotes +hold a space or a `$(...)`, and a name containing a single quote closes them and +runs the rest as your shell. Escape it, or the program that just crashed picks +the command. Offer it; never run it unprompted. The user may well want to keep being told. -Say how to undo it in the same breath, so it is not a one-way door: the same -command with `off` un-mutes, and each mute is one file in -`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the -directory appears with the first mute, so before that there is nothing to list. +Say how to undo it in the same breath, so it is not a one-way door — and the +command answers both halves itself: + +```bash +omarchy-crash-mute '' off # un-mute this one +omarchy-crash-mute # list what is muted +``` The key is a bare name, so programs sharing one share a mute, and anything run through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` diff --git a/manual/17-ai.md b/manual/17-ai.md index 86c99354..57698f42 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,7 +39,7 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. -Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in single quotes, which is what carries a name with a space in it; a name containing a quote character closes those quotes, so that one has to be escaped as well. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end. `omarchy crash mute hyprland` stops the notifications for that program only, `omarchy crash mute hyprland off` brings them back, and `omarchy crash mute` on its own lists what you've muted. It takes the binary's path as happily as its name, so `omarchy crash mute /usr/bin/hyprland` does the same thing. Quote a name with a space in it, as in `omarchy crash mute 'Some App'`. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. ### Desktop apps diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index 2f705310..1b7e93d0 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -122,8 +122,12 @@ run_watch() { fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" } +# Through the real command rather than writing the flag by hand: these assertions +# are then the guard that the thing the diagnosis runs and the thing the watcher +# reads have not drifted apart. mute() { - HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" + HOME="$watch_home" PATH="$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null } announced() { @@ -214,6 +218,19 @@ for empty_comm in / a/ . ..; do done pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" +# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field +# collapses and every field after it shifts along one -- the pid becomes a path, +# the crash reads as somebody else's, and it is dropped without a word. +reset_entries +crash_entry "" - +crash_entry nautilus /usr/bin/nautilus +run_watch +announced unknown || + fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it" +announced nautilus || + fail "an empty comm derails the rest of the journal entry" +pass "an empty comm is announced rather than parsed into the next field" + # Only "." and ".." are special. A leading dot is an ordinary filename, and # folding those into the fallback would have one program's mute silence another. for dotted_comm in .hidden ...; do @@ -235,12 +252,135 @@ run_watch pass "the fallback name can be muted like any other" mute unknown off +# What omarchy-crash-mute does on its own. That it agrees with the watcher is +# already covered above, which drives it for every mute it makes. +mute_home="$TMPDIR/mute-home" +mkdir -p "$mute_home" + +crash_mute() { + HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@" +} + +mute_flag() { + [[ $1 == "--" ]] && shift + printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1" +} + +crash_mute | grep -Fq "No programs muted" || + fail "an empty mute list prints nothing, so a user cannot tell it from a broken command" +pass "the command says so when nothing is muted" + +crash_mute hyprland >/dev/null +crash_mute | grep -Fqx hyprland || + fail "a muted program is missing from the list, so a mute cannot be found again to lift it" +pass "the command lists what it muted" + +# The watcher keys on the basename, so the command has to take the path a crash +# recorded and land on the same flag the watcher will look for. +crash_mute /usr/lib/chromium/chromium-browser >/dev/null +[[ -f $(mute_flag chromium-browser) ]] || + fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag" +pass "the command reduces a path to the name the watcher checks" + +crash_mute hyprland off >/dev/null +[[ ! -f $(mute_flag hyprland) ]] || + fail "off leaves the program muted, making the mute a one-way door" +pass "the command un-mutes" + +# Muting is not flipping. The diagnosis offers this on a program the user may +# already have muted, and asking for a mute twice has to leave it muted. +crash_mute hyprland >/dev/null +crash_mute hyprland >/dev/null +[[ -f $(mute_flag hyprland) ]] || + fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on" +pass "asking to mute twice leaves it muted" + +# A program may legitimately be called .hidden, and a mute nobody can see is a +# mute nobody can lift. +crash_mute .hidden >/dev/null +crash_mute | grep -Fqx .hidden || + fail "a mute on a dotted name is missing from the list, so it can never be found and lifted" +pass "the list shows a name that begins with a dot" + +# It turns what it is given into a path, so it has to refuse whatever is not one +# component of one. +for bad_name in . .. /; do + ! crash_mute "$bad_name" >/dev/null 2>&1 || + fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read" +done +pass "the command refuses a name that is not a name" + +! crash_mute hyprland sideways >/dev/null 2>&1 || + fail "an action it does not know is treated as a mute, so a typo silences a program" +pass "the command refuses an action it does not know" + +# And says what it refused, or the user retypes the same thing. Captured rather +# than piped: the command exits non-zero here, which pipefail would surface as +# the pipeline's status and read as a failed assertion. +refusal=$(crash_mute hyprland sideways 2>&1) || true +grep -Fq "Not an action" <<<"$refusal" || + fail "an unknown action is refused without naming it, leaving the user nothing to correct" +pass "the command names the action it refused" + +crash_mute ../bar-off >/dev/null +[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] || + fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead" +pass "the command cannot be talked into writing outside crash-ignore/" + +# A program may be called -h, and the router answers that with its own help +# before the command runs. A leading -- is the way through, so it has to be +# consumed rather than taken for the program name. +crash_mute -- -h >/dev/null 2>&1 || + fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all" +[[ -f $(mute_flag -- -h) ]] || + fail "a leading -- is taken for the program name, so muting -h mutes something else" +pass "a leading -- lets a program named like a flag be muted" + +# toggle is advertised, so it has to flip both ways rather than quietly mute. +crash_mute toggler off >/dev/null +crash_mute toggler toggle >/dev/null +[[ -f $(mute_flag toggler) ]] || + fail "toggle does not mute an un-muted program" +crash_mute toggler toggle >/dev/null +[[ ! -f $(mute_flag toggler) ]] || + fail "toggle mutes but never un-mutes, so the advertised action only goes one way" +pass "toggle flips a mute both ways" + +# The listing means what the watcher means, and the watcher honours a regular +# file. Anything else in there is not a mute, however much it looks like one. +mkdir -p "$(mute_flag notactuallymuted)" +! crash_mute | grep -Fqx notactuallymuted || + fail "a directory is reported as muted while that program's crashes keep arriving" +pass "the listing counts only the flags the watcher honours" +rmdir "$(mute_flag notactuallymuted)" + +# A mute that could not be written must not be reported as one. Without this the +# command can print success for a flag that was never created. +failing_bin="$TMPDIR/failing-bin" +mkdir -p "$failing_bin" +cat >"$failing_bin/omarchy-toggle" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$failing_bin/omarchy-toggle" + +status=0 +refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$? +(( status != 0 )) || + fail "a mute that could not be written exits zero, so nothing downstream learns it failed" +! grep -Fq "Muted crash notifications" <<<"$refusal" || + fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not" +pass "a mute that could not be written is not reported as one" + skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" -grep -Fq 'crash-ignore/' "$skill" || - fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" -grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || - fail "the watcher no longer reads the flag the diagnosis offers to write" -pass "the diagnosis and the watcher name the same flag" +grep -Fq 'omarchy-crash-mute' "$skill" || + fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out" +pass "the diagnosis names the command that mutes" + +grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" || + fail "the crash group has no description, so the router lists a group it cannot describe" +pass "the crash group is described in the router" run_node_test <<'JS' const fs = require('fs') From b68d4142d77c0ebedbadd7ed8b05ca1cf3e319b3 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 12:22:54 +0200 Subject: [PATCH 33/73] Cut the crash-mute section of the skill to what it instructs The section had grown a paragraph per review round, each one explaining why the last was right, until one offer took a third of the file. Most of it was reassurance about what the command refuses rather than anything an agent has to do, and the command enforces that itself whatever the prose says. What is left is the instruction: offer it and never run it unprompted, say how to lift it, which of the two names to pass and why the binary is the better one, quote it because the name is the crashed program's to choose, and name the interpreter collision before muting python or node on someone's behalf. Fifty-four lines to thirty-two, with nothing dropped that changes what the agent does. --- default/agents/skills/diagnose-crash/SKILL.md | 65 ++++++------------- 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 3ae58493..ea773007 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -92,56 +92,33 @@ diagnosis may make is the mute below, and only when the user asks for it. ## Offer to stop the notifications for this program -A crash that is now understood keeps announcing itself, and understanding it -rarely stops it happening: an upstream bug waiting on a release, a program that -dumps core every time it exits, a driver that misbehaves on this hardware. Finish -by offering to silence crash notifications for **that one program**: +A crash you have explained often keeps happening anyway. Finish by offering to +silence notifications for **that one program**, and never run it unprompted. Say +how to lift it in the same breath, so it is not a one-way door. ```bash -omarchy-crash-mute '' -``` - -`` is the `process:` name in the crash facts, or the `binary:` path — -the command reduces a path to the same name the watcher keys on, so passing -`/usr/lib/chromium/chromium-browser` and passing `chromium-browser` land on the -same flag. Prefer the binary's path wherever the crash recorded one: the kernel -truncates the process name to 15 characters and does not truncate a basename, and -a mute on the truncated form matches nothing, forever, while looking like it -worked. - -That is also the answer for a diagnosis started by hand from `omarchy agent crash -`, which is handed no name at all: give the command the `Executable:` line -from `coredumpctl info` and let it do the reducing. Some crashes record no -executable — pass the process name then, and `unknown` where the crash has -neither, which is the name such a crash is announced under. - -The name is still whatever the crashed program's author chose to call a file, so -handle it as hostile text rather than as a word. The command refuses a name that -is not one — it cannot be talked into writing a flag outside its own directory — -but that is no help if the name reaches a shell unescaped first: single quotes -hold a space or a `$(...)`, and a name containing a single quote closes them and -runs the rest as your shell. Escape it, or the program that just crashed picks -the command. - -Offer it; never run it unprompted. The user may well want to keep being told. - -Say how to undo it in the same breath, so it is not a one-way door — and the -command answers both halves itself: - -```bash -omarchy-crash-mute '' off # un-mute this one +omarchy-crash-mute '' # silence it +omarchy-crash-mute '' off # let it speak again omarchy-crash-mute # list what is muted ``` -The key is a bare name, so programs sharing one share a mute, and anything run -through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` -silences every other Python or Node program on the machine, which is rarely what -the user means: say so rather than quietly doing it. +Pass the `binary:` path from the crash facts, or the `process:` name where no +binary was recorded; the command reduces either to the name the watcher keys on. +A diagnosis run by hand from `omarchy agent crash ` has neither, so take +them from `coredumpctl info`. Prefer the binary: a process name is truncated to +15 characters and a basename is not, so muting the truncated form matches +nothing, forever, while looking like it worked. -This silences one program. Every other crash still notifies, and the muted -program still crashes — nothing here fixes anything, and a mute offered instead -of a fix that was within reach is the wrong answer. If the user wants crash -notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. +Quote it. The name is whatever the crashed program's author called a file, and a +single quote inside one closes yours and runs the rest as your shell. + +The key is a bare name, so anything run through an interpreter is keyed as the +interpreter: muting `python3.13` silences every Python program on the machine. +Say so rather than quietly doing it. + +None of this fixes anything, and a mute offered in place of a fix that was within +reach is the wrong answer. For every program rather than one, the switch is +_Trigger > Toggle > Crash Capture_. ## If it is an Omarchy bug From 7026ede90b4c78dd3398f30b9611f72a63566121 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 16:53:55 +0200 Subject: [PATCH 34/73] Strip image tags after the newline rewrite, not before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card binds the body Text to styledBody, which rewrites newlines to
*after* sanitizeBody has run. That rewrite inserts tag syntax into text the stripper deliberately kept: a kept tag may hold a `<` of its own, and `` is one tag named `x` to both the stripper and Qt, so it survives whole — until the rewrite splits it into `` and a live image tag the input never contained. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server: that body issues the GET after this branch's sanitizer and issues nothing before it, because the one-pass /]*>/gi it replaces deleted the inner substring outright. The whole-tag bound is still the right trade — it is what stops the stripper manufacturing tags — but it only holds if nothing edits the string afterwards. So move the rewrite into NotificationLogic, next to the reasoning it depends on, and strip again after it. What Qt parses is then what was checked last. The tests assert on styledBody for the same reason, since sanitizeBody's output is no longer the string that reaches the renderer, and a regex assertion pins the card's binding because no JavaScript assertion can see a QML property. --- .../notifications/NotificationLogic.js | 13 +++++ .../components/NotificationCard.qml | 2 +- test/shell.d/notifications-test.sh | 48 ++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index b3f7be09..b5a6f3ea 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -77,6 +77,18 @@ function stripImageTags(text) { return out } +// What the card renders, and the last thing to touch the string before Qt parses +// it. The newline rewrite belongs here rather than in the card because it inserts +// `
` into text stripImageTags chose to KEEP, and a kept tag may hold a `<` of +// its own: `` is one tag named `x` to both the +// stripper and Qt, until the rewrite splits it into `` and a live image tag +// the input never contained. Measured against Qt 6.11.2 — the rewritten form +// fetches, the original does not. So strip again after, and what Qt parses is what +// was checked last. +function styledBody(body, app, appIcon) { + return stripImageTags(sanitizeBody(body, app, appIcon).replace(/\r\n|\r|\n/g, "
")) +} + function sanitizeBody(body, app, appIcon) { var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text @@ -438,6 +450,7 @@ if (typeof module !== "undefined") { module.exports = { isChromiumDerived: isChromiumDerived, sanitizeBody: sanitizeBody, + styledBody: styledBody, summaryStartsWithGlyph: summaryStartsWithGlyph, shouldBypassDnd: shouldBypassDnd, isEphemeralApp: isEphemeralApp, diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index 1171ddc4..64e3870b 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -44,7 +44,7 @@ BorderSurface { readonly property bool singleLineToast: sanitizedBody.length === 0 readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph readonly property string sanitizedBody: sanitizeBody(body) - readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "
") + readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon) readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4) readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15) diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 6dfc54a8..f06e0481 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -44,8 +44,12 @@ function survivingTagNames(text) { return names } +// Assert on styledBody, not sanitizeBody: styledBody is the string the card +// binds to the StyledText, so it is the only one Qt ever parses. Checking the +// sanitizer's output instead would pass a body whose surviving tag the newline +// rewrite later splits open. function assertNoImageSurvives(body, description) { - const out = notifications.sanitizeBody(body, 'Slack', '') + const out = notifications.styledBody(body, 'Slack', '') const names = survivingTagNames(out) assert( !names.includes('img'), @@ -99,6 +103,48 @@ assertNoImageSurvives( 'notifications leave no image tag when U+0085 follows the angle bracket' ) +// The card rewrites newlines to
for the StyledText, which puts tag syntax +// inside a tag the stripper kept: `` is one tag named `x` +// to both the stripper and Qt, and the rewrite splits it into `` and a +// live image tag. Measured against Qt 6.11.2 — the rewritten form issues the GET +// and the original does not — so the strip has to run after the rewrite, which +// is what styledBody() does. +assertNoImageSurvives( + '', + 'notifications leave no image tag when a newline rewrite splits a kept tag' +) + +assertNoImageSurvives( + '', + 'notifications leave no image tag when a CRLF rewrite splits a kept tag' +) + +assertEqual( + notifications.styledBody('', 'Slack', ''), + '', + 'notifications drop the image half of a tag the newline rewrite splits' +) + +// The rewrite itself still happens, and body markup other than images survives it. +assertEqual( + notifications.styledBody('bold\nsecond line', 'Slack', ''), + 'bold
second line', + 'notifications keep body markup and the line break the card renders' +) + +// The order above is only worth anything if the card actually renders it, and no +// JavaScript assertion can see a QML binding. Pin the binding itself: the rewrite +// belongs in the logic module, where the strip runs after it. +const cardQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/components/NotificationCard.qml'), 'utf8') +assert( + /readonly property string styledBody: NotificationLogic\.styledBody\(body, app, appIcon\)/.test(cardQml), + 'the notification card renders the body that was stripped after the newline rewrite' +) +assert( + !//.test(cardQml), + 'the notification card does not rewrite newlines itself, which would leave tag syntax unchecked' +) + assertEqual( notifications.sanitizeBody('trailing Date: Thu, 27 Aug 2026 17:28:13 +0200 Subject: [PATCH 35/73] Close six ways the textFormat scan reported success without checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these is a Text rendering external data with no textFormat, written in a form that passed silently. None exists in this tree, so they were holes in the guard rather than live exposures — but a guard is only worth what it catches, and every one of them is a single line someone could plausibly write. Text /* why */ { strip_noise knew // and not /* */, so a block comment between the type name and its brace hid the element from every rule at once QQ.Text { ... } a namespaced import made the name compare unequal to `Text`, and the element was skipped outright visible: textFormatEnabled textFormat was matched as a substring, so a lookalike property exempted the whole block component Info: a component root with its Text on the next line; Text { the one-line form was covered and this was not an unreadable subdirectory rglob() swallows a directory it cannot enter, so a locked subtree scanned as though it were empty The scan moves out of the heredoc into qml-text-format-scan.py, taking its root as an argument, because nothing could run it over anything but the real tree — and a scanner whose only input always passes cannot be shown to fail. The test now runs it over nineteen fixtures, one per form above and one per form the scan already handled, so a later edit that loosens it fails here instead of going unnoticed until something renders a remote image. Two limits stay open and are written down in the module docstring rather than papered over: text assigned from elsewhere (a Binding element, PropertyChanges, an onCompleted assignment, a property alias onto a child) is invisible to a scanner that reads each element's own declaration, and a regex literal holding a brace throws off the brace depth. Neither shape exists in this tree and both need a QML parser, not another regex. Co-Authored-By: Codex XHigh Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/qml-text-format-scan.py | 373 ++++++++++++++++++++ test/shell.d/qml-text-format-test.sh | 507 +++++++++++++-------------- 2 files changed, 608 insertions(+), 272 deletions(-) create mode 100644 test/shell.d/qml-text-format-scan.py diff --git a/test/shell.d/qml-text-format-scan.py b/test/shell.d/qml-text-format-scan.py new file mode 100644 index 00000000..d405e06c --- /dev/null +++ b/test/shell.d/qml-text-format-scan.py @@ -0,0 +1,373 @@ +"""Report every QML Text that renders a non-literal value without a textFormat. + +Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per +violation, exits 1 on an unreadable tree). Lives in its own file rather than a +heredoc so the test can run it over fixtures and prove it still fails when it +should — a guard nothing can fail is a guard nobody should trust. + +Two limits are deliberate, because a line scanner cannot close them. It reads +each Text element's own declaration, so text assigned from somewhere else — +`Binding { target: label; property: "text" }`, `PropertyChanges`, a +`Component.onCompleted` assignment, a `property alias` onto a child's text — +is invisible to it. And a regex literal containing a brace throws off the brace +depth. Neither shape exists in this tree; both would need a QML parser. +""" + +import os +import re +import sys +from pathlib import Path + +BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S) + + +def strip_block_comments(text): + """Blank out /* */ comments, keeping every newline so line numbers hold. + + strip_noise() only knows `//`, so before this a block comment between a + type name and its brace — `Text /* why */ {` — hid the element from + OPEN_ELEMENT and from the unscannable-form check alike, and the block + passed with no textFormat at all. + """ + out = [] + i = 0 + quote = None + while i < len(text): + c = text[i] + if quote: + if c == '\\': + out.append(text[i:i + 2]) + i += 2 + continue + if c == quote: + quote = None + out.append(c) + i += 1 + continue + if c in '"\'': + quote = c + out.append(c) + i += 1 + continue + if c == '/' and text.startswith('//', i): + end = text.find('\n', i) + if end == -1: + break + out.append(text[i:end]) + i = end + continue + if c == '/' and text.startswith('/*', i): + end = text.find('*/', i + 2) + end = len(text) if end == -1 else end + 2 + out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end])) + i = end + continue + out.append(c) + i += 1 + return ''.join(out) + + +# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is +# the same element and was skipped, because the name compared unequal to `Text`. +TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text' + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + depth += n_open - n_close + if opened and n_open > 0: + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') +# As a property of this block, not as a substring: `visible: root.textFormatEnabled` +# used to read as a declaration and exempt the element. +INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if INLINE_TEXT_FORMAT.search(body): + continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$') + + +def opens_component(lines, start): + """True when the Text block at `start` is a component root declared above it.""" + for back in range(start - 1, -1, -1): + code = strip_noise(lines[back]).strip() + if not code: + continue + return bool(COMPONENT_OPENER.match(lines[back])) + return False + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + +root = Path(sys.argv[1]) +found = [] +scanned = 0 + + +def unreadable(error): + # rglob() swallows a directory it cannot enter, so a shell/ subtree with no + # read permission scanned as though it were empty and the run reported + # success. Same failure as an empty tree, and it fails the same way. + raise SystemExit(f'cannot read {error.filename}: {error.strerror}') + + +qml = [] +for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable): + dirnames.sort() + qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml')) + +for path in sorted(qml): + scanned += 1 + lines = strip_block_comments(path.read_text()).splitlines() + rel = path.relative_to(root) + found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) + + for b in blocks(lines): + if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']: + continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + # `component Info:` may also put its `Text {` on the following line, + # which INLINE_COMPONENT cannot match and which then reads as an + # ordinary nested block with no binding of its own — a caller's dynamic + # text passing in silence. + if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + + if 'text' not in b['props']: + continue + tline = b['props']['text'] + if exempt_as_literal(lines, tline): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + +for line in found: + print(line) diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 0b286a76..4d987ce4 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -18,6 +18,11 @@ # bare string literal. A literal carries no external data, so AutoText has # nothing to promote; this test is what catches the edit that later turns such # a literal into an expression. +# +# The scan itself lives in qml-text-format-scan.py. It is run twice: over the +# real tree, and over the fixtures below, which are the forms that have already +# slipped past it once. A guard nothing can fail is a guard nobody should trust, +# and every one of those fixtures passed silently before it was written down. set -euo pipefail @@ -25,279 +30,9 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command python3 -violations=$(ROOT="$ROOT" python3 <<'PY' -import os -import re -from pathlib import Path +SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py" -OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') -INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') -INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') -PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') -STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') -PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -# A binding that runs onto the next line: this line ends on an operator, or the -# next line opens with one. -TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') -LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') - - -def strip_noise(line, keep_strings=False): - out = [] - i = 0 - quote = None - while i < len(line): - c = line[i] - if quote: - if keep_strings: - out.append(c) - if c == '\\': - if keep_strings and i + 1 < len(line): - out.append(line[i + 1]) - i += 2 - continue - if c == quote: - quote = None - if not keep_strings: - out.append('S') - i += 1 - continue - if c in '"\'': - quote = c - if keep_strings: - out.append(c) - i += 1 - continue - if c == '/' and i + 1 < len(line) and line[i + 1] == '/': - break - out.append(c) - i += 1 - return ''.join(out) - - -def is_pure_literal(expr): - residue = STRING_LITERAL.sub('', expr) - residue = re.sub(r'[\s+]', '', residue) - return residue == '' and STRING_LITERAL.search(expr) is not None - - -def binding_expression(lines, start): - """The whole right-hand side of the binding beginning on line `start`. - - The literal exemption has to be judged on the complete expression. Reading - only the physical `text:` line would exempt `text: "prefix"` while - `+ externalValue` sits underneath, letting a dynamic AutoText binding - through. Reading a wrapped concatenation of literals as dynamic would be - the opposite error, so follow the expression to its end either way. - """ - parts = [] - parens = brackets = 0 - i = start - while i < len(lines): - parts.append(strip_noise(lines[i], keep_strings=True)) - counted = strip_noise(lines[i]) - parens += counted.count('(') - counted.count(')') - brackets += counted.count('[') - counted.count(']') - # Look past blank and comment-only lines for the continuation. A - # comment or a blank line dropped into a wrapped expression does not - # end it, and stopping there would read `text: "prefix"` as the whole - # binding and exempt it as a literal while `+ externalValue` waits - # below — the exact misreading this function exists to prevent. - following = '' - for ahead in range(i + 1, len(lines)): - candidate = strip_noise(lines[ahead]) - if candidate.strip(): - following = candidate - break - continues = (parens > 0 or brackets > 0 - or TRAILING_OPERATOR.search(counted.rstrip()) - or LEADING_OPERATOR.match(following)) - if not continues: - break - i += 1 - - chunk = ' '.join(parts) - return chunk.split(':', 1)[1] if ':' in chunk else chunk - - -def exempt_as_literal(lines, tline): - """True when the binding is only string literals, however many lines.""" - return is_pure_literal(binding_expression(lines, tline)) - - -def blocks(lines): - stack = [] - done = [] - depth = 0 - for idx, raw in enumerate(lines): - code = strip_noise(raw) - opened = OPEN_ELEMENT.search(code) - prop = PROP.match(code) - if (prop and stack and stack[-1]['depth'] == depth - and not opened and not PROPERTY_DECL.match(code)): - stack[-1]['props'].setdefault(prop.group(1), idx) - n_open = code.count('{') - n_close = code.count('}') - depth += n_open - n_close - if opened and n_open > 0: - # OPEN_ELEMENT anchors at the end of the line, so the element it - # matched is the innermost one opened here and its depth is the - # depth after every brace on the line. - stack.append({'name': opened.group(1), 'depth': depth, - 'props': {}, 'start': idx}) - while stack and depth < stack[-1]['depth']: - done.append(stack.pop()) - done.extend(stack) - return done - - -INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') -INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') - - -def inline_violations(lines, rel): - """Whole Text blocks written on one line. - - OPEN_ELEMENT anchors at the end of the line, so the brace scanner never - sees these. A Repeater delegate is a plausible place for one. - """ - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw, keep_strings=True) - for match in INLINE_TEXT.finditer(code): - body = match.group(1) - if 'textFormat' in body: - continue - # A component root written on one line needs the default whether or - # not this line binds `text`, for the same reason the block form - # does: every caller supplies the binding. - if INLINE_COMPONENT_ONELINE.match(code): - out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') - continue - binding = INLINE_BINDING.search(body) - if not binding or is_pure_literal(binding.group(1)): - continue - out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') - return out - - -# `Text { text: someValue` with the block carrying on below is valid QML and is -# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the -# line so the brace tracker never opens the block, and INLINE_TEXT needs the -# closing brace on the same line. A dynamic AutoText binding written that way -# passes this file in silence, which is the one failure a test like this must -# not have. -# -# Rather than teach a line scanner to parse QML, require the two forms it can -# read: the whole block on one line, or nothing after the opening brace. Every -# Text in this tree is already written that way, so keeping to it costs nothing. -UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') -BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') - -UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' - 'opening brace last on the line, or write the whole block on ' - 'one line with no nested braces') - - -def unscannable_violations(lines, rel): - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw) - - # `Text` with its brace on the next line. OPEN_ELEMENT needs both on - # one line, so the block is never opened and everything in it is - # attributed to the enclosing element instead. - if BARE_TEXT_OPENER.search(code): - following = '' - for ahead in range(idx + 1, len(lines)): - candidate = strip_noise(lines[ahead]).strip() - if candidate: - following = candidate - break - if following.startswith('{'): - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - continue - - for match in UNSCANNABLE_TEXT.finditer(code): - # A complete one-line block with no nested braces is fine — - # inline_violations reads those. Count rather than looking for a - # `}`, because `Text { text: ({ a: external }).a }` closes on this - # line yet INLINE_TEXT's brace-free body pattern cannot match it, - # so treating any `}` as "handled elsewhere" would drop it. - rest = code[match.end() - 1:] - depth = 1 - closed = False - for char in rest: - if char == '{': - depth += 1 - elif char == '}': - depth -= 1 - if depth == 0: - closed = True - break - if closed and '{' not in rest: - continue - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - return out - - -root = Path(os.environ['ROOT']) -found = [] -scanned = 0 -for path in sorted((root / 'shell').rglob('*.qml')): - scanned += 1 - lines = path.read_text().splitlines() - rel = path.relative_to(root) - found.extend(inline_violations(lines, rel)) - found.extend(unscannable_violations(lines, rel)) - - for b in blocks(lines): - if b['name'] != 'Text' or 'textFormat' in b['props']: - continue - - # Read the block's own properties. A nested child declaring textFormat - # says nothing about its parent, so `Text { Text { textFormat: ... } }` - # must still report the outer element. - # The root element of a component takes its binding from callers, so it - # needs the default whether or not this file binds `text`. Require both - # depth 1 and column 0: the scanner attributes one element per line, so - # a `Row { Text {` line would report depth 1 for a nested block, and - # falling through to the binding check below is the safe reading. - # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` - # line still reads as `Row` here, so leading whitespace can be ignored - # without letting a nested block be mistaken for the file's root. - if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): - found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') - continue - - # A QML inline component is a root for the same reason, and the rule - # above cannot see one: `component InfoValue: Text {` sits inside - # another element, so its depth is not 1 and its line does not start - # with `Text`. Its `text` comes from every caller, so the file it lives - # in never binds it and the binding check below lets it through in - # silence. Only one file-level root Text exists in this tree, so - # without this the root rule is very nearly dead code. - if INLINE_COMPONENT.match(lines[b['start']]): - found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') - continue - - if 'text' not in b['props']: - continue - tline = b['props']['text'] - if exempt_as_literal(lines, tline): - continue - found.append(f'{rel}:{tline + 1}: text binding without textFormat') - -# A scan that read nothing reports nothing, and an all-clear from a run that -# never opened a file is the one result this test must never give. Only a -# checkout with no shell/ QML at all reaches this. -if scanned == 0: - raise SystemExit('no .qml files found under shell/; the scan read nothing') - -for line in found: - print(line) -PY -) +violations=$(python3 "$SCAN" "$ROOT") if [[ -n $violations ]]; then count=$(printf '%s\n' "$violations" | wc -l) @@ -311,3 +46,231 @@ deliberate, documented feature, and strip before it reaches the renderer." fi pass "every Text with a dynamic text binding declares textFormat" + +# The scanner's own tests. Each fixture is a Text that renders external data +# with no textFormat, written in a form that once passed. `caught` asserts the +# scan reports something; `clean` asserts it does not, so the fixtures prove the +# scanner can fail rather than that it fails at everything. +fixture_root=$(mktemp -d) +trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT + +function scan_fixture { + local name=$1 + local dir="$fixture_root/$name" + mkdir -p "$dir/shell/Ui" + cat > "$dir/shell/Ui/Fixture.qml" + python3 "$SCAN" "$dir" 2>&1 +} + +function caught { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -z $output ]]; then + fail "$description" "the scan reported nothing for fixture $name" + fi + pass "$description" +} + +function clean { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -n $output ]]; then + fail "$description" "the scan reported: $output" + fi + pass "$description" +} + +caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: external + } +} +QML + +clean literal "the scan leaves a string literal alone" <<'QML' +import QtQuick +Item { + Text { + text: "a literal" + } +} +QML + +clean declared "the scan leaves a declared textFormat alone" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + textFormat: Text.PlainText + text: external + } +} +QML + +# strip_noise() knew `//` and not `/* */`, so a block comment between the type +# name and its brace hid the whole element from every rule. +caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML' +import QtQuick +Item { + property string external: "x" + Text /* explanation */ { + text: external + } +} +QML + +caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML' +import QtQuick +Item { + property string external: "x" + /* + * Text { text: "not this one" } + */ + Text { + text: external + } +} +QML + +# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to +# `Text` and was skipped outright. +caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { + text: external + } +} +QML + +# textFormat was matched as a substring, so any property whose name merely +# started that way exempted the element. +caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external } +} +QML + +caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external + color: "red" + } +} +QML + +caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + property bool textFormatEnabled: true + Text { text: external; visible: textFormatEnabled } +} +QML + +# A component root takes its text from every caller, so the file it lives in +# never binds it. The one-line form was covered; this one was not. +caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML' +import QtQuick +Item { + component Info: + Text { + } +} +QML + +caught component-one-line "the scan reads a component root written on one line" <<'QML' +import QtQuick +Item { + component Info: Text { color: "red" } +} +QML + +# Forms the scanner cannot read are reported rather than passed, which is the +# whole reason it can be a line scanner at all. +caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text + { + text: external + } +} +QML + +caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { text: external + color: "red" + } +} +QML + +# A wrapped binding is judged whole: a literal first line says nothing about +# what is concatenated onto it below. +caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: "prefix" + + external + } +} +QML + +clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML' +import QtQuick +Item { + Text { + text: "one" + + "two" + } +} +QML + +# A nested child's textFormat says nothing about its parent. +caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML' +import QtQuick +Text { + text: external.value + Text { + textFormat: Text.PlainText + text: "literal" + } +} +QML + +# A scan that reads less than the tree holds must not report success. Both of +# these once did. +empty_root=$(mktemp -d) +mkdir -p "$empty_root/shell" +if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then + rm -rf "$empty_root" + fail "the scan fails when it reads no files" "an empty shell/ tree exited 0" +fi +rm -rf "$empty_root" +pass "the scan fails when it reads no files" + +blind_root="$fixture_root/blind" +mkdir -p "$blind_root/shell/Ui/locked" +printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml" +printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml" +chmod 000 "$blind_root/shell/Ui/locked" +if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then + chmod 755 "$blind_root/shell/Ui/locked" + fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0" +fi +chmod 755 "$blind_root/shell/Ui/locked" +pass "the scan fails when a directory hides files from it" From 5c336885d22f97e4cb00b2512eea5d3dba74f38e Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Thu, 27 Aug 2026 17:43:39 +0100 Subject: [PATCH 36/73] Harden CUPS printer discovery Run cups-browsed as a locked service account with a dedicated cache and a focused systemd sandbox. Restrict automatic queues to driverless IPP printers, remove wheel from passwordless CUPS administration, replace cups-pdf with Polkit-backed setup, and migrate existing systems safely. Reported-By: Erik Hunstad (Bad Sector Labs) Co-Authored-By: Daybreak Blue --- etc/cups/cups-browsed.conf | 12 +- .../cups-browsed.service.d/10-omarchy.conf | 11 ++ etc/sysusers.d/omarchy-cups-browsed.conf | 1 + install/config/all.sh | 1 + install/config/printing.sh | 83 +++++++++ install/omarchy-base.packages | 2 +- migrations/1787815267.sh | 34 ++++ test/acceptance.d/system-test.sh | 68 ++++++- test/shell.d/cups-hardening-test.sh | 169 ++++++++++++++++++ 9 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 etc/systemd/system/cups-browsed.service.d/10-omarchy.conf create mode 100644 etc/sysusers.d/omarchy-cups-browsed.conf create mode 100644 install/config/printing.sh create mode 100644 migrations/1787815267.sh create mode 100644 test/shell.d/cups-hardening-test.sh diff --git a/etc/cups/cups-browsed.conf b/etc/cups/cups-browsed.conf index ed1bdbad..19863e59 100644 --- a/etc/cups/cups-browsed.conf +++ b/etc/cups/cups-browsed.conf @@ -1,4 +1,8 @@ -# Omarchy override of cups-browsed's shipped config. The only behavioural -# change vs the upstream default (all-commented) is enabling auto-registration -# of remote IPP printers discovered via Avahi/mDNS. -CreateRemotePrinters Yes +# Keep state away from /var/cache/cups, which is writable by the account CUPS +# uses for print filters. cups-browsed is the only writer to this directory. +CacheDir /var/cache/cups-browsed + +# Auto-create queues only for modern driverless IPP printers. Remote queues +# exported by another CUPS server can still be added manually when needed. +CreateIPPPrinterQueues Driverless +CreateRemoteCUPSPrinterQueues No diff --git a/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf new file mode 100644 index 00000000..54107197 --- /dev/null +++ b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf @@ -0,0 +1,11 @@ +[Service] +User=cups-browsed +Group=cups-browsed +CacheDirectory=cups-browsed +CacheDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictSUIDSGID=yes diff --git a/etc/sysusers.d/omarchy-cups-browsed.conf b/etc/sysusers.d/omarchy-cups-browsed.conf new file mode 100644 index 00000000..fa602c18 --- /dev/null +++ b/etc/sysusers.d/omarchy-cups-browsed.conf @@ -0,0 +1 @@ +u cups-browsed - "CUPS printer discovery" / - diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..aa1044f1 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,5 +7,6 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/locate.sh" +run_logged "$OMARCHY_INSTALL/config/printing.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/printing.sh b/install/config/printing.sh new file mode 100644 index 00000000..b99e03c3 --- /dev/null +++ b/install/config/printing.sh @@ -0,0 +1,83 @@ +# cups-browsed manages queues through CUPS and does not need Unix root. Give +# only its locked service account passwordless CUPS administration; interactive +# users go through cups-pk-helper and Polkit instead. +cups_files_conf="${OMARCHY_CUPS_FILES_CONF:-/etc/cups/cups-files.conf}" +cups_browsed_sysusers_conf="${OMARCHY_CUPS_BROWSED_SYSUSERS_CONF:-/etc/sysusers.d/omarchy-cups-browsed.conf}" + +if [[ -f $cups_browsed_sysusers_conf ]]; then + systemd-sysusers "$cups_browsed_sysusers_conf" +fi + +if [[ -L $cups_files_conf ]]; then + echo "Refusing to rewrite symlinked CUPS authorization config: $cups_files_conf" >&2 + false +elif [[ -f $cups_files_conf ]]; then + staged_conf=$(mktemp --tmpdir="${cups_files_conf%/*}" ".${cups_files_conf##*/}.XXXXXX") + + if ! awk ' + NR == FNR { + if ($1 == "SystemGroup") { + for (i = 2; i <= NF; i++) { + if (substr($i, 1, 1) == "#") + break + if ($i != "wheel" && !seen_group[$i]) { + system_groups[++system_group_count] = $i + seen_group[$i] = 1 + } + } + } + next + } + + $1 == "SystemGroup" { + comment_start = index($0, "#") + if (!wrote_system_group) { + printf "SystemGroup" + for (i = 1; i <= system_group_count; i++) + printf " %s", system_groups[i] + if (!seen_group["cups-browsed"]) + printf " cups-browsed" + if (comment_start) + printf " %s", substr($0, comment_start) + print "" + wrote_system_group = 1 + } else if (comment_start) { + print substr($0, comment_start) + } + next + } + + $1 == "PeerCred" { + comment_start = index($0, "#") + if (!saw_peer_cred) { + printf "PeerCred on" + if (comment_start) + printf " %s", substr($0, comment_start) + print "" + } else if (comment_start) { + print substr($0, comment_start) + } + saw_peer_cred = 1 + next + } + + { print } + + END { + if (!wrote_system_group) + print "SystemGroup sys root cups-browsed" + if (!saw_peer_cred) + print "PeerCred on" + } + ' "$cups_files_conf" "$cups_files_conf" >"$staged_conf"; then + rm -f "$staged_conf" + false + fi + + if ! chmod --reference="$cups_files_conf" "$staged_conf" || + ! chown --reference="$cups_files_conf" "$staged_conf" || + ! mv -f "$staged_conf" "$cups_files_conf"; then + rm -f "$staged_conf" + false + fi +fi diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 4097b645..f4c5e2ef 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -19,7 +19,7 @@ cliamp cups cups-browsed cups-filters -cups-pdf +cups-pk-helper ddcutil docker docker-buildx diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh new file mode 100644 index 00000000..c5b89c2f --- /dev/null +++ b/migrations/1787815267.sh @@ -0,0 +1,34 @@ +echo "Separate printer discovery from root and print-filter access" + +machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}" + +[[ ! -e $machine_marker ]] || exit 0 + +# CUPS-PDF accepts a job-controlled post-processing command in a backend that +# CUPS launches as root. Native application print-to-file support replaces it. +omarchy-pkg-drop cups-pdf + +# system-config-printer uses this helper to request printer administration +# through Polkit now that the desktop user's wheel group is no longer @SYSTEM. +if omarchy-pkg-present cups; then + omarchy-pkg-add cups-pk-helper +fi + +cups_browsed_was_active=0 +if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + cups_browsed_was_active=1 + sudo systemctl stop cups-browsed.service +fi + +if omarchy-pkg-present cups; then + sudo env OMARCHY_PATH="$OMARCHY_PATH" \ + bash -euo pipefail "$OMARCHY_PATH/install/config/printing.sh" + sudo systemctl daemon-reload + sudo systemctl try-reload-or-restart cups.service +fi + +if (( cups_browsed_was_active )) && omarchy-pkg-present cups-browsed; then + sudo systemctl restart cups-browsed.service +fi + +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 53b2c1d7..c5c8da5c 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -64,6 +64,72 @@ verify_services() { pass "user audio services are running" } +verify_printing_security() { + local cups_browsed_pid lpinfo_output printer_name printer_process printer_tmp + + ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" + pass "the root CUPS-PDF backend is not installed" + + getent passwd cups-browsed >/dev/null || fail "the cups-browsed service account exists" + [[ $(systemctl show -P User cups-browsed.service) == "cups-browsed" ]] || + fail "cups-browsed runs as its service account" + [[ $(systemctl show -P Group cups-browsed.service) == "cups-browsed" ]] || + fail "cups-browsed runs as its service group" + systemctl is-active --quiet cups-browsed.service || fail "cups-browsed is running" + + cups_browsed_pid=$(systemctl show -P MainPID cups-browsed.service) + [[ -r /proc/$cups_browsed_pid/status ]] || fail "cups-browsed has a readable process status" + [[ $(awk '/^Uid:/{print $2}' "/proc/$cups_browsed_pid/status") != 0 ]] || + fail "cups-browsed does not run with root UID" + [[ $(awk '/^CapEff:/{print $2}' "/proc/$cups_browsed_pid/status") == "0000000000000000" ]] || + fail "cups-browsed has no effective Linux capabilities" + + [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || + fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" + ! id -nG cups-browsed | grep -qw cups || + fail "cups-browsed is separate from the print-filter group" + + if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" + elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" + fi + + pass "CUPS discovery is isolated from root, filters, and passwordless desktop administration" + + # A live driverless printer proves the non-root daemon can still discover and + # create queues without the CAP_NET_BIND_SERVICE Ubuntu carries downstream. + printer_name="OmarchyAcceptancePrinter" + printer_tmp=$(mktemp -d) + printf '#!/bin/bash\nexit 0\n' >"$printer_tmp/command" + chmod 0700 "$printer_tmp/command" + mkdir -m 0700 "$printer_tmp/spool" + + ippeveprinter -p 18631 -d "$printer_tmp/spool" -c "$printer_tmp/command" "$printer_name" \ + >"$printer_tmp/ippeveprinter.log" 2>&1 & + printer_process=$! + + printing_test_cleanup() { + kill "$printer_process" >/dev/null 2>&1 || true + wait "$printer_process" >/dev/null 2>&1 || true + rm -rf "$printer_tmp" + } + trap printing_test_cleanup EXIT + + for _ in {1..30}; do + lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" && break + sleep 1 + done + + lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" || + fail "non-root cups-browsed discovers a driverless IPP printer" "$(<"$printer_tmp/ippeveprinter.log")" + + printing_test_cleanup + trap - EXIT + + pass "non-root cups-browsed still creates driverless IPP queues without capabilities" +} + verify_runtime_tools() { # Docker access is intentionally NOT granted to the desktop user: the docker # group is root-equivalent, so a rogue process running as the user could @@ -107,7 +173,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_defaults verify_services verify_printing_security verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh new file mode 100644 index 00000000..7ba8fea3 --- /dev/null +++ b/test/shell.d/cups-hardening-test.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +packages="$ROOT/install/omarchy-base.packages" +cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf" +sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" +service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" + +grep -qxF cups-browsed "$packages" || fail "cups-browsed remains in the base package set" +grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" +! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" + +pass "the base install keeps discovery and replaces CUPS-PDF with Polkit administration" + +grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || + fail "cups-browsed keeps state outside the print-filter cache" +grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" || + fail "automatic queues are limited to driverless IPP printers" +grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" || + fail "remote CUPS queues are not created automatically" +! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" || + fail "the unsupported CreateRemotePrinters directive is gone" + +pass "cups-browsed uses explicit supported discovery policy and an isolated cache" + +grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" || + fail "a locked cups-browsed system account is declared" + +for setting in \ + 'User=cups-browsed' \ + 'Group=cups-browsed' \ + 'CacheDirectory=cups-browsed' \ + 'CacheDirectoryMode=0750' \ + 'UMask=0027' \ + 'NoNewPrivileges=yes' \ + 'ProtectSystem=strict' \ + 'ProtectHome=yes' \ + 'PrivateTmp=yes' \ + 'RestrictSUIDSGID=yes'; do + grep -qxF "$setting" "$service_dropin" || + fail "cups-browsed service hardening includes $setting" +done + +! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" || + fail "cups-browsed is not granted an unverified network capability" + +pass "cups-browsed runs as its confined service account without added capabilities" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" "$test_tmp/etc/cups" "$test_tmp/var/lib/omarchy/migrations" + +cat >"$mock_bin/systemd-sysusers" <<'SH' +#!/bin/bash +printf 'sysusers\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +cat >"$mock_bin/chown" <<'SH' +#!/bin/bash +printf 'chown\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == "cups" || $1 == "cups-browsed" ]] +SH +for command in omarchy-pkg-add omarchy-pkg-drop; do + cat >"$mock_bin/$command" <<'SH' +#!/bin/bash +printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +done +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +exit 0 +SH +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +exec "$@" +SH +chmod +x "$mock_bin"/* + +authorization_conf="$test_tmp/etc/cups/cups-files.conf" +cat >"$authorization_conf" <<'CONF' +# Keep this custom preamble. +SystemGroup sys root wheel custom-admin wheel # Keep this inline comment. +SystemGroup wheel print-operators # Keep this second inline comment. +PeerCred off # Keep this PeerCred comment. +PeerCred off # Keep this second PeerCred comment. +CONF + +log="$test_tmp/actions.log" +export OMARCHY_CUPS_TEST_LOG="$log" + +run_printing_setup() { + PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" +} + +run_printing_setup + +grep -qxF 'SystemGroup sys root custom-admin print-operators cups-browsed # Keep this inline comment.' "$authorization_conf" || + fail "printing setup reserves CUPS administration for the service account" +grep -qxF '# Keep this second inline comment.' "$authorization_conf" || + fail "printing setup preserves comments from consolidated SystemGroup directives" +grep -qxF 'PeerCred on # Keep this PeerCred comment.' "$authorization_conf" || + fail "printing setup enables peer credentials for the service account" +grep -qxF '# Keep this second PeerCred comment.' "$authorization_conf" || + fail "printing setup preserves comments from duplicate PeerCred directives" +grep -qxF '# Keep this custom preamble.' "$authorization_conf" || + fail "printing setup preserves unrelated CUPS configuration" +[[ $(grep -c '^SystemGroup ' "$authorization_conf") == 1 ]] || + fail "printing setup emits one SystemGroup directive" + +cp "$authorization_conf" "$test_tmp/first-run.conf" +run_printing_setup +cmp -s "$authorization_conf" "$test_tmp/first-run.conf" || + fail "printing setup is idempotent" + +pass "printing setup narrows CUPS authorization without clobbering other configuration" + +ln -s "$authorization_conf" "$test_tmp/etc/cups/symlinked.conf" +if PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$test_tmp/etc/cups/symlinked.conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" 2>/dev/null; then + fail "printing setup refuses a symlinked authorization file" +fi + +pass "printing setup refuses to rewrite a symlinked privileged configuration" + +marker="$test_tmp/var/lib/omarchy/migrations/1787815267" +PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" || + fail "the migration removes CUPS-PDF" +grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" || + fail "the migration installs authenticated printer administration" +grep -qxF $'systemctl\tstop cups-browsed.service' "$log" || + fail "the migration stops the root cups-browsed process before reconfiguration" +grep -qxF $'systemctl\tdaemon-reload' "$log" || + fail "the migration reloads the hardened service" +grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" || + fail "the migration applies narrowed CUPS authorization" +grep -qxF $'systemctl\trestart cups-browsed.service' "$log" || + fail "the migration resumes an active cups-browsed service" +[[ -f $marker ]] || fail "the migration records machine-wide completion" + +actions_after_first_run=$(wc -l <"$log") +PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" +[[ $(wc -l <"$log") == "$actions_after_first_run" ]] || + fail "the machine-wide migration repeats privileged work" + +pass "the migration safely converts an active existing installation once" From 9ece53cede223add78664959a7d807d94f247df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:04:25 +0200 Subject: [PATCH 37/73] Prove the web app name guard, and reject before the icon is fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash guard was the only thing keeping a name out of the directory structure, and nothing tested it: deleting it left the suite green, because creating the launcher directly in the applications directory already makes the redirect fail on its own, with a raw bash error instead of the message. The assertion is on the message now, alongside the traversal case the guard actually closes -- on quattro a name of `../../../../escaped` writes its launcher clean outside the applications directory. The interactive prompt read the name, fetched the favicon, wrote it and updated the icon cache before the name was ever checked, so a URL typed into the Name field left an icon behind on every attempt. Validating as soon as the name is read covers both paths from one place. Removing by name also scanned unconditionally, so a machine with no applications directory printed a find error where omarchy-remove-gaming-xbox-cloud does not hide stderr. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-webapp-install | 22 ++++++---- bin/omarchy-webapp-remove | 2 +- test/shell.d/webapp-name-test.sh | 71 ++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index b7985968..d976622d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -13,6 +13,18 @@ safe_icon_name() { | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//' } +require_plain_name() { + # The name becomes a filename. A slash would turn it into directory levels, so + # the launcher lands somewhere omarchy-webapp-remove cannot address and the app + # is stuck in the launcher; a leading ../ leaves the applications directory + # altogether. Refuse rather than silently renaming what the user typed -- most + # often it is a URL entered in the name field. + if [[ $1 == */* ]]; then + echo "App name cannot contain '/': $1" + exit 1 + fi +} + icon_name_from_ref() { local ref="$1" local name @@ -68,6 +80,7 @@ fetch_site_icon() { if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") + require_plain_name "$APP_NAME" APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then APP_URL="https://$APP_URL" @@ -104,14 +117,7 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi -# The name becomes a filename. A slash would turn it into directory levels, so -# the launcher lands somewhere omarchy-webapp-remove cannot address and the app -# is stuck in the launcher. Refuse rather than silently renaming what the user -# typed -- most often it is a URL entered in the name field. -if [[ $APP_NAME == */* ]]; then - echo "App name cannot contain '/': $APP_NAME" - exit 1 -fi +require_plain_name "$APP_NAME" if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index 303b3a5d..b3244637 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -19,7 +19,7 @@ while IFS= read -r -d '' file; do WEB_APPS+=("$(basename "${file%.desktop}")") WEB_APP_PATHS+=("$file") fi -done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0) +done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0 2>/dev/null) # The launcher matching a chosen name, or empty when nothing was indexed under # it (an app removed between the scan and the pick, say). diff --git a/test/shell.d/webapp-name-test.sh b/test/shell.d/webapp-name-test.sh index cd66d445..903f851a 100644 --- a/test/shell.d/webapp-name-test.sh +++ b/test/shell.d/webapp-name-test.sh @@ -24,16 +24,73 @@ run_remove() { } apps_dir="$tmp_dir/home/.local/share/applications" +icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps" # A URL typed into the name field is the reported way in. Every slash used to -# become a directory level, leaving a launcher nothing could address. -if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then +# become a directory level, leaving a launcher nothing could address. Assert on +# the message: creating the launcher directly in the applications directory +# already makes the redirect fail on its own, so a bare non-zero exit would pass +# just as well with no validation at all. +output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) && fail "webapp install rejects a name containing a slash" -fi +[[ $output == *"App name cannot contain '/'"* ]] || + fail "webapp install says why it refused a slashed name" "$output" [[ -e "$apps_dir/http:" ]] && fail "webapp install does not create a directory from a slashed name" pass "webapp install rejects a name that would nest the launcher" +# The name was a path fragment until something said otherwise, so ../ climbed +# out of the applications directory entirely and wrote wherever it landed. +if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name that climbs out of the applications directory" +fi +[[ -e "$tmp_dir/escaped.desktop" ]] && + fail "webapp install writes no launcher outside the applications directory" +pass "webapp install refuses a name that would escape the applications directory" + +# The interactive prompt reads the name long before it is used as a path, and +# fetches the site icon in between. Rejecting only at the write leaves that icon +# behind in the user's icon theme, once per attempt. +mkdir -p "$tmp_dir/ibin" +cp "$tmp_dir/bin"/* "$tmp_dir/ibin/" +cat >"$tmp_dir/ibin/gum" <<'STUB' +#!/bin/bash +count_file="${GUM_STUB_COUNT:?}" +count=$(cat "$count_file" 2>/dev/null || echo 0) +count=$((count + 1)) +echo "$count" >"$count_file" +if (( count == 1 )); then + echo "http://example.test/oops" +else + echo "https://example.com" +fi +STUB +cat >"$tmp_dir/ibin/curl" <<'STUB' +#!/bin/bash +# Answer any download with a real PNG so the icon fetch reports success. +out="" +prev="" +for arg in "$@"; do + [[ $prev == "-o" ]] && out="$arg" + prev="$arg" +done +if [[ -n $out ]]; then + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out" +fi +STUB +chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl" + +if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \ + GUM_STUB_COUNT="$tmp_dir/gum-count" \ + "$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then + fail "interactive webapp install rejects a name containing a slash" +fi +if compgen -G "$icons_dir/*.png" >/dev/null; then + fail "interactive webapp install downloads no icon for a name it refuses" \ + "$(ls "$icons_dir")" +fi +pass "webapp install refuses a slashed name before fetching its icon" + # A normal name still installs and removes. run_install "Example App" "https://example.com" hey >/dev/null [[ -f "$apps_dir/Example App.desktop" ]] || @@ -59,3 +116,11 @@ run_remove "127.0.0.1:4000" >/dev/null [[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && fail "webapp remove deletes a launcher left nested by an older install" pass "webapp remove reaches a nested legacy launcher" + +# Removing by name on a machine with no applications directory yet must stay +# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr. +noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null) +[[ -n $noise ]] && + fail "webapp remove stays quiet with no applications directory" "$noise" +pass "webapp remove stays quiet when there is no applications directory" From c2587dff086f894055653a047796e5ec19727f3e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 19:45:45 +0200 Subject: [PATCH 38/73] Escape .desktop values with parameter expansion, not sed GNU sed's N auto-prints the pattern space and exits at end of input, so the `:a;N;$!ba` slurp skipped every following s/// for a value with no newline in it. That is every value except the injection attempt the escaping exists to stop, so the Exec quoting's own backslashes were written to the file unescaped and GLib refused to load the entry. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6-sol (xhigh) --- bin/omarchy-webapp-install | 17 +++- test/shell.d/webapp-install-escaping-test.sh | 92 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100755 test/shell.d/webapp-install-escaping-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index e4f0039d..f95d9ff2 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -70,9 +70,20 @@ desktop_string_escape() { # a raw newline would start a new key line and let a value inject a second # Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value # written into the .desktop file passes through here. - printf '%s' "$1" \ - | sed -e ':a;N;$!ba' \ - -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\n/\\n/g' -e 's/^ /\\s/' + # + # Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space + # and exits at end of input, so a `:a;N;$!ba` slurp skips every following s/// + # for a value with no newline in it - which is every value except the injection + # attempt this exists to stop. + local value="$1" + + value=${value//\\/\\\\} + value=${value//$'\t'/\\t} + value=${value//$'\r'/\\r} + value=${value//$'\n'/\\n} + [[ $value == " "* ]] && value="\\s${value# }" + + printf '%s' "$value" } desktop_exec_arg() { diff --git a/test/shell.d/webapp-install-escaping-test.sh b/test/shell.d/webapp-install-escaping-test.sh new file mode 100755 index 00000000..6f1e0c70 --- /dev/null +++ b/test/shell.d/webapp-install-escaping-test.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command gio + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-launch-webapp" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >>"$OMARCHY_TEST_ARGV" +SH +chmod +x "$mock_bin"/* + +export HOME="$test_tmp/home" +export PATH="$mock_bin:$PATH" +export OMARCHY_TEST_ARGV="$test_tmp/argv" + +applications="$HOME/.local/share/applications" + +install_webapp() { + bash "$ROOT/bin/omarchy-webapp-install" "$@" >/dev/null +} + +desktop_value() { + sed -n "s/^$2=//p" "$1" | head -1 +} + +# gio launch returns before the entry it spawned has run, so poll for the argv the +# stub records rather than reading the log once. +launched_argument() { + local file="$1" attempt + + : >"$OMARCHY_TEST_ARGV" + gio launch "$file" >/dev/null 2>&1 || return 1 + for ((attempt = 0; attempt < 200; attempt++)); do + [[ -s $OMARCHY_TEST_ARGV ]] && break + sleep 0.01 + done + + head -1 "$OMARCHY_TEST_ARGV" +} + +# The Exec quoting escapes a dollar sign with a backslash, and the file syntax has +# to escape that backslash in turn. Left single, GLib reads \$ as an invalid escape +# and refuses the whole entry, so the web app vanishes from the launcher. +install_webapp 'Dollar App' 'https://example.com/a$b' someicon +dollar_file="$applications/Dollar App.desktop" + +[[ -f $dollar_file ]] || fail "web app install writes a desktop entry" + +[[ $(desktop_value "$dollar_file" Exec) == 'omarchy-launch-webapp "https://example.com/a\\$b"' ]] || + fail "Exec escapes the backslash its own quoting introduced" "$(desktop_value "$dollar_file" Exec)" +pass "Exec escapes the backslash its own quoting introduced" + +[[ $(launched_argument "$dollar_file") == 'https://example.com/a$b' ]] || + fail "a URL containing a dollar sign reaches the browser unchanged" +pass "a URL containing a dollar sign reaches the browser unchanged" + +# An unescaped % is read as a Desktop Entry field code and eaten, so ?q=a%20b used +# to arrive as ?q=a0b. +install_webapp 'Percent App' 'https://example.com/s?q=a%20b' someicon +percent_file="$applications/Percent App.desktop" + +[[ $(launched_argument "$percent_file") == 'https://example.com/s?q=a%20b' ]] || + fail "a percent-encoded URL reaches the browser unchanged" +pass "a percent-encoded URL reaches the browser unchanged" + +# A lone backslash is not a Desktop Entry escape sequence, so GLib cannot interpret +# a value that contains one. +install_webapp 'Back\slash App' 'https://example.com' someicon +backslash_file="$applications/Back\slash App.desktop" + +[[ $(desktop_value "$backslash_file" Name) == 'Back\\slash App' ]] || + fail "a backslash in the app name is escaped" "$(desktop_value "$backslash_file" Name)" +pass "a backslash in the app name is escaped" + +# The property the escaping exists for: a newline in a value must not be able to +# start a second key line. +inject_name=$(printf 'Inject\nExec=evil') +install_webapp "$inject_name" 'https://example.com' someicon +inject_file="$applications/$inject_name.desktop" + +(( $(grep -c '^Exec=' "$inject_file") == 1 )) || + fail "a newline in the app name cannot inject a second Exec" "$(cat "$inject_file")" +pass "a newline in the app name cannot inject a second Exec" From 521f1ae9acfd01a62d11ef8cd8b9479b2f9d6e9a Mon Sep 17 00:00:00 2001 From: omabot Date: Thu, 27 Aug 2026 19:45:27 +0200 Subject: [PATCH 39/73] Resume cups-browsed on whether it is enabled, not on whether it was running The migration recomputed whether cups-browsed was active at the start of every run, after an earlier run may already have stopped it. A run interrupted between that stop and the completion marker left the service down, and the retry that followed read it as inactive, skipped the restart, and wrote the marker anyway: printer discovery stayed off until the next reboot with no migration left to bring it back. Keying the restart to whether the unit is enabled survives the interruption, and reports not-enabled for a unit the user masked or disabled, which restarting would fail on and abort the migration short of its marker. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- migrations/1787815267.sh | 9 +++-- test/shell.d/cups-hardening-test.sh | 55 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh index c5b89c2f..31a8e797 100644 --- a/migrations/1787815267.sh +++ b/migrations/1787815267.sh @@ -14,9 +14,8 @@ if omarchy-pkg-present cups; then omarchy-pkg-add cups-pk-helper fi -cups_browsed_was_active=0 +# Stop the root-running daemon before changing the authorization it relies on. if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then - cups_browsed_was_active=1 sudo systemctl stop cups-browsed.service fi @@ -27,7 +26,11 @@ if omarchy-pkg-present cups; then sudo systemctl try-reload-or-restart cups.service fi -if (( cups_browsed_was_active )) && omarchy-pkg-present cups-browsed; then +# Resume on whether the unit is enabled, not on whether it was running when this +# run started: an interrupted earlier run leaves it stopped, and a retry that +# recomputed that would skip the restart and still write the marker below. A +# masked or disabled unit reports not-enabled and is left alone. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then sudo systemctl restart cups-browsed.service fi diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 7ba8fea3..f5c92e44 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -167,3 +167,58 @@ PATH="$mock_bin:$PATH" \ fail "the machine-wide migration repeats privileged work" pass "the migration safely converts an active existing installation once" + +# An interrupted earlier run leaves cups-browsed stopped, so the retry that +# follows finds it inactive. It must still be restarted: the retry records the +# machine-wide marker either way, so a restart skipped here would leave printer +# discovery off until the next reboot with nothing left to run. +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +[[ $1 == "is-active" ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/systemctl" + +retry_log="$test_tmp/retry.log" +retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry" + +OMARCHY_CUPS_TEST_LOG="$retry_log" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" || + fail "the retry resumes cups-browsed after an interrupted earlier run" + +pass "a run following an interrupted one still resumes printer discovery" + +# A unit the user masked or disabled reports not-enabled, and restarting it +# would fail and abort the migration before it records completion. +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +[[ $1 == "is-active" || $1 == "is-enabled" ]] && exit 1 +exit 0 +SH +chmod +x "$mock_bin/systemctl" + +masked_log="$test_tmp/masked.log" +masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked" + +OMARCHY_CUPS_TEST_LOG="$masked_log" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +! grep -qxF $'systemctl\trestart cups-browsed.service' "$masked_log" || + fail "the migration leaves a masked or disabled cups-browsed alone" +[[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" + +pass "a masked or disabled cups-browsed is left alone and does not fail the migration" From 2b923cf5bd8cbde76eeef1b21354c399aa7f99b7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 19:52:37 +0200 Subject: [PATCH 40/73] Pin the theme name check to C, and read a colon before any slash as scp-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bracket range is collated, not ASCII, so `[a-z]` admits `é` under en_US.UTF-8 and refuses it under C: the same theme URL installed on one desktop and was refused on the next, and the manual's stated set was only true in the C locale. Pinning the comparison makes the written set the shipped one everywhere. git reads a URL as scp-style when a colon appears before any slash, so the path after it need not contain one. Requiring a slash left the prefix on `git@host:omarchy-blue-theme.git` and derived the name `git@host:omarchy-blue`, which the allowlist then refuses -- a repo that clones fine could no longer be installed at all. Co-Authored-By: Codex XHigh --- bin/omarchy-theme-install | 11 ++++++-- test/shell.d/theme-install-guards-test.sh | 34 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index 6b442678..b324fe55 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -23,9 +23,12 @@ omarchy-git-url-check "$REPO_URL" || exit 1 THEMES_DIR="$HOME/.config/omarchy/themes" -# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path +# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path. +# git reads a URL as scp-style when a colon appears before any slash, so the path +# after it need not hold one: `git@host:omarchy-blue-theme.git` is a repo in that +# user's home, and leaving its prefix on names the theme after the whole URL. REPO_PATH="$REPO_URL" -[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" +[[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}" THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_PATH="$THEMES_DIR/$THEME_NAME" @@ -37,7 +40,9 @@ THEME_PATH="$THEMES_DIR/$THEME_NAME" # ~/.config/omarchy with it, and one called `a';'id` would carry its own # command into that picker. The leading character is kept out of `.` and `-`, # which also covers `host:-s/foo.git` leaving basename with `.git`. -if [[ ! $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]; then +# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in +# `é` under en_US.UTF-8. Pin the locale so the set is the one written here. +if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); then echo "Error: '$REPO_URL' does not give a usable theme name." exit 1 fi diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index 2b29bba8..6c6044d0 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -135,6 +135,40 @@ grep -Fq "/themes/_private" "$git_calls" || pass "a plus and a leading underscore are still usable theme names" +# git reads a colon before any slash as the scp-style separator, so the path +# after it does not have to hold one. Without that reading, the whole URL becomes +# the theme name and the allowlist above refuses a repo that clones fine. +install_theme "git@example.com:omarchy-blue-theme.git" || + fail "omarchy-theme-install accepts a home-relative scp-style URL" +grep -Fq "/themes/blue" "$git_calls" || + fail "omarchy-theme-install names the theme after the repo, not the whole URL" "$(cat "$git_calls")" + +# A colon that is part of a local path, not an scp separator, keeps its prefix. +install_theme "/srv/git:mirrors/omarchy-blue-theme.git" || + fail "omarchy-theme-install accepts a local path holding a colon" +grep -Fq "/themes/blue" "$git_calls" || + fail "omarchy-theme-install reads a colon after a slash as part of the path" "$(cat "$git_calls")" + +pass "an scp-style URL with no slash after the colon still names the theme" + +# The allowlist is a bracket range, and a range follows the locale's collation +# rather than ASCII: under en_US.UTF-8 an unpinned `[a-z]` takes in `é`, so the +# same URL would install on one desktop and be refused on the next. +if locale -a 2>/dev/null | grep -qix 'en_US.utf-\?8'; then + for locale_name in C en_US.UTF-8; do + if LC_ALL=$locale_name install_theme "https://github.com/example/omarchy-café-theme.git"; then + fail "omarchy-theme-install refuses a non-ASCII theme name under LC_ALL=$locale_name" "$(cat "$git_calls")" + fi + + [[ ! -s $git_calls ]] || + fail "omarchy-theme-install refuses a non-ASCII name before running git" "$(cat "$git_calls")" + done + + pass "the accepted set does not move with the desktop's locale" +else + pass "no en_US.UTF-8 locale; skipping the locale-pinning check" +fi + # basename reads a leading dash as an option once the scp-style prefix is gone. install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" From 68fc0cf6e6a12c7774b35b1874d0344a61ddabd9 Mon Sep 17 00:00:00 2001 From: omabot Date: Thu, 27 Aug 2026 19:55:36 +0200 Subject: [PATCH 41/73] Match the CUPS directives being rewritten the way cupsd reads them cupsd compares directive names with _cups_strcasecmp, so a hand-edited "systemgroup sys root wheel" is live configuration, but matching $1 against the canonical spelling skipped it and appended a second directive at the end of the file. parse_groups accumulates the groups of every SystemGroup directive it reads rather than replacing them, so both lines took effect and wheel kept the passwordless administration this is meant to remove, with the migration reporting success. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- install/config/printing.sh | 6 +++--- test/shell.d/cups-hardening-test.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/install/config/printing.sh b/install/config/printing.sh index b99e03c3..31c969ea 100644 --- a/install/config/printing.sh +++ b/install/config/printing.sh @@ -16,7 +16,7 @@ elif [[ -f $cups_files_conf ]]; then if ! awk ' NR == FNR { - if ($1 == "SystemGroup") { + if (tolower($1) == "systemgroup") { for (i = 2; i <= NF; i++) { if (substr($i, 1, 1) == "#") break @@ -29,7 +29,7 @@ elif [[ -f $cups_files_conf ]]; then next } - $1 == "SystemGroup" { + tolower($1) == "systemgroup" { comment_start = index($0, "#") if (!wrote_system_group) { printf "SystemGroup" @@ -47,7 +47,7 @@ elif [[ -f $cups_files_conf ]]; then next } - $1 == "PeerCred" { + tolower($1) == "peercred" { comment_start = index($0, "#") if (!saw_peer_cred) { printf "PeerCred on" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index f5c92e44..2f0bc96a 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -222,3 +222,31 @@ OMARCHY_CUPS_TEST_LOG="$masked_log" \ [[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" pass "a masked or disabled cups-browsed is left alone and does not fail the migration" + +# cupsd compares directive names case-insensitively, so a hand-edited lowercase +# directive is live configuration. Matching it exactly would skip the line and +# append a second one, and cupsd accumulates the groups of every SystemGroup +# directive it reads -- leaving wheel with passwordless administration. +lowercase_conf="$test_tmp/etc/cups/lowercase.conf" +cat >"$lowercase_conf" <<'CONF' +systemgroup sys root wheel +peercred off +CONF + +PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$lowercase_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" + +! grep -qiE '^[[:space:]]*systemgroup\b.*\bwheel\b' "$lowercase_conf" || + fail "printing setup removes wheel from a lowercase SystemGroup directive" "$(cat "$lowercase_conf")" +[[ $(grep -ciE '^[[:space:]]*systemgroup\b' "$lowercase_conf") == 1 ]] || + fail "printing setup leaves one SystemGroup directive whatever case it was written in" "$(cat "$lowercase_conf")" +grep -qxF 'SystemGroup sys root cups-browsed' "$lowercase_conf" || + fail "printing setup reserves administration for the service account" "$(cat "$lowercase_conf")" +[[ $(grep -ciE '^[[:space:]]*peercred\b' "$lowercase_conf") == 1 ]] || + fail "printing setup leaves one PeerCred directive" "$(cat "$lowercase_conf")" +grep -qxF 'PeerCred on' "$lowercase_conf" || + fail "printing setup enables peer credentials whatever case they were written in" "$(cat "$lowercase_conf")" + +pass "printing setup rewrites directives cupsd reads case-insensitively" From b07374f03c72bb841c2e8dfe1e93b8a76a5ea737 Mon Sep 17 00:00:00 2001 From: omabot Date: Thu, 27 Aug 2026 19:55:36 +0200 Subject: [PATCH 42/73] Reserve the cups-browsed account name at install The username prompt already refuses the service accounts a desktop user must not claim, cups and lp among them. A user who took cups-browsed would get a primary group of that name, and the CUPS authorization written here puts that group in SystemGroup, handing that desktop user the passwordless administration the rest of this change removes. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- install/provisioning/setup-form.sh | 2 +- test/shell.d/setup-form-test.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/install/provisioning/setup-form.sh b/install/provisioning/setup-form.sh index 909668eb..f4265d1d 100644 --- a/install/provisioning/setup-form.sh +++ b/install/provisioning/setup-form.sh @@ -79,7 +79,7 @@ Turkish|trq Ukrainian|ua' OMARCHY_USERNAME_PATTERN='^[a-z_][a-z0-9_-]*[$]?$' -OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' +OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|cups-browsed|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' OMARCHY_HOSTNAME_DEFAULT='omarchy' diff --git a/test/shell.d/setup-form-test.sh b/test/shell.d/setup-form-test.sh index 4c797e4f..2b9878eb 100755 --- a/test/shell.d/setup-form-test.sh +++ b/test/shell.d/setup-form-test.sh @@ -144,11 +144,12 @@ pass "keyboard prompt propagates Esc and Ctrl+C without dying under set -e" # Username -TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:dhh" "0:david" +TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:cups-browsed" "0:dhh" "0:david" assert_status 0 "username prompt accepts a valid name" [[ $(field username) == "david" ]] || fail "username prompt keeps re-asking until the name is valid" assert_notices "username prompt explains each rejection" "Username must be alphanumeric with no spaces Username is reserved for system +Username is reserved for system That username already exists on this machine" pass "username prompt rejects malformed, reserved, and taken names" From 93824100269bb1b08e85fe86f103b40d5caaf611 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 20:10:15 +0200 Subject: [PATCH 43/73] Validate the web app URL before anything fetches it The scheme check ran after the interactive branch, which had already handed the URL to curl: a refused `file://` or `ftp://` URL was dereferenced first, the whole string went out to Google's favicon endpoint in a `domain=` query, and a fetch that happened to succeed left an orphan icon and a rebuilt icon cache behind. Validating immediately after normalization puts the refusal ahead of every dereference in both branches. A leading space also kept the URL out of the scheme test entirely. `normalize_webapp_url` saw no scheme, prefixed it, and ` file:///etc/passwd` became `https:// file:///etc/passwd`, which passes `^https?://`. The desktop `Exec` field is unquoted, so it splits back into two arguments, and `omarchy-launch-webapp` forwards everything after the first to the browser, where a bare argument is another URL to open. Refusing whitespace closes that without touching desktop-entry escaping. Scheme comparison is case-insensitive because schemes are: `HTTPS://example.com` installed before this check existed and has no reason to stop. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- bin/omarchy-webapp-install | 20 +++++++--- test/shell.d/webapp-install-test.sh | 60 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index 308f8841..ebf6ea39 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -54,13 +54,21 @@ normalize_webapp_url() { printf '%s' "$url" } +# A space in the URL becomes a second desktop Exec argument, which +# omarchy-launch-webapp hands to the browser as another URL to open. Schemes +# are case-insensitive. require_http_url() { local url=$1 - if [[ $url =~ ^https?:// ]]; then - return 0 + + if [[ $url =~ [[:space:]] ]]; then + echo "Error: web app URL must not contain whitespace." >&2 + exit 1 + fi + + if [[ ! ${url,,} =~ ^https?:// ]]; then + echo "Error: web app URL must be http or https." >&2 + exit 1 fi - echo "Error: web app URL must be http or https." >&2 - exit 1 } fetch_site_icon() { @@ -91,6 +99,7 @@ if (( $# < 3 )); then APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") APP_URL=$(normalize_webapp_url "$APP_URL") + require_http_url "$APP_URL" # Try to fetch the site's icon automatically first. mkdir -p "$ICON_DIR" @@ -108,6 +117,7 @@ if (( $# < 3 )); then else APP_NAME="$1" APP_URL=$(normalize_webapp_url "$2") + require_http_url "$APP_URL" ICON_REF="$3" CUSTOM_EXEC="$4" # Optional custom exec command MIME_TYPES="$5" # Optional mime types @@ -120,8 +130,6 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi -require_http_url "$APP_URL" - if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" diff --git a/test/shell.d/webapp-install-test.sh b/test/shell.d/webapp-install-test.sh index f436a2d3..f845d928 100644 --- a/test/shell.d/webapp-install-test.sh +++ b/test/shell.d/webapp-install-test.sh @@ -58,3 +58,63 @@ for url in "javascript:alert(1)" "file:///etc/passwd" "data:text/html,hi" "ftp:/ [[ ! -e $(desktop_for Bad) ]] || fail "webapp install does not write a desktop file for '$url'" done pass "webapp install refuses non-http(s) URLs" + +# A leading space keeps the URL out of the scheme test, and the desktop Exec +# field splits it back into two arguments the browser both opens. +for url in " javascript:alert(1)" " file:///etc/passwd" "https://example.com data:text/html,hi"; do + if install_webapp "Sneak" "$url" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "webapp install refuses whitespace in '$url'" "$(cat "$(desktop_for Sneak)")" + fi + grep -Fq 'must not contain whitespace' "$tmpdir/err" || + fail "webapp install names the whitespace refusal for '$url'" "$(cat "$tmpdir/err")" + [[ ! -e $(desktop_for Sneak) ]] || fail "webapp install writes no desktop file for '$url'" +done +pass "webapp install refuses a URL carrying whitespace" + +# Schemes are case-insensitive, and HTTPS://example.com installed before the +# scheme test existed. +if install_webapp "Upper" "HTTPS://example.com" "webapp" >"$tmpdir/out" 2>"$tmpdir/err"; then + : +else + fail "webapp install accepts an uppercase scheme" "$(cat "$tmpdir/err")" +fi +grep -Fxq 'Exec=omarchy-launch-webapp HTTPS://example.com' "$(desktop_for Upper)" || + fail "webapp install keeps the uppercase scheme" "$(cat "$(desktop_for Upper)")" +pass "webapp install accepts an uppercase http scheme" + +# The interactive prompt fetches the site's icon, so a refused URL must be +# refused before anything dereferences it. +stubs="$tmpdir/stubs" +mkdir -p "$stubs" + +cat >"$stubs/gum" <<'GUM' +#!/bin/bash +count=$(cat "$GUM_COUNT" 2>/dev/null || echo 0) +count=$((count + 1)) +printf '%s\n' "$count" >"$GUM_COUNT" +sed -n "${count}p" "$GUM_ANSWERS" +GUM + +cat >"$stubs/curl" <<'CURL' +#!/bin/bash +printf '%s\n' "$*" >>"$CURL_LOG" +exit 1 +CURL + +chmod +x "$stubs/gum" "$stubs/curl" + +printf 'Evil\nfile:///etc/passwd\n' >"$tmpdir/answers" +: >"$tmpdir/gum-count" +: >"$tmpdir/curl-log" + +if GUM_ANSWERS="$tmpdir/answers" GUM_COUNT="$tmpdir/gum-count" CURL_LOG="$tmpdir/curl-log" \ + PATH="$stubs:$PATH" HOME="$home" "$ROOT/bin/omarchy-webapp-install" \ + >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "interactive webapp install refuses a file: URL" "$(cat "$tmpdir/out")" +fi +grep -Fq 'must be http or https' "$tmpdir/err" || + fail "interactive webapp install names the scheme refusal" "$(cat "$tmpdir/err")" +[[ ! -s $tmpdir/curl-log ]] || + fail "interactive webapp install refuses before fetching the URL" "$(cat "$tmpdir/curl-log")" +[[ ! -e $(desktop_for Evil) ]] || fail "interactive webapp install writes no desktop file" +pass "interactive webapp install refuses a bad URL before fetching it" From 9d8c0176d172a4397b560aae7b8fa751aac586dd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 21:40:31 +0200 Subject: [PATCH 44/73] Make the cache tests fail when either check is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither half of the validation was covered. Dropping `&& -c $cached` from the wrapper left the whole file green: all three poison values fail on the pathname prefix, so none of them ever reached the character-device test. A path that matches the hiddev glob but is not a device now covers it, and it is the real case rather than a synthetic one -- the display replugs, the interface renumbers, and the cached node is gone. It is added only when the host has no such node, so a machine with the display attached cannot fail there spuriously. The no-XDG_RUNTIME_DIR assertion had the same problem for the opposite reason: its decoy held a path the validation rejects on its own, so restoring the `${XDG_RUNTIME_DIR:-/tmp}` fallback left it passing. It asserts on the open now instead of on the contents -- a FIFO with no writer blocks whoever opens it, so a wrapper that consults the path hangs and one that ignores it exits. mkfifo is atomic and fails outright if the path is taken, so it still neither overwrites a file nor follows a symlink at the fixed path. Clearing created_tmp_cache as soon as the decoy is removed keeps this run's EXIT trap from deleting a concurrent run's decoy at the same fixed path, which would have let that run pass against the old code. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- .../brightness-display-apple-cache-test.sh | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/test/shell.d/brightness-display-apple-cache-test.sh b/test/shell.d/brightness-display-apple-cache-test.sh index c3ac8d53..18b65cd0 100755 --- a/test/shell.d/brightness-display-apple-cache-test.sh +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -82,7 +82,19 @@ cache_file="$xdg_dir/omarchy-brightness-display-apple.device" regular_file="$TMPDIR/not-a-device" : >"$regular_file" -for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do +poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil") + +# The cases above all fail on the pathname prefix, so none of them reaches the -c +# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path +# that matches the hiddev glob but is not a character device is what -c is for, +# and it is the realistic stale cache: the display replugs, the interface +# renumbers, and the cached node is simply gone. Add it only when the host really +# has no such node, so a machine with the display attached cannot fail here. +if [[ ! -e /dev/hiddev999 ]]; then + poisons+=("/dev/hiddev999") +fi + +for poison in "${poisons[@]}"; do printf '%s\n' "$poison" >"$cache_file" output=$(run_wrapper "$xdg_dir" "+5%") if grep -qF -- "$poison -- +5%" "$asd_log"; then @@ -91,10 +103,10 @@ for poison in "/dev/null" "$regular_file" "/tmp/omarchy-evil"; do done pass "wrapper rejects a cached path that is not a hiddev character device" -# NOTE: the complementary arm (a cache value that DOES match /dev/hiddev* but is -# not a character device) cannot be built without root -- only real device nodes -# live under /dev. It is covered by the -c test and exercised below only when a -# real hiddev node happens to be present. +# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path +# that does not exist. The remaining arm -- a path under /dev that exists, matches +# the glob, and is not a character device -- cannot be built without root, since +# only real device nodes live there. # --- A legitimate cached hiddev node is trusted (only where HW is present) ---- real_hiddev="" @@ -115,20 +127,25 @@ else fi # --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------ -# Create the decoy atomically with noclobber (O_EXCL) instead of check-then-create: -# this refuses to overwrite an existing file or follow a symlink at the fixed path, -# closing the TOCTOU/symlink race. The fixed path is required -- it is exactly the -# path the old code would have formed, so a decoy anywhere else would prove nothing. -# If the path is already taken, skip rather than touch it; the EXIT trap removes the -# decoy only when this test created it. -if ( set -C; printf '%s\n' "/dev/null" >"$tmp_cache" ) 2>/dev/null; then +# Assert on the open, not on the contents. A decoy holding a rejectable path proves +# nothing: the validation above refuses it whether or not the /tmp fallback is still +# there, so that assertion passes against both wrappers. A FIFO with no writer blocks +# whoever opens it, so a wrapper that consults the path hangs and one that ignores it +# exits -- which separates the two. mkfifo is atomic and fails outright if the path is +# taken, so it neither overwrites a file nor follows a symlink; the fixed path is +# required, being exactly the path the old code would have formed. Clear the flag as +# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this +# run's EXIT trap. +if mkfifo "$tmp_cache" 2>/dev/null; then created_tmp_cache=1 - output=$(run_wrapper "" "+5%") - used=1 - grep -qF -- "/dev/null -- +5%" "$asd_log" || used=0 + status=0 + env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \ + timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$? rm -f "$tmp_cache" - (( used == 0 )) || - fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" "$output" + created_tmp_cache=0 + (( status != 124 )) || + fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \ + "it blocked reading the FIFO decoy at $tmp_cache" pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset" else pass "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case" From d1845245d3c7441270f4da91e0032281486c2fd9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 21:40:31 +0200 Subject: [PATCH 45/73] Unquote the new variables inside [[ ]] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md asks for unquoted variables inside `[[ ]]`, with quotes reserved for string literals being compared. The three conditions added here quoted them. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- bin/omarchy-brightness-display-apple | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 1027686d..c9cce2ff 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -8,7 +8,7 @@ # caching (detect every run) rather than fall back to a predictable, world-writable # /tmp path another user could pre-create. device_cache="" -if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then +if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then device_cache="$XDG_RUNTIME_DIR/omarchy-brightness-display-apple.device" fi no_osd=0 @@ -34,7 +34,7 @@ find_apple_display_device() { local cached="" local device="" - if [[ -n "$device_cache" && -r $device_cache ]]; then + if [[ -n $device_cache && -r $device_cache ]]; then read -r cached <"$device_cache" || true # Trust a cached value only if it still names a hiddev character device. A # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and @@ -50,7 +50,7 @@ find_apple_display_device() { device="$(detect_apple_display_device)" || return 1 [[ -n $device ]] || return 1 - if [[ -n "$device_cache" ]]; then + if [[ -n $device_cache ]]; then printf '%s\n' "$device" >"$device_cache" fi printf '%s\n' "$device" From a165185a3f48b2344f89d2e5f14a8f3e9e02ffdd Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 27 Aug 2026 23:53:44 +0100 Subject: [PATCH 46/73] [Security] Complete Windows VM mount hardening --- bin/omarchy-windows-vm | 835 ++++++++++++++---- manual/28-windows-vm.md | 6 +- test/shell.d/windows-vm-compose-test.sh | 426 +++++++-- .../shell.d/windows-vm-mount-boundary-test.sh | 168 ++++ 4 files changed, 1194 insertions(+), 241 deletions(-) create mode 100644 test/shell.d/windows-vm-mount-boundary-test.sh diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index c22c4815..0676b770 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -28,6 +28,23 @@ LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" CREDENTIALS_FILE="$HOME/.config/windows/credentials" IMAGE="dockurr/windows" CONTAINER="omarchy-windows" +VM_LOCK_DIR=/run/lock/omarchy-windows-vm +# Removal is the only path that recursively proves there are no bind aliases. +# Bound every metadata walk so a large or hostile caller tree fails closed +# instead of hanging a privileged action indefinitely. +TREE_SCAN_TIMEOUT_SECONDS=5 +TREE_SCAN_KILL_AFTER_SECONDS=1 +TREE_SCAN_TIMEOUT=/usr/bin/timeout +TREE_SCAN_FIND=/usr/bin/find + +# The privileged process must not resolve mount, stat, Docker, or any other +# helper from a caller-controlled PATH, and validation must not change with an +# inherited locale. pkexec normally sanitizes both; pin them here as defense in +# depth for every direct __priv entry as well. +if ((EUID == 0)); then + export PATH=/usr/bin:/usr/sbin:/bin:/sbin + export LC_ALL=C.UTF-8 +fi # --- privilege helpers ------------------------------------------------------- @@ -45,17 +62,19 @@ docker_needs_sudo() { omarchy-sudo-docker; } # prompt the user grants for the trusted helper could run an attacker's binary # as root. Fails closed (empty output) when no trustworthy target is found. priv_target() { - local candidate owner mode - for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do - [[ -n $candidate && -x $candidate ]] || continue - owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue - mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue - [[ $owner == "0" ]] || continue - ((8#$mode & 022)) && continue # writable by group or other -> reject - printf '%s\n' "$candidate" - return 0 + local candidate=/usr/bin/omarchy-windows-vm canonical probe owner mode + [[ -f $candidate && ! -L $candidate && -x $candidate ]] || return 1 + canonical=$(realpath -e -- "$candidate" 2>/dev/null) || return 1 + [[ $canonical == "$candidate" ]] || return 1 + probe=$candidate + while :; do + owner=$(stat -Lc '%u' "$probe" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$probe" 2>/dev/null) || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || return 1 + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") done - return 1 + printf '%s\n' "$candidate" } # Run a privileged VM action. write_compose always elevates (the compose is @@ -68,11 +87,15 @@ priv() { local action="$1" shift if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then - # Existing installs used bind sources in $HOME. One privileged run is - # needed to atomically move those directories below the root-owned mount - # boundary, even when the caller can otherwise reach Docker directly. - if [[ $action != up && $action != up_wait ]] || ! compose_needs_mount_migration; then - "__priv_$action" "$@" + # Bring-up normally runs directly for a docker-group user, but recreating + # the protected bind anchors after reboot (or migrating an old compose) + # still needs one privileged invocation. + if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { + [[ $action != up && $action != up_wait ]] || { + ! compose_needs_mount_migration && mounts_ready >/dev/null 2>&1 + } + }; then + with_vm_lock "__priv_$action" "$@" return fi fi @@ -86,6 +109,28 @@ priv() { dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } +with_vm_lock() { + local fd rc=0 + if ((EUID == 0)); then + assert_boundary_dir /run 0 && assert_boundary_dir /run/lock 0 || return 1 + if getent group docker >/dev/null 2>&1; then + install -d -o root -g docker -m 0750 -- "$VM_LOCK_DIR" || return 1 + else + install -d -o root -g root -m 0700 -- "$VM_LOCK_DIR" || return 1 + fi + fi + assert_boundary_dir "$VM_LOCK_DIR" 0 || return 1 + # Flock the directory inode itself. Concurrent first callers may both run + # install -d, but mkdir is atomic and they necessarily open the same stable + # inode below the root-owned /run/lock parent. + exec {fd}<"$VM_LOCK_DIR/." || return 1 + flock -x "$fd" || { exec {fd}<&-; return 1; } + "$@" || rc=$? + flock -u "$fd" || rc=1 + exec {fd}<&- + return "$rc" +} + # --- validation (shared by the user-side prompts and the root-side writer) ---- valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } @@ -115,7 +160,7 @@ resolve_caller() { local entry canonical parent owner mode if ((EUID == 0)); then - [[ ${PKEXEC_UID:-} =~ ^[0-9]+$ ]] && ((10#$PKEXEC_UID > 0)) || { + [[ ${PKEXEC_UID:-} =~ ^[0-9]{1,10}$ ]] && ((10#$PKEXEC_UID > 0)) || { echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2 return 1 } @@ -153,7 +198,7 @@ resolve_caller() { return 1 } # The user must not be able to rename or replace their home while root is - # moving the legacy data entry out of it. + # opening and pinning the familiar data entries below it. parent=$(dirname -- "$CALLER_HOME") while :; do owner=$(stat -Lc '%u' "$parent") || return 1 @@ -167,16 +212,23 @@ resolve_caller() { done fi - # Keep the potentially large disk on the same filesystem as the user's home, - # but outside that user-writable directory. The already-validated home parent - # is root-owned, so this sibling tree provides a stable rename boundary. - MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + # Docker only ever sees fixed paths below the root-owned runtime tree. The + # user's real data stays wherever ~/.windows and ~/Windows resolve (including + # separately mounted homes and legitimate symlinks); those sources are pinned + # into these anchors with bind mounts before Docker is allowed to start. + MOUNT_ROOT="$RUNTIME_DIR/mounts" USERS_DIR="$MOUNT_ROOT/users" CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID" EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage" EXPECTED_SHARED="$CALLER_DATA_ROOT/shared" LEGACY_STORAGE="$CALLER_HOME/.windows" LEGACY_SHARED="$CALLER_HOME/Windows" + # The first protected-anchor implementation used a root-owned sibling of + # home. Recognize that exact derived pair during upgrade, but never accept a + # path read from user input. + OLD_MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" + OLD_EXPECTED_STORAGE="$OLD_MOUNT_ROOT/users/$CALLER_UID/storage" + OLD_EXPECTED_SHARED="$OLD_MOUNT_ROOT/users/$CALLER_UID/shared" } boundary_owner() { @@ -200,8 +252,25 @@ assert_boundary_dir() { [[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022)) } +prepare_boundary_component() { + local path="$1" parent="$2" owner="$3" mode="$4" + assert_boundary_dir "$parent" "$owner" || return 1 + if [[ -e $path || -L $path ]]; then + assert_boundary_dir "$path" "$owner" || return 1 + else + if ((EUID == 0)); then + install -d -o root -g root -m "$mode" -- "$path" || return 1 + else + install -d -m "$mode" -- "$path" || return 1 + fi + fi + chmod "$mode" -- "$path" || return 1 + if ((EUID == 0)); then chown root:root -- "$path" || return 1; fi + assert_boundary_dir "$path" "$owner" +} + prepare_runtime_tree() { - local owner probe + local owner probe runtime_parent owner=$(boundary_owner) if ((EUID == 0)); then [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || { @@ -220,114 +289,395 @@ prepare_runtime_tree() { [[ $probe == / ]] && break probe=$(dirname -- "$probe") done - if [[ -e $MOUNT_ROOT || -L $MOUNT_ROOT ]]; then - assert_boundary_dir "$MOUNT_ROOT" 0 || { - echo "omarchy-windows-vm: unsafe mount root: $MOUNT_ROOT" >&2 - return 1 - } - fi fi - mkdir -p -- "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" - chmod 0755 "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" - if ((EUID == 0)); then - chown root:root "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT" + + runtime_parent=$(dirname -- "$RUNTIME_DIR") + if ((EUID == 0)) && [[ ! -e $runtime_parent && ! -L $runtime_parent ]]; then + [[ $runtime_parent == /var/lib/omarchy ]] || return 1 + assert_boundary_dir /var/lib 0 || return 1 + install -d -o root -g root -m 0755 -- "$runtime_parent" || return 1 fi - assert_boundary_dir "$RUNTIME_DIR" "$owner" && - assert_boundary_dir "$MOUNT_ROOT" "$owner" && - assert_boundary_dir "$USERS_DIR" "$owner" && - assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" || { + prepare_boundary_component "$RUNTIME_DIR" "$runtime_parent" "$owner" 0755 && + prepare_boundary_component "$MOUNT_ROOT" "$RUNTIME_DIR" "$owner" 0711 && + prepare_boundary_component "$USERS_DIR" "$MOUNT_ROOT" "$owner" 0711 && + prepare_boundary_component "$CALLER_DATA_ROOT" "$USERS_DIR" "$owner" 0711 || { echo "omarchy-windows-vm: unsafe VM mount boundary" >&2 return 1 } } - # Move an existing home entry first, then inspect the pinned object below the -# root-owned parent. This closes the check/use gap where an attacker could swap -# a checked home directory for a symlink before Docker resolved it. -prepare_mount_leaf() { - local legacy="$1" stable="$2" rejected source_dev target_dev - - if [[ ! -e $stable && ! -L $stable ]]; then - if [[ -e $legacy || -L $legacy ]]; then - # rename(2) pins the exact directory entry the caller presented. GNU mv - # falls back to a privileged recursive copy across filesystems, which - # would reopen the source path and reintroduce the race, so fail closed in - # that uncommon layout instead of copying as root. - source_dev=$(stat -c '%d' -- "$legacy") || return 1 - target_dev=$(stat -Lc '%d' -- "$CALLER_DATA_ROOT") || return 1 - [[ $source_dev == "$target_dev" ]] || { - echo "omarchy-windows-vm: cannot safely migrate $legacy across filesystems" >&2 - echo "Move it onto the filesystem containing $MOUNT_ROOT, then retry." >&2 - return 1 - } - mv --no-copy -T -- "$legacy" "$stable" || return 1 - if [[ ! -d $stable || -L $stable ]]; then - rejected="$CALLER_DATA_ROOT/rejected-$(basename -- "$stable")-$$" - mv --no-copy -T -- "$stable" "$rejected" 2>/dev/null || true - echo "omarchy-windows-vm: refusing non-directory VM data entry at $legacy" >&2 - return 1 - fi - else - install -d -m 0700 "$stable" - fi - fi - - [[ -d $stable && ! -L $stable ]] && [[ $(realpath -e -- "$stable" 2>/dev/null) == "$stable" ]] || { - echo "omarchy-windows-vm: unsafe VM data directory: $stable" >&2 +# Open the source directory itself and keep the descriptor alive until after the +# bind. /proc/$BASHPID/fd refers to this exact shell process (including when a +# function runs in a pipeline subshell), not the short-lived mount subprocess, +# so a rename or symlink swap after open cannot change which inode is mounted. +open_mount_source() { + local path="$1" label="$2" fd record uid identity + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label source is not a directory: $path" >&2 return 1 } - if ((EUID == 0)); then - chown "$CALLER_UID:$CALLER_GID" "$stable" - fi + # Appending /. makes a directory-to-FIFO swap fail with ENOTDIR instead of + # leaving the privileged helper blocked while opening an attacker-held pipe. + exec {fd}<"$path/." || { + echo "omarchy-windows-vm: cannot open $label source: $path" >&2 + return 1 + } + [[ -d /proc/$BASHPID/fd/$fd ]] || { + exec {fd}<&- + return 1 + } + record=$(stat -Lc '%u|%d:%i' "/proc/$BASHPID/fd/$fd" 2>/dev/null) || { + exec {fd}<&- + return 1 + } + IFS='|' read -r uid identity <<<"$record" + [[ $uid == "$CALLER_UID" ]] || { + exec {fd}<&- + echo "omarchy-windows-vm: $label source must be a directory owned by uid $CALLER_UID" >&2 + return 1 + } + OPENED_MOUNT_FD=$fd + OPENED_MOUNT_ID=$identity +} - if [[ -L $legacy ]]; then - [[ $(realpath -e -- "$legacy" 2>/dev/null) == "$stable" ]] || { - echo "omarchy-windows-vm: $legacy does not point to its protected mount anchor" >&2 - return 1 +# Return 0 when ancestor_id contains the already-open descendant directory, 1 +# when the walk reaches the namespace root without finding it, and 2 on any +# error or an implausibly deep walk. Every hop is opened relative to a pinned +# directory FD; no caller-mutable pathname is re-resolved. +pinned_dir_contains() { + local ancestor_id="$1" descendant_fd="$2" walk_fd parent_fd current_id parent_id depth + exec {walk_fd}<"/proc/$BASHPID/fd/$descendant_fd/." || return 2 + for ((depth = 0; depth < 256; depth++)); do + current_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$walk_fd" 2>/dev/null) || { + exec {walk_fd}<&- + return 2 } - elif [[ -e $legacy ]]; then - echo "omarchy-windows-vm: refusing to replace existing data at $legacy" >&2 + if [[ $current_id == "$ancestor_id" ]]; then + exec {walk_fd}<&- + return 0 + fi + exec {parent_fd}<"/proc/$BASHPID/fd/$walk_fd/.." || { + exec {walk_fd}<&- + return 2 + } + parent_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$parent_fd" 2>/dev/null) || { + exec {parent_fd}<&- + exec {walk_fd}<&- + return 2 + } + if [[ $parent_id == "$current_id" ]]; then + exec {parent_fd}<&- + exec {walk_fd}<&- + return 1 + fi + exec {walk_fd}<&- + walk_fd=$parent_fd + done + exec {walk_fd}<&- + return 2 +} + +# A bind alias can give the same directory inode a second parent chain, so an +# upward walk alone is insufficient for destructive removal. Search from a +# pinned tree root for the other pinned inode without following symlinks or +# crossing the removal traversal's filesystem boundary. Return 0 when found, 1 +# when absent, and 2 on timeout, traversal error, or unexpected output. +pinned_tree_contains() { + local root_fd="$1" needle_fd="$2" found rc + # -xdev still evaluates a nested mountpoint itself before pruning its + # children, so a direct different-filesystem alias of the needle is found too. + # This matches removal's traversal boundary without skipping mount aliases. + if found=$("$TREE_SCAN_TIMEOUT" --signal=TERM --kill-after="${TREE_SCAN_KILL_AFTER_SECONDS}s" \ + "${TREE_SCAN_TIMEOUT_SECONDS}s" "$TREE_SCAN_FIND" -P "/proc/$BASHPID/fd/$root_fd/." \ + -xdev -type d -samefile "/proc/$BASHPID/fd/$needle_fd/." \ + -printf 'found\n' -quit 2>/dev/null); then + rc=0 + else + rc=$? + fi + ((rc == 0)) || return 2 + case "$found" in + found) return 0 ;; + "") return 1 ;; + *) return 2 ;; + esac +} + +validate_pinned_sources_disjoint() { + local storage_fd="$1" storage_id="$2" shared_fd="$3" shared_id="$4" rc + if [[ $storage_id == "$shared_id" ]]; then + echo "omarchy-windows-vm: storage and shared must be different directories" >&2 + return 1 + fi + if pinned_dir_contains "$storage_id" "$shared_fd"; then + echo "omarchy-windows-vm: shared directory must not be inside storage" >&2 return 1 else - ln -s -- "$stable" "$legacy" || return 1 - if ((EUID == 0)); then - chown -h "$CALLER_UID:$CALLER_GID" "$legacy" - fi + rc=$? + ((rc == 1)) || { + echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } fi + if pinned_dir_contains "$shared_id" "$storage_fd"; then + echo "omarchy-windows-vm: storage directory must not be inside shared" >&2 + return 1 + else + rc=$? + ((rc == 1)) || { + echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } + fi +} + +removal_trees_disjoint() { + local storage_fd shared_fd anchor_storage_fd anchor_shared_fd storage_id shared_id rc=1 scan_rc + local scan scan_root_fd scan_needle_fd scan_label + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id" || + ! mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" || + ! mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_storage_fd}<"$EXPECTED_STORAGE/."; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_shared_fd}<"$EXPECTED_SHARED/."; then + exec {anchor_storage_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Scan the protected anchor views first. Also scan the pinned caller views: + # a submount attached after the original non-recursive bind is intentionally + # absent from the anchor view, but removal must still refuse that alias. + rc=0 + for scan in \ + "$anchor_storage_fd:$anchor_shared_fd:shared below protected storage" \ + "$anchor_shared_fd:$anchor_storage_fd:storage below protected shared" \ + "$storage_fd:$shared_fd:shared below storage source" \ + "$shared_fd:$storage_fd:storage below shared source"; do + IFS=: read -r scan_root_fd scan_needle_fd scan_label <<<"$scan" + if pinned_tree_contains "$scan_root_fd" "$scan_needle_fd"; then + echo "omarchy-windows-vm: refusing removal: $scan_label" >&2 + rc=1 + break + else + scan_rc=$? + if ((scan_rc != 1)); then + echo "omarchy-windows-vm: removal containment scan failed or timed out" >&2 + rc=1 + break + fi + fi + done + + exec {anchor_storage_fd}<&- + exec {anchor_shared_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} + +prepare_mount_anchor() { + local path="$1" owner + owner=$(boundary_owner) + [[ ! -L $path ]] || return 1 + if mountpoint -q -- "$path" 2>/dev/null; then return 0; fi + prepare_boundary_component "$path" "$CALLER_DATA_ROOT" "$owner" 0700 || return 1 + [[ -z $(find "$path" -mindepth 1 -print -quit) ]] || { + echo "omarchy-windows-vm: refusing to hide data below mount anchor: $path" >&2 + return 1 + } +} + +mounted_leaf_matches() { + local stable="$1" identity="$2" actual owner mode canonical + [[ -d $stable && ! -L $stable ]] || return 1 + canonical=$(realpath -e -- "$stable" 2>/dev/null) || return 1 + [[ $canonical == "$stable" ]] || return 1 + mountpoint -q -- "$stable" 2>/dev/null || return 1 + [[ $(mount_layer_count "$stable") == 1 ]] || return 1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || return 1 + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$stable" 2>/dev/null) || return 1 + [[ $actual == "$identity" && $owner == "$CALLER_UID" && $mode == 700 ]] +} + +bind_mount_leaf() { + local fd="$1" identity="$2" stable="$3" actual owner + MOUNT_LEAF_NEW=0 + if mountpoint -q -- "$stable" 2>/dev/null; then + mounted_leaf_matches "$stable" "$identity" || { + echo "omarchy-windows-vm: protected mount at $stable no longer matches its home source" >&2 + return 1 + } + return 0 + fi + + # util-linux normally canonicalizes a /proc//fd link back to a pathname, + # which would throw away the FD pin. Pass the procfd to mount(2) unchanged. + mount --no-canonicalize --bind "/proc/$BASHPID/fd/$fd" "$stable" || return 1 + MOUNT_LEAF_NEW=1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || actual="" + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || owner="" + if [[ $actual != "$identity" || $owner != "$CALLER_UID" ]]; then + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "omarchy-windows-vm: could not roll back unverified bind at $stable" >&2 + fi + echo "omarchy-windows-vm: bind verification failed for $stable" >&2 + return 1 + fi + mounted_leaf_matches "$stable" "$identity" || { + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "omarchy-windows-vm: could not roll back invalid bind at $stable" >&2 + fi + return 1 + } } prepare_caller_mounts() { - resolve_caller && prepare_runtime_tree && - prepare_mount_leaf "$LEGACY_STORAGE" "$EXPECTED_STORAGE" && - prepare_mount_leaf "$LEGACY_SHARED" "$EXPECTED_SHARED" + local storage_fd storage_id shared_fd shared_id storage_mode shared_mode + CALLER_MOUNTS_NEW_STORAGE=0 + CALLER_MOUNTS_NEW_SHARED=0 + resolve_caller && prepare_runtime_tree || return 1 + prepare_mount_anchor "$EXPECTED_STORAGE" && prepare_mount_anchor "$EXPECTED_SHARED" || return 1 + + # Pre-open and validate both sources before changing either mount anchor. + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Privacy is an explicit preflight step for both already-pinned sources, not + # a side effect halfway through the two-mount transaction. Old umask-022 + # installs are hardened together before either Docker-facing anchor changes. + chmod 0700 -- "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + } + storage_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$storage_fd" 2>/dev/null) || storage_mode="" + shared_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$shared_fd" 2>/dev/null) || shared_mode="" + if [[ $storage_mode != 700 || $shared_mode != 700 ]]; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + else + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! bind_mount_leaf "$shared_fd" "$shared_id" "$EXPECTED_SHARED"; then + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + + exec {storage_fd}<&- + exec {shared_fd}<&- } -# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the -# compose atomically as root. Re-validation here is the security boundary: the -# writer refuses rather than emit a compose an attacker could have influenced. -# Only these fixed keys are honored; image, container name, devices, caps, and -# port bindings are hard-coded and never taken from input. -__priv_write_compose() { - local ram cores disk username password tz key value +mounts_ready() { + local storage_fd storage_id shared_fd shared_id rc=1 + resolve_caller || return 1 + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if assert_boundary_dir "$RUNTIME_DIR" "$(boundary_owner)" && + assert_boundary_dir "$MOUNT_ROOT" "$(boundary_owner)" && + assert_boundary_dir "$USERS_DIR" "$(boundary_owner)" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$(boundary_owner)" && + mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && + mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + rc=0 + fi + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} - while IFS='=' read -r key value; do - case "$key" in - RAM) ram="$value" ;; - CORES) cores="$value" ;; - DISK) disk="$value" ;; - USERNAME) username="$value" ;; - PASSWORD) password="$value" ;; - TZ) tz="$value" ;; - esac - done +mount_layer_count() { + local path="$1" + awk -v path="$path" '$5 == path { count++ } END { print count + 0 }' /proc/self/mountinfo +} - valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } - valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } - valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } - valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } - valid_password "$password" || { echo "invalid password" >&2; exit 2; } - valid_tz "$tz" || tz="UTC" - prepare_caller_mounts || exit 2 +mount_descendant_count() { + local path="$1" + awk -v prefix="$path/" 'index($5, prefix) == 1 { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +rollback_new_caller_mounts() { + local failed=0 + if ((CALLER_MOUNTS_NEW_SHARED)); then + if umount -- "$EXPECTED_SHARED"; then CALLER_MOUNTS_NEW_SHARED=0; else failed=1; fi + fi + if ((CALLER_MOUNTS_NEW_STORAGE)); then + if umount -- "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=0; else failed=1; fi + fi + ((failed == 0)) || echo "omarchy-windows-vm: could not roll back newly created VM mounts" >&2 + return "$failed" +} + +write_compose_atomically() ( + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + local tmp="" rc esc_password + cleanup_writer() { + rc=$? + trap - EXIT + [[ -z $tmp ]] || rm -f -- "$tmp" || true + if ((rc != 0)) && ! rollback_new_caller_mounts; then rc=1; fi + exit "$rc" + } + trap cleanup_writer EXIT # Neutralize anything in the password that could be misread when the compose # is parsed. Two layers apply, in this order at parse time: docker compose @@ -336,13 +686,12 @@ __priv_write_compose() { # double-quote) and the interpolation layer last ($ -> $$), so a password # containing " \ or $ reaches the guest verbatim. unescape() reverses this in # the opposite order for the RDP credentials. - local esc_password=${password//\\/\\\\} + esc_password=${password//\\/\\\\} esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\$/\$\$} - local tmp - tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") - cat >"$tmp" <"$tmp" </dev/null || true - chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true - mv -f "$tmp" "$COMPOSE_FILE" + chmod 0640 "$tmp" || exit 1 + if ((EUID == 0)); then + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || exit 1 + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || exit 1 + tmp="" + trap - EXIT +) + +# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the +# compose atomically as root. Re-validation here is the security boundary: the +# writer refuses rather than emit a compose an attacker could have influenced. +# Only these fixed keys are honored; image, container name, devices, caps, and +# port bindings are hard-coded and never taken from input. +__priv_write_compose() { + local ram cores disk username password tz key value + + while IFS='=' read -r key value; do + case "$key" in + RAM) ram="$value" ;; + CORES) cores="$value" ;; + DISK) disk="$value" ;; + USERNAME) username="$value" ;; + PASSWORD) password="$value" ;; + TZ) tz="$value" ;; + esac + done + + valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } + valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } + valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } + valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } + valid_password "$password" || { echo "invalid password" >&2; exit 2; } + valid_tz "$tz" || tz="UTC" + prepare_caller_mounts || exit 2 + # Readable by root and the docker group only. Any failure after mounting rolls + # back just the binds this writer created and leaves an old compose untouched. + write_compose_atomically "$ram" "$cores" "$disk" "$username" "$password" "$tz" || exit 2 } # Read the host source of a bind mount out of the compose (e.g. /storage). @@ -392,8 +771,19 @@ get_mount_source() { compose_needs_mount_migration() { [[ -f $COMPOSE_FILE ]] || return 1 resolve_caller || return 1 - [[ $(get_mount_source /storage) == "$LEGACY_STORAGE" && - $(get_mount_source /shared) == "$LEGACY_SHARED" ]] + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 + compose_mount_pair_is_migratable "$(get_mount_source /storage)" "$(get_mount_source /shared)" +} + +compose_mount_pair_is_migratable() { + local storage="$1" shared="$2" + [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]] || + [[ $storage == "$OLD_EXPECTED_STORAGE" && $shared == "$OLD_EXPECTED_SHARED" ]] +} + +mount_source_count() { + local destination="$1" + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l } rewrite_compose_mounts() { @@ -404,11 +794,14 @@ rewrite_compose_mounts() { /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } { print } ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } - chmod 0640 "$tmp" + chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } if ((EUID == 0)); then - chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { + rm -f "$tmp" + return 1 + } fi - mv -f -- "$tmp" "$COMPOSE_FILE" + mv -fT -- "$tmp" "$COMPOSE_FILE" || { rm -f "$tmp"; return 1; } } assert_compose_trusted() { @@ -421,7 +814,7 @@ assert_compose_trusted() { } assert_mounts_safe() { - local storage shared owner + local storage shared resolve_caller || return 1 assert_compose_trusted || { echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 @@ -430,13 +823,21 @@ assert_mounts_safe() { storage=$(get_mount_source /storage) shared=$(get_mount_source /shared) - if [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]]; then + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || { + echo "omarchy-windows-vm: refusing duplicate or missing VM mounts in the compose" >&2 + return 1 + } + + if compose_mount_pair_is_migratable "$storage" "$shared"; then ((EUID == 0)) || { echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2 return 1 } prepare_caller_mounts || return 1 - rewrite_compose_mounts || return 1 + if ! rewrite_compose_mounts; then + rollback_new_caller_mounts || true + return 1 + fi storage=$EXPECTED_STORAGE shared=$EXPECTED_SHARED fi @@ -445,15 +846,14 @@ assert_mounts_safe() { echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2 return 1 } - owner=$(boundary_owner) - assert_boundary_dir "$RUNTIME_DIR" "$owner" && - assert_boundary_dir "$MOUNT_ROOT" "$owner" && - assert_boundary_dir "$USERS_DIR" "$owner" && - assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" && - [[ -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]] && - [[ -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] && - [[ $(realpath -e -- "$EXPECTED_STORAGE" 2>/dev/null) == "$EXPECTED_STORAGE" ]] && - [[ $(realpath -e -- "$EXPECTED_SHARED" 2>/dev/null) == "$EXPECTED_SHARED" ]] || { + + # Mounts disappear at reboot. Root recreates them from the already-opened, + # caller-owned sources; a docker-group invocation may proceed directly only + # while the exact pinned pair is still present. + if ((EUID == 0)); then + prepare_caller_mounts || return 1 + fi + mounts_ready || { echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2 return 1 } @@ -495,19 +895,109 @@ __priv_up_wait() { __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_remove() { - resolve_caller || return 1 - dc down 2>/dev/null || true + # Rebuild/verify both pinned binds before deleting through the storage anchor. + # In particular, a legitimate ~/.windows symlink means deleting only the link + # from the user side would strand the virtual disk in its external target. + assert_mounts_safe || return 1 + [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" ]] || return 1 + [[ $EXPECTED_SHARED == "$USERS_DIR/$CALLER_UID/shared" ]] || return 1 + [[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && + $(mount_layer_count "$EXPECTED_SHARED") == 1 && + $(mount_descendant_count "$EXPECTED_STORAGE") == 0 && + $(mount_descendant_count "$EXPECTED_SHARED") == 0 ]] || { + echo "omarchy-windows-vm: refusing removal with unknown or stacked VM mounts" >&2 + return 1 + } + + dc down || { + echo "omarchy-windows-vm: could not stop the Windows VM; storage was not deleted" >&2 + return 1 + } + if docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "omarchy-windows-vm: Windows container still exists; storage was not deleted" >&2 + return 1 + fi + docker info >/dev/null 2>&1 || { + echo "omarchy-windows-vm: cannot verify Docker state; storage was not deleted" >&2 + return 1 + } + mounts_ready || { + echo "omarchy-windows-vm: VM mount identity changed during removal" >&2 + return 1 + } + removal_trees_disjoint || return 1 + + # find -xdev deliberately empties the verified disk source without crossing + # into another mounted filesystem. Shared files are never traversed. + find "$EXPECTED_STORAGE" -xdev -mindepth 1 -delete || return 1 + [[ -z $(find "$EXPECTED_STORAGE" -mindepth 1 -print -quit) ]] || return 1 + + # Release only the single known top mounts checked above. Unmount shared first + # so a storage-unmount failure cannot expose shared data to deletion. + umount -- "$EXPECTED_SHARED" || return 1 + umount -- "$EXPECTED_STORAGE" || return 1 docker rmi "$IMAGE" 2>/dev/null || true rm -f "$COMPOSE_FILE" - # Shared files intentionally survive removal. The storage leaf cannot be - # swapped by the user because its parent is the protected boundary. - if [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" && -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]]; then - rm -rf --one-file-system -- "$EXPECTED_STORAGE" - fi + rmdir -- "$EXPECTED_STORAGE" "$EXPECTED_SHARED" "$CALLER_DATA_ROOT" 2>/dev/null || true } # --- config helpers ---------------------------------------------------------- +# Validate both familiar home entries before creating or changing either. A +# legitimate symlink is kept exactly as-is; only its caller-owned directory +# target is used. The privileged half repeats the ownership check on pinned FDs. +preflight_user_mount_source() { + local path="$1" label="$2" uid owner + uid=$(id -u) + if [[ -L $path ]]; then + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label is a broken or non-directory symlink: $path" >&2 + return 1 + } + elif [[ -e $path ]]; then + [[ -d $path ]] || { + echo "omarchy-windows-vm: $label is not a directory: $path" >&2 + return 1 + } + else + return 0 + fi + owner=$(stat -Lc '%u' -- "$path") || return 1 + [[ $owner == "$uid" ]] || { + echo "omarchy-windows-vm: $label must be owned by uid $uid: $path" >&2 + return 1 + } +} + +prepare_user_mount_sources() { + local storage="$HOME/.windows" shared="$HOME/Windows" storage_id shared_id + preflight_user_mount_source "$storage" storage && + preflight_user_mount_source "$shared" shared || return 1 + [[ -e $storage || -L $storage ]] || install -d -m 0700 -- "$storage" || return 1 + [[ -e $shared || -L $shared ]] || install -d -m 0700 -- "$shared" || return 1 + storage_id=$(stat -Lc '%d:%i' -- "$storage") || return 1 + shared_id=$(stat -Lc '%d:%i' -- "$shared") || return 1 + [[ $storage_id != "$shared_id" ]] || { + echo "omarchy-windows-vm: storage and shared must be different directories" >&2 + return 1 + } + chmod 0700 -- "$storage" "$shared" +} + +storage_space_path() { + if [[ -d $HOME/.windows ]]; then + realpath -e -- "$HOME/.windows" + else + printf '%s\n' "$HOME" + fi +} + +available_storage_gb() { + local path + path=$(storage_space_path) || return 1 + df -P -- "$path" | awk 'NR==2 {print int($4/1024/1024)}' +} + # Feed the collected settings to the elevated writer. write_compose() { local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" @@ -531,12 +1021,19 @@ unescape() { # password is not world-readable. The password is one validated printable line # (no newline), so plain KEY=VALUE is safe. write_credentials() { - local username="$1" password="$2" old_umask - mkdir -p "$(dirname "$CREDENTIALS_FILE")" + local username="$1" password="$2" old_umask dir tmp + dir=$(dirname -- "$CREDENTIALS_FILE") + mkdir -p "$dir" || return 1 + chmod 0700 "$dir" || return 1 old_umask=$(umask) umask 077 - printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE" - chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true + tmp=$(mktemp "$dir/.credentials.XXXXXX") || { umask "$old_umask"; return 1; } + if ! printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$tmp" || + ! chmod 0600 "$tmp" || ! mv -fT -- "$tmp" "$CREDENTIALS_FILE"; then + rm -f -- "$tmp" + umask "$old_umask" + return 1 + fi umask "$old_umask" } @@ -575,6 +1072,11 @@ migrate_legacy_compose() { password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") [[ -z $tz ]] && tz="UTC" + prepare_user_mount_sources || { + echo "Could not validate the existing Windows VM data directories." >&2 + return 1 + } + write_credentials "$username" "$password" || return 1 # The elevated writer derives both mount anchors from the authenticated uid; # it never consumes volume paths from this user-owned legacy file. if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then @@ -582,7 +1084,6 @@ migrate_legacy_compose() { echo "Re-run: omarchy-windows-vm install" >&2 return 1 fi - write_credentials "$username" "$password" rm -f "$LEGACY_COMPOSE_FILE" } @@ -607,7 +1108,10 @@ check_prerequisites() { fi # Check disk space - AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then echo "❌ Insufficient disk space!" echo " Available: ${AVAILABLE_SPACE}GB" @@ -622,6 +1126,7 @@ install_windows() { # Set up trap to handle Ctrl+C trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT + prepare_user_mount_sources || exit 1 check_prerequisites omarchy-pkg-add freerdp openbsd-netcat gum @@ -678,7 +1183,10 @@ EOF SELECTED_CORES=2 fi - AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image # Check if we have enough space for minimum @@ -765,14 +1273,17 @@ EOF tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) # Write the root-owned compose from the validated settings (one prompt if - # sudoless Docker is off). The writer creates protected storage/shared mount - # anchors and leaves the familiar home entries as symlinks to them. + # sudoless Docker is off). The writer pins the familiar home directories (or + # their legitimate symlink targets) into root-protected bind anchors. write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ "$USERNAME" "$PASSWORD" "$tz" || { echo "❌ Failed to write the Windows VM configuration." exit 1 } - write_credentials "$USERNAME" "$PASSWORD" + write_credentials "$USERNAME" "$PASSWORD" || { + echo "❌ Failed to store private RDP credentials." >&2 + exit 1 + } echo "" echo "Starting Windows VM installation..." @@ -817,9 +1328,17 @@ remove_windows() { echo "Removing Windows VM..." - migrate_legacy_compose 2>/dev/null || true + if [[ ! -f $COMPOSE_FILE && -f $LEGACY_COMPOSE_FILE ]]; then + migrate_legacy_compose || { + echo "❌ Could not safely migrate the VM before removal." >&2 + exit 1 + } + fi if [[ -f $COMPOSE_FILE ]]; then - priv remove || true + priv remove || { + echo "❌ Windows VM removal stopped before user-side cleanup; inspect the VM data before retrying." >&2 + exit 1 + } fi rm -f "$HOME/.local/share/applications/windows-vm.desktop" @@ -1009,7 +1528,7 @@ __priv) echo "omarchy-windows-vm: unknown privileged action" >&2 exit 1 } - "__priv_${action}" "$@" + with_vm_lock "__priv_${action}" "$@" ;; install) install_windows diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index e12a74d8..29946466 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -28,7 +28,11 @@ omarchy windows vm launch # start and connect The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`. -Those familiar home paths are links to per-user mount anchors in a root-owned `.omarchy-windows` directory beside your home directory. Keeping the anchors on the home filesystem preserves the expected disk location, while their protected parent prevents another process running as you from swapping a checked directory for a symlink while the privileged VM is starting. +Those familiar home paths stay on their own filesystems. They can also be symlinks to directories you own, which is useful when the virtual disk lives on a larger drive. The installer measures free space on the filesystem that actually contains `~/.windows`, not necessarily the filesystem containing your home directory. + +Keep the disk and shared paths as separate, non-overlapping directories. Removal deliberately empties the disk directory but preserves the shared directory. Immediately before deletion, Omarchy performs a bounded containment check and refuses to remove anything if that check times out or cannot prove the two trees are separate. + +Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents. The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 953f3403..77c298a8 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -1,82 +1,118 @@ #!/bin/bash -# -# The Windows VM compose file is written by an elevated, input-validated writer -# into a root-owned directory. These tests pin the security-critical behavior: -# no input can inject a host-root bind mount or a privileged flag, the password -# survives both the YAML and the compose-interpolation layer, only known -# privileged actions dispatch, and legacy configs migrate without redownloading. +# Security regression coverage for the Windows VM compose/mount boundary. set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +# Bind mounts need CAP_SYS_ADMIN in a private mount namespace. Keep the +# caller's uid so the non-root development path is exercised. +if [[ ${OMARCHY_WINDOWS_TEST_NAMESPACE:-0} != 1 ]]; then + if unshare --user --map-current-user --keep-caps --mount true 2>/dev/null; then + exec env OMARCHY_WINDOWS_TEST_NAMESPACE=1 \ + unshare --user --map-current-user --keep-caps --mount --propagation private bash "$0" + fi + pass "unprivileged mount namespaces unavailable; skipping Windows VM mount runtime tests" + exit 0 +fi + TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT export OMARCHY_WINDOWS_DIR="$TMPDIR/win" export HOME="$TMPDIR/home" mkdir -p "$HOME" -# Source the command's functions; the dispatcher just prints usage for "help". set -- help source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" +unmount_all() { + local path + resolve_caller >/dev/null 2>&1 || return 0 + for path in "$EXPECTED_SHARED" "$EXPECTED_STORAGE"; do + while mountpoint -q -- "$path" 2>/dev/null; do umount -- "$path" || break; done + done +} + +cleanup() { + set +e + unmount_all + rm -rf "$TMPDIR" +} +trap cleanup EXIT + write() { # RAM CORES DISK USER PASS TZ printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ "$@" | __priv_write_compose } -# --- valid compose, with the dangerous bits pinned and unreachable by input --- -rm -f "$COMPOSE" -write 4G 2 64G alice 's3cret' Europe/Copenhagen +fd_count() { find "/proc/$$/fd" -mindepth 1 -maxdepth 1 -printf x | wc -c; } + +reset_case() { + unmount_all + rm -rf "$OMARCHY_WINDOWS_DIR" "$HOME/.windows" "$HOME/Windows" + mkdir -p "$HOME" +} + +# Fixed protected anchors consume the pinned source inodes. +prepare_user_mount_sources +write 4G 2 64G alice s3cret Europe/Copenhagen resolve_caller [[ -f $COMPOSE ]] || fail "writer produced a compose file" grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" -grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the per-uid protected anchor" -grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared files use the per-uid protected anchor" -[[ -L $HOME/.windows && $(realpath "$HOME/.windows") == "$EXPECTED_STORAGE" ]] || fail "home storage link targets the protected anchor" -[[ -L $HOME/Windows && $(realpath "$HOME/Windows") == "$EXPECTED_SHARED" ]] || fail "home shared link targets the protected anchor" -grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" -pass "writer derives protected per-uid anchors and emits no host-root mount" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor" +[[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories" +[[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source" +[[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source" +[[ $(stat -Lc '%a' "$EXPECTED_STORAGE") == 700 && $(stat -Lc '%a' "$EXPECTED_SHARED") == 700 ]] || fail "mount leaves are not private" +grep -q -- '- /:/' "$COMPOSE" && fail "compose contains host-root bind" +pass "writer emits fixed anchors bound to exact private source inodes" -# --- injection attempts are rejected, no file written --- +# Input cannot widen a mount or compose field. rm -f "$COMPOSE" -write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username was accepted" -[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" +write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username accepted" +[[ ! -f $COMPOSE ]] || fail "bad input wrote compose" printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose -grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller-supplied storage path affected the compose" -grep -q -- '- /:/storage' "$COMPOSE" && fail "host root was accepted as storage" -write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM was accepted" -pass "injection attempts are rejected and caller-supplied paths are ignored" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller storage affected compose" +grep -q -- '- /:/storage' "$COMPOSE" && fail "host root accepted as storage" +write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM accepted" +pass "input cannot inject a host path or compose field" -# --- password survives YAML (" \) and compose interpolation ($) --- -rm -f "$COMPOSE" tricky='p@$$w:rd$HOME"x\y' write 8G 4 64G bob "$tricky" UTC -grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" -recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") -[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" -pass "password with \" \\ and \$ round-trips" +grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "dollar not escaped" +[[ $(unescape "$(read_compose_value PASSWORD "$COMPOSE")") == "$tricky" ]] || fail "password did not round-trip" +pass "password with quote, backslash, and dollar round-trips" -# --- only known privileged actions may dispatch --- for action in write_compose up up_wait down status remove; do - valid_priv_action "$action" || fail "known privileged action rejected: $action" + valid_priv_action "$action" || fail "known action rejected: $action" done for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do - valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]" + valid_priv_action "$action" && fail "action whitelist accepted: [$action]" done -pass "privileged action whitelist accepts known actions and rejects the rest" +pass "privileged action dispatch is allowlisted" -# --- legacy per-user compose migrates into the root-owned location --- -# A rogue process could have rewritten the user-owned legacy compose to bind -# mount host / into the guest, so migration must ignore its volume paths and -# reconstruct them from the current user's $HOME. -rm -rf "$OMARCHY_WINDOWS_DIR" -rm -rf "$MOUNT_ROOT" -rm -f "$HOME/.windows" "$HOME/Windows" -mkdir -p "$HOME/.config/windows" "$HOME/.windows" "$HOME/Windows" -touch "$HOME/.windows/existing-disk" "$HOME/Windows/existing-shared-file" +# A PATH symlink to bash must never become the pkexec target. Hide the packaged +# file from priv_target's stat checks to exercise the historical fallback. +attack_bin="$TMPDIR/attack-bin" +mkdir -p "$attack_bin" +ln -s /bin/bash "$attack_bin/omarchy-windows-vm" +printf 'printf exploited >"$TMPDIR/exploited"\n' >"$TMPDIR/__priv" +stat() { + [[ ${!#} == /usr/bin/omarchy-windows-vm ]] && return 1 + command stat "$@" +} +PATH="$attack_bin:$PATH" priv_target >/dev/null 2>&1 && fail "PATH symlink became a privileged target" +unset -f stat +[[ ! -e $TMPDIR/exploited ]] || fail "attacker __priv script executed" +pass "pkexec target is only the canonical packaged regular file, never a PATH symlink" + +# Legacy migration keeps directories and legitimate symlinks in place. +reset_case +external_shared="$TMPDIR/external-shared" +mkdir -m 0755 -p "$HOME/.windows" "$external_shared" "$HOME/.config/windows" +ln -s "$external_shared" "$HOME/Windows" +touch "$HOME/.windows/existing-disk" "$external_shared/existing-shared-file" LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" COMPOSE_FILE="$COMPOSE" cat >"$LEGACY_COMPOSE_FILE" <<'LEG' @@ -93,50 +129,276 @@ services: - /./:/storage - /etc:/shared LEG -# In production the write elevates via pkexec; here run it in-process. -priv() { local a=$1; shift; "__priv_$a" "$@"; } +priv() { local action=$1; shift; "__priv_$action" "$@"; } migrate_legacy_compose -[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" -grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" resolve_caller -grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "migration uses the protected storage anchor" -grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "migration uses the protected shared anchor" -[[ -f $EXPECTED_STORAGE/existing-disk ]] || fail "migration preserves the existing disk data" -[[ -f $EXPECTED_SHARED/existing-shared-file ]] || fail "migration preserves existing shared files" -[[ -L $HOME/.windows && -L $HOME/Windows ]] || fail "migration replaces home entries with compatibility links" -grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" -grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path" -[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose" -pass "legacy migration pins existing data and ignores tampered legacy volumes" +[[ -f $COMPOSE ]] || fail "migration did not write compose" +grep -q 'USERNAME: "legacyuser"' "$COMPOSE" || fail "migration lost settings" +[[ -f $HOME/.windows/existing-disk && -f $external_shared/existing-shared-file ]] || fail "migration lost data" +[[ ! -L $HOME/.windows && $(readlink "$HOME/Windows") == "$external_shared" ]] || fail "migration consumed source path" +[[ $(stat -Lc '%a' "$HOME/.windows") == 700 && $(stat -Lc '%a' "$external_shared") == 700 ]] || fail "migration did not harden legacy directories" +grep -q -- '- /:/' "$COMPOSE" && fail "migration copied malicious storage" +grep -q -- '- /etc:/shared' "$COMPOSE" && fail "migration copied malicious share" +[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration left legacy compose" +pass "migration preserves data and symlinks while hardening permissions" -# --- bring-up accepts only the derived pair in a trusted compose --- -assert_mounts_safe || fail "real directory mount sources are accepted" +# Bring-up re-proves compose trust, cardinality, and mounted identities. +assert_mounts_safe || fail "verified sources rejected" sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE" -assert_mounts_safe 2>/dev/null && fail "a tampered host path must be refused" +assert_mounts_safe 2>/dev/null && fail "tampered host path accepted" sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" +printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted" +write 16G 6 128G legacyuser legacypass America/New_York chmod 0666 "$COMPOSE" -assert_mounts_safe 2>/dev/null && fail "a user-writable compose must be refused" +assert_mounts_safe 2>/dev/null && fail "writable compose accepted" chmod 0640 "$COMPOSE" -pass "bring-up rejects unexpected mounts and a writable compose" +pass "bring-up rejects tampered, duplicate, and writable compose inputs" -# --- a symlink supplied as legacy data is renamed below the protected parent -# before inspection, then quarantined rather than followed --- -rm -rf "$OMARCHY_WINDOWS_DIR" -rm -rf "$MOUNT_ROOT" -rm -f "$HOME/.windows" "$HOME/Windows" -ln -s / "$HOME/.windows" -mkdir -p "$HOME/Windows" -rm -f "$COMPOSE" -write 4G 2 64G dave pw UTC 2>/dev/null && fail "a symlinked legacy data entry was accepted" -[[ ! -f $COMPOSE ]] || fail "no compose is written for a symlinked legacy entry" -[[ ! -L $MOUNT_ROOT/users/$(id -u)/storage ]] || fail "the mount anchor must not remain a symlink" -find "$MOUNT_ROOT/users/$(id -u)" -maxdepth 1 -type l -name 'rejected-storage-*' | grep -q . || fail "the rejected symlink was not quarantined" -pass "migration pins and rejects a symlinked legacy data entry" +# Both sources are pinned before a bind; bad symlinks stay untouched. +reset_case +mkdir -p "$HOME/.windows" +ln -s / "$HOME/Windows" +before_fds=$(fd_count) +prepare_user_mount_sources 2>/dev/null && fail "root symlink passed user preflight" +[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "rejected symlink consumed" +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "root symlink passed privileged preflight" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "one source mounted before other failed" +[[ $(fd_count) == "$before_fds" ]] || fail "source preflight leaked FD" +find "$CALLER_DATA_ROOT" -name 'rejected-*' -print -quit | grep -q . && fail "source was quarantined" +pass "invalid second source leaves paths and anchors untouched and leaks no FD" -# --- credentials are stored privately and round-trip (incl. = in password) --- -export CREDENTIALS_FILE="$TMPDIR/creds" -write_credentials 'carol' 'p=a$$w"x' -[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600" -[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips" -[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips" -pass "credentials are written 0600 and round-trip" +# Distinct caller-owned symlink targets are supported and remain links. +reset_case +external_storage="$TMPDIR/external-storage" +external_shared2="$TMPDIR/external-shared-2" +mkdir -p "$external_storage" "$external_shared2" +ln -s "$external_storage" "$HOME/.windows" +ln -s "$external_shared2" "$HOME/Windows" +prepare_user_mount_sources +write 4G 2 64G symlinked pw UTC +resolve_caller +[[ $(readlink "$HOME/.windows") == "$external_storage" && $(readlink "$HOME/Windows") == "$external_shared2" ]] || fail "writer replaced symlinks" +[[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned" +pass "legitimate caller-owned symlinks remain in place" + +# Same-inode sources fail before mounting and close both descriptors. +reset_case +same="$TMPDIR/same-source" +mkdir -p "$same" +ln -s "$same" "$HOME/.windows" +ln -s "$same" "$HOME/Windows" +before_fds=$(fd_count) +prepare_user_mount_sources 2>/dev/null && fail "same source passed user preflight" +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "same source passed root preflight" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "same source left mount" +[[ $(fd_count) == "$before_fds" ]] || fail "same source leaked FDs" +pass "storage and shared must differ and failure closes FDs" + +# Ancestor/descendant aliases are just as destructive as same-inode aliases: +# removal must never recurse from storage into shared (or accept the inverse). +reset_case +shared_inside="$TMPDIR/shared-inside-storage" +mkdir -p "$shared_inside/storage/shared" +ln -s "$shared_inside/storage" "$HOME/.windows" +ln -s "$shared_inside/storage/shared" "$HOME/Windows" +prepare_user_mount_sources +before_fds=$(fd_count) +write 4G 2 64G nested pw UTC 2>/dev/null && fail "shared-inside-storage sources were accepted" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "shared-inside-storage failure left a mount" +[[ $(fd_count) == "$before_fds" ]] || fail "shared-inside-storage failure leaked FDs" + +reset_case +storage_inside="$TMPDIR/storage-inside-shared" +mkdir -p "$storage_inside/shared/storage" +ln -s "$storage_inside/shared/storage" "$HOME/.windows" +ln -s "$storage_inside/shared" "$HOME/Windows" +prepare_user_mount_sources +before_fds=$(fd_count) +write 4G 2 64G nested pw UTC 2>/dev/null && fail "storage-inside-shared sources were accepted" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "storage-inside-shared failure left a mount" +[[ $(fd_count) == "$before_fds" ]] || fail "storage-inside-shared failure leaked FDs" +pass "pinned-FD ancestry checks reject overlap in both directions before mounting" + +# Exact bind-alias bypass regression: the shared FD's visible parent is the +# alias directory, but its inode is still reachable below storage. +reset_case +alias_under="$TMPDIR/bind-alias-under" +alias_shared="$TMPDIR/bind-alias-shared" +mkdir -p "$alias_under/storage/shared" "$alias_shared" +mount --no-canonicalize --bind "$alias_under/storage/shared" "$alias_shared" +ln -s "$alias_under/storage" "$HOME/.windows" +ln -s "$alias_shared" "$HOME/Windows" +prepare_user_mount_sources +before_fds=$(fd_count) +resolve_caller +open_mount_source "$LEGACY_STORAGE" storage +alias_storage_fd=$OPENED_MOUNT_FD +alias_storage_id=$OPENED_MOUNT_ID +open_mount_source "$LEGACY_SHARED" shared +alias_shared_fd=$OPENED_MOUNT_FD +pinned_dir_contains "$alias_storage_id" "$alias_shared_fd" && fail "bind-alias repro unexpectedly shared the underlying parent walk" +pinned_tree_contains "$alias_storage_fd" "$alias_shared_fd" || fail "tree-rooted discovery missed the bind-alias inode" +exec {alias_storage_fd}<&- +exec {alias_shared_fd}<&- +write 4G 2 64G alias pw UTC +resolve_caller +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +dc() { :; } +docker() { [[ $1 == inspect ]] && return 1; :; } +__priv_remove 2>/dev/null && fail "removal accepted a shared bind alias into storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "bind-alias removal refusal changed state" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "bind-alias removal refusal changed mounts" +[[ $(fd_count) == "$before_fds" ]] || fail "bind-alias removal refusal leaked FDs" +unmount_all +umount -- "$alias_shared" +pass "cheap startup permits a bind alias, but bounded removal discovery refuses it" + +# A late writer failure rolls back both newly-created binds. +reset_case +prepare_user_mount_sources +mv() { return 1; } +write 4G 2 64G rollback pw UTC 2>/dev/null && fail "forced writer failure succeeded" +unset -f mv +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "writer failure left binds" +[[ ! -f $COMPOSE ]] || fail "writer failure replaced compose" +pass "atomic writer failure rolls back both new bind mounts" + +# Revalidate ancestry during removal: move the already-bound shared inode below +# storage, keep its familiar path as a symlink, and prove nothing is deleted. +reset_case +prepare_user_mount_sources +write 4G 2 64G moved pw UTC +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +mv "$HOME/Windows" "$HOME/.windows/moved-shared" +ln -s "$HOME/.windows/moved-shared" "$HOME/Windows" +dc() { :; } +docker() { [[ $1 == inspect ]] && return 1; :; } +__priv_remove 2>/dev/null && fail "removal accepted a shared inode moved below storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/.windows/moved-shared/keep.txt && -f $COMPOSE ]] || fail "overlap rejection changed disk, shared data, or compose" +pass "removal revalidates pinned ancestry and leaves moved shared data untouched" + +# Even when both familiar paths remain disjoint, a same-filesystem bind of the +# pinned shared inode introduced below storage must stop removal before change. +reset_case +prepare_user_mount_sources +write 4G 2 64G removal-alias pw UTC +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +mkdir "$HOME/.windows/shared-bind-alias" +mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/shared-bind-alias" +__priv_remove 2>/dev/null && fail "removal missed a shared bind alias introduced below storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "removal bind-alias rejection changed state" +umount -- "$HOME/.windows/shared-bind-alias" +pass "removal tree discovery catches a shared alias not used by either home path" + +# A direct alias on another filesystem is still visited by find -xdev at its +# mountpoint and must be rejected, while unrelated separate filesystems remain +# supported by the root suite. +reset_case +prepare_user_mount_sources +mount -t tmpfs -o uid="$(id -u)",gid="$(id -g)",mode=0700,size=8m crossdev-shared "$HOME/Windows" +touch "$HOME/Windows/keep.txt" +write 4G 2 64G crossdev-alias pw UTC +touch "$HOME/.windows/disk.img" +mkdir "$HOME/.windows/crossdev-shared-alias" +mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/crossdev-shared-alias" +__priv_remove 2>/dev/null && fail "removal missed a different-device shared alias below storage" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "cross-device alias rejection changed state" +umount -- "$HOME/.windows/crossdev-shared-alias" +unmount_all +umount -- "$HOME/Windows" +pass "removal catches a direct different-filesystem shared alias at the xdev boundary" + +# Recursive alias discovery is destructive-removal-only and bounded. A hung or +# failing scanner must fail closed before the disk, share, compose, or mounts +# are changed. +reset_case +prepare_user_mount_sources +write 4G 2 64G scan-failure pw UTC +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +scan_helper="$TMPDIR/tree-scan-helper" +saved_tree_scan_find=$TREE_SCAN_FIND +saved_tree_scan_timeout=$TREE_SCAN_TIMEOUT_SECONDS +saved_tree_scan_kill_after=$TREE_SCAN_KILL_AFTER_SECONDS +printf '#!/bin/bash\n/bin/sleep 10\n' >"$scan_helper" +chmod 0700 "$scan_helper" +TREE_SCAN_FIND=$scan_helper +TREE_SCAN_TIMEOUT_SECONDS=0.05 +TREE_SCAN_KILL_AFTER_SECONDS=0.05 +__priv_remove 2>/dev/null && fail "removal continued after its containment scan timed out" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "timed-out containment scan changed state" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "timed-out containment scan changed mounts" + +printf '#!/bin/bash\nexit 42\n' >"$scan_helper" +__priv_remove 2>/dev/null && fail "removal continued after its containment scanner failed" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed containment scan changed state" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "failed containment scan changed mounts" +TREE_SCAN_FIND=$saved_tree_scan_find +TREE_SCAN_TIMEOUT_SECONDS=$saved_tree_scan_timeout +TREE_SCAN_KILL_AFTER_SECONDS=$saved_tree_scan_kill_after +pass "removal scan timeout and errors fail closed without changing VM state" + +# Removal rejects stacks, then deletes disk only through verified binds. +reset_case +prepare_user_mount_sources +write 4G 2 64G remove pw UTC +resolve_caller +touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt" +mount --no-canonicalize --bind "$HOME/.windows" "$EXPECTED_STORAGE" +dc() { :; } +docker() { [[ $1 == inspect ]] && return 1; :; } +__priv_remove 2>/dev/null && fail "removal accepted stacked storage mount" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "rejected removal changed state" +umount -- "$EXPECTED_STORAGE" +dc() { return 1; } +__priv_remove 2>/dev/null && fail "removal deleted data after docker-compose down failed" +[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed down changed data or compose" +dc() { :; } +__priv_remove +[[ ! -e $HOME/.windows/disk.img ]] || fail "removal preserved disk data" +[[ -e $HOME/Windows/keep.txt ]] || fail "removal deleted shared data" +[[ ! -f $COMPOSE ]] || fail "removal left compose" +resolve_caller +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "removal left binds" +pass "removal rejects stacks, deletes disk, and preserves shared files" + +# Credentials replace a planted link rather than following it, and a failed +# atomic rename preserves the last complete private file. +credentials_dir="$TMPDIR/credentials" +CREDENTIALS_FILE="$credentials_dir/credentials" +credentials_victim="$TMPDIR/credentials-victim" +mkdir -m 0755 -p "$credentials_dir" +printf 'victim\n' >"$credentials_victim" +ln -s "$credentials_victim" "$CREDENTIALS_FILE" +write_credentials carol 'p=a$$w"x' +[[ -f $CREDENTIALS_FILE && ! -L $CREDENTIALS_FILE ]] || fail "credentials did not replace a planted symlink" +[[ $(stat -c '%a' "$credentials_dir") == 700 && $(stat -c '%a' "$CREDENTIALS_FILE") == 600 ]] || fail "credentials path is not private" +[[ $(cat "$credentials_victim") == victim ]] || fail "credentials write changed a symlink victim" +[[ $(read_credential USERNAME) == carol && $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "credentials did not round-trip" +credentials_before=$(cat "$CREDENTIALS_FILE") +mv() { return 1; } +write_credentials changed replacement 2>/dev/null && fail "forced credentials rename failure succeeded" +unset -f mv +[[ $(cat "$CREDENTIALS_FILE") == "$credentials_before" ]] || fail "failed credentials rename replaced the live file" +! find "$credentials_dir" -name '.credentials.*' -print -quit | grep -q . || fail "failed credentials write left a temporary file" +pass "credentials are atomically replaced as a private regular file" + +# Free-space accounting follows the real storage target. +reset_case +mkdir -p "$external_storage" "$HOME/Windows" +ln -s "$external_storage" "$HOME/.windows" +prepare_user_mount_sources +df_log="$TMPDIR/df-path" +df() { + printf '%s\n' "${!#}" >"$df_log" + printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nmock 104857600 0 94371840 0%% /mock\n' +} +[[ $(available_storage_gb) == 90 ]] || fail "free-space parsed wrong value" +unset -f df +[[ $(cat "$df_log") == "$external_storage" ]] || fail "free-space used home filesystem" +pass "disk-space checks follow the storage symlink target" diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh new file mode 100644 index 00000000..509143b2 --- /dev/null +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# Exercise the real EUID-0/PKEXEC_UID boundary in an isolated user+mount namespace. + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if ((EUID != 0)); then + if unshare --user --map-auto --map-root-user --mount true 2>/dev/null; then + exec unshare --user --map-auto --map-root-user --mount --propagation private bash "$0" + fi + pass "automatic subordinate-id namespace unavailable; skipping root Windows VM boundary probe" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Hide host state before creating the production paths used by the root helper. +mount -t tmpfs -o mode=0755,size=8m run-test /run +mkdir -p /run/lock +mount -t tmpfs -o mode=0755,size=16m var-test /var +mkdir -p /var/lib/omarchy +mount -t tmpfs -o mode=0755,size=16m home-parent /home +mkdir /home/alice +mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice + +export HOME=/home/alice +unset OMARCHY_WINDOWS_DIR +set -- help +source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 + +# The namespace maps the host filesystem's uid 0 to nobody. Only / remains on +# that filesystem; all paths the helper mutates are isolated tmpfs mounts. +stat() { + if [[ ${!#} == / && $* == *"%u"* ]]; then printf '0\n'; return; fi + command stat "$@" +} + +TEST_PASSWD_HOME=/home/alice +getent() { + if [[ $1 == passwd && ${2:-} == 1000 ]]; then + printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME" + return 0 + fi + return 2 +} + +assert_no_runtime_mutation() { + [[ ! -e /var/lib/omarchy/windows && ! -L /var/lib/omarchy/windows ]] || + fail "$1 mutated the production runtime" +} + +unset PKEXEC_UID +resolve_caller 2>/dev/null && fail "root accepted missing PKEXEC_UID" +assert_no_runtime_mutation "missing PKEXEC_UID" +PKEXEC_UID=0 +resolve_caller 2>/dev/null && fail "root accepted PKEXEC_UID=0" +assert_no_runtime_mutation "zero PKEXEC_UID" +PKEXEC_UID=not-a-number +resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID" +assert_no_runtime_mutation "nonnumeric PKEXEC_UID" +PKEXEC_UID=1001 +resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd" +assert_no_runtime_mutation "missing passwd entry" + +PKEXEC_UID=1000 +resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller" +assert_no_runtime_mutation "wrong-owned home" +chown 1000:1000 /home/alice + +chmod 0777 /home +resolve_caller 2>/dev/null && fail "root accepted writable home parent" +assert_no_runtime_mutation "writable parent" +chmod 0755 /home + +mkdir /home/real-alice +chown 1000:1000 /home/real-alice +ln -s /home/real-alice /home/link-alice +TEST_PASSWD_HOME=/home/link-alice +resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home" +assert_no_runtime_mutation "symlinked home" +TEST_PASSWD_HOME=/home/alice +resolve_caller || fail "valid root PKEXEC_UID/home boundary was rejected" +pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and writable-parent boundaries without mutation" + +# Put each familiar source on its own filesystem. Both start with legacy 0755 +# permissions and world-readable payloads to prove migration hardens the leaves. +mkdir /home/storage-target /home/shared-target +mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target +mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target +ln -s /home/storage-target /home/alice/.windows +ln -s /home/shared-target /home/alice/Windows +chown -h 1000:1000 /home/alice/.windows /home/alice/Windows +printf disk >/home/storage-target/disk.img +printf shared >/home/shared-target/shared.txt +chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt +chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt + +home_dev=$(command stat -Lc '%d' /home/alice) +storage_dev=$(command stat -Lc '%d' /home/storage-target) +[[ $home_dev != "$storage_dev" ]] || fail "storage target did not land on a separate filesystem" + +with_vm_lock prepare_caller_mounts || fail "root could not create verified production bind anchors" +resolve_caller +[[ $(readlink /home/alice/.windows) == /home/storage-target && + $(readlink /home/alice/Windows) == /home/shared-target ]] || fail "root consumed legitimate symlinks" +[[ $(command stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(command stat -Lc '%d:%i' /home/storage-target) ]] || fail "storage bind identity differs from pinned source" +[[ $(command stat -Lc '%d:%i' "$EXPECTED_SHARED") == $(command stat -Lc '%d:%i' /home/shared-target) ]] || fail "shared bind identity differs from pinned source" +[[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem" +[[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 && + $(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes" +[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 && + $(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700" +if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then + fail "another local account read the VM disk through its anchor" +fi +if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then + fail "another local account read shared files through their anchor" +fi +pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account" + +expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}') +actual_space=$(available_storage_gb) +[[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem" +[[ $(command df -P -- /home/alice | awk 'NR==2 {print int($4/1024/1024)}') != "$actual_space" ]] || fail "test filesystems do not distinguish home from storage" +pass "disk-space accounting measures the actual storage filesystem, not home" + +# Exercise the real root writer and final guard against the production paths. +printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=alice\nPASSWORD=pw\nTZ=UTC\n' | + with_vm_lock __priv_write_compose +[[ $(command stat -Lc '%u:%a' "$COMPOSE_FILE") == 0:640 ]] || fail "root compose ownership/mode is wrong" +with_vm_lock assert_mounts_safe || fail "final root mount/compose assertion rejected the verified pair" +pass "root writer and final pre-Docker guard revalidate the pinned production mounts" + +# Upgrade the exact sibling-anchor pair emitted by the earlier fix without +# moving or replacing either familiar home symlink. +sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE" +sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE" +compose_needs_mount_migration || fail "previous protected anchor pair was not recognized for upgrade" +with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors" +grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor" +grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor" +[[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link" +pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary" + +# Preflight both sources before either bind on a clean anchor pair. +umount "$EXPECTED_SHARED" +umount "$EXPECTED_STORAGE" +rm /home/alice/Windows +ln -s / /home/alice/Windows +chown -h 1000:1000 /home/alice/Windows +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source" +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind" +[[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink" +pass "root preflights both sources before mounting either and preserves rejection evidence" + +# mountpoint(1) follows symlinks, so explicitly pin the invariant that even a +# root-planted anchor symlink to the expected mounted source is rejected. +rm /home/alice/Windows +ln -s /home/shared-target /home/alice/Windows +chown -h 1000:1000 /home/alice/Windows +rmdir "$EXPECTED_STORAGE" +ln -s /home/storage-target "$EXPECTED_STORAGE" +storage_id=$(command stat -Lc '%d:%i' /home/storage-target) +mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && fail "symlink mount anchor passed final identity check" +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root followed a symlink mount anchor" +[[ -L $EXPECTED_STORAGE ]] || fail "rejected anchor symlink was consumed" +pass "final guard rejects a symlink even when it resolves to the expected mounted source" From 4fc14173b79ad42b6f67550248c1810f71648398 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Fri, 28 Aug 2026 19:37:04 +0100 Subject: [PATCH 47/73] [Security] Add Windows VM boundary race regressions --- test/shell.d/windows-vm-compose-test.sh | 23 +++++++++++++++++++ .../shell.d/windows-vm-mount-boundary-test.sh | 20 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 77c298a8..0495e1e0 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -183,6 +183,29 @@ resolve_caller [[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned" pass "legitimate caller-owned symlinks remain in place" +# Reproduce the original post-validation race at the last possible moment: +# replace the familiar shared path with / only after the final guard returns, +# inside the mocked Docker Compose invocation. Compose must still consume the +# protected anchor bound to the inode that was validated earlier. +raced_shared="$HOME/Windows.before-race" +shared_id_before_race=$(stat -Lc '%d:%i' "$external_shared2") +race_ran=0 +dc() { + [[ $1 == up && ${2:-} == -d ]] || return 1 + mv -T -- "$HOME/Windows" "$raced_shared" + ln -s / "$HOME/Windows" + race_ran=1 + [[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1 + [[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$shared_id_before_race" ]] || return 1 +} +__priv_up || fail "post-validation home-path swap changed the Docker mount source" +(( race_ran == 1 )) || fail "post-validation race hook did not run" +[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "race did not replace the familiar shared path" +rm "$HOME/Windows" +mv -T -- "$raced_shared" "$HOME/Windows" +unset -f dc +pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode" + # Same-inode sources fail before mounting and close both descriptors. reset_case same="$TMPDIR/same-source" diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index 509143b2..a49e5652 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -119,6 +119,26 @@ if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared fi pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account" +# Existing production boundary components are never repaired in place when +# their ownership or write permissions are unsafe. Both the preparation path +# and the final pre-Docker guard must fail closed without disturbing the binds. +chmod 0731 "$MOUNT_ROOT" +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a group-writable mount boundary instead of rejecting it" +mounts_ready 2>/dev/null && fail "final guard accepted a group-writable mount boundary" +[[ $(command stat -Lc '%a' "$MOUNT_ROOT") == 731 ]] || fail "rejection unexpectedly changed the writable boundary" +chmod 0711 "$MOUNT_ROOT" + +chown 1000:1000 "$USERS_DIR" +with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a caller-owned mount boundary instead of rejecting it" +mounts_ready 2>/dev/null && fail "final guard accepted a caller-owned mount boundary" +[[ $(command stat -Lc '%u' "$USERS_DIR") == 1000 ]] || fail "rejection unexpectedly changed the boundary owner" +chown root:root "$USERS_DIR" + +[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && + $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "boundary rejection changed the verified mount pair" +mounts_ready || fail "restored production boundaries were rejected" +pass "root rejects wrong-owned and group-writable production mount boundaries without mutation" + expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}') actual_space=$(available_storage_gb) [[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem" From 5925929cb6c72a8fe4de3ac297179a0020cdbcfa Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 28 Aug 2026 19:00:03 -0400 Subject: [PATCH 48/73] Stop the browser policy EXIT trap from reporting a clean run as failed --- bin/omarchy-theme-set-browser-policy | 7 ++++++- test/shell.d/browser-policy-dir-test.sh | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy index 6f628f9b..ccd07722 100755 --- a/bin/omarchy-theme-set-browser-policy +++ b/bin/omarchy-theme-set-browser-policy @@ -76,8 +76,13 @@ require_root "$color" failed=0 staged="" +# Bash 5.3 makes the EXIT trap's last command decide the script's exit status, +# so this handler must not end on a false test. Every successful run clears +# staged, and a trailing `[[ -n $staged ]] && ...` would report that as failure. cleanup() { - [[ -n $staged ]] && rm -f "$staged" + if [[ -n $staged ]]; then + rm -f "$staged" + fi } trap cleanup EXIT diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 20b8067c..39a87c2b 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -279,6 +279,21 @@ grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null || fail "omarchy-theme-set-browser exits non-zero when a policy write fails" pass "omarchy-theme-set-browser exits non-zero when a policy write fails" +# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a +# handler ending on a false test turns a clean run into a failure and aborts the +# migration that calls this through omarchy-theme-set-browser. +policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy") +[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler" +eval "$policy_cleanup" +staged="" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged" +staged=$test_tmp/staged-policy +: >"$staged" +cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file" +[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file" +unset -f cleanup +pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" + policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" From 6b10dbf1910f77deb3ac1082adc6e55101efe1cc Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 28 Aug 2026 19:00:03 -0400 Subject: [PATCH 49/73] Harden Firefox policy dirs even when the theme refresh fails --- migrations/1787515927.sh | 5 ++++- test/shell.d/browser-policy-dir-test.sh | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/migrations/1787515927.sh b/migrations/1787515927.sh index f1f33869..9adae003 100644 --- a/migrations/1787515927.sh +++ b/migrations/1787515927.sh @@ -9,8 +9,11 @@ for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do repaired=1 done +# Repainting the policy color is cosmetic and the next theme change redoes it. +# Under bash -euo pipefail a failure here would abort the migration before the +# Firefox directories below are hardened, and the marker would never be written. if (( repaired )); then - omarchy-theme-set-browser + omarchy-theme-set-browser || true fi for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do diff --git a/test/shell.d/browser-policy-dir-test.sh b/test/shell.d/browser-policy-dir-test.sh index 39a87c2b..0d66d216 100755 --- a/test/shell.d/browser-policy-dir-test.sh +++ b/test/shell.d/browser-policy-dir-test.sh @@ -294,6 +294,10 @@ cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a st unset -f cleanup pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status" +grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null || + fail "the policy-directory migration hardens Firefox even when the theme refresh fails" +pass "the policy-directory migration does not abort on a failed theme refresh" + policy_files=( "$ROOT/bin/omarchy-install-browser" "$ROOT/bin/omarchy-provision-owner" From 74997fd523f4862964322fec38ae35dc71b9b822 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 02:18:44 -0400 Subject: [PATCH 50/73] Ship CUPS authorization through settings package --- etc/cups/cups-files.conf | 90 ++++++++++++++++ install/config/all.sh | 1 - install/config/printing.sh | 83 --------------- install/post-install/pacman.sh | 10 +- migrations/1787815267.sh | 24 ++++- test/acceptance.d/system-test.sh | 2 +- test/shell.d/cups-hardening-test.sh | 153 +++++++++++----------------- 7 files changed, 180 insertions(+), 183 deletions(-) create mode 100644 etc/cups/cups-files.conf delete mode 100644 install/config/printing.sh diff --git a/etc/cups/cups-files.conf b/etc/cups/cups-files.conf new file mode 100644 index 00000000..e515117f --- /dev/null +++ b/etc/cups/cups-files.conf @@ -0,0 +1,90 @@ +# +# File/directory/user/group configuration file for the CUPS scheduler. +# See "man cups-files.conf" for a complete description of this file. +# + +# List of events that are considered fatal errors for the scheduler... +#FatalErrors config + +# Strip domain in local username? +#StripUserDomain No + +# Do we call fsync() after writing configuration or status files? +#SyncOnClose No + +# Default user and group for filters/backends/helper programs; this cannot be +# any user or group that resolves to ID 0 for security reasons... +User 209 +Group 209 + +# Administrator user group, used to match @SYSTEM in cupsd.conf policy rules... +# This cannot contain the Group value for security reasons... +SystemGroup cups-browsed sys root + + +# Are Unix domain socket peer credentials used for authorization? +PeerCred on + +# User that is substituted for unauthenticated (remote) root accesses... +#RemoteRoot remroot + +# Do we allow file: device URIs other than to /dev/null? +#FileDevice No + +# Permissions for configuration and log files... +#ConfigFilePerm 0640 +#LogFilePerm 0644 + +# Location of the file logging all access to the scheduler; may be the name +# "syslog". If not an absolute path, the value of ServerRoot is used as the +# root directory. Also see the "AccessLogLevel" directive in cupsd.conf. +AccessLog /var/log/cups/access_log + +# Location of cache files used by the scheduler... +#CacheDir /var/cache/cups + +# Location of data files used by the scheduler... +#DataDir /usr/share/cups + +# Location of the static web content served by the scheduler... +#DocumentRoot /usr/share/cups/doc + +# Location of the file logging all messages produced by the scheduler and any +# helper programs; may be the name "syslog". If not an absolute path, the value +# of ServerRoot is used as the root directory. Also see the "LogLevel" +# directive in cupsd.conf. +ErrorLog /var/log/cups/error_log + +# Location of the file logging all pages printed by the scheduler and any +# helper programs; may be the name "syslog". If not an absolute path, the value +# of ServerRoot is used as the root directory. Also see the "PageLogFormat" +# directive in cupsd.conf. +PageLog /var/log/cups/page_log + +# Location of the file listing all of the local printers... +#Printcap /etc/printcap + +# Format of the Printcap file... +#PrintcapFormat bsd +#PrintcapFormat plist +#PrintcapFormat solaris + +# Location of all spool files... +#RequestRoot /var/spool/cups + +# Location of helper programs... +#ServerBin /usr/lib/cups + +# SSL/TLS keychain for the scheduler... +#ServerKeychain ssl + +# Location of other configuration files... +#ServerRoot /etc/cups + +# Location of scheduler state files... +#StateDir /run/cups + +# Location of scheduler/helper temporary files. This directory is emptied on +# scheduler startup and cannot be one of the standard (public) temporary +# directory locations for security reasons... +#TempDir /var/spool/cups/tmp diff --git a/install/config/all.sh b/install/config/all.sh index aa1044f1..91256dc7 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,6 +7,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/locate.sh" -run_logged "$OMARCHY_INSTALL/config/printing.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/printing.sh b/install/config/printing.sh deleted file mode 100644 index 31c969ea..00000000 --- a/install/config/printing.sh +++ /dev/null @@ -1,83 +0,0 @@ -# cups-browsed manages queues through CUPS and does not need Unix root. Give -# only its locked service account passwordless CUPS administration; interactive -# users go through cups-pk-helper and Polkit instead. -cups_files_conf="${OMARCHY_CUPS_FILES_CONF:-/etc/cups/cups-files.conf}" -cups_browsed_sysusers_conf="${OMARCHY_CUPS_BROWSED_SYSUSERS_CONF:-/etc/sysusers.d/omarchy-cups-browsed.conf}" - -if [[ -f $cups_browsed_sysusers_conf ]]; then - systemd-sysusers "$cups_browsed_sysusers_conf" -fi - -if [[ -L $cups_files_conf ]]; then - echo "Refusing to rewrite symlinked CUPS authorization config: $cups_files_conf" >&2 - false -elif [[ -f $cups_files_conf ]]; then - staged_conf=$(mktemp --tmpdir="${cups_files_conf%/*}" ".${cups_files_conf##*/}.XXXXXX") - - if ! awk ' - NR == FNR { - if (tolower($1) == "systemgroup") { - for (i = 2; i <= NF; i++) { - if (substr($i, 1, 1) == "#") - break - if ($i != "wheel" && !seen_group[$i]) { - system_groups[++system_group_count] = $i - seen_group[$i] = 1 - } - } - } - next - } - - tolower($1) == "systemgroup" { - comment_start = index($0, "#") - if (!wrote_system_group) { - printf "SystemGroup" - for (i = 1; i <= system_group_count; i++) - printf " %s", system_groups[i] - if (!seen_group["cups-browsed"]) - printf " cups-browsed" - if (comment_start) - printf " %s", substr($0, comment_start) - print "" - wrote_system_group = 1 - } else if (comment_start) { - print substr($0, comment_start) - } - next - } - - tolower($1) == "peercred" { - comment_start = index($0, "#") - if (!saw_peer_cred) { - printf "PeerCred on" - if (comment_start) - printf " %s", substr($0, comment_start) - print "" - } else if (comment_start) { - print substr($0, comment_start) - } - saw_peer_cred = 1 - next - } - - { print } - - END { - if (!wrote_system_group) - print "SystemGroup sys root cups-browsed" - if (!saw_peer_cred) - print "PeerCred on" - } - ' "$cups_files_conf" "$cups_files_conf" >"$staged_conf"; then - rm -f "$staged_conf" - false - fi - - if ! chmod --reference="$cups_files_conf" "$staged_conf" || - ! chown --reference="$cups_files_conf" "$staged_conf" || - ! mv -f "$staged_conf" "$cups_files_conf"; then - rm -f "$staged_conf" - false - fi -fi diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 23580b3c..327b812f 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,11 +3,13 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# omarchy-settings skips this override until cups-browsed is actually present -# to avoid pacman creating cups-browsed.conf.pacnew during ISO package install. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -d /etc/cups ]]; then +# omarchy-settings skips these overrides until CUPS is actually present to +# avoid pacman creating .pacnew files during ISO package installation. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-files.conf ]]; then + systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew + install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf + rm -f /etc/cups/cups-browsed.conf.pacnew /etc/cups/cups-files.conf.pacnew fi source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh index 31a8e797..b3f9282a 100644 --- a/migrations/1787815267.sh +++ b/migrations/1787815267.sh @@ -4,6 +4,28 @@ machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/178 [[ ! -e $machine_marker ]] || exit 0 +# Existing releases allowed a desktop user or shared group named cups-browsed, +# which systemd-sysusers would silently reuse for passwordless CUPS access. +if omarchy-pkg-present cups; then + cups_browsed_account=$(getent passwd cups-browsed || true) + cups_browsed_group=$(getent group cups-browsed || true) + + if [[ -n $cups_browsed_account || -n $cups_browsed_group ]]; then + IFS=: read -r _ _ cups_browsed_uid cups_browsed_gid cups_browsed_description cups_browsed_home cups_browsed_shell <<<"$cups_browsed_account" + IFS=: read -r _ _ cups_browsed_group_gid cups_browsed_group_members <<<"$cups_browsed_group" + other_primary_user=$(getent passwd | awk -F: -v gid="$cups_browsed_gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }') + + if [[ ! $cups_browsed_uid =~ ^[0-9]+$ || ! $cups_browsed_group_gid =~ ^[0-9]+$ ]] || + ((cups_browsed_uid <= 0 || cups_browsed_uid >= 1000)) || + [[ $cups_browsed_gid != $cups_browsed_group_gid ]] || + [[ $cups_browsed_description != "CUPS printer discovery" || $cups_browsed_home != "/" || $cups_browsed_shell != "/usr/bin/nologin" ]] || + [[ -n $cups_browsed_group_members || -n $other_primary_user ]]; then + echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2 + false + fi + fi +fi + # CUPS-PDF accepts a job-controlled post-processing command in a backend that # CUPS launches as root. Native application print-to-file support replaces it. omarchy-pkg-drop cups-pdf @@ -20,8 +42,6 @@ if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then fi if omarchy-pkg-present cups; then - sudo env OMARCHY_PATH="$OMARCHY_PATH" \ - bash -euo pipefail "$OMARCHY_PATH/install/config/printing.sh" sudo systemctl daemon-reload sudo systemctl try-reload-or-restart cups.service fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index c5c8da5c..899cc6e7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -86,7 +86,7 @@ verify_printing_security() { [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" - ! id -nG cups-browsed | grep -qw cups || + [[ " $(id -nG cups-browsed) " != *" cups "* ]] || fail "cups-browsed is separate from the print-filter group" if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 2f0bc96a..96110fff 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" packages="$ROOT/install/omarchy-base.packages" cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf" +cups_files_conf="$ROOT/etc/cups/cups-files.conf" sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" @@ -26,6 +27,21 @@ grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" || pass "cups-browsed uses explicit supported discovery policy and an isolated cache" +grep -qxF 'SystemGroup cups-browsed sys root' "$cups_files_conf" || + fail "only the printer discovery account receives passwordless CUPS administration" +grep -qxF 'PeerCred on' "$cups_files_conf" || + fail "the packaged CUPS policy enables peer credentials" +[[ $(grep -ciE '^[[:space:]]*SystemGroup[[:space:]]' "$cups_files_conf") == 1 ]] || + fail "the packaged CUPS policy has one SystemGroup directive" +[[ $(grep -ciE '^[[:space:]]*PeerCred[[:space:]]' "$cups_files_conf") == 1 ]] || + fail "the packaged CUPS policy has one PeerCred directive" +[[ ! -e $ROOT/install/config/printing.sh ]] || + fail "printing policy is not rewritten by an install script" +! grep -q 'config/printing.sh' "$ROOT/install/config/all.sh" "$ROOT/migrations/1787815267.sh" || + fail "neither install nor update invokes a printing rewrite script" + +pass "CUPS authorization ships as a canonical package override" + grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" || fail "a locked cups-browsed system account is declared" @@ -53,15 +69,25 @@ test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/etc/cups" "$test_tmp/var/lib/omarchy/migrations" +mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" -cat >"$mock_bin/systemd-sysusers" <<'SH' +passwd_db="$test_tmp/passwd" +group_db="$test_tmp/group" +touch "$passwd_db" "$group_db" + +cat >"$mock_bin/getent" <<'SH' #!/bin/bash -printf 'sysusers\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" -SH -cat >"$mock_bin/chown" <<'SH' -#!/bin/bash -printf 'chown\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +case "$1" in + passwd) database="$OMARCHY_CUPS_TEST_PASSWD" ;; + group) database="$OMARCHY_CUPS_TEST_GROUP" ;; + *) exit 2 ;; +esac + +if (($# == 1)); then + cat "$database" +else + awk -F: -v name="$2" '$1 == name { print; found = 1 } END { exit !found }' "$database" +fi SH cat >"$mock_bin/omarchy-pkg-present" <<'SH' #!/bin/bash @@ -85,62 +111,40 @@ exec "$@" SH chmod +x "$mock_bin"/* -authorization_conf="$test_tmp/etc/cups/cups-files.conf" -cat >"$authorization_conf" <<'CONF' -# Keep this custom preamble. -SystemGroup sys root wheel custom-admin wheel # Keep this inline comment. -SystemGroup wheel print-operators # Keep this second inline comment. -PeerCred off # Keep this PeerCred comment. -PeerCred off # Keep this second PeerCred comment. -CONF - log="$test_tmp/actions.log" +touch "$log" export OMARCHY_CUPS_TEST_LOG="$log" +export OMARCHY_CUPS_TEST_PASSWD="$passwd_db" +export OMARCHY_CUPS_TEST_GROUP="$group_db" -run_printing_setup() { - PATH="$mock_bin:$PATH" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ - bash -euo pipefail "$ROOT/install/config/printing.sh" -} - -run_printing_setup - -grep -qxF 'SystemGroup sys root custom-admin print-operators cups-browsed # Keep this inline comment.' "$authorization_conf" || - fail "printing setup reserves CUPS administration for the service account" -grep -qxF '# Keep this second inline comment.' "$authorization_conf" || - fail "printing setup preserves comments from consolidated SystemGroup directives" -grep -qxF 'PeerCred on # Keep this PeerCred comment.' "$authorization_conf" || - fail "printing setup enables peer credentials for the service account" -grep -qxF '# Keep this second PeerCred comment.' "$authorization_conf" || - fail "printing setup preserves comments from duplicate PeerCred directives" -grep -qxF '# Keep this custom preamble.' "$authorization_conf" || - fail "printing setup preserves unrelated CUPS configuration" -[[ $(grep -c '^SystemGroup ' "$authorization_conf") == 1 ]] || - fail "printing setup emits one SystemGroup directive" - -cp "$authorization_conf" "$test_tmp/first-run.conf" -run_printing_setup -cmp -s "$authorization_conf" "$test_tmp/first-run.conf" || - fail "printing setup is idempotent" - -pass "printing setup narrows CUPS authorization without clobbering other configuration" - -ln -s "$authorization_conf" "$test_tmp/etc/cups/symlinked.conf" +printf 'cups-browsed:x:1000:1000:Desktop user:/home/cups-browsed:/usr/bin/bash\n' >"$passwd_db" +printf 'cups-browsed:x:1000:\n' >"$group_db" if PATH="$mock_bin:$PATH" \ - OMARCHY_CUPS_FILES_CONF="$test_tmp/etc/cups/symlinked.conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ - bash -euo pipefail "$ROOT/install/config/printing.sh" 2>/dev/null; then - fail "printing setup refuses a symlinked authorization file" + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/desktop-collision-marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then + fail "the migration accepts an existing desktop user named cups-browsed" fi +[[ ! -s $log ]] || fail "an account collision stops the migration before changing the system" -pass "printing setup refuses to rewrite a symlinked privileged configuration" +printf 'alice:x:1000:947:Desktop user:/home/alice:/usr/bin/bash\n' >"$passwd_db" +printf 'cups-browsed:x:947:alice\n' >"$group_db" +if PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/group-collision-marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then + fail "the migration accepts an existing cups-browsed group with members" +fi +[[ ! -s $log ]] || fail "a group collision stops the migration before changing the system" + +printf 'cups-browsed:x:947:947:CUPS printer discovery:/:/usr/bin/nologin\n' >"$passwd_db" +printf 'cups-browsed:x:947:\n' >"$group_db" + +pass "the migration rejects account and group collisions before changing printing" marker="$test_tmp/var/lib/omarchy/migrations/1787815267" PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ bash -euo pipefail "$ROOT/migrations/1787815267.sh" @@ -153,7 +157,7 @@ grep -qxF $'systemctl\tstop cups-browsed.service' "$log" || grep -qxF $'systemctl\tdaemon-reload' "$log" || fail "the migration reloads the hardened service" grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" || - fail "the migration applies narrowed CUPS authorization" + fail "the migration reloads the packaged CUPS authorization" grep -qxF $'systemctl\trestart cups-browsed.service' "$log" || fail "the migration resumes an active cups-browsed service" [[ -f $marker ]] || fail "the migration records machine-wide completion" @@ -168,10 +172,8 @@ PATH="$mock_bin:$PATH" \ pass "the migration safely converts an active existing installation once" -# An interrupted earlier run leaves cups-browsed stopped, so the retry that -# follows finds it inactive. It must still be restarted: the retry records the -# machine-wide marker either way, so a restart skipped here would leave printer -# discovery off until the next reboot with nothing left to run. +# An interrupted earlier run leaves cups-browsed stopped. A retry still needs +# to resume an enabled service before recording completion. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" @@ -186,8 +188,6 @@ retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry" OMARCHY_CUPS_TEST_LOG="$retry_log" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \ bash -euo pipefail "$ROOT/migrations/1787815267.sh" @@ -196,8 +196,7 @@ grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" || pass "a run following an interrupted one still resumes printer discovery" -# A unit the user masked or disabled reports not-enabled, and restarting it -# would fail and abort the migration before it records completion. +# A masked or disabled unit is deliberately left alone. cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" @@ -212,8 +211,6 @@ masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked" OMARCHY_CUPS_TEST_LOG="$masked_log" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \ bash -euo pipefail "$ROOT/migrations/1787815267.sh" @@ -222,31 +219,3 @@ OMARCHY_CUPS_TEST_LOG="$masked_log" \ [[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" pass "a masked or disabled cups-browsed is left alone and does not fail the migration" - -# cupsd compares directive names case-insensitively, so a hand-edited lowercase -# directive is live configuration. Matching it exactly would skip the line and -# append a second one, and cupsd accumulates the groups of every SystemGroup -# directive it reads -- leaving wheel with passwordless administration. -lowercase_conf="$test_tmp/etc/cups/lowercase.conf" -cat >"$lowercase_conf" <<'CONF' -systemgroup sys root wheel -peercred off -CONF - -PATH="$mock_bin:$PATH" \ - OMARCHY_CUPS_FILES_CONF="$lowercase_conf" \ - OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ - bash -euo pipefail "$ROOT/install/config/printing.sh" - -! grep -qiE '^[[:space:]]*systemgroup\b.*\bwheel\b' "$lowercase_conf" || - fail "printing setup removes wheel from a lowercase SystemGroup directive" "$(cat "$lowercase_conf")" -[[ $(grep -ciE '^[[:space:]]*systemgroup\b' "$lowercase_conf") == 1 ]] || - fail "printing setup leaves one SystemGroup directive whatever case it was written in" "$(cat "$lowercase_conf")" -grep -qxF 'SystemGroup sys root cups-browsed' "$lowercase_conf" || - fail "printing setup reserves administration for the service account" "$(cat "$lowercase_conf")" -[[ $(grep -ciE '^[[:space:]]*peercred\b' "$lowercase_conf") == 1 ]] || - fail "printing setup leaves one PeerCred directive" "$(cat "$lowercase_conf")" -grep -qxF 'PeerCred on' "$lowercase_conf" || - fail "printing setup enables peer credentials whatever case they were written in" "$(cat "$lowercase_conf")" - -pass "printing setup rewrites directives cupsd reads case-insensitively" From 2b91fdc0d3d6e8e5c9c659abda685e48f1e1f167 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 25 Aug 2026 18:42:15 +0100 Subject: [PATCH 51/73] [Security] Pin Plymouth asset reads before elevation --- bin/omarchy-plymouth-set | 19 ++++- test/shell.d/plymouth-set-test.sh | 130 ++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 30 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 1fd70d50..56426f1f 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -51,6 +51,15 @@ theme_dir="/usr/share/plymouth/themes/omarchy" staging_dir=$(mktemp -d) trap 'rm -rf "$staging_dir"' EXIT +# Publish a staged asset without ever asking a privileged process to resolve +# its user-writable source path. The shell opens source before sudo starts, so +# the inherited descriptor pins the bytes and a replacement symlink to a +# root-only file either loses the race or fails under the caller's permissions. +publish_asset() { + local source="$1" destination="$2" + sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" +} + find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + cp "$logo_path" "$staging_dir/logo.png" @@ -63,7 +72,9 @@ for asset in bullet.png entry.png lock.png progress_bar.png; do magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" done -sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/" +for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do + publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -81,12 +92,12 @@ sed \ -e "s/#ffffff/#$text_hex/g" \ "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null -sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" +publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 for asset in bullet.png entry.png lock.png; do - sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" + publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 done for asset in entry lock; do magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" + publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 done sudo rm -f "$sddm_dir/logo.svg" diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index eb8b120b..56b6447e 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -3,30 +3,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -source_dir="$test_tmp/source" -theme_dir="$test_tmp/theme" - -mkdir -m 0700 "$source_dir" -mkdir -m 0755 "$theme_dir" -touch "$source_dir/logo.png" - -cp -a --no-preserve=mode,ownership "$source_dir/." "$theme_dir/" - -[[ $(stat -c %a "$theme_dir") == "755" ]] || - fail "Plymouth asset copy preserves the theme directory permissions" - -grep -Fq \ - 'cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"' \ - "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" - -pass "Plymouth asset copy preserves the package-owned directory metadata" +trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from -# ~/.config/omarchy/themes, and both copies below land in world-readable -# /usr/share, so a symlink there would republish whatever it points at. +# ~/.config/omarchy/themes. Both installed copies are world-readable, so a +# symlink there must not republish whatever it points at. secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" @@ -34,12 +15,9 @@ ln -s "$secret" "$test_tmp/logo-link.png" output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" +((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" -grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" - pass "a themed logo cannot republish a file it merely points at" # Style > Unlock picks a theme by name and hands the answer to @@ -141,3 +119,103 @@ run_unlock_action "default" [[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")" pass "the unlock picker still applies a theme and still resets on default" + +# Exercise the full publisher with sudo and ImageMagick shims. Immediately +# after the unprivileged shell opens each staged source, the sudo shim renames +# that source away and replaces its pathname with a symlink to a simulated +# root-only secret. Reading via the inherited stdin descriptor must still +# publish the original bytes. The shim restores the source after each read so +# every Plymouth and SDDM asset gets attacked independently. +fake_bin="$test_tmp/bin" +fake_root="$test_tmp/root" +stages="$test_tmp/stages" +attack_log="$test_tmp/attacked" +sudo_log="$test_tmp/sudo.log" +mkdir -p "$fake_bin" "$fake_root" "$stages" + +cat >"$fake_bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_SUDO_LOG" + +case "$1" in +tee) + destination="$2" + mapped="$TEST_FAKE_ROOT$destination" + mkdir -p "$(dirname -- "$mapped")" + + stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) + asset=$(basename -- "$destination") + source="$stage/$asset" + pinned="$stage/.pinned-$asset" + + if [[ -n $stage && -f $source && ! -L $source ]]; then + mv -T -- "$source" "$pinned" + ln -s "$TEST_SECRET" "$source" + printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" + /usr/bin/tee "$mapped" + result=$? + rm -f -- "$source" + mv -T -- "$pinned" "$source" + exit "$result" + fi + exec /usr/bin/tee "$mapped" + ;; +chmod) + exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" + ;; +rm) + destination=${@: -1} + exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + ;; +plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 1 + ;; +esac +SH + +cat >"$fake_bin/magick" <<'SH' +#!/bin/bash +source="$1" +destination=${@: -1} +[[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" +SH + +cat >"$fake_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$fake_bin"/* + +printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +printf 'caller-selected logo\n' >"$test_tmp/logo.png" + +output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_SECRET="$secret" \ + TEST_ATTACK_LOG="$attack_log" \ + TEST_SUDO_LOG="$sudo_log" \ + bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +status=$? + +((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" + +expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' +actual_assets=$(sort -u "$attack_log") +[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" + +! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" +grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" +[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" + +if grep -F "$stages/" "$sudo_log" >/dev/null; then + fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" +fi + +pass "privileged publication uses pinned descriptors for every staged asset" From 0f15e2330f532fd018d4f3f44719a1866ec58121 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 27 Aug 2026 23:35:43 +0100 Subject: [PATCH 52/73] [Security] Harden Plymouth asset publication --- bin/omarchy-plymouth-set | 313 ++++++++++++++++++++------ bin/omarchy-refresh-plymouth | 12 +- test/shell.d/plymouth-set-test.sh | 362 ++++++++++++++++++++++++------ 3 files changed, 543 insertions(+), 144 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 56426f1f..f8f84c36 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -5,75 +5,258 @@ # omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png # omarchy:requires-sudo=true -# Configure the Plymouth boot theme with a custom background color, text color, and logo. -# Stages the change in a temp dir, then commits the staged files to /usr/share and -# rebuilds the initramfs. Also syncs the SDDM login screen (the post-logout -# screen) with the same colors and logo so boot/login stay visually unified. +set -euo pipefail -if (( $# != 3 )); then +# Configure the Plymouth boot theme with a custom background color, text color, +# and logo. Assets are prepared without privileges, pinned to their recorded +# hashes, then published one at a time through root-owned temporary files. + +refresh_default=false +if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then + refresh_default=true +elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -bg_hex="${1#\#}" -text_hex="${2#\#}" -logo_path="$3" +if ! $refresh_default; then + bg_hex="${1#\#}" + text_hex="${2#\#}" + logo_path="$3" -if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then - echo "Invalid background color: $1 (expected #RRGGBB)" >&2 - exit 1 + if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "Invalid background color: $1 (expected #RRGGBB)" >&2 + exit 1 + fi + + if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then + echo "Invalid text color: $2 (expected #RRGGBB)" >&2 + exit 1 + fi + + if [[ ! -f $logo_path ]]; then + echo "Logo file not found: $logo_path" >&2 + exit 1 + fi + + # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from + # ~/.config/omarchy/themes, where an installed theme can make it a symlink to + # anything. The copies below land in world-readable /usr/share, so following + # one would republish whatever it points at. + if [[ -L $logo_path ]]; then + echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 + exit 1 + fi fi -if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then - echo "Invalid text color: $2 (expected #RRGGBB)" >&2 - exit 1 -fi - -if [[ ! -f $logo_path ]]; then - echo "Logo file not found: $logo_path" >&2 - exit 1 -fi - -# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from -# ~/.config/omarchy/themes, where an installed theme can make it a symlink to -# anything. The copies below land in world-readable /usr/share, so following one -# would republish whatever it points at. -if [[ -L $logo_path ]]; then - echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 - exit 1 -fi - -bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') -bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') -bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') - theme_dir="/usr/share/plymouth/themes/omarchy" -staging_dir=$(mktemp -d) -trap 'rm -rf "$staging_dir"' EXIT +sddm_dir="/usr/share/sddm/themes/omarchy" +max_asset_size=$((64 * 1024 * 1024)) -# Publish a staged asset without ever asking a privileged process to resolve -# its user-writable source path. The shell opens source before sudo starts, so -# the inherited descriptor pins the bytes and a replacement symlink to a -# root-only file either loses the race or fails under the caller's permissions. -publish_asset() { +plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png +) +plymouth_default_assets=( + "${plymouth_theme_assets[@]}" + logos/oma.png +) +sddm_theme_assets=( + Main.qml + bullet.png + entry-failed.png + entry.png + lock-failed.png + lock.png + logo.png +) + +staging_dir=$(mktemp -d) +trap 'rm -rf -- "$staging_dir"' EXIT +chmod 0700 "$staging_dir" +plymouth_stage="$staging_dir/plymouth" +sddm_stage="$staging_dir/sddm" +mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + +copy_regular_file() { local source="$1" destination="$2" - sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" + + if [[ ! -f $source || -L $source ]]; then + echo "Refusing non-regular or symlinked asset: $source" >&2 + exit 1 + fi + + # -P ensures a source swapped to a symlink is copied as a symlink instead of + # followed; the post-copy check then rejects it. + cp -P --reflink=never -- "$source" "$destination" + if [[ ! -f $destination || -L $destination ]]; then + rm -f -- "$destination" + echo "Asset changed while it was being staged: $source" >&2 + exit 1 + fi + chmod 0600 "$destination" } -find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + -cp "$logo_path" "$staging_dir/logo.png" +if $refresh_default; then + assets_to_stage=("${plymouth_default_assets[@]}") +else + assets_to_stage=("${plymouth_theme_assets[@]}") +fi -sed -i \ - -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ - -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ - "$staging_dir/omarchy.script" - -for asset in bullet.png entry.png lock.png progress_bar.png; do - magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" +for asset in "${assets_to_stage[@]}"; do + copy_regular_file "$OMARCHY_PATH/default/plymouth/$asset" "$plymouth_stage/$asset" done -for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do - publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + copy_regular_file "$OMARCHY_PATH/default/sddm/omarchy/$asset" "$sddm_stage/$asset" + done + + copy_regular_file "$logo_path" "$plymouth_stage/logo.png" + copy_regular_file "$logo_path" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}') + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}') + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}') + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + sed -i \ + -e "s/#1a1b26/#$bg_hex/g" \ + -e "s/#ffffff/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done +fi + +declare -A staged_hashes=() +declare -A staged_sizes=() + +record_staged_asset() { + local source="$1" hash size + + if [[ ! -f $source || -L $source ]]; then + echo "Refusing non-regular or symlinked staged asset: $source" >&2 + exit 1 + fi + + size=$(stat -c %s -- "$source") + if (( size == 0 || size > max_asset_size )); then + echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 + exit 1 + fi + hash=$(sha256sum -- "$source") + staged_sizes["$source"]=$size + staged_hashes["$source"]=${hash%% *} +} + +# Record every asset before the first sudo prompt. Root verifies both values +# after consuming stdin, so an in-place rewrite after this point fails instead +# of changing what is published. This pins the recorded result, not the trust +# of an already-user-writable OMARCHY_PATH checkout. +for asset in "${assets_to_stage[@]}"; do + record_staged_asset "$plymouth_stage/$asset" +done +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + record_staged_asset "$sddm_stage/$asset" + done +fi + +publish_asset() { + local source="$1" destination="$2" + local expected_hash="${staged_hashes[$source]}" + local expected_size="${staged_sizes[$source]}" + + # The caller's shell opens source before sudo starts. Root reads only stdin, + # verifies the recorded bytes, and never opens or chmods the final pathname. + # Its temporary file is on the destination filesystem, so mv is atomic for + # this one asset and replaces a destination symlink instead of following it. + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH + + destination=$1 + expected_hash=$2 + expected_size=$3 + max_size=$4 + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] + [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] + (( expected_size > 0 && expected_size <= max_size )) + + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] + [[ -d $parent && ! -L $parent ]] + canonical_parent=$(realpath -e -- "$parent") + [[ $canonical_parent == "$parent" ]] + [[ $(stat -c %u -- "$parent") == 0 ]] + parent_mode=$(stat -c %a -- "$parent") + (( (8#$parent_mode & 0022) == 0 )) + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cleanup() { rm -f -- "$temporary"; } + trap cleanup EXIT HUP INT TERM + + head -c "$((expected_size + 1))" >"$temporary" + actual_size=$(stat -c %s -- "$temporary") + (( actual_size == expected_size )) + actual_hash=$(sha256sum -- "$temporary") + [[ ${actual_hash%% *} == "$expected_hash" ]] + + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + trap - EXIT HUP INT TERM + ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" +} + +remove_legacy_asset() { + local destination="$1" + + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH + + destination=$1 + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + [[ -d $parent && ! -L $parent ]] + canonical_parent=$(realpath -e -- "$parent") + [[ $canonical_parent == "$parent" ]] + [[ $(stat -c %u -- "$parent") == 0 ]] + parent_mode=$(stat -c %a -- "$parent") + (( (8#$parent_mode & 0022) == 0 )) + rm -f -- "$destination" + ' bash "$destination" +} + +for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" done sudo plymouth-set-default-theme omarchy @@ -83,21 +266,9 @@ else sudo mkinitcpio -P fi -# Sync the SDDM login screen with the same colors and logo. -sddm_dir="/usr/share/sddm/themes/omarchy" -sddm_template="$OMARCHY_PATH/default/sddm/omarchy/Main.qml" - -sed \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ - "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null - -publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 -for asset in bullet.png entry.png lock.png; do - publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 -done -for asset in entry lock; do - magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 -done -sudo rm -f "$sddm_dir/logo.svg" +if ! $refresh_default; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + remove_legacy_asset "$sddm_dir/logo.svg" +fi diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 58042dba..160e0c8a 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -3,11 +3,7 @@ # omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it. # omarchy:requires-sudo=true -sudo cp -r "$OMARCHY_PATH/default/plymouth/." /usr/share/plymouth/themes/omarchy/ -sudo plymouth-set-default-theme omarchy - -if omarchy-cmd-present limine-mkinitcpio; then - sudo limine-mkinitcpio -else - sudo mkinitcpio -P -fi +# Reuse the fixed-file publisher so root never resolves the source checkout or +# follows a destination symlink while restoring the packaged assets. +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$script_dir/omarchy-plymouth-set" --refresh-default diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 56b6447e..ba246eae 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -1,21 +1,37 @@ #!/bin/bash +set -uo pipefail + source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT +secret="$test_tmp/secret" +trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT + +plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png +) +plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) +sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # ~/.config/omarchy/themes. Both installed copies are world-readable, so a # symlink there must not republish whatever it points at. -secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" -output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) +output=$(OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" +(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" pass "a themed logo cannot republish a file it merely points at" @@ -120,66 +136,89 @@ run_unlock_action "default" pass "the unlock picker still applies a theme and still resets on default" -# Exercise the full publisher with sudo and ImageMagick shims. Immediately -# after the unprivileged shell opens each staged source, the sudo shim renames -# that source away and replaces its pathname with a symlink to a simulated -# root-only secret. Reading via the inherited stdin descriptor must still -# publish the original bytes. The shim restores the source after each read so -# every Plymouth and SDDM asset gets attacked independently. fake_bin="$test_tmp/bin" -fake_root="$test_tmp/root" +root_tools="$test_tmp/root-tools" stages="$test_tmp/stages" -attack_log="$test_tmp/attacked" -sudo_log="$test_tmp/sudo.log" -mkdir -p "$fake_bin" "$fake_root" "$stages" +mkdir -p "$fake_bin" "$root_tools" "$stages" cat >"$fake_bin/sudo" <<'SH' #!/bin/bash -printf '%s\n' "$*" >>"$TEST_SUDO_LOG" +set -u + +for argument in "$@"; do + if [[ $argument == *"$TEST_STAGES"* ]]; then + printf '%s\n' "$argument" >>"$TEST_LEAK_LOG" + fi +done case "$1" in -tee) - destination="$2" - mapped="$TEST_FAKE_ROOT$destination" - mkdir -p "$(dirname -- "$mapped")" +/bin/bash) + [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + code=$3 + shell_name=$4 + original_destination=$5 + printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) - asset=$(basename -- "$destination") - source="$stage/$asset" - pinned="$stage/.pinned-$asset" - - if [[ -n $stage && -f $source && ! -L $source ]]; then - mv -T -- "$source" "$pinned" - ln -s "$TEST_SECRET" "$source" - printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" - /usr/bin/tee "$mapped" - result=$? - rm -f -- "$source" - mv -T -- "$pinned" "$source" - exit "$result" + if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then + expected_size=${7:-0} + case "$original_destination" in + /usr/share/plymouth/themes/omarchy/*) + relative=${original_destination#/usr/share/plymouth/themes/omarchy/} + stage_kind=plymouth + ;; + /usr/share/sddm/themes/omarchy/*) + relative=${original_destination#/usr/share/sddm/themes/omarchy/} + stage_kind=sddm + ;; + *) exit 91 ;; + esac + stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) + source="$stage_root/$stage_kind/$relative" + /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" + printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" fi - exec /usr/bin/tee "$mapped" - ;; -chmod) - exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" - ;; -rm) - destination=${@: -1} - exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + + mapped_destination="$TEST_FAKE_ROOT$original_destination" + shift 5 + + # The production helper intentionally resets PATH. For this unprivileged + # simulation only, substitute stat/chown shims so a uid-1000 test directory + # behaves like the root-owned /usr/share directory used in production. + code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ + /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" exit 0 ;; *) echo "unexpected sudo command: $*" >&2 - exit 1 + exit 92 ;; esac SH +cat >"$root_tools/stat" <<'SH' +#!/bin/bash +last=${!#} +if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then + printf '0\n' + exit 0 +fi +exec /usr/bin/stat "$@" +SH + +cat >"$root_tools/chown" <<'SH' +#!/bin/bash +last=${!#} +[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +exit 0 +SH + cat >"$fake_bin/magick" <<'SH' #!/bin/bash -source="$1" +source=$1 destination=${@: -1} [[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" SH @@ -188,34 +227,227 @@ cat >"$fake_bin/omarchy-cmd-present" <<'SH' #!/bin/bash exit 1 SH -chmod +x "$fake_bin"/* -printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +chmod +x "$fake_bin"/* "$root_tools"/* + printf 'caller-selected logo\n' >"$test_tmp/logo.png" -output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ - TMPDIR="$stages" \ - OMARCHY_PATH="$ROOT" \ - TEST_FAKE_ROOT="$fake_root" \ - TEST_STAGES="$stages" \ - TEST_SECRET="$secret" \ - TEST_ATTACK_LOG="$attack_log" \ - TEST_SUDO_LOG="$sudo_log" \ - bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +setup_run() { + run_dir=$(mktemp -d "$test_tmp/run.XXXXXXXX") + fake_root="$run_dir/root" + sudo_log="$run_dir/sudo.log" + leak_log="$run_dir/leaked-stage-path.log" + mutate_log="$run_dir/mutated.log" + theme="$fake_root/usr/share/plymouth/themes/omarchy" + sddm="$fake_root/usr/share/sddm/themes/omarchy" + + mkdir -p "$theme/logos" "$sddm" + chmod 0755 \ + "$fake_root/usr" \ + "$fake_root/usr/share" \ + "$fake_root/usr/share/plymouth" \ + "$fake_root/usr/share/plymouth/themes" \ + "$theme" \ + "$theme/logos" \ + "$fake_root/usr/share/sddm" \ + "$fake_root/usr/share/sddm/themes" \ + "$sddm" + + local asset destination + for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + printf 'old plymouth %s\n' "$asset" >"$destination" + chmod 0600 "$destination" + done + for asset in "${sddm_theme_assets[@]}" metadata.desktop theme.conf; do + destination="$sddm/$asset" + printf 'old sddm %s\n' "$asset" >"$destination" + chmod 0600 "$destination" + done + + plymouth_victim="$run_dir/plymouth-victim" + sddm_victim="$run_dir/sddm-victim" + legacy_victim="$run_dir/legacy-victim" + printf 'PLYMOUTH VICTIM\n' >"$plymouth_victim" + printf 'SDDM VICTIM\n' >"$sddm_victim" + printf 'LEGACY VICTIM\n' >"$legacy_victim" + chmod 0600 "$plymouth_victim" "$sddm_victim" "$legacy_victim" + + rm -f "$theme/omarchy.script" "$sddm/Main.qml" + ln -s "$plymouth_victim" "$theme/omarchy.script" + ln -s "$sddm_victim" "$sddm/Main.qml" + ln -s "$legacy_victim" "$sddm/logo.svg" +} + +run_set() { + local requested_umask="$1" + shift + ( + umask "$requested_umask" + PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_ROOT_TOOLS="$root_tools" \ + TEST_SUDO_LOG="$sudo_log" \ + TEST_LEAK_LOG="$leak_log" \ + TEST_MUTATE_LOG="$mutate_log" \ + "$@" \ + /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" + ) +} + +assert_no_temporary_files() { + local directory="$1" leftovers + leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) + [[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers" +} + +for requested_umask in 022 027 077; do + setup_run + output=$(run_set "$requested_umask" env 2>&1) + status=$? + (( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output" + + for asset in "${plymouth_theme_assets[@]}"; do + destination="$theme/$asset" + [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" + [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" + [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" + [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" + done + for asset in "${sddm_theme_assets[@]}"; do + destination="$sddm/$asset" + [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" + [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" + [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" + [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" + done + + cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" + cmp -s "$test_tmp/logo.png" "$sddm/logo.png" || fail "SDDM receives the selected logo under umask $requested_umask" + grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask" + grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask" + + [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "Plymouth destination symlink never changes its victim" + [[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "Main.qml destination symlink never changes its victim" + [[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "legacy logo.svg removal never changes its victim" + [[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "legacy logo.svg is removed" + + [[ $(cat "$theme/logos/oma.png") == 'old plymouth logos/oma.png' && $(stat -c %a "$theme/logos/oma.png") == 600 ]] || fail "normal theme set does not broaden into the refresh-only nested asset" + [[ $(cat "$sddm/metadata.desktop") == 'old sddm metadata.desktop' ]] || fail "normal theme set leaves SDDM metadata unchanged" + [[ $(cat "$sddm/theme.conf") == 'old sddm theme.conf' ]] || fail "normal theme set leaves SDDM theme.conf unchanged" + [[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")" + [[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask" + assert_no_temporary_files "$fake_root" +done + +pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" + +# Swap the first staged source to an unreadable file after all hashes have been +# recorded but in the DEBUG hook immediately before Bash opens the redirection. +# The caller-side open must fail, so sudo never starts and nothing is published. +setup_run +preopen_hook="$run_dir/preopen-hook" +preopen_marker="$run_dir/preopen-marker" +printf 'ROOT ONLY\n' >"$secret" +chmod 000 "$secret" +cat >"$preopen_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && + $BASH_COMMAND == sudo\ /bin/bash\ -c* && + ! -e $TEST_PREOPEN_MARKER ]]; then + mv -T -- "$source" "$source.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$source" + printf "swapped\n" >"$TEST_PREOPEN_MARKER" + fi + ' DEBUG +fi +SH + +output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) +status=$? +chmod 0600 "$secret" + +(( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" +[[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" +if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then + fail "sudo started despite the caller-side open failure" +fi +assert_no_temporary_files "$fake_root" + +pass "an unreadable source swap before open fails without publication" + +# Rewrite a staged file in place after Bash has opened it but before root reads +# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +setup_run +mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' +output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) status=$? -((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" +(( status != 0 )) || fail "an in-place rewrite after open aborts publication" +[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" +[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +assert_no_temporary_files "$fake_root" -expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' -actual_assets=$(sort -u "$attack_log") -[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" +pass "recorded size and SHA-256 reject a same-inode rewrite after open" -! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" -grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" -[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" +# Root rejects both a symlinked parent and a group/world-writable parent before +# it creates a temporary file or touches the live destination. +setup_run +mv "$theme" "$theme.real" +ln -s "$theme.real" "$theme" +output=$(run_set 022 env 2>&1) +status=$? +(( status != 0 )) || fail "a symlinked destination parent is rejected" +[[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged" +assert_no_temporary_files "$fake_root" -if grep -F "$stages/" "$sudo_log" >/dev/null; then - fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" -fi +setup_run +chmod 0777 "$theme" +output=$(run_set 022 env 2>&1) +status=$? +(( status != 0 )) || fail "a writable destination parent is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged" +assert_no_temporary_files "$fake_root" -pass "privileged publication uses pinned descriptors for every staged asset" +pass "publication rejects symlinked and non-root-writable destination parents" + +# Refresh uses the same publisher but its explicit contract includes the +# packaged nested logos/oma.png asset. It must not touch the SDDM theme. +setup_run +output=$( + PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_ROOT_TOOLS="$root_tools" \ + TEST_SUDO_LOG="$sudo_log" \ + TEST_LEAK_LOG="$leak_log" \ + TEST_MUTATE_LOG="$mutate_log" \ + /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 +) +status=$? +(( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" + +for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" + [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" + [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" +done +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" +! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" +[[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" + +grep -Fq 'sudo /bin/bash -c' "$ROOT/bin/omarchy-plymouth-set" || fail "publisher invokes Bash by its trusted absolute path" +grep -Fq 'PATH=/usr/bin:/bin' "$ROOT/bin/omarchy-plymouth-set" || fail "root helper resets PATH before resolving utilities" + +pass "refresh safely publishes its complete fixed asset set, including logos/oma.png" From 20a23b8c169b69e84f3998ae26cdc65dd8860ed7 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Fri, 28 Aug 2026 19:37:04 +0100 Subject: [PATCH 53/73] [Security] Complete Plymouth publication coverage Keep SDDM color substitution collision-free for White themes, based on the fix proposed in #8469. Co-authored-by: itz4blitz --- bin/omarchy-plymouth-set | 9 ++++++-- test/shell.d/plymouth-set-test.sh | 36 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index f8f84c36..c9c663b5 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -135,9 +135,14 @@ if ! $refresh_default; then magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" done + # Substitute through unique tokens. Otherwise White's #ffffff background is + # immediately mistaken for the template's text placeholder by the next sed + # expression and rewritten to black. sed -i \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ + -e 's/#1a1b26/#__OMARCHY_SDDM_BG__/g' \ + -e 's/#ffffff/#__OMARCHY_SDDM_TEXT__/g' \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ "$sddm_stage/Main.qml" for asset in bullet.png entry.png lock.png; do diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index ba246eae..72fdca8c 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -22,6 +22,15 @@ plymouth_theme_assets=( plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) +# Keep the refresh allowlist synchronized with every packaged Plymouth asset. +# An added default file must make this test fail until its publication contract +# is explicitly reviewed and included above. +packaged_plymouth_assets=$(find "$ROOT/default/plymouth" -type f -printf '%P\n' | LC_ALL=C sort) +allowlisted_plymouth_assets=$(printf '%s\n' "${plymouth_default_assets[@]}" | LC_ALL=C sort) +[[ $packaged_plymouth_assets == "$allowlisted_plymouth_assets" ]] || + fail "Plymouth refresh allowlist differs from the packaged asset set" "$packaged_plymouth_assets" +pass "Plymouth refresh allowlist covers every packaged asset" + # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # ~/.config/omarchy/themes. Both installed copies are world-readable, so a # symlink there must not republish whatever it points at. @@ -279,9 +288,9 @@ setup_run() { ln -s "$legacy_victim" "$sddm/logo.svg" } -run_set() { - local requested_umask="$1" - shift +run_set_colors() { + local requested_umask="$1" background="$2" text="$3" + shift 3 ( umask "$requested_umask" PATH="$fake_bin:$ROOT/bin:$PATH" \ @@ -294,10 +303,16 @@ run_set() { TEST_LEAK_LOG="$leak_log" \ TEST_MUTATE_LOG="$mutate_log" \ "$@" \ - /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" + /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) } +run_set() { + local requested_umask="$1" + shift + run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@" +} + assert_no_temporary_files() { local directory="$1" leftovers leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) @@ -345,6 +360,19 @@ done pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" +# White uses #ffffff behind #000000 text. A direct two-expression sed first +# writes the white background and then consumes it as if it were the template's +# text placeholder, producing a black-on-black greeter. +setup_run +output=$(run_set_colors 022 '#ffffff' '#000000' env 2>&1) +status=$? +(( status == 0 )) || fail "White theme publishes through the safe asset pipeline" "$output" +grep -Fq 'color: "#ffffff"' "$sddm/Main.qml" || fail "White theme preserves its SDDM background color" +if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then + fail "SDDM color substitution left an intermediate token behind" +fi +pass "White theme keeps a white SDDM background instead of becoming black-on-black" + # Swap the first staged source to an unreadable file after all hashes have been # recorded but in the DEBUG hook immediately before Bash opens the redirection. # The caller-side open must fail, so sudo never starts and nothing is published. From a465dfa68141ff0100dce51c1b9d499b60f09247 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 14:42:47 +0200 Subject: [PATCH 54/73] Use the runtime path for Plymouth refresh --- bin/omarchy-refresh-plymouth | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 160e0c8a..fa47ffff 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -5,5 +5,4 @@ # Reuse the fixed-file publisher so root never resolves the source checkout or # follows a destination symlink while restoring the packaged assets. -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -exec "$script_dir/omarchy-plymouth-set" --refresh-default +exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default From 05fb36d3cc719fedbc534602f6a17c22eb2b928d Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 15:05:13 +0200 Subject: [PATCH 55/73] Build Plymouth assets in a root-owned stage --- bin/omarchy-plymouth-set | 422 ++++++++++++++---------------- test/shell.d/plymouth-set-test.sh | 112 ++++---- 2 files changed, 261 insertions(+), 273 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index c9c663b5..68f13ff8 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -7,19 +7,25 @@ set -euo pipefail -# Configure the Plymouth boot theme with a custom background color, text color, -# and logo. Assets are prepared without privileges, pinned to their recorded -# hashes, then published one at a time through root-owned temporary files. +# Build the authoritative theme in a root-owned directory, then publish each +# fixed destination atomically. The caller opens the selected logo before sudo, +# so the privileged process never resolves a user-controlled input path. refresh_default=false -if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then +if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then refresh_default=true elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -if ! $refresh_default; then +logo_fd= +if $refresh_default; then + mode=refresh + bg_hex= + text_hex= +else + mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -39,230 +45,215 @@ if ! $refresh_default; then exit 1 fi - # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from - # ~/.config/omarchy/themes, where an installed theme can make it a symlink to - # anything. The copies below land in world-readable /usr/share, so following - # one would republish whatever it points at. if [[ -L $logo_path ]]; then echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 exit 1 fi + + # Open the logo while still unprivileged. A replacement symlink to a root-only + # file therefore fails here instead of being followed after sudo starts. + if ! exec {logo_fd}<"$logo_path"; then + echo "Unable to open logo file as the current user: $logo_path" >&2 + exit 1 + fi + if [[ ! -f /proc/$$/fd/$logo_fd ]]; then + echo "Logo input is no longer a regular file: $logo_path" >&2 + exit 1 + fi fi -theme_dir="/usr/share/plymouth/themes/omarchy" -sddm_dir="/usr/share/sddm/themes/omarchy" -max_asset_size=$((64 * 1024 * 1024)) +run_root_transaction() { + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH -plymouth_theme_assets=( - bullet.png - entry.png - lock.png - logo.png - omarchy.plymouth - omarchy.script - preview-unlock.png - progress_bar.png - progress_box.png -) -plymouth_default_assets=( - "${plymouth_theme_assets[@]}" - logos/oma.png -) -sddm_theme_assets=( - Main.qml - bullet.png - entry-failed.png - entry.png - lock-failed.png - lock.png - logo.png -) + mode=$1 + source_root=$2 + bg_hex=$3 + text_hex=$4 + max_asset_size=$5 -staging_dir=$(mktemp -d) -trap 'rm -rf -- "$staging_dir"' EXIT -chmod 0700 "$staging_dir" -plymouth_stage="$staging_dir/plymouth" -sddm_stage="$staging_dir/sddm" -mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + [[ $mode == "set" || $mode == "refresh" ]] + [[ $source_root == /* ]] + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + [[ $max_asset_size =~ ^[0-9]+$ ]] + (( max_asset_size > 0 )) -copy_regular_file() { - local source="$1" destination="$2" + if [[ $mode == "set" ]]; then + [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] + [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] + fi - if [[ ! -f $source || -L $source ]]; then - echo "Refusing non-regular or symlinked asset: $source" >&2 - exit 1 - fi + theme_dir=/usr/share/plymouth/themes/omarchy + sddm_dir=/usr/share/sddm/themes/omarchy + plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png + ) + plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) + sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - # -P ensures a source swapped to a symlink is copied as a symlink instead of - # followed; the post-copy check then rejects it. - cp -P --reflink=never -- "$source" "$destination" - if [[ ! -f $destination || -L $destination ]]; then - rm -f -- "$destination" - echo "Asset changed while it was being staged: $source" >&2 - exit 1 - fi - chmod 0600 "$destination" + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_file() { + local source=$1 canonical uid file_mode size + + [[ -f $source && ! -L $source ]] + canonical=$(realpath -e -- "$source") + [[ $canonical == "$source" ]] + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + file_mode=$(stat -c %a -- "$source") + size=$(stat -c %s -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + (( size > 0 && size <= max_asset_size )) + } + + copy_trusted_file() { + local source=$1 destination=$2 + + validate_trusted_file "$source" + cp --reflink=never -- "$source" "$destination" + chown 0:0 -- "$destination" + chmod 0600 -- "$destination" + } + + staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) + temporary= + cleanup() { + [[ -z $temporary ]] || rm -f -- "$temporary" + rm -rf -- "$staging_dir" + } + trap cleanup EXIT HUP INT TERM + chown 0:0 -- "$staging_dir" + chmod 0700 -- "$staging_dir" + + plymouth_stage=$staging_dir/plymouth + sddm_stage=$staging_dir/sddm + mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" + + if [[ $mode == "refresh" ]]; then + assets_to_stage=("${plymouth_default_assets[@]}") + else + assets_to_stage=("${plymouth_theme_assets[@]}") + fi + + for asset in "${assets_to_stage[@]}"; do + copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" + done + + if [[ $mode == "set" ]]; then + # stdin was opened by the unprivileged caller. Read no more than the + # documented limit into the root-owned stage before doing other work. + head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" + logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") + (( logo_size > 0 && logo_size <= max_asset_size )) + chown 0:0 -- "$plymouth_stage/logo.png" + chmod 0600 -- "$plymouth_stage/logo.png" + cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) "BEGIN{printf \"%.3f\", n/255}") + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml" + sed -i \ + -e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \ + -e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done + chown -R 0:0 -- "$staging_dir" + find "$staging_dir" -type f -exec chmod 0600 -- {} + + fi + + publish_asset() { + local source=$1 destination=$2 parent filename source_size copied_size + + [[ -f $source && ! -L $source ]] + (( $(stat -c %u -- "$source") == 0 )) + source_size=$(stat -c %s -- "$source") + (( source_size > 0 && source_size <= max_asset_size )) + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]] + validate_trusted_directory "$parent" + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cp --reflink=never -- "$source" "$temporary" + copied_size=$(stat -c %s -- "$temporary") + (( copied_size == source_size )) + cmp -s -- "$source" "$temporary" + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + temporary= + } + + for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + + if [[ $mode == "set" ]]; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + validate_trusted_directory "$sddm_dir" + rm -f -- "$sddm_dir/logo.svg" + fi + ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } if $refresh_default; then - assets_to_stage=("${plymouth_default_assets[@]}") + run_root_transaction &2 - exit 1 - fi - - size=$(stat -c %s -- "$source") - if (( size == 0 || size > max_asset_size )); then - echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 - exit 1 - fi - hash=$(sha256sum -- "$source") - staged_sizes["$source"]=$size - staged_hashes["$source"]=${hash%% *} -} - -# Record every asset before the first sudo prompt. Root verifies both values -# after consuming stdin, so an in-place rewrite after this point fails instead -# of changing what is published. This pins the recorded result, not the trust -# of an already-user-writable OMARCHY_PATH checkout. -for asset in "${assets_to_stage[@]}"; do - record_staged_asset "$plymouth_stage/$asset" -done -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - record_staged_asset "$sddm_stage/$asset" - done -fi - -publish_asset() { - local source="$1" destination="$2" - local expected_hash="${staged_hashes[$source]}" - local expected_size="${staged_sizes[$source]}" - - # The caller's shell opens source before sudo starts. Root reads only stdin, - # verifies the recorded bytes, and never opens or chmods the final pathname. - # Its temporary file is on the destination filesystem, so mv is atomic for - # this one asset and replaces a destination symlink instead of following it. - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - expected_hash=$2 - expected_size=$3 - max_size=$4 - - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] - [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] - (( expected_size > 0 && expected_size <= max_size )) - - parent=${destination%/*} - filename=${destination##*/} - [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - - temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cleanup() { rm -f -- "$temporary"; } - trap cleanup EXIT HUP INT TERM - - head -c "$((expected_size + 1))" >"$temporary" - actual_size=$(stat -c %s -- "$temporary") - (( actual_size == expected_size )) - actual_hash=$(sha256sum -- "$temporary") - [[ ${actual_hash%% *} == "$expected_hash" ]] - - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" - sync -f -- "$temporary" - mv --no-copy -fT -- "$temporary" "$destination" - trap - EXIT HUP INT TERM - ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" -} - -remove_legacy_asset() { - local destination="$1" - - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - parent=${destination%/*} - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - rm -f -- "$destination" - ' bash "$destination" -} - -for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" -done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -270,10 +261,3 @@ if omarchy-cmd-present limine-mkinitcpio; then else sudo mkinitcpio -P fi - -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" - done - remove_legacy_asset "$sddm_dir/logo.svg" -fi diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 72fdca8c..b85d14f8 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -162,40 +162,20 @@ done case "$1" in /bin/bash) - [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + [[ ${2:-} == -c && $# == 9 ]] || exit 90 code=$3 shell_name=$4 - original_destination=$5 - printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - - if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then - expected_size=${7:-0} - case "$original_destination" in - /usr/share/plymouth/themes/omarchy/*) - relative=${original_destination#/usr/share/plymouth/themes/omarchy/} - stage_kind=plymouth - ;; - /usr/share/sddm/themes/omarchy/*) - relative=${original_destination#/usr/share/sddm/themes/omarchy/} - stage_kind=sddm - ;; - *) exit 91 ;; - esac - stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) - source="$stage_root/$stage_kind/$relative" - /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" - printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" - fi - - mapped_destination="$TEST_FAKE_ROOT$original_destination" - shift 5 + shift 4 + printf 'root transaction\n' >>"$TEST_SUDO_LOG" # The production helper intentionally resets PATH. For this unprivileged - # simulation only, substitute stat/chown shims so a uid-1000 test directory - # behaves like the root-owned /usr/share directory used in production. + # simulation only, substitute trusted tools and map fixed system destinations + # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} + code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ - /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" + /bin/bash -c "$code" "$shell_name" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" @@ -211,21 +191,29 @@ SH cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} -if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then +if [[ ${1:-} == -c && ${2:-} == %u ]]; then + if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + printf '1000\n' + exit 0 + fi printf '0\n' exit 0 fi +if [[ ${1:-} == -c && ${2:-} == %a && $last == /tmp ]]; then + printf '755\n' + exit 0 +fi exec /usr/bin/stat "$@" SH cat >"$root_tools/chown" <<'SH' #!/bin/bash last=${!#} -[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +[[ $last == "$TEST_FAKE_ROOT"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93 exit 0 SH -cat >"$fake_bin/magick" <<'SH' +cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 destination=${@: -1} @@ -246,7 +234,6 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" - mutate_log="$run_dir/mutated.log" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -301,7 +288,6 @@ run_set_colors() { TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ "$@" \ /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) @@ -330,14 +316,12 @@ for requested_umask in 022 027 077; do [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" done for asset in "${sddm_theme_assets[@]}"; do destination="$sddm/$asset" [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" done cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" @@ -373,9 +357,9 @@ if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then fi pass "White theme keeps a white SDDM background instead of becoming black-on-black" -# Swap the first staged source to an unreadable file after all hashes have been -# recorded but in the DEBUG hook immediately before Bash opens the redirection. -# The caller-side open must fail, so sudo never starts and nothing is published. +# Swap the selected logo to an unreadable file in the DEBUG hook immediately +# before Bash opens its descriptor. The caller-side open must fail, so sudo +# never starts and nothing is published. setup_run preopen_hook="$run_dir/preopen-hook" preopen_marker="$run_dir/preopen-marker" @@ -385,11 +369,10 @@ cat >"$preopen_hook" <<'SH' if [[ $0 == */bin/omarchy-plymouth-set ]]; then set -T trap ' - if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && - $BASH_COMMAND == sudo\ /bin/bash\ -c* && + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && ! -e $TEST_PREOPEN_MARKER ]]; then - mv -T -- "$source" "$source.before-preopen-swap" - ln -s -- "$TEST_SECRET" "$source" + mv -T -- "$logo_path" "$logo_path.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$logo_path" printf "swapped\n" >"$TEST_PREOPEN_MARKER" fi ' DEBUG @@ -399,32 +382,55 @@ SH output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) status=$? chmod 0600 "$secret" +rm -f "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-preopen-swap" "$test_tmp/logo.png" (( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" [[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" -if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then fail "sudo started despite the caller-side open failure" fi assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" -# Rewrite a staged file in place after Bash has opened it but before root reads -# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +# Plant both a malicious script and a root-file symlink where the old +# caller-owned stage lived. The privileged transaction must ignore that tree: +# executable/config assets come only from its root-trusted source and are built +# in its own root-owned stage. setup_run -mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' -output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) +attacker_stage="$stages/tmp.attacker" +mkdir -p "$attacker_stage/plymouth" +printf 'MALICIOUS BOOT SCRIPT\n' >"$attacker_stage/plymouth/omarchy.script" +ln -s "$secret" "$attacker_stage/plymouth/logo.png" + +output=$(run_set 022 env 2>&1) status=$? -(( status != 0 )) || fail "an in-place rewrite after open aborts publication" -[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" -[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" -[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +(( status == 0 )) || fail "a planted caller-owned stage cannot disrupt publication" "$output" +! grep -Rqs 'MALICIOUS BOOT SCRIPT' "$fake_root" || fail "caller-owned staged content reached the boot theme" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "the trusted Plymouth script replaces the planted destination symlink" +grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "the installed script was derived from the trusted packaged source" +unexpected_stages=$(find "$stages" -mindepth 1 -maxdepth 1 ! -name tmp.attacker -print) +[[ -z $unexpected_stages ]] || fail "the caller created an authoritative staging directory" "$unexpected_stages" assert_no_temporary_files "$fake_root" -pass "recorded size and SHA-256 reject a same-inode rewrite after open" +pass "caller-owned content cannot enter the root-owned boot-image stage" + +# A user-owned source checkout would put the same pre-hash race on the input +# side of the root stage. Refuse it before any fixed destination is replaced. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned packaged source tree is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +assert_no_temporary_files "$fake_root" + +pass "root rejects packaged assets that a desktop process could rewrite" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -459,7 +465,6 @@ output=$( TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 ) status=$? @@ -469,7 +474,6 @@ for asset in "${plymouth_default_assets[@]}"; do destination="$theme/$asset" cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" done [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" From d3b7810a7690044768a423f11cc5b1d7914799b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 16:00:50 +0200 Subject: [PATCH 56/73] Say why the privileged Plymouth transaction refused Every check inside the root shell is a bare [[ ]] or (( )) assertion that aborts under set -e, so a refusal exited with status 1 and no output at all. The floating-terminal wrapper then printed its green "Done!" for any status but 130, so a failed boot-theme change read as a success. The refusal a working machine actually hits is omarchy dev link, which points OMARCHY_PATH at a checkout the desktop user owns. Name that case outright and point at omarchy dev unlink; report every other rejection through an ERR trap that names what failed validation. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-plymouth-set | 35 ++++++++++++++++++++++++++++--- test/shell.d/plymouth-set-test.sh | 19 +++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 68f13ff8..51d37e45 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -64,23 +64,47 @@ fi run_root_transaction() { sudo /bin/bash -c ' - set -euo pipefail + set -eEuo pipefail PATH=/usr/bin:/bin export PATH + # Every check below is a bare assertion that aborts under set -e. Name the + # subject of each one so a refusal reaches the user instead of exiting mute. + failure_context="the privileged Plymouth transaction" + failure_reported= + report_failure() { + [[ -z $failure_reported ]] || return 0 + failure_reported=1 + printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2 + } + trap report_failure ERR + mode=$1 source_root=$2 bg_hex=$3 text_hex=$4 max_asset_size=$5 + failure_context="the arguments of the privileged transaction" [[ $mode == "set" || $mode == "refresh" ]] [[ $source_root == /* ]] - canonical_source_root=$(realpath -e -- "$source_root") - [[ $canonical_source_root == "$source_root" ]] [[ $max_asset_size =~ ^[0-9]+$ ]] (( max_asset_size > 0 )) + failure_context="the Omarchy source tree $source_root" + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + + # The one refusal a healthy machine can hit: omarchy dev link points + # OMARCHY_PATH at a checkout the desktop user can rewrite, and executable + # boot assets cannot come from there. Say so rather than failing mute. + source_root_uid=$(stat -c %u -- "$source_root") + if (( source_root_uid != 0 )); then + printf "omarchy-plymouth-set: %s is not root-owned, so it cannot supply executable boot assets.\n" "$source_root" >&2 + printf "omarchy-plymouth-set: this is expected on a development checkout; run omarchy dev unlink to publish from /usr/share/omarchy.\n" >&2 + exit 1 + fi + if [[ $mode == "set" ]]; then [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] @@ -105,10 +129,12 @@ run_root_transaction() { validate_trusted_directory() { local directory=$1 canonical uid directory_mode + failure_context="directory $directory" canonical=$(realpath -e -- "$directory") [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] while :; do + failure_context="directory $directory (must be root-owned and not group- or world-writable)" uid=$(stat -c %u -- "$directory") directory_mode=$(stat -c %a -- "$directory") (( uid == 0 )) @@ -122,6 +148,7 @@ run_root_transaction() { validate_trusted_file() { local source=$1 canonical uid file_mode size + failure_context="packaged source file $source" [[ -f $source && ! -L $source ]] canonical=$(realpath -e -- "$source") [[ $canonical == "$source" ]] @@ -170,6 +197,7 @@ run_root_transaction() { if [[ $mode == "set" ]]; then # stdin was opened by the unprivileged caller. Read no more than the # documented limit into the root-owned stage before doing other work. + failure_context="the selected logo (expected 1 to $max_asset_size bytes)" head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") (( logo_size > 0 && logo_size <= max_asset_size )) @@ -211,6 +239,7 @@ run_root_transaction() { publish_asset() { local source=$1 destination=$2 parent filename source_size copied_size + failure_context="destination $destination" [[ -f $source && ! -L $source ]] (( $(stat -c %u -- "$source") == 0 )) source_size=$(stat -c %s -- "$source") diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index b85d14f8..ae146c98 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -428,10 +428,27 @@ status=$? (( status != 0 )) || fail "a user-owned packaged source tree is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" [[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source says why it refused" "$output" assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" +# omarchy dev link points OMARCHY_PATH at a checkout the desktop user owns, so +# this refusal fires on a working machine, not only under attack. Every check in +# the privileged transaction is a bare assertion that aborts under set -e, so +# without a diagnostic the whole Plymouth menu would just close in silence. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected" +[[ $output == *"is not root-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" +[[ $output == *"omarchy dev unlink"* ]] || fail "the refusal names the way back to a trusted tree" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +pass "a development checkout is refused with an explanation instead of in silence" + # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. setup_run @@ -441,6 +458,7 @@ output=$(run_set 022 env 2>&1) status=$? (( status != 0 )) || fail "a symlinked destination parent is rejected" [[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected symlinked parent says why it refused" "$output" assert_no_temporary_files "$fake_root" setup_run @@ -449,6 +467,7 @@ output=$(run_set 022 env 2>&1) status=$? (( status != 0 )) || fail "a writable destination parent is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected writable parent says why it refused" "$output" assert_no_temporary_files "$fake_root" pass "publication rejects symlinked and non-root-writable destination parents" From 079d11651159d0e1850e45bb734be1f64b8328c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 16:01:01 +0200 Subject: [PATCH 57/73] Cover the Plymouth parent-chain walk and guard its test harness Deleting the whole ancestor walk from validate_trusted_directory and checking only the immediate parent left the suite green, so the invariant the design rests on had no coverage: a writable ancestor lets an attacker swap a validated directory out from under the leaf. Reject a run whose destination grandparent is world-writable while the destination itself is pristine. The harness also rewrites the root script's fixed /usr/share paths by string substitution. A drifted string silently no-ops that rewrite and would point the simulation at the real system tree, so assert each substitution landed. Assert the scratch directory exists too: a failed mktemp left it empty and the suite carried on building paths from it. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- test/shell.d/plymouth-set-test.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index ae146c98..77d21af3 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -5,6 +5,8 @@ set -uo pipefail source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) +[[ -n $test_tmp && -d $test_tmp ]] || + fail "the test creates its own scratch directory before touching anything" secret="$test_tmp/secret" trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT @@ -174,6 +176,13 @@ case "$1" in code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} + + # Each rewrite above silently no-ops if the production text drifts, which + # would point this simulation at the real /usr/share. Refuse instead. + [[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94 + [[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94 + [[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94 + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ /bin/bash -c "$code" "$shell_name" "$@" ;; @@ -472,6 +481,24 @@ assert_no_temporary_files "$fake_root" pass "publication rejects symlinked and non-root-writable destination parents" +# Walking the whole chain, not just the immediate parent, is what closes the +# rename race: a writable ancestor lets an attacker swap an entire validated +# directory out from under the leaf. Leave the destination itself pristine so +# only the ancestor can be at fault. +setup_run +chmod 0777 "$fake_root/usr/share/plymouth" +output=$(run_set 022 env 2>&1) +status=$? +chmod 0755 "$fake_root/usr/share/plymouth" + +(( status != 0 )) || fail "a writable destination ancestor is rejected" "$output" +[[ $(stat -c %a "$theme") == 755 ]] || fail "only the ancestor, not the destination, was untrustworthy" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable ancestor leaves the live destination unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected ancestor says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "publication walks the whole parent chain, not only the immediate parent" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run From bf10b75150f7cd89d7ab42b0f56818e6a7792525 Mon Sep 17 00:00:00 2001 From: Erik Melton <798237+ErikMelton@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:00:10 +0200 Subject: [PATCH 58/73] Protect the Windows VM web console --- bin/omarchy-windows-vm | 54 ++++++++++++++----- manual/28-windows-vm.md | 4 +- test/shell.d/windows-vm-compose-test.sh | 9 +++- .../shell.d/windows-vm-mount-boundary-test.sh | 12 ++++- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 0676b770..f1582e2d 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -92,7 +92,7 @@ priv() { # still needs one privileged invocation. if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { [[ $action != up && $action != up_wait ]] || { - ! compose_needs_mount_migration && mounts_ready >/dev/null 2>&1 + ! compose_needs_security_migration && mounts_ready >/dev/null 2>&1 } }; then with_vm_lock "__priv_$action" "$@" @@ -703,6 +703,7 @@ services: DISK_SIZE: "$disk" USERNAME: "$username" PASSWORD: "$esc_password" + PROTECT: "Y" TZ: "$tz" ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew" devices: @@ -765,14 +766,20 @@ get_mount_source() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 } -# True only for the exact pair emitted by older Omarchy releases. The result is -# used by priv() to force a one-time elevated migration for sudoless-Docker -# users; arbitrary or mixed bind sources are never classified as migratable. -compose_needs_mount_migration() { +# True only when a trusted compose has an exact security upgrade path. The +# result forces a one-time elevated migration for sudoless-Docker users. An +# arbitrary or mixed bind pair is never classified as migratable. +compose_needs_security_migration() { + local storage shared [[ -f $COMPOSE_FILE ]] || return 1 resolve_caller || return 1 [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 - compose_mount_pair_is_migratable "$(get_mount_source /storage)" "$(get_mount_source /shared)" + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + if compose_mount_pair_is_migratable "$storage" "$shared"; then + return 0 + fi + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] && ! compose_web_protected } compose_mount_pair_is_migratable() { @@ -786,14 +793,26 @@ mount_source_count() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l } -rewrite_compose_mounts() { +compose_web_protected() { + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 ]] +} + +rewrite_compose_security() { local tmp tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1 awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" ' + /^ environment:$/ { print; print " PROTECT: \"Y\""; next } + /^[[:space:]]+PROTECT:/ { next } /^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next } /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } { print } ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$tmp" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$tmp" | wc -l) == 1 ]] || { + rm -f "$tmp" + return 1 + } chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } if ((EUID == 0)); then chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { @@ -814,7 +833,7 @@ assert_compose_trusted() { } assert_mounts_safe() { - local storage shared + local storage shared needs_rewrite=0 mounts_prepared=0 resolve_caller || return 1 assert_compose_trusted || { echo "omarchy-windows-vm: refusing an untrusted compose file" >&2 @@ -834,10 +853,8 @@ assert_mounts_safe() { return 1 } prepare_caller_mounts || return 1 - if ! rewrite_compose_mounts; then - rollback_new_caller_mounts || true - return 1 - fi + mounts_prepared=1 + needs_rewrite=1 storage=$EXPECTED_STORAGE shared=$EXPECTED_SHARED fi @@ -847,6 +864,19 @@ assert_mounts_safe() { return 1 } + if ! compose_web_protected; then + ((EUID == 0)) || { + echo "omarchy-windows-vm: web-console protection needs an authorized migration" >&2 + return 1 + } + needs_rewrite=1 + fi + + if ((needs_rewrite)) && ! rewrite_compose_security; then + if ((mounts_prepared)); then rollback_new_caller_mounts || true; fi + return 1 + fi + # Mounts disappear at reboot. Root recreates them from the already-opened, # caller-owned sources; a docker-group invocation may proceed directly only # while the exact pinned pair is still present. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 29946466..0551b23f 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -4,7 +4,7 @@ Omarchy offers an easy way to run Windows through a Docker VM. You can install i Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself. -The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. +The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The browser prompts for the same username and password before opening the console. ![windows-vm](images/windows-vm.webp) @@ -34,7 +34,7 @@ Keep the disk and shared paths as separate, non-overlapping directories. Removal Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents. -The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. +The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The web console also requires the configured Windows username and password, preventing another local account from driving the VM through port 8006. ## Limits and licensing diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 0495e1e0..64825526 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -61,6 +61,7 @@ grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor" grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor" +grep -q 'PROTECT: "Y"' "$COMPOSE" || fail "web console is not password protected" [[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories" [[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source" [[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source" @@ -150,10 +151,16 @@ sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE" assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted" write 16G 6 128G legacyuser legacypass America/New_York +sed -i 's/PROTECT: "Y"/PROTECT: "N"/' "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "unprotected web console accepted" +sed -i 's/PROTECT: "N"/PROTECT: "Y"/' "$COMPOSE" +printf ' PROTECT: "N"\n' >>"$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "duplicate web protection setting accepted" +sed -i '$d' "$COMPOSE" chmod 0666 "$COMPOSE" assert_mounts_safe 2>/dev/null && fail "writable compose accepted" chmod 0640 "$COMPOSE" -pass "bring-up rejects tampered, duplicate, and writable compose inputs" +pass "bring-up rejects tampered, duplicate, unprotected, and writable compose inputs" # Both sources are pinned before a bind; bad symlinks stay untouched. reset_case diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index a49e5652..34f36cc2 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -156,13 +156,23 @@ pass "root writer and final pre-Docker guard revalidate the pinned production mo # moving or replacing either familiar home symlink. sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE" sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE" -compose_needs_mount_migration || fail "previous protected anchor pair was not recognized for upgrade" +sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE" +compose_needs_security_migration || fail "previous protected compose was not recognized for upgrade" with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor" grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor" +grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "upgrade did not protect the web console" [[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link" pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary" +# A compose that already uses the fixed anchors still needs an authorized +# upgrade when it predates web-console authentication. +sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE" +compose_needs_security_migration || fail "unprotected fixed-anchor compose was not recognized for upgrade" +with_vm_lock assert_mounts_safe || fail "root could not protect an existing fixed-anchor compose" +grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "fixed-anchor upgrade did not protect the web console" +pass "existing fixed-anchor compose gains web-console authentication" + # Preflight both sources before either bind on a clean anchor pair. umount "$EXPECTED_SHARED" umount "$EXPECTED_STORAGE" From 11fa6b9809094e347e47a67d9c0962aa4b37e3b9 Mon Sep 17 00:00:00 2001 From: Erik Melton <798237+ErikMelton@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:02:38 +0200 Subject: [PATCH 59/73] Race Windows VM mount sources concurrently --- test/shell.d/windows-vm-compose-test.sh | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 64825526..189536f4 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -213,6 +213,65 @@ mv -T -- "$raced_shared" "$HOME/Windows" unset -f dc pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode" +# Run the same attack as a genuinely concurrent process. A successful bring-up +# deliberately waits inside the Docker boundary until the attacker has replaced +# the familiar path with /, then verifies that the real bind anchor still names +# the caller-owned directory that was pinned before the race. +reset_case +prepare_user_mount_sources +touch "$HOME/Windows/safe-marker" +write 4G 2 64G concurrent pw UTC +resolve_caller +concurrent_shared_id=$(stat -Lc '%d:%i' "$HOME/Windows") +host_root_id=$(stat -Lc '%d:%i' /) +race_source="$HOME/Windows.race-source" +race_stop="$TMPDIR/stop-concurrent-race" +race_swaps="$TMPDIR/concurrent-race-swaps" +( + set +e + while [[ ! -e $race_stop ]]; do + if [[ -d $HOME/Windows && ! -L $HOME/Windows ]] && mv -T -- "$HOME/Windows" "$race_source" 2>/dev/null; then + ln -s / "$HOME/Windows" 2>/dev/null || true + printf x >>"$race_swaps" + sleep 0.002 + fi + if [[ -L $HOME/Windows ]]; then + rm -f -- "$HOME/Windows" + mv -T -- "$race_source" "$HOME/Windows" 2>/dev/null || true + sleep 0.005 + fi + done +) & +racer_pid=$! +concurrent_dc_calls=0 +dc() { + local attempt + [[ $1 == up && ${2:-} == -d ]] || return 1 + for ((attempt = 0; attempt < 20000; attempt++)); do + if [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]]; then + break + fi + done + [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]] || return 1 + ((concurrent_dc_calls++)) + [[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1 + [[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || return 1 + [[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") != "$host_root_id" ]] || return 1 + [[ -f $EXPECTED_SHARED/safe-marker ]] +} +for ((attempt = 0; attempt < 200; attempt++)); do + if __priv_up 2>/dev/null; then break; fi +done +touch "$race_stop" +wait "$racer_pid" +unset -f dc +if [[ -L $HOME/Windows ]]; then rm -f -- "$HOME/Windows"; fi +if [[ ! -e $HOME/Windows && -d $race_source ]]; then mv -T -- "$race_source" "$HOME/Windows"; fi +[[ -s $race_swaps ]] || fail "concurrent attacker never swapped the shared path" +((concurrent_dc_calls > 0)) || fail "concurrent race never reached Docker while the familiar path named host root" +[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || fail "concurrent race changed the protected shared inode" +pass "a concurrent home-path swap cannot redirect Docker away from the pinned shared inode" + # Same-inode sources fail before mounting and close both descriptors. reset_case same="$TMPDIR/same-source" From 363db1f569a9017acca3619b0a6e22757c77696a Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 16:19:31 +0200 Subject: [PATCH 60/73] Keep Plymouth publishing working in dev mode --- bin/omarchy-plymouth-set | 96 +++++++++++++++++++++---------- test/shell.d/plymouth-set-test.sh | 47 ++++++++++++--- 2 files changed, 107 insertions(+), 36 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 51d37e45..f4c9b792 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -76,6 +76,9 @@ run_root_transaction() { [[ -z $failure_reported ]] || return 0 failure_reported=1 printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2 + if [[ -n ${failure_hint:-} ]]; then + printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2 + fi } trap report_failure ERR @@ -95,14 +98,65 @@ run_root_transaction() { canonical_source_root=$(realpath -e -- "$source_root") [[ $canonical_source_root == "$source_root" ]] - # The one refusal a healthy machine can hit: omarchy dev link points - # OMARCHY_PATH at a checkout the desktop user can rewrite, and executable - # boot assets cannot come from there. Say so rather than failing mute. + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + failure_context="directory $directory" + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + failure_context="directory $directory (must be root-owned and not group- or world-writable)" + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_configuration_file() { + local configuration=$1 canonical uid configuration_mode size + + failure_context="root configuration $configuration" + [[ -f $configuration && ! -L $configuration ]] + canonical=$(realpath -e -- "$configuration") + [[ $canonical == "$configuration" ]] + validate_trusted_directory "${configuration%/*}" + uid=$(stat -c %u -- "$configuration") + configuration_mode=$(stat -c %a -- "$configuration") + size=$(stat -c %s -- "$configuration") + (( uid == 0 )) + (( (8#$configuration_mode & 0022) == 0 )) + (( size > 0 && size <= 4096 )) + } + + # A packaged tree must be root-owned. A development checkout is the one + # deliberate exception: omarchy dev link records its canonical path in a + # root-owned /etc/omarchy.conf. That is already an explicit decision to run + # privileged Omarchy commands from user-editable code in the checkout, so + # reading its packaged assets does not widen the development trust boundary. + development_source=false source_root_uid=$(stat -c %u -- "$source_root") if (( source_root_uid != 0 )); then - printf "omarchy-plymouth-set: %s is not root-owned, so it cannot supply executable boot assets.\n" "$source_root" >&2 - printf "omarchy-plymouth-set: this is expected on a development checkout; run omarchy dev unlink to publish from /usr/share/omarchy.\n" >&2 - exit 1 + omarchy_conf=/etc/omarchy.conf + failure_context="$source_root is user-owned and $omarchy_conf must contain its trusted dev-link authorization; run omarchy dev link to authorize it" + failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree" + validate_trusted_configuration_file "$omarchy_conf" + + quoted_source_root=$source_root + quoted_source_root=${quoted_source_root//\\/\\\\} + quoted_source_root=${quoted_source_root//\"/\\\"} + quoted_source_root=${quoted_source_root//\$/\\\$} + quoted_source_root=${quoted_source_root//\`/\\\`} + expected_config_line="export OMARCHY_PATH=\"$quoted_source_root\"" + mapfile -t omarchy_config_lines <"$omarchy_conf" + (( ${#omarchy_config_lines[@]} == 1 )) + [[ ${omarchy_config_lines[0]} == "$expected_config_line" ]] + development_source=true + failure_hint= fi if [[ $mode == "set" ]]; then @@ -126,25 +180,6 @@ run_root_transaction() { plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - validate_trusted_directory() { - local directory=$1 canonical uid directory_mode - - failure_context="directory $directory" - canonical=$(realpath -e -- "$directory") - [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] - - while :; do - failure_context="directory $directory (must be root-owned and not group- or world-writable)" - uid=$(stat -c %u -- "$directory") - directory_mode=$(stat -c %a -- "$directory") - (( uid == 0 )) - (( (8#$directory_mode & 0022) == 0 )) - [[ $directory == "/" ]] && break - directory=${directory%/*} - [[ -n $directory ]] || directory=/ - done - } - validate_trusted_file() { local source=$1 canonical uid file_mode size @@ -152,13 +187,16 @@ run_root_transaction() { [[ -f $source && ! -L $source ]] canonical=$(realpath -e -- "$source") [[ $canonical == "$source" ]] - validate_trusted_directory "${source%/*}" - uid=$(stat -c %u -- "$source") file_mode=$(stat -c %a -- "$source") size=$(stat -c %s -- "$source") - (( uid == 0 )) - (( (8#$file_mode & 0022) == 0 )) (( size > 0 && size <= max_asset_size )) + + if ! $development_source; then + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + fi } copy_trusted_file() { diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 77d21af3..8a40cbf7 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -174,12 +174,14 @@ case "$1" in # simulation only, substitute trusted tools and map fixed system destinations # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/omarchy_conf=\/etc\/omarchy.conf/omarchy_conf=$TEST_OMARCHY_CONF} code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} # Each rewrite above silently no-ops if the production text drifts, which # would point this simulation at the real /usr/share. Refuse instead. [[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94 + [[ $code == *"omarchy_conf=$TEST_OMARCHY_CONF"* ]] || exit 94 [[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94 [[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94 @@ -243,6 +245,7 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" + omarchy_conf="$run_dir/omarchy.conf" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -295,6 +298,7 @@ run_set_colors() { TEST_FAKE_ROOT="$fake_root" \ TEST_STAGES="$stages" \ TEST_ROOT_TOOLS="$root_tools" \ + TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ "$@" \ @@ -442,21 +446,49 @@ assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" -# omarchy dev link points OMARCHY_PATH at a checkout the desktop user owns, so -# this refusal fires on a working machine, not only under attack. Every check in -# the privileged transaction is a bare assertion that aborts under set -e, so -# without a diagnostic the whole Plymouth menu would just close in silence. +# A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical +# checkout recorded by root in /etc/omarchy.conf is the supported dev-link +# exception; an unrelated or stale authorization must not weaken the check. setup_run output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) status=$? (( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected" -[[ $output == *"is not root-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" -[[ $output == *"omarchy dev unlink"* ]] || fail "the refusal names the way back to a trusted tree" "$output" +[[ $output == *"user-owned"* ]] || fail "the refusal names the untrusted source tree" "$output" +[[ $output == *"omarchy dev link"* ]] || fail "the refusal names how to authorize a development checkout" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged" assert_no_temporary_files "$fake_root" -pass "a development checkout is refused with an explanation instead of in silence" +setup_run +printf 'export OMARCHY_PATH="/some/other/checkout"\n' >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a stale dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0666 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a writable dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output" +cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets" + +pass "only the checkout explicitly authorized by omarchy dev link may be user-owned" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -509,6 +541,7 @@ output=$( TEST_FAKE_ROOT="$fake_root" \ TEST_STAGES="$stages" \ TEST_ROOT_TOOLS="$root_tools" \ + TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 From eb76684c60331905870cb231dff4cf3b8d2eb5d6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:13:06 +0200 Subject: [PATCH 61/73] Retrofit the Chromium first-run EULA opt-out onto existing installs Chromium 151 flipped MasterPrefs::eula_required from false to true, so a first run with no seed now stops on a blank terms-of-service dialog before the browser opens. The opt-out is written in two places that each run exactly once: the install leaf that runs during ISO finalization, and the one-time 3.x upgrade. A machine already on Quattro runs neither again, so it keeps the old seed and still meets the dialog the first time anyone launches Chromium, including from every user account created after the install. The migration writes the same seed those two paths write. It compares before writing so the second user on a shared machine no-ops rather than repeating a machine-wide repair, and the literal is duplicated rather than sourced because a migration repairs the state of its own moment and must not drift when the seed later changes. Co-Authored-By: Codex XHigh --- migrations/1787691200.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migrations/1787691200.sh diff --git a/migrations/1787691200.sh b/migrations/1787691200.sh new file mode 100644 index 00000000..71c163e5 --- /dev/null +++ b/migrations/1787691200.sh @@ -0,0 +1,18 @@ +echo "Skip Chromium's new first-run EULA on machines already on Quattro" + +# Chromium 151 flipped MasterPrefs::eula_required from false to true, so an +# unconfigured first run now stops on a blank terms-of-service dialog before the +# browser opens. Omarchy answers that in the seed it writes next to the Chromium +# binary, but that seed is only laid down by a fresh install and by the one-time +# 3.x upgrade, so machines already on Quattro never receive it. Retrofit it here. +# +# The literal is deliberately duplicated rather than sourced: a migration repairs +# the state of its own moment, and must not drift when the seed later changes. + +chromium_prefs="/usr/lib/chromium/initial_preferences" +chromium_seed='{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' + +if [[ $(cat "$chromium_prefs" 2>/dev/null) != "$chromium_seed" ]]; then + sudo mkdir -p "$(dirname "$chromium_prefs")" + echo "$chromium_seed" | sudo tee "$chromium_prefs" >/dev/null +fi From e9ba17e52ee6c2904289ad4e5a76f588ce0dd662 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:13:06 +0200 Subject: [PATCH 62/73] Keep the explanation for the Chromium color scheme defaults The comment above the seed was the only thing recording that color_scheme and color_scheme2 are both zero in order to follow system appearance rather than force dark. Generalizing it to "first-run defaults" left two magic numbers with nothing to explain them, so the next person touching an unrelated first-run setting has no way to tell that changing them regresses theme following. Name both things the seed does. Co-Authored-By: Codex XHigh --- install/config/theme-system.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install/config/theme-system.sh b/install/config/theme-system.sh index c8272486..a8963439 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -10,7 +10,8 @@ gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true mkdir -p /etc/chromium/policies/managed chmod a+rw /etc/chromium/policies/managed -# Set Chromium first-run defaults +# Seed Chromium's first run: follow system appearance ("device") instead of dark, +# and skip the terms-of-service dialog Chromium 151 turned on by default. mkdir -p /usr/lib/chromium echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ /usr/lib/chromium/initial_preferences From 24c18df5b7edbfdc32a88e52aebb893b93fa2ecd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 15:48:28 +0200 Subject: [PATCH 63/73] Temporarily remove automatic printer discovery cups-browsed is the daemon that watches the network and creates print queues by itself. Hardening it took a root daemon with a predictable cache down to a confined service account, but a daemon that turns anything advertising itself on the network into a print queue is a lot of exposure for a convenience, so it comes out of the default install while that is reworked. Only the discovery half: CUPS itself stays and printing keeps working, with each printer added by hand in Print Settings. The migration disables the unit before removing the package because that is the only order that works: pacman deletes the unit file but not the enable symlink, and once the unit is gone systemd can no longer resolve it by name to clean that up. It then removes the queues discovery generated. cups-browsed keeps those when it stops, since KeepGeneratedQueuesOnShutdown defaults to Yes, and they route through its own implicitclass backend, which goes with the package, so they cannot print again. Idle ones go. A queue with jobs on it is left alone and named: implicitclass only needs cups-browsed to choose a destination, so a job already past that point finishes on its own, and deleting the queue would abort it. One printer's job does not hold up the removal. A printer added by hand has an ipp:// or usb:// device and is left where it is. A queue whose jobs cannot be asked about is left alone rather than assumed idle, including one named so that lpstat would misread it -- "all" is its word for every destination, and a leading dash or a comma reads as another option or a list. Where CUPS does not answer at all, or a queue will not delete, discovery is still stopped but the package stays and no marker is written. omarchy-migrate records a migration for the user as soon as it exits zero, so that is where the machine stays until someone removes the package by hand, and the message says so rather than implying a retry. The queue list is read under LC_ALL=C because lpstat translates "device for", and captured rather than piped, so a cupsd it cannot reach is reported instead of reading like a machine with nothing to clean up. It removes with plain pacman -R rather than omarchy-pkg-drop, which passes -n and would discard /etc/cups/cups-browsed.conf instead of keeping it as a .pacsave. A removal meant to be temporary should not delete the machine's copy of its own configuration. Without -s either, so it only ever removes the package it names: sweeping newly unneeded dependencies is nothing today, but it is not a promise a rolling dependency graph can keep. Queue names come off the network, since cups-browsed names its queues after what the printer advertised. CUPS allows every printable character but space, tab, / and #, and lpstat and lpadmin take a destination as an option value, so a name with a leading dash or a comma is reported rather than passed to them and guessed at. Migration state is per user, so a machine-wide marker records the one removal. Without it, an account whose first migration run came after someone deliberately reinstalled discovery would quietly take it back out again. The install-time override for cups-browsed.conf now waits for cups-browsed rather than for CUPS. Guarding it on a file CUPS still ships would write a configuration file for a package nothing installed, and pacman would later land the package's own copy beside it as a .pacnew. The hardened configuration stays in the tree. omarchy-settings still ships the cups-browsed.conf override, the sysusers account and the service drop-in, so they are what discovery returns onto. Co-Authored-By: Codex XHigh --- bin/omarchy-upgrade-to-quattro | 1 - install/config/enable-services.sh | 8 +- install/omarchy-base.packages | 1 - install/post-install/pacman.sh | 19 +- manual/46-faq.md | 8 +- migrations/1788009111.sh | 131 +++++++ test/acceptance.d/system-test.sh | 67 +--- .../cups-browsed-removal-migration-test.sh | 364 ++++++++++++++++++ test/shell.d/cups-hardening-test.sh | 31 +- 9 files changed, 563 insertions(+), 67 deletions(-) create mode 100644 migrations/1788009111.sh create mode 100644 test/shell.d/cups-browsed-removal-migration-test.sh diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 32ea6465..bf103dc0 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1364,7 +1364,6 @@ EOF as_root systemctl disable docker.service >/dev/null 2>&1 || true enable_system_service cups.service - enable_system_service cups-browsed.service enable_system_service avahi-daemon.service enable_system_service linux-modules-cleanup.service enable_system_service docker.socket diff --git a/install/config/enable-services.sh b/install/config/enable-services.sh index b7e2ba8b..964f0867 100644 --- a/install/config/enable-services.sh +++ b/install/config/enable-services.sh @@ -1,16 +1,14 @@ # Enable services only. Installs are followed by reboot, so don't start/reload # daemons mid-install. UFW and hardware-gated services stay in their own scripts. systemctl enable cups.service -systemctl enable cups-browsed.service systemctl enable avahi-daemon.service systemctl enable linux-modules-cleanup.service systemctl enable docker.socket systemctl enable systemd-resolved.service systemctl enable NetworkManager.service -# Don't let network-online.target (pulled in by cups-browsed) hold up -# graphical.target waiting for DHCP/Wi-Fi association. Nothing in the session -# needs to block on the network. Mirrors the systemd-networkd-wait-online mask -# in install/hardware/network.sh. +# Don't let network-online.target hold up graphical.target waiting for +# DHCP/Wi-Fi association. Nothing in the session needs to block on the network. +# Mirrors the systemd-networkd-wait-online mask in install/hardware/network.sh. systemctl mask NetworkManager-wait-online.service systemctl enable power-profiles-daemon.service systemctl enable sddm.service diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index f4c5e2ef..c5013ba6 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -17,7 +17,6 @@ chromium clang cliamp cups -cups-browsed cups-filters cups-pk-helper ddcutil diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 327b812f..22422820 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,13 +3,22 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# omarchy-settings skips these overrides until CUPS is actually present to -# avoid pacman creating .pacnew files during ISO package installation. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-files.conf ]]; then +# Each override waits for the package that owns the file it replaces, the way +# omarchy-settings does, so pacman does not turn it into a .pacnew during ISO +# package installation. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then + install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf + rm -f /etc/cups/cups-files.conf.pacnew +fi + +# cups-browsed is no longer part of the default install, so this waits for a +# machine that adds discovery back by hand. Writing the override before then +# would leave a configuration file for a package nothing installed, and pacman +# would later land the package's own copy beside it as a .pacnew. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf ]]; then systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf - rm -f /etc/cups/cups-browsed.conf.pacnew /etc/cups/cups-files.conf.pacnew + rm -f /etc/cups/cups-browsed.conf.pacnew fi source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/manual/46-faq.md b/manual/46-faq.md index 65e1f2e7..6c625407 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -44,7 +44,13 @@ The plain open source Chromium build doesn't ship with the OAuth credentials tha ### How do I add a printer? -Printing is set up and running out of the box, so a printer on your network is usually already discovered. Launch _Print Settings_ from the app launcher (`Super + Space`) to see what's there, add one by hand, or set the default. Printing to a PDF file works without any printer at all. +Printing is set up and running out of the box, and you add each printer yourself from _Print Settings_ in the app launcher (`Super + Space`). + +Choose _Add_, and give it a moment to look: a printer plugged in over USB, and most network printers, are found for you. If yours isn't in the list, pick _Network Printer > Internet Printing Protocol (ipp)_ and enter its address — the printer's own display or its web page will tell you what that is, usually something like `192.168.1.50` with a queue of `ipp/print`. _Forward_ then offers a driver, where a modern printer works best on the driverless _IPP Everywhere_ profile and an older one wants the model's own driver. + +Right-click a printer and choose _Set as Default_ to pick which one your apps reach for first, and _Properties_ to set paper size, duplex and quality. + +Automatic discovery, where printers on the network appear without being added, is temporarily switched off while it's reworked, which is why the first step above is yours rather than automatic. Printing to a PDF file works without any printer at all. ### How do I change where screenshots or screenrecordings are saved? diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh new file mode 100644 index 00000000..1f0d67f5 --- /dev/null +++ b/migrations/1788009111.sh @@ -0,0 +1,131 @@ +echo "Temporarily remove automatic printer discovery" + +# cups-browsed is the daemon that watches the network and creates print queues +# by itself. Hardening it (1787815267) took a root daemon with a predictable +# cache down to a confined service account, but a daemon that turns anything +# advertising itself on the network into a print queue is a lot of exposure for +# a convenience, so it comes out of the default install while that is reworked. +# Temporarily, and only the discovery half: CUPS itself stays, printing keeps +# working, and a printer is added by hand in Print Settings instead of +# appearing on its own. +machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" + +[[ ! -e $machine_marker ]] || exit 0 + +# Nothing to do on a machine that never had it. Checked before any sudo so those +# runs never prompt for a password, and before the marker so no machine pays a +# password prompt for a removal it does not need. +omarchy-pkg-present cups-browsed || exit 0 + +# Ask pacman whether the removal is possible before touching the service. If +# something here depends on cups-browsed, the alternative is a machine whose +# discovery daemon has been stopped and whose package removal then failed -- +# broken rather than removed. +if ! pacman -R --print cups-browsed >/dev/null 2>&1; then + echo " Something else on this machine still depends on cups-browsed, so it is staying installed." + exit 0 +fi + +# Disable before removing, and this is the only window in which it works. +# pacman deletes the unit file but not the enable symlink systemd wrote under +# /etc, and once the unit is gone `systemctl disable` refuses it by name and +# leaves the symlink dangling with nothing left that can clean it. Stopping is +# part of the same step: a daemon whose executable has been unlinked keeps +# running until it is told not to. A masked or already-disabled unit reports +# not-enabled and is left alone. Doing it before the queue list is read also +# means the list cannot grow a new entry while it is being acted on. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl disable --now cups-browsed.service +elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service +fi + +# cups-browsed keeps the queues it generated when it stops -- +# KeepGeneratedQueuesOnShutdown defaults to Yes and nothing here overrides it -- +# and those queues route through its own implicitclass backend, which goes with +# the package, so none of them can print again. The idle ones are removed rather +# than left in Print Settings looking like printers; the ones with jobs on them +# are handled below. Only queues on that backend: a printer added by hand has an +# ipp:// or usb:// device and is left alone. +# +# LC_ALL=C because lpstat translates "device for", and on a German or French +# machine the untranslated pattern would match nothing and read exactly like a +# machine that had no queues to clean up. Captured rather than piped so that +# failing to reach cupsd is distinguishable from finding nothing. The name is +# matched greedily because CUPS allows a colon in a queue name but never a +# space, so the last ": implicitclass://" is the separator and an earlier colon +# belongs to the name. +if ! queue_report=$(LC_ALL=C lpstat -v 2>/dev/null); then + echo " Could not ask CUPS which queues discovery had created." + echo " Discovery is off, but cups-browsed stays installed; remove it by hand once CUPS answers." + exit 0 +fi + +generated_queues=$(printf '%s\n' "$queue_report" | + sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') + +unremoved=0 + +while IFS= read -r queue; do + [[ -n $queue ]] || continue + + # A queue name is whatever the printer advertised, put through cups-browsed's + # own sanitizer. `lpstat -o` takes its destination as an optional argument, so + # a leading dash reads as the next option and a comma as a list separator, and + # "all" is its word for every destination. The jobs on such a queue cannot be + # asked about, so it is left alone and named. Never a reason to keep the + # package, though: that would hand a printer that picked its own name a veto + # over the removal. + if [[ $queue == -* || $queue == *,* || $queue == "all" ]]; then + echo " Cannot safely ask about jobs on the queue named '$queue'; remove it in Print Settings." + continue + fi + + # Removing a queue aborts what is printing on it. The implicitclass backend + # only needs cups-browsed to pick a destination, so a job already past that + # point finishes on its own even though the daemon has stopped -- and a job + # that has not is one this cannot route anyway. Either way the queue is left + # for the person whose job it is, and named so they know to remove it. + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then + if [[ -n $job_report ]]; then + echo " $queue still has jobs, so it is being left alone." + echo " Once they finish or are cancelled, remove it in Print Settings; it cannot print again." + continue + fi + else + echo " Could not check for jobs on $queue, so it is being left alone." + continue + fi + + if ! sudo lpadmin -x "$queue"; then + echo " Could not remove the queue $queue." + unremoved=1 + fi +done <<<"$generated_queues" + +# A queue that would not delete is a CUPS that is not answering as expected, so +# the package stays rather than deleting the backend out from under it. Discovery +# is stopped either way, which is the half that mattered. omarchy-migrate records +# this migration for the user as soon as it exits zero, so this is where the +# machine stays until someone removes the package by hand -- said plainly rather +# than dressed up as a retry. +if ((unremoved)); then + echo " Leaving cups-browsed installed. Discovery is off; remove the package by hand once those queues are gone." + exit 0 +fi + +# Raw pacman rather than omarchy-pkg-drop, which passes -n: that discards the +# files pacman has marked as backups instead of renaming them .pacsave, and +# /etc/cups/cups-browsed.conf is one of them. A removal meant to be temporary +# should leave the machine's copy of its own configuration behind. Plain -R +# rather than -Rs, so this only ever removes the one package it names: -s also +# sweeps dependencies that have become unneeded, which is nothing today but is +# a promise the dependency graph of a rolling distribution cannot keep. +# pacman's systemd hook reloads the system manager once the unit file goes. +sudo pacman -R --noconfirm cups-browsed + +# Migration state is per user, so every account on this machine runs every +# migration. Without machine-wide state, an account whose first run comes after +# someone deliberately reinstalled cups-browsed would quietly take it back out +# again. This records that the machine has had its one removal. +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 899cc6e7..deb42f4e 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -47,7 +47,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service cups-browsed.service docker.socket \ + avahi-daemon.service cups.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -65,29 +65,24 @@ verify_services() { } verify_printing_security() { - local cups_browsed_pid lpinfo_output printer_name printer_process printer_tmp + local lpinfo_output ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" pass "the root CUPS-PDF backend is not installed" - getent passwd cups-browsed >/dev/null || fail "the cups-browsed service account exists" - [[ $(systemctl show -P User cups-browsed.service) == "cups-browsed" ]] || - fail "cups-browsed runs as its service account" - [[ $(systemctl show -P Group cups-browsed.service) == "cups-browsed" ]] || - fail "cups-browsed runs as its service group" - systemctl is-active --quiet cups-browsed.service || fail "cups-browsed is running" + # Automatic discovery is temporarily out of the default install: a daemon that + # turns anything advertising itself on the network into a print queue is more + # exposure than the convenience is worth while it is reworked. CUPS itself + # stays, so what a stock machine proves here is that printing runs and that + # the desktop user still cannot administer it without authenticating. + ! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is not installed" + ! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "no discovery service is enabled" + ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "no discovery service is running" + pass "automatic printer discovery is absent from a stock install" - cups_browsed_pid=$(systemctl show -P MainPID cups-browsed.service) - [[ -r /proc/$cups_browsed_pid/status ]] || fail "cups-browsed has a readable process status" - [[ $(awk '/^Uid:/{print $2}' "/proc/$cups_browsed_pid/status") != 0 ]] || - fail "cups-browsed does not run with root UID" - [[ $(awk '/^CapEff:/{print $2}' "/proc/$cups_browsed_pid/status") == "0000000000000000" ]] || - fail "cups-browsed has no effective Linux capabilities" - - [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || - fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" - [[ " $(id -nG cups-browsed) " != *" cups "* ]] || - fail "cups-browsed is separate from the print-filter group" + systemctl is-active --quiet cups.service || fail "CUPS is running" if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then fail "the desktop user cannot administer CUPS without authentication" @@ -95,39 +90,7 @@ verify_printing_security() { fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" fi - pass "CUPS discovery is isolated from root, filters, and passwordless desktop administration" - - # A live driverless printer proves the non-root daemon can still discover and - # create queues without the CAP_NET_BIND_SERVICE Ubuntu carries downstream. - printer_name="OmarchyAcceptancePrinter" - printer_tmp=$(mktemp -d) - printf '#!/bin/bash\nexit 0\n' >"$printer_tmp/command" - chmod 0700 "$printer_tmp/command" - mkdir -m 0700 "$printer_tmp/spool" - - ippeveprinter -p 18631 -d "$printer_tmp/spool" -c "$printer_tmp/command" "$printer_name" \ - >"$printer_tmp/ippeveprinter.log" 2>&1 & - printer_process=$! - - printing_test_cleanup() { - kill "$printer_process" >/dev/null 2>&1 || true - wait "$printer_process" >/dev/null 2>&1 || true - rm -rf "$printer_tmp" - } - trap printing_test_cleanup EXIT - - for _ in {1..30}; do - lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" && break - sleep 1 - done - - lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" || - fail "non-root cups-browsed discovers a driverless IPP printer" "$(<"$printer_tmp/ippeveprinter.log")" - - printing_test_cleanup - trap - EXIT - - pass "non-root cups-browsed still creates driverless IPP queues without capabilities" + pass "CUPS runs with passwordless desktop administration still denied" } verify_runtime_tools() { diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh new file mode 100644 index 00000000..82797b4d --- /dev/null +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -0,0 +1,364 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1788009111.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" + +# Every privileged step is stubbed: a real run here would take printer discovery +# off the developer's own machine. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ " $BROWSED_INSTALLED " == *" $1 "* ]] +SH + +# pacman answers the removal preflight from the state each case sets up, and +# logs the removal itself so the flags it is called with are visible. +cat >"$mock_bin/pacman" <<'SH' +#!/bin/bash +printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" +[[ $* == *--print* ]] || exit 0 +[[ -z $BROWSED_REMOVAL_BLOCKED ]] +SH + +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" +unit=${*: -1} +case $1 in + is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; + is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; + *) : ;; +esac +SH + +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\t%s\n' "$*" >>"$BROWSED_LOG" +exec "$@" +SH + +cat >"$mock_bin/install" <<'SH' +#!/bin/bash +printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" +exec /usr/bin/install "$@" +SH + +# lpstat reports the queues cups-browsed generated. Only those route through its +# implicitclass backend; the ipp:// and usb:// entries are printers a person +# added and must survive. +cat >"$mock_bin/lpstat" <<'SH' +#!/bin/bash +printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" +case $1 in + -v) + [[ -z $BROWSED_LPSTAT_FAILS ]] || exit 1 + # lpstat translates "device for". Only a caller that pinned the locale gets + # the string the migration parses. + if [[ ${LC_ALL:-} == "C" ]]; then + printf '%s\n' "$BROWSED_QUEUES" + else + printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' + fi + ;; + -o) + [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 + # Jobs are listed per queue: "- ". + [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]] && printf '%s-7 alice 1024\n' "$2" + ;; +esac +exit 0 +SH + +cat >"$mock_bin/lpadmin" <<'SH' +#!/bin/bash +printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_LPADMIN_FAILS ]] +SH + +chmod +x "$mock_bin"/* + +log="$test_tmp/actions.log" +output="$test_tmp/migration.out" +marker="$test_tmp/var/lib/omarchy/migrations/1788009111" + +run_migration() { + : >"$log" + : >"$output" + + # No LC_ALL in the environment, so the mock above sees "C" only when the + # migration pinned it for the call itself -- which is what a non-English + # desktop depends on. + env -u LC_ALL -u LANGUAGE \ + BROWSED_LOG="$log" \ + BROWSED_INSTALLED="${installed:-}" \ + BROWSED_ENABLED="${enabled:-}" \ + BROWSED_ACTIVE="${active:-}" \ + BROWSED_REMOVAL_BLOCKED="${blocked:-}" \ + BROWSED_QUEUES="${queues:-}" \ + BROWSED_BUSY_QUEUES="${busy:-}" \ + BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ + BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ + BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ + bash -euo pipefail "$migration" >"$output" +} + +# ------------------------------------------------ a machine that has discovery + +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +# CUPS allows a colon in a destination name but never a space, so "Front:Desk" +# is a legal queue and the separator is the last ": implicitclass://". +queues=$'device for Office: implicitclass://Office/\ndevice for Front:Desk: implicitclass://Front:Desk/\ndevice for -p: implicitclass://-p/\ndevice for a,b: implicitclass://a,b/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' +rm -f "$marker" +run_migration + +grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || + fail "the removal disables and stops the discovery service" "$(cat "$log")" +pass "the removal disables and stops the discovery service" + +# -Rns would discard /etc/cups/cups-browsed.conf rather than keep it as a +# .pacsave, and a removal meant to be temporary must not delete configuration. +grep -qxF $'pacman\t-R --noconfirm cups-browsed' "$log" || + fail "the removal keeps configuration pacman marked as a backup" "$(cat "$log")" +if grep -qE -- '-Rns|-Rn ' "$log"; then + fail "the removal never passes -n" "$(cat "$log")" +fi +# -s would also sweep dependencies that became unneeded, which is a promise a +# rolling dependency graph cannot keep. +if grep -q -- '-Rs --noconfirm' "$log"; then + fail "the removal names only the package it means to remove" "$(cat "$log")" +fi +pass "the removal keeps the machine's own cups-browsed.conf and touches nothing else" + +# pacman deletes the unit file but not the enable symlink, and once the unit is +# gone systemd cannot resolve it by name to clean that up. +disable_line=$(grep -n 'disable --now' "$log" | tail -1 | cut -d: -f1 || true) +removal_line=$(grep -n $'^pacman\t-R --noconfirm' "$log" | head -1 | cut -d: -f1 || true) +[[ -n $disable_line && -n $removal_line ]] || + fail "the removal both disables the unit and removes the package" "$(cat "$log")" +(( disable_line < removal_line )) || + fail "the removal disables before the unit file goes" "$(cat "$log")" +pass "the removal disables before the unit file goes" + +# cups-browsed keeps its generated queues on shutdown, and they print through +# the implicitclass backend that goes with the package. +grep -qxF $'sudo\tlpadmin -x Office' "$log" || + fail "the queues discovery generated are removed with it" "$(cat "$log")" +grep -q 'lpadmin -x Desk' "$log" && + fail "a printer added by hand over ipp survives" "$(cat "$log")" +grep -q 'lpadmin -x Attic' "$log" && + fail "a printer added by hand over usb survives" "$(cat "$log")" +grep -qxF $'sudo\tlpadmin -x Front:Desk' "$log" || + fail "a queue whose name contains a colon is still matched" "$(cat "$log")" + +# cups-browsed names queues after what the printer advertised, so the name came +# off the network. lpstat and lpadmin take a destination as an option value: a +# leading dash reads as another option and a comma separates a list. +if grep -qF -- $'lpstat\t-o -p' "$log"; then + fail "a queue named like an option is never passed to lpstat" "$(cat "$log")" +fi +if grep -q -- 'lpadmin -x -p' "$log"; then + fail "a queue named like an option is never passed to lpadmin" "$(cat "$log")" +fi +if grep -q 'lpadmin -x a,b' "$log"; then + fail "a queue name holding a comma is never passed as a destination list" "$(cat "$log")" +fi +if grep -qF -- $'lpstat\t-o all' "$log"; then + fail "a queue named all is never asked about, since lpstat reads it as every destination" "$(cat "$log")" +fi +grep -qF -- "Cannot safely ask about jobs on the queue named '-p'" "$output" || + fail "a queue that cannot be asked about safely is reported" "$(cat "$output")" +pass "generated queues go, hand-added printers stay, unaddressable names are reported" + +# The queues have to go while cups-browsed's backend is still installed. +cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1 || true) +[[ -n $cleanup_line ]] || fail "the removal cleans up generated queues" "$(cat "$log")" +(( cleanup_line < removal_line )) || + fail "generated queues go before the backend that serves them" "$(cat "$log")" +pass "generated queues go before the backend that serves them" + +[[ -f $marker ]] || fail "the removal records machine-wide completion" +pass "the removal records machine-wide completion" + +# ------------------------------------ a second user, after a deliberate reinstall + +# Migration state is per user. The account that runs this after someone put +# discovery back on purpose must not quietly take it away again. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +run_migration + +[[ ! -s $log ]] || fail "a machine that already had its removal is left alone" "$(cat "$log")" +pass "a second user does not undo a deliberate reinstall" + +# ----------------------------------------- a machine that never had discovery + +installed="" +enabled="" +active="cups.service" +use_marker="$test_tmp/var/lib/omarchy/migrations/never-had-it" +run_migration + +[[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" +[[ ! -e $use_marker ]] || + fail "a machine with nothing to remove is not made to pay for a marker" "$(cat "$log")" +pass "a machine without discovery does no privileged work and gets no password prompt" + +# ------------------------------------------------- a blocked package removal + +# Something depending on cups-browsed makes pacman refuse. Stopping the service +# first and only then discovering that would leave discovery broken rather than +# removed. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/blocked" +run_migration + +grep -q 'disable --now' "$log" && + fail "a refused removal never stops the service" "$(cat "$log")" +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "a refused removal does not go on to remove anything" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a refused removal is not recorded as done" +# The preflight has to ask about the same command the removal will run. +grep -qxF $'pacman\t-R --print cups-browsed' "$log" || + fail "the preflight asks pacman about the removal it will actually run" "$(cat "$log")" +pass "a removal pacman would refuse changes nothing at all" + +# --------------------------------------------------- a queue that is printing + +# lpadmin -x cancels the jobs on the queue it removes. A stale queue can be +# deleted whenever someone notices it; an aborted print cannot come back. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' +busy="Office" +use_marker="$test_tmp/var/lib/omarchy/migrations/printing" +run_migration + +# The implicitclass backend only needs cups-browsed to choose a destination, so +# a job already past that point finishes even though the daemon has stopped. +# Deleting the queue would abort it. +if grep -q 'lpadmin -x Office' "$log"; then + fail "a queue with jobs on it is left for them to finish" "$(cat "$log")" +fi +grep -qxF $'sudo\tlpadmin -x Spare' "$log" || + fail "an idle generated queue is still removed" "$(cat "$log")" +grep -q 'Office still has jobs' "$output" || + fail "a queue left alone is named so it can be removed later" "$(cat "$output")" +# One printer's job must not keep discovery on the machine. +grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || + fail "a busy queue does not hold up the removal" "$(cat "$log")" +pass "a queue with jobs is left for them to finish, and does not hold up the removal" + +# ------------------------------------------------- a job query that fails + +# Treating a failed query as an idle queue would delete it and abort whatever +# was on it, which is the one outcome this is trying to avoid. +lpstat_o_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/nojobs" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "a queue whose jobs could not be checked is left alone" "$(cat "$log")" +fi +grep -q 'Could not check for jobs' "$output" || + fail "a job query that failed is said out loud" "$(cat "$output")" +pass "a queue whose jobs cannot be checked is left alone, not assumed idle" + +lpstat_o_fails="" + +# ------------------------------------------------------ CUPS out of reach + +# Failing to reach cupsd must not read like a machine with no queues to clean. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service" +busy="" +lpstat_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/nocups" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "nothing is removed when the queue list could not be read" "$(cat "$log")" +fi +# Removing the backend without having read the queue list would strand every +# generated queue permanently, and the marker would stop anyone retrying. +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits until the queue list can be read" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || + fail "an unread queue list is not recorded as a finished removal" +grep -qi 'could not ask cups' "$output" || + fail "a queue list that could not be read is said out loud" "$(cat "$output")" +# Stopping discovery is the half that mattered, and it is idempotent. +grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || + fail "discovery is still stopped when the queue list cannot be read" "$(cat "$log")" +pass "an unreadable queue list stops discovery but finalizes nothing" + +lpstat_fails="" + +# ------------------------------------------------ a queue that will not go + +# A queue left behind would route through a backend the removal is about to +# delete, so the package waits rather than stranding it for good. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/' +busy="" +lpstat_fails="" +lpadmin_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/stuck" +run_migration + +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits while a generated queue is still there" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a half-done cleanup is not recorded as finished" +grep -q 'Could not remove the queue Office' "$output" || + fail "a queue that would not go is named" "$(cat "$output")" +pass "a queue that will not go keeps the package and the marker back" + +lpadmin_fails="" + +# -------------------------------------------------- a masked but running daemon + +installed="cups-browsed" +enabled="" +active="cups-browsed.service" +blocked="" +queues="" +use_marker="$test_tmp/var/lib/omarchy/migrations/masked" +run_migration + +grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || + fail "a running unit is stopped even when it is not enabled" "$(cat "$log")" +grep -q 'disable --now' "$log" && + fail "a masked unit is not disabled" "$(cat "$log")" +pass "a masked but running daemon is stopped without being disabled" + +# A machine whose discovery never created a queue has nothing to clean up, and +# no reason to reach for CUPS administration. +grep -q 'lpadmin' "$log" && + fail "no queues means no CUPS administration" "$(cat "$log")" +pass "a machine with no generated queues does not touch CUPS administration" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 96110fff..d60e156f 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -10,11 +10,38 @@ cups_files_conf="$ROOT/etc/cups/cups-files.conf" sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" -grep -qxF cups-browsed "$packages" || fail "cups-browsed remains in the base package set" +# Only discovery goes. Everything else printing needs stays, or this stops +# being a removal of one daemon and becomes a removal of printing. +grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set" +grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set" +grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set" grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" ! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" -pass "the base install keeps discovery and replaces CUPS-PDF with Polkit administration" +# Automatic discovery is temporarily out of the default install while it is +# reworked. The hardened configuration below still ships: it is what a +# hand-installed cups-browsed gets, and what discovery comes back onto. +! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" +! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || + fail "a fresh install does not enable a discovery service it no longer installs" +! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" || + fail "the Quattro upgrade does not enable a discovery service it no longer installs" + +pass "the base install keeps CUPS and Polkit administration, without automatic discovery" + +# The install-time override for a file has to wait for the package that owns +# it. CUPS still ships /etc/cups/cups-files.conf, so a guard on that file no +# longer says anything about cups-browsed: writing its override on a machine +# without the package leaves a configuration file for a package nothing +# installed, and pacman lands its own copy beside it as a .pacnew later. +post_install_pacman="$ROOT/install/post-install/pacman.sh" + +grep -q 'cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf' "$post_install_pacman" || + fail "the discovery override waits for the package that owns the file it replaces" +grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || + fail "the CUPS authorization override waits for the file it replaces" + +pass "install-time overrides wait for the packages that own their files" grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || fail "cups-browsed keeps state outside the print-filter cache" From d67a7c00bcb7276182635bdf12b4f8ca3b18fafe Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:39:33 +0200 Subject: [PATCH 64/73] Cover the ownership and mode checks the publisher rests on Mutating each root-side control one at a time showed three that no test could see: a destination directory root does not own, a single user-owned asset inside an otherwise root-owned packaged directory, and an asset left group- or world-writable by its own mode. Deleting any of the three left the suite green, because the existing cases mark a whole tree untrusted and are caught by the directory check before the per-file one is reached. The harness already had the hook for the ownership pair: TEST_UNTRUSTED_SOURCE makes the stat shim report a chosen prefix as uid 1000, so those two only need it pointed at a destination directory and at a single file rather than at a whole tree. A mode has to be real, so that case stages a copy of the packaged tree the shim reports as root-owned and loosens one asset in it. The empty logo is refused by the destination size bound rather than the caller-side one, so that case pins the behaviour without isolating the check; the two bounds are exactly redundant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki --- test/shell.d/plymouth-set-test.sh | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 8a40cbf7..194879eb 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -531,6 +531,69 @@ assert_no_temporary_files "$fake_root" pass "publication walks the whole parent chain, not only the immediate parent" +# Mode is not the only thing that decides a destination directory. One that is +# merely user-owned still lets its owner put the file back after we publish, so +# ownership has to refuse it even when 0755 looks harmless. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$theme" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned destination directory is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned destination directory keeps its live file" +[[ $output == *"refusing to publish"* ]] || fail "a rejected destination directory says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "publication refuses a destination directory root does not own" + +# The packaged tree is validated file by file, not only directory by directory. +# A single user-owned asset inside an otherwise root-owned directory is still +# content a desktop process can rewrite, and the directory check cannot see it. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth/bullet.png" 2>&1) +status=$? + +(( status != 0 )) || fail "a single user-owned packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "one untrusted asset leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged asset says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root checks every packaged asset, not only its directory" + +# Ownership is not the only way a packaged asset stays rewritable: a group- or +# world-writable mode does it too. Stage a tree the shim reports as root-owned +# so only the real mode can decide, then loosen one asset. +setup_run +writable_root=$(mktemp -d "$test_tmp/writable-source.XXXXXXXX") +writable_root=$(realpath -e -- "$writable_root") +mkdir -p "$writable_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_root/default/" +chmod 0666 "$writable_root/default/plymouth/bullet.png" +output=$(run_set 022 env OMARCHY_PATH="$writable_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a world-writable packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged asset leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected writable asset says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root refuses a packaged asset its own mode leaves rewritable" + +# The caller streams the logo to root over a descriptor, so root is the only +# place its length can be judged. An empty selection must not become an empty +# published logo. +setup_run +cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep" +: >"$test_tmp/logo.png" +output=$(run_set 022 env 2>&1) +status=$? +mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png" + +(( status != 0 )) || fail "an empty logo is rejected" "$output" +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an empty logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "an empty logo cannot be published" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run From 1dbc7d5bce67943a191fb6624c3a8568a659777e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:44:31 +0200 Subject: [PATCH 65/73] Close each discovered queue before inspecting or removing it Checking a queue for jobs and then deleting it leaves a window in between, and the sudo that does the deleting can sit at a password prompt for as long as someone takes to type. A job submitted in that window was cancelled by a deletion that had already decided the queue was empty. cupsreject closes the queue to new work first, which also stops more jobs piling onto one that is being left behind and can no longer route them. It comes from the cups package, which stays. What a queue with jobs on it means is now said rather than implied: whatever is already at the printer finishes, whatever is still waiting cannot be routed with the daemon gone, and the person who owns them has to cancel what is left. A queue that another administrator removed while this was running is the outcome wanted, not a failure to keep the package installed for, so a deletion that fails is checked against whether the destination is still there. Co-Authored-By: Codex XHigh --- migrations/1788009111.sh | 36 +++++++--- .../cups-browsed-removal-migration-test.sh | 72 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh index 1f0d67f5..fc2d262f 100644 --- a/migrations/1788009111.sh +++ b/migrations/1788009111.sh @@ -81,15 +81,28 @@ while IFS= read -r queue; do continue fi - # Removing a queue aborts what is printing on it. The implicitclass backend - # only needs cups-browsed to pick a destination, so a job already past that - # point finishes on its own even though the daemon has stopped -- and a job - # that has not is one this cannot route anyway. Either way the queue is left - # for the person whose job it is, and named so they know to remove it. + # Close the queue to new jobs before looking at what is on it. Otherwise a job + # submitted between the check and the removal -- the sudo below can sit at a + # password prompt for as long as someone takes to type it -- is cancelled by a + # deletion that decided the queue was empty. It also stops more jobs piling + # onto a queue that is being left behind and can no longer route them. + if ! sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue"; then + echo " Could not stop $queue accepting new jobs, so it is being left alone." + unremoved=1 + continue + fi + + # Removing a queue cancels the jobs on it. implicitclass needs cups-browsed + # only to pick a destination, so a job already past that point finishes on its + # own; one still waiting cannot, because the daemon that would route it has + # stopped. Neither is this migration's to throw away, so the queue is left for + # whoever owns them, and what will and will not happen is said rather than + # implied. if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then if [[ -n $job_report ]]; then - echo " $queue still has jobs, so it is being left alone." - echo " Once they finish or are cancelled, remove it in Print Settings; it cannot print again." + echo " $queue still has jobs and is no longer taking new ones, so it is being left alone." + echo " Anything already sent to the printer finishes; anything still waiting cannot be routed now." + echo " Cancel what is left and remove the queue in Print Settings." continue fi else @@ -97,9 +110,14 @@ while IFS= read -r queue; do continue fi + # A queue another administrator removed while this was running is a queue that + # is gone, which is the outcome wanted -- not a failure worth keeping the + # package for. if ! sudo lpadmin -x "$queue"; then - echo " Could not remove the queue $queue." - unremoved=1 + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + echo " Could not remove the queue $queue." + unremoved=1 + fi fi done <<<"$generated_queues" diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh index 82797b4d..63f6a053 100644 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -68,6 +68,11 @@ case $1 in printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' fi ;; + -p) + # A destination that no longer exists is unknown to lpstat. + [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] + exit $? + ;; -o) [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 # Jobs are listed per queue: "- ". @@ -83,6 +88,12 @@ printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" [[ -z $BROWSED_LPADMIN_FAILS ]] SH +cat >"$mock_bin/cupsreject" <<'SH' +#!/bin/bash +printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_REJECT_FAILS ]] +SH + chmod +x "$mock_bin"/* log="$test_tmp/actions.log" @@ -107,6 +118,8 @@ run_migration() { BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ + BROWSED_REJECT_FAILS="${reject_fails:-}" \ + BROWSED_GONE_QUEUES="${gone_queues:-}" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ @@ -262,6 +275,19 @@ if grep -q 'lpadmin -x Office' "$log"; then fi grep -qxF $'sudo\tlpadmin -x Spare' "$log" || fail "an idle generated queue is still removed" "$(cat "$log")" + +# A job submitted between the check and the deletion -- the sudo in between can +# sit at a password prompt -- would be cancelled by a deletion that had decided +# the queue was empty. +reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1 || true) +probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1 || true) +delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1 || true) +[[ -n $reject_line && -n $probe_line && -n $delete_line ]] || + fail "a queue is closed, inspected and removed in that order" "$(cat "$log")" +(( reject_line < probe_line && probe_line < delete_line )) || + fail "a queue stops taking new jobs before it is inspected or removed" "$(cat "$log")" +pass "a queue stops taking new jobs before it is inspected or removed" + grep -q 'Office still has jobs' "$output" || fail "a queue left alone is named so it can be removed later" "$(cat "$output")" # One printer's job must not keep discovery on the machine. @@ -341,6 +367,52 @@ pass "a queue that will not go keeps the package and the marker back" lpadmin_fails="" +# --------------------------------------------- a queue removed by someone else + +# Another administrator deleting the queue mid-run is the outcome wanted, not a +# failure worth keeping the package installed for. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/' +busy="" +lpstat_fails="" +lpstat_o_fails="" +lpadmin_fails="1" +gone_queues="Office" +use_marker="$test_tmp/var/lib/omarchy/migrations/vanished" +run_migration + +grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || + fail "a queue that is already gone does not hold up the removal" "$(cat "$log")" +[[ -f $use_marker ]] || fail "a queue that is already gone still finishes the migration" +pass "a queue someone else removed counts as removed" + +lpadmin_fails="" +gone_queues="" + +# ------------------------------------------- a queue that will not stop taking jobs + +# Deleting a queue that is still accepting work races whatever arrives next. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +reject_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/openqueue" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "a queue still accepting jobs is not deleted" "$(cat "$log")" +fi +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits while a queue is still accepting jobs" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a queue still taking jobs is not recorded as done" +pass "a queue that will not stop taking jobs is neither inspected nor deleted" + +reject_fails="" + # -------------------------------------------------- a masked but running daemon installed="cups-browsed" From aac1b009e4ce00916d45842c88d7096aa2c0c5b6 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 17:47:42 +0200 Subject: [PATCH 66/73] Cover non-regular Plymouth logo descriptors --- test/shell.d/plymouth-set-test.sh | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 194879eb..a2a7935a 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -409,6 +409,43 @@ assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" +# Opening a directory read-only succeeds on Linux, but the resulting descriptor +# is not a regular file. Swap one in immediately before exec: this gets past the +# open itself and makes the /proc descriptor check the only caller-side control +# that can stop sudo from starting. +setup_run +nonregular_hook="$run_dir/nonregular-hook" +nonregular_marker="$run_dir/nonregular-marker" +cat >"$nonregular_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && + ! -e $TEST_NONREGULAR_MARKER ]]; then + mv -T -- "$logo_path" "$logo_path.before-nonregular-swap" + mkdir -- "$logo_path" + printf "swapped\n" >"$TEST_NONREGULAR_MARKER" + fi + ' DEBUG +fi +SH + +output=$(TEST_NONREGULAR_MARKER="$nonregular_marker" BASH_ENV="$nonregular_hook" run_set 077 env 2>&1) +status=$? +rmdir "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-nonregular-swap" "$test_tmp/logo.png" + +(( status != 0 )) || fail "a non-regular opened logo descriptor aborts publication" +[[ -s $nonregular_marker ]] || fail "the non-regular pre-open source swap ran deterministically" "$output" +[[ $output == *"no longer a regular file"* ]] || fail "the descriptor check says why it refused the opened directory" "$output" +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then + fail "sudo started despite the non-regular opened logo descriptor" +fi +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "a non-regular opened logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "the caller refuses an opened descriptor that is not a regular file" + # Plant both a malicious script and a root-file symlink where the old # caller-owned stage lived. The privileged transaction must ignore that tree: # executable/config assets come only from its root-trusted source and are built From 1b92b7f5be8683a71c9d176d5a5fc859518143b3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 18:03:57 +0200 Subject: [PATCH 67/73] Correct the dev-link help now that Plymouth follows the link omarchy-dev-link told people /usr/share/plymouth was not covered by a link and to reach for omarchy-dev-pkg-test. Since the publisher authorizes a dev-linked checkout out of the root-owned /etc/omarchy.conf, the Plymouth and SDDM themes are republished from the checkout like every other $OMARCHY_PATH-resolved tree, so the help was left describing the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-dev-link | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-dev-link b/bin/omarchy-dev-link index ccaa404f..b09473b5 100755 --- a/bin/omarchy-dev-link +++ b/bin/omarchy-dev-link @@ -34,8 +34,12 @@ systemd, shell, or app-launcher environment; reboot to make every layer agree. Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/, themes/, applications/, config/. Files installed at fixed system paths (/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user -creation, /usr/share/plymouth) are NOT covered — for those, use -omarchy-dev-pkg-test to build and install the package from the checkout. +creation) are NOT covered — for those, use omarchy-dev-pkg-test to build +and install the package from the checkout. + +The Plymouth and SDDM themes under /usr/share are the exception: omarchy +plymouth set and omarchy refresh plymouth republish them from the checkout, +reading this link's authorization out of the root-owned /etc/omarchy.conf. Also writes $sudoers_file so sudo resolves omarchy-* from the checkout instead of the packaged copies. That part takes effect From bb5b178e5f5d26fa2059c552ec2aac38a1e25fe5 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 14:24:05 -0400 Subject: [PATCH 68/73] Remove unused cups-browsed install override --- install/post-install/pacman.sh | 15 ++------------- test/acceptance.d/system-test.sh | 12 +++++++++++- test/shell.d/cups-hardening-test.sh | 18 ++++++++---------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 22422820..da84975b 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,22 +3,11 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# Each override waits for the package that owns the file it replaces, the way -# omarchy-settings does, so pacman does not turn it into a .pacnew during ISO -# package installation. +# Wait for CUPS to own the file, the way omarchy-settings does, so pacman does +# not turn the override into a .pacnew during ISO package installation. if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf rm -f /etc/cups/cups-files.conf.pacnew fi -# cups-browsed is no longer part of the default install, so this waits for a -# machine that adds discovery back by hand. Writing the override before then -# would leave a configuration file for a package nothing installed, and pacman -# would later land the package's own copy beside it as a .pacnew. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf ]]; then - systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf - cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew -fi - source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index deb42f4e..75e9ac81 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -65,7 +65,7 @@ verify_services() { } verify_printing_security() { - local lpinfo_output + local lpinfo_output path ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" pass "the root CUPS-PDF backend is not installed" @@ -80,6 +80,16 @@ verify_printing_security() { fail "no discovery service is enabled" ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || fail "no discovery service is running" + + for path in \ + /etc/cups/cups-browsed.conf \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no installed package files" "$path still exists" + done pass "automatic printer discovery is absent from a stock install" systemctl is-active --quiet cups.service || fail "CUPS is running" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index d60e156f..a8aefb94 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -19,8 +19,8 @@ grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is i ! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" # Automatic discovery is temporarily out of the default install while it is -# reworked. The hardened configuration below still ships: it is what a -# hand-installed cups-browsed gets, and what discovery comes back onto. +# reworked. The hardened configuration below stays as the baseline discovery +# comes back onto. ! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" ! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || fail "a fresh install does not enable a discovery service it no longer installs" @@ -29,19 +29,17 @@ grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is i pass "the base install keeps CUPS and Polkit administration, without automatic discovery" -# The install-time override for a file has to wait for the package that owns -# it. CUPS still ships /etc/cups/cups-files.conf, so a guard on that file no -# longer says anything about cups-browsed: writing its override on a machine -# without the package leaves a configuration file for a package nothing -# installed, and pacman lands its own copy beside it as a .pacnew later. +# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is +# applied after the ISO installs that package. cups-browsed is absent, so the +# installer must not write any of its package-owned configuration. post_install_pacman="$ROOT/install/post-install/pacman.sh" -grep -q 'cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf' "$post_install_pacman" || - fail "the discovery override waits for the package that owns the file it replaces" +! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" || + fail "a fresh install does not write configuration for absent printer discovery" grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || fail "the CUPS authorization override waits for the file it replaces" -pass "install-time overrides wait for the packages that own their files" +pass "the fresh install applies CUPS hardening without writing discovery configuration" grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || fail "cups-browsed keeps state outside the print-filter cache" From 96d5682460eb5935c4b7de689facb32a45d01d4b Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 14:58:33 -0400 Subject: [PATCH 69/73] Fix cups-browsed removal migration --- migrations/1788009111.sh | 155 ++---- test/acceptance.d/system-test.sh | 1 + .../cups-browsed-removal-migration-test.sh | 482 ++++++++---------- 3 files changed, 242 insertions(+), 396 deletions(-) diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh index fc2d262f..676fd5d8 100644 --- a/migrations/1788009111.sh +++ b/migrations/1788009111.sh @@ -1,149 +1,72 @@ echo "Temporarily remove automatic printer discovery" -# cups-browsed is the daemon that watches the network and creates print queues -# by itself. Hardening it (1787815267) took a root daemon with a predictable -# cache down to a confined service account, but a daemon that turns anything -# advertising itself on the network into a print queue is a lot of exposure for -# a convenience, so it comes out of the default install while that is reworked. -# Temporarily, and only the discovery half: CUPS itself stays, printing keeps -# working, and a printer is added by hand in Print Settings instead of -# appearing on its own. machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" [[ ! -e $machine_marker ]] || exit 0 - -# Nothing to do on a machine that never had it. Checked before any sudo so those -# runs never prompt for a password, and before the marker so no machine pays a -# password prompt for a removal it does not need. omarchy-pkg-present cups-browsed || exit 0 -# Ask pacman whether the removal is possible before touching the service. If -# something here depends on cups-browsed, the alternative is a machine whose -# discovery daemon has been stopped and whose package removal then failed -- -# broken rather than removed. -if ! pacman -R --print cups-browsed >/dev/null 2>&1; then - echo " Something else on this machine still depends on cups-browsed, so it is staying installed." - exit 0 -fi +# Check the full removal transaction before changing the service or queues. +pacman -Rs --print cups-browsed >/dev/null -# Disable before removing, and this is the only window in which it works. -# pacman deletes the unit file but not the enable symlink systemd wrote under -# /etc, and once the unit is gone `systemctl disable` refuses it by name and -# leaves the symlink dangling with nothing left that can clean it. Stopping is -# part of the same step: a daemon whose executable has been unlinked keeps -# running until it is told not to. A masked or already-disabled unit reports -# not-enabled and is left alone. Doing it before the queue list is read also -# means the list cannot grow a new entry while it is being acted on. +# Disable the unit while its package still owns the unit file so systemd can +# remove the enable symlink cleanly. if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl disable --now cups-browsed.service + sudo systemctl disable --now cups-browsed.service >/dev/null elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl stop cups-browsed.service + sudo systemctl stop cups-browsed.service >/dev/null fi -# cups-browsed keeps the queues it generated when it stops -- -# KeepGeneratedQueuesOnShutdown defaults to Yes and nothing here overrides it -- -# and those queues route through its own implicitclass backend, which goes with -# the package, so none of them can print again. The idle ones are removed rather -# than left in Print Settings looking like printers; the ones with jobs on them -# are handled below. Only queues on that backend: a printer added by hand has an -# ipp:// or usb:// device and is left alone. +# cups-browsed leaves its implicitclass queues behind when stopped. Remove idle +# discovery queues before removing the backend they require, but leave queues +# with jobs for the user to resolve. # -# LC_ALL=C because lpstat translates "device for", and on a German or French -# machine the untranslated pattern would match nothing and read exactly like a -# machine that had no queues to clean up. Captured rather than piped so that -# failing to reach cupsd is distinguishable from finding nothing. The name is -# matched greedily because CUPS allows a colon in a queue name but never a -# space, so the last ": implicitclass://" is the separator and an earlier colon -# belongs to the name. -if ! queue_report=$(LC_ALL=C lpstat -v 2>/dev/null); then - echo " Could not ask CUPS which queues discovery had created." - echo " Discovery is off, but cups-browsed stays installed; remove it by hand once CUPS answers." - exit 0 +# A healthy CUPS server with no configured printers reports this condition on +# stderr and exits 1. Treat that as an empty queue list; every other failure +# keeps the migration pending so it can be retried. +if queue_report=$(LC_ALL=C lpstat -v 2>&1); then + : +elif [[ $queue_report == "lpstat: No destinations added." ]]; then + queue_report="" +else + printf '%s\n' "$queue_report" >&2 + exit 1 fi generated_queues=$(printf '%s\n' "$queue_report" | sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') -unremoved=0 - while IFS= read -r queue; do [[ -n $queue ]] || continue - # A queue name is whatever the printer advertised, put through cups-browsed's - # own sanitizer. `lpstat -o` takes its destination as an optional argument, so - # a leading dash reads as the next option and a comma as a list separator, and - # "all" is its word for every destination. The jobs on such a queue cannot be - # asked about, so it is left alone and named. Never a reason to keep the - # package, though: that would hand a printer that picked its own name a veto - # over the removal. - if [[ $queue == -* || $queue == *,* || $queue == "all" ]]; then - echo " Cannot safely ask about jobs on the queue named '$queue'; remove it in Print Settings." - continue - fi - - # Close the queue to new jobs before looking at what is on it. Otherwise a job - # submitted between the check and the removal -- the sudo below can sit at a - # password prompt for as long as someone takes to type it -- is cancelled by a - # deletion that decided the queue was empty. It also stops more jobs piling - # onto a queue that is being left behind and can no longer route them. - if ! sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue"; then - echo " Could not stop $queue accepting new jobs, so it is being left alone." - unremoved=1 - continue - fi - - # Removing a queue cancels the jobs on it. implicitclass needs cups-browsed - # only to pick a destination, so a job already past that point finishes on its - # own; one still waiting cannot, because the daemon that would route it has - # stopped. Neither is this migration's to throw away, so the queue is left for - # whoever owns them, and what will and will not happen is said rather than - # implied. - if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then - if [[ -n $job_report ]]; then - echo " $queue still has jobs and is no longer taking new ones, so it is being left alone." - echo " Anything already sent to the printer finishes; anything still waiting cannot be routed now." - echo " Cancel what is left and remove the queue in Print Settings." + if ! reject_error=$(sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue" 2>&1); then + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$reject_error" >&2 + exit 1 + else continue fi + fi + + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>&1); then + [[ -z $job_report ]] || continue + elif LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$job_report" >&2 + exit 1 else - echo " Could not check for jobs on $queue, so it is being left alone." + # The queue disappeared after the initial snapshot, which is already the + # desired state. continue fi - # A queue another administrator removed while this was running is a queue that - # is gone, which is the outcome wanted -- not a failure worth keeping the - # package for. - if ! sudo lpadmin -x "$queue"; then + if ! delete_error=$(sudo lpadmin -x "$queue" 2>&1); then + # Treat a concurrent disappearance as success. A queue that still exists + # means CUPS did not complete the deletion, so retry the migration later. if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then - echo " Could not remove the queue $queue." - unremoved=1 + printf '%s\n' "$delete_error" >&2 + exit 1 fi fi done <<<"$generated_queues" -# A queue that would not delete is a CUPS that is not answering as expected, so -# the package stays rather than deleting the backend out from under it. Discovery -# is stopped either way, which is the half that mattered. omarchy-migrate records -# this migration for the user as soon as it exits zero, so this is where the -# machine stays until someone removes the package by hand -- said plainly rather -# than dressed up as a retry. -if ((unremoved)); then - echo " Leaving cups-browsed installed. Discovery is off; remove the package by hand once those queues are gone." - exit 0 -fi - -# Raw pacman rather than omarchy-pkg-drop, which passes -n: that discards the -# files pacman has marked as backups instead of renaming them .pacsave, and -# /etc/cups/cups-browsed.conf is one of them. A removal meant to be temporary -# should leave the machine's copy of its own configuration behind. Plain -R -# rather than -Rs, so this only ever removes the one package it names: -s also -# sweeps dependencies that have become unneeded, which is nothing today but is -# a promise the dependency graph of a rolling distribution cannot keep. -# pacman's systemd hook reloads the system manager once the unit file goes. -sudo pacman -R --noconfirm cups-browsed - -# Migration state is per user, so every account on this machine runs every -# migration. Without machine-wide state, an account whose first run comes after -# someone deliberately reinstalled cups-browsed would quietly take it back out -# again. This records that the machine has had its one removal. +omarchy-pkg-drop cups-browsed >/dev/null sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 75e9ac81..ab3affa7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -83,6 +83,7 @@ verify_printing_security() { for path in \ /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ /usr/bin/cups-browsed \ /usr/lib/cups/backend/implicitclass \ /usr/lib/systemd/system/cups-browsed.service \ diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh index 63f6a053..a311e6e7 100644 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -2,7 +2,7 @@ set -euo pipefail -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$(cd -- "$(dirname -- "$0")" && pwd)/base-test.sh" migration="$ROOT/migrations/1788009111.sh" @@ -12,26 +12,31 @@ trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" -# Every privileged step is stubbed: a real run here would take printer discovery -# off the developer's own machine. +# Every privileged step is stubbed: a real run here would take printer +# discovery off the developer's own machine. cat >"$mock_bin/omarchy-pkg-present" <<'SH' #!/bin/bash [[ " $BROWSED_INSTALLED " == *" $1 "* ]] SH -# pacman answers the removal preflight from the state each case sets up, and -# logs the removal itself so the flags it is called with are visible. +cat >"$mock_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf 'omarchy-pkg-drop\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_DROP_FAILS ]] +SH + cat >"$mock_bin/pacman" <<'SH' #!/bin/bash printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" -[[ $* == *--print* ]] || exit 0 -[[ -z $BROWSED_REMOVAL_BLOCKED ]] +if [[ $* == *--print* ]]; then + [[ -z $BROWSED_REMOVAL_BLOCKED ]] +fi SH cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" -unit=${*: -1} +unit=$3 case $1 in is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; @@ -51,386 +56,303 @@ printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" exec /usr/bin/install "$@" SH -# lpstat reports the queues cups-browsed generated. Only those route through its -# implicitclass backend; the ipp:// and usb:// entries are printers a person -# added and must survive. +# lpstat reports only package-generated queues on the implicitclass backend. +# Manual ipp:// and usb:// printers must survive. cat >"$mock_bin/lpstat" <<'SH' #!/bin/bash printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" case $1 in -v) - [[ -z $BROWSED_LPSTAT_FAILS ]] || exit 1 - # lpstat translates "device for". Only a caller that pinned the locale gets - # the string the migration parses. - if [[ ${LC_ALL:-} == "C" ]]; then + if [[ -n $BROWSED_LPSTAT_FAILS ]]; then + echo "lpstat: Scheduler is not responding." >&2 + exit 1 + elif [[ -z $BROWSED_QUEUES ]]; then + echo "lpstat: No destinations added." >&2 + exit 1 + elif [[ $LC_ALL == "C" ]]; then printf '%s\n' "$BROWSED_QUEUES" else printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' fi ;; -p) - # A destination that no longer exists is unknown to lpstat. [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] - exit $? ;; -o) - [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 - # Jobs are listed per queue: "- ". - [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]] && printf '%s-7 alice 1024\n' "$2" + if [[ -n $BROWSED_LPSTAT_O_FAILS ]]; then + echo "lpstat: Scheduler is not responding." >&2 + exit 1 + fi + if [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]]; then + printf '%s-7 alice 1024\n' "$2" + fi ;; esac -exit 0 SH cat >"$mock_bin/lpadmin" <<'SH' #!/bin/bash printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_LPADMIN_FAILS ]] +if [[ -n $BROWSED_LPADMIN_FAILS ]]; then + echo "lpadmin: Printer does not exist." >&2 + exit 1 +fi SH cat >"$mock_bin/cupsreject" <<'SH' #!/bin/bash printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_REJECT_FAILS ]] +if [[ -n $BROWSED_REJECT_FAILS ]]; then + echo "cupsreject: Unable to reject jobs." >&2 + exit 1 +fi SH chmod +x "$mock_bin"/* log="$test_tmp/actions.log" output="$test_tmp/migration.out" -marker="$test_tmp/var/lib/omarchy/migrations/1788009111" +errors="$test_tmp/migration.err" +status=0 + +start_case() { + installed="cups-browsed" + enabled="cups-browsed.service" + active="cups-browsed.service cups.service" + blocked="" + drop_fails="" + queues="" + busy="" + lpstat_fails="" + lpstat_o_fails="" + lpadmin_fails="" + reject_fails="" + gone_queues="" + use_marker="$test_tmp/var/lib/omarchy/migrations/$1" + rm -f "$use_marker" +} run_migration() { : >"$log" : >"$output" + : >"$errors" - # No LC_ALL in the environment, so the mock above sees "C" only when the - # migration pinned it for the call itself -- which is what a non-English - # desktop depends on. - env -u LC_ALL -u LANGUAGE \ + if env -u LC_ALL -u LANGUAGE \ BROWSED_LOG="$log" \ - BROWSED_INSTALLED="${installed:-}" \ - BROWSED_ENABLED="${enabled:-}" \ - BROWSED_ACTIVE="${active:-}" \ - BROWSED_REMOVAL_BLOCKED="${blocked:-}" \ - BROWSED_QUEUES="${queues:-}" \ - BROWSED_BUSY_QUEUES="${busy:-}" \ - BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ - BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ - BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ - BROWSED_REJECT_FAILS="${reject_fails:-}" \ - BROWSED_GONE_QUEUES="${gone_queues:-}" \ + BROWSED_INSTALLED="$installed" \ + BROWSED_ENABLED="$enabled" \ + BROWSED_ACTIVE="$active" \ + BROWSED_REMOVAL_BLOCKED="$blocked" \ + BROWSED_DROP_FAILS="$drop_fails" \ + BROWSED_QUEUES="$queues" \ + BROWSED_BUSY_QUEUES="$busy" \ + BROWSED_LPSTAT_FAILS="$lpstat_fails" \ + BROWSED_LPSTAT_O_FAILS="$lpstat_o_fails" \ + BROWSED_LPADMIN_FAILS="$lpadmin_fails" \ + BROWSED_REJECT_FAILS="$reject_fails" \ + BROWSED_GONE_QUEUES="$gone_queues" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ - bash -euo pipefail "$migration" >"$output" + OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="$use_marker" \ + bash -euo pipefail "$migration" >"$output" 2>"$errors"; then + status=0 + else + status=$? + fi } -# ------------------------------------------------ a machine that has discovery +assert_description_only() { + [[ $(<"$output") == "Temporarily remove automatic printer discovery" ]] || + fail "the migration prints only its description" "$(cat "$output")" +} -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" -# CUPS allows a colon in a destination name but never a space, so "Front:Desk" -# is a legal queue and the separator is the last ": implicitclass://". -queues=$'device for Office: implicitclass://Office/\ndevice for Front:Desk: implicitclass://Front:Desk/\ndevice for -p: implicitclass://-p/\ndevice for a,b: implicitclass://a,b/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' -rm -f "$marker" +assert_quiet_success() { + (( status == 0 )) || fail "the migration succeeds" "$(cat "$errors")" + assert_description_only + [[ ! -s $errors ]] || fail "a successful migration is quiet" "$(cat "$errors")" +} + +# ------------------------------------------------ a machine that has printers + +start_case printers +queues=$'device for Office: implicitclass://Office/\ndevice for Front_Desk: implicitclass://Front_Desk/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' run_migration +assert_quiet_success +grep -qxF $'pacman\t-Rs --print cups-browsed' "$log" || + fail "the migration preflights the package removal" "$(cat "$log")" grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "the removal disables and stops the discovery service" "$(cat "$log")" -pass "the removal disables and stops the discovery service" - -# -Rns would discard /etc/cups/cups-browsed.conf rather than keep it as a -# .pacsave, and a removal meant to be temporary must not delete configuration. -grep -qxF $'pacman\t-R --noconfirm cups-browsed' "$log" || - fail "the removal keeps configuration pacman marked as a backup" "$(cat "$log")" -if grep -qE -- '-Rns|-Rn ' "$log"; then - fail "the removal never passes -n" "$(cat "$log")" -fi -# -s would also sweep dependencies that became unneeded, which is a promise a -# rolling dependency graph cannot keep. -if grep -q -- '-Rs --noconfirm' "$log"; then - fail "the removal names only the package it means to remove" "$(cat "$log")" -fi -pass "the removal keeps the machine's own cups-browsed.conf and touches nothing else" - -# pacman deletes the unit file but not the enable symlink, and once the unit is -# gone systemd cannot resolve it by name to clean that up. -disable_line=$(grep -n 'disable --now' "$log" | tail -1 | cut -d: -f1 || true) -removal_line=$(grep -n $'^pacman\t-R --noconfirm' "$log" | head -1 | cut -d: -f1 || true) -[[ -n $disable_line && -n $removal_line ]] || - fail "the removal both disables the unit and removes the package" "$(cat "$log")" -(( disable_line < removal_line )) || - fail "the removal disables before the unit file goes" "$(cat "$log")" -pass "the removal disables before the unit file goes" - -# cups-browsed keeps its generated queues on shutdown, and they print through -# the implicitclass backend that goes with the package. -grep -qxF $'sudo\tlpadmin -x Office' "$log" || - fail "the queues discovery generated are removed with it" "$(cat "$log")" + fail "the migration disables and stops discovery" "$(cat "$log")" +for queue in Office Front_Desk all; do + grep -qxF "sudo lpadmin -x $queue" "$log" || + fail "the migration removes generated queue $queue" "$(cat "$log")" +done grep -q 'lpadmin -x Desk' "$log" && - fail "a printer added by hand over ipp survives" "$(cat "$log")" + fail "the migration leaves a manually-added IPP printer alone" "$(cat "$log")" grep -q 'lpadmin -x Attic' "$log" && - fail "a printer added by hand over usb survives" "$(cat "$log")" -grep -qxF $'sudo\tlpadmin -x Front:Desk' "$log" || - fail "a queue whose name contains a colon is still matched" "$(cat "$log")" + fail "the migration leaves a manually-added USB printer alone" "$(cat "$log")" +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "the migration uses the standard package removal helper" "$(cat "$log")" +[[ -f $use_marker ]] || fail "the migration records machine-wide completion" -# cups-browsed names queues after what the printer advertised, so the name came -# off the network. lpstat and lpadmin take a destination as an option value: a -# leading dash reads as another option and a comma separates a list. -if grep -qF -- $'lpstat\t-o -p' "$log"; then - fail "a queue named like an option is never passed to lpstat" "$(cat "$log")" -fi -if grep -q -- 'lpadmin -x -p' "$log"; then - fail "a queue named like an option is never passed to lpadmin" "$(cat "$log")" -fi -if grep -q 'lpadmin -x a,b' "$log"; then - fail "a queue name holding a comma is never passed as a destination list" "$(cat "$log")" -fi -if grep -qF -- $'lpstat\t-o all' "$log"; then - fail "a queue named all is never asked about, since lpstat reads it as every destination" "$(cat "$log")" -fi -grep -qF -- "Cannot safely ask about jobs on the queue named '-p'" "$output" || - fail "a queue that cannot be asked about safely is reported" "$(cat "$output")" -pass "generated queues go, hand-added printers stay, unaddressable names are reported" - -# The queues have to go while cups-browsed's backend is still installed. -cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1 || true) -[[ -n $cleanup_line ]] || fail "the removal cleans up generated queues" "$(cat "$log")" -(( cleanup_line < removal_line )) || - fail "generated queues go before the backend that serves them" "$(cat "$log")" -pass "generated queues go before the backend that serves them" - -[[ -f $marker ]] || fail "the removal records machine-wide completion" -pass "the removal records machine-wide completion" +disable_line=$(grep -n 'disable --now' "$log" | head -1 | cut -d: -f1) +cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1) +removal_line=$(grep -n 'omarchy-pkg-drop' "$log" | head -1 | cut -d: -f1) +(( disable_line < cleanup_line && cleanup_line < removal_line )) || + fail "the service and queues are handled before package removal" "$(cat "$log")" +pass "generated queues are removed, manual printers survive, and normal output is quiet" # ------------------------------------ a second user, after a deliberate reinstall -# Migration state is per user. The account that runs this after someone put -# discovery back on purpose must not quietly take it away again. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" run_migration - -[[ ! -s $log ]] || fail "a machine that already had its removal is left alone" "$(cat "$log")" +assert_quiet_success +[[ ! -s $log ]] || fail "a completed machine-wide removal is left alone" "$(cat "$log")" pass "a second user does not undo a deliberate reinstall" # ----------------------------------------- a machine that never had discovery +start_case no-package installed="" -enabled="" -active="cups.service" -use_marker="$test_tmp/var/lib/omarchy/migrations/never-had-it" run_migration - +assert_quiet_success [[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" -[[ ! -e $use_marker ]] || - fail "a machine with nothing to remove is not made to pay for a marker" "$(cat "$log")" -pass "a machine without discovery does no privileged work and gets no password prompt" +[[ ! -e $use_marker ]] || fail "a machine without discovery gets no marker" +pass "a machine without cups-browsed does no privileged work" + +# --------------------------------------------- a machine that has no printers + +start_case no-printers +run_migration +assert_quiet_success +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "no printers does not prevent package removal" "$(cat "$log")" +grep -qE 'cupsreject|lpadmin' "$log" && + fail "no printers requires no queue administration" "$(cat "$log")" +[[ -f $use_marker ]] || fail "the no-printer migration records completion" +pass "CUPS reporting no destinations is a successful empty migration" # ------------------------------------------------- a blocked package removal -# Something depending on cups-browsed makes pacman refuse. Stopping the service -# first and only then discovering that would leave discovery broken rather than -# removed. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" +start_case blocked blocked="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/blocked" run_migration - +(( status != 0 )) || fail "a blocked package removal remains pending" +assert_description_only grep -q 'disable --now' "$log" && - fail "a refused removal never stops the service" "$(cat "$log")" -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "a refused removal does not go on to remove anything" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a refused removal is not recorded as done" -# The preflight has to ask about the same command the removal will run. -grep -qxF $'pacman\t-R --print cups-browsed' "$log" || - fail "the preflight asks pacman about the removal it will actually run" "$(cat "$log")" -pass "a removal pacman would refuse changes nothing at all" + fail "a blocked removal does not stop discovery" "$(cat "$log")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a blocked removal does not invoke package removal" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a blocked removal gets no marker" +pass "a package dependency prevents any partial migration" -# --------------------------------------------------- a queue that is printing +# --------------------------------------------------- a queue that has jobs -# lpadmin -x cancels the jobs on the queue it removes. A stale queue can be -# deleted whenever someone notices it; an aborted print cannot come back. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case busy queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' busy="Office" -use_marker="$test_tmp/var/lib/omarchy/migrations/printing" run_migration - -# The implicitclass backend only needs cups-browsed to choose a destination, so -# a job already past that point finishes even though the daemon has stopped. -# Deleting the queue would abort it. -if grep -q 'lpadmin -x Office' "$log"; then - fail "a queue with jobs on it is left for them to finish" "$(cat "$log")" -fi +assert_quiet_success +grep -q 'lpadmin -x Office' "$log" && + fail "a queue with jobs is not deleted" "$(cat "$log")" grep -qxF $'sudo\tlpadmin -x Spare' "$log" || - fail "an idle generated queue is still removed" "$(cat "$log")" + fail "an idle generated queue is deleted" "$(cat "$log")" +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "a busy queue does not retain the discovery package" "$(cat "$log")" -# A job submitted between the check and the deletion -- the sudo in between can -# sit at a password prompt -- would be cancelled by a deletion that had decided -# the queue was empty. -reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1 || true) -probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1 || true) -delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1 || true) -[[ -n $reject_line && -n $probe_line && -n $delete_line ]] || - fail "a queue is closed, inspected and removed in that order" "$(cat "$log")" +reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1) +probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1) +delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1) (( reject_line < probe_line && probe_line < delete_line )) || - fail "a queue stops taking new jobs before it is inspected or removed" "$(cat "$log")" -pass "a queue stops taking new jobs before it is inspected or removed" - -grep -q 'Office still has jobs' "$output" || - fail "a queue left alone is named so it can be removed later" "$(cat "$output")" -# One printer's job must not keep discovery on the machine. -grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || - fail "a busy queue does not hold up the removal" "$(cat "$log")" -pass "a queue with jobs is left for them to finish, and does not hold up the removal" + fail "a queue is closed before it is checked and deleted" "$(cat "$log")" +pass "busy queues survive while idle discovery queues are removed" # ------------------------------------------------- a job query that fails -# Treating a failed query as an idle queue would delete it and abort whatever -# was on it, which is the one outcome this is trying to avoid. +start_case job-query-fails +queues=$'device for Office: implicitclass://Office/' lpstat_o_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/nojobs" run_migration - -if grep -q 'lpadmin -x' "$log"; then - fail "a queue whose jobs could not be checked is left alone" "$(cat "$log")" -fi -grep -q 'Could not check for jobs' "$output" || - fail "a job query that failed is said out loud" "$(cat "$output")" -pass "a queue whose jobs cannot be checked is left alone, not assumed idle" - -lpstat_o_fails="" +(( status != 0 )) || fail "a failed job query keeps the migration pending" +assert_description_only +grep -q 'lpadmin -x' "$log" && + fail "a queue with unknown job state is not deleted" "$(cat "$log")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a failed job query retains the package" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a failed job query gets no marker" +pass "a failed job query is retryable instead of falsely completing" # ------------------------------------------------------ CUPS out of reach -# Failing to reach cupsd must not read like a machine with no queues to clean. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service" -busy="" +start_case no-cups +queues=$'device for Office: implicitclass://Office/' lpstat_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/nocups" run_migration - -if grep -q 'lpadmin -x' "$log"; then - fail "nothing is removed when the queue list could not be read" "$(cat "$log")" -fi -# Removing the backend without having read the queue list would strand every -# generated queue permanently, and the marker would stop anyone retrying. -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits until the queue list can be read" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || - fail "an unread queue list is not recorded as a finished removal" -grep -qi 'could not ask cups' "$output" || - fail "a queue list that could not be read is said out loud" "$(cat "$output")" -# Stopping discovery is the half that mattered, and it is idempotent. -grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "discovery is still stopped when the queue list cannot be read" "$(cat "$log")" -pass "an unreadable queue list stops discovery but finalizes nothing" - -lpstat_fails="" +(( status != 0 )) || fail "an unavailable CUPS server keeps the migration pending" +assert_description_only +grep -qxF "lpstat: Scheduler is not responding." "$errors" || + fail "the underlying CUPS failure is preserved" "$(cat "$errors")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "an unavailable CUPS server retains the package" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "an unavailable CUPS server gets no marker" +pass "an actual CUPS failure remains pending without custom telemetry" # ------------------------------------------------ a queue that will not go -# A queue left behind would route through a backend the removal is about to -# delete, so the package waits rather than stranding it for good. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case stuck-queue queues=$'device for Office: implicitclass://Office/' -busy="" -lpstat_fails="" lpadmin_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/stuck" run_migration +(( status != 0 )) || fail "a failed queue deletion keeps the migration pending" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a persistent generated queue retains the backend" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a failed queue deletion gets no marker" +pass "a persistent generated queue prevents partial completion" -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits while a generated queue is still there" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a half-done cleanup is not recorded as finished" -grep -q 'Could not remove the queue Office' "$output" || - fail "a queue that would not go is named" "$(cat "$output")" -pass "a queue that will not go keeps the package and the marker back" +# --------------------------------------------- a queue removed concurrently -lpadmin_fails="" - -# --------------------------------------------- a queue removed by someone else - -# Another administrator deleting the queue mid-run is the outcome wanted, not a -# failure worth keeping the package installed for. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case vanished-queue queues=$'device for Office: implicitclass://Office/' -busy="" -lpstat_fails="" -lpstat_o_fails="" lpadmin_fails="1" gone_queues="Office" -use_marker="$test_tmp/var/lib/omarchy/migrations/vanished" run_migration +assert_quiet_success +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "a concurrently removed queue does not block package removal" "$(cat "$log")" +pass "a queue removed concurrently counts as removed" -grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || - fail "a queue that is already gone does not hold up the removal" "$(cat "$log")" -[[ -f $use_marker ]] || fail "a queue that is already gone still finishes the migration" -pass "a queue someone else removed counts as removed" +# ------------------------------------------- a queue that cannot be closed -lpadmin_fails="" -gone_queues="" - -# ------------------------------------------- a queue that will not stop taking jobs - -# Deleting a queue that is still accepting work races whatever arrives next. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" +start_case open-queue +queues=$'device for Office: implicitclass://Office/' reject_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/openqueue" run_migration - -if grep -q 'lpadmin -x' "$log"; then +(( status != 0 )) || fail "a queue that cannot be closed keeps the migration pending" +grep -q 'lpadmin -x' "$log" && fail "a queue still accepting jobs is not deleted" "$(cat "$log")" -fi -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits while a queue is still accepting jobs" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a queue still taking jobs is not recorded as done" -pass "a queue that will not stop taking jobs is neither inspected nor deleted" - -reject_fails="" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a queue still accepting jobs retains the backend" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a queue still accepting jobs gets no marker" +pass "a queue that cannot be closed is retried later" # -------------------------------------------------- a masked but running daemon -installed="cups-browsed" +start_case masked enabled="" active="cups-browsed.service" -blocked="" -queues="" -use_marker="$test_tmp/var/lib/omarchy/migrations/masked" run_migration - +assert_quiet_success grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || - fail "a running unit is stopped even when it is not enabled" "$(cat "$log")" + fail "a masked running daemon is stopped" "$(cat "$log")" grep -q 'disable --now' "$log" && fail "a masked unit is not disabled" "$(cat "$log")" -pass "a masked but running daemon is stopped without being disabled" +pass "a masked running daemon is stopped without noisy output" -# A machine whose discovery never created a queue has nothing to clean up, and -# no reason to reach for CUPS administration. -grep -q 'lpadmin' "$log" && - fail "no queues means no CUPS administration" "$(cat "$log")" -pass "a machine with no generated queues does not touch CUPS administration" +# ------------------------------------------------ a package removal that fails + +start_case drop-fails +drop_fails="1" +run_migration +(( status != 0 )) || fail "a failed package removal keeps the migration pending" +assert_description_only +[[ ! -e $use_marker ]] || fail "a failed package removal gets no marker" +pass "a package removal failure cannot be recorded as complete" From e47784be1146d5b1807b33691caafc74fee3f452 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 21:02:45 +0200 Subject: [PATCH 70/73] Name the dev-link authorization when it is the thing that failed Validating /etc/omarchy.conf walks its parent chain to /, and that walk leaves its own subject behind in the global failure_context. The comparison that follows -- the one deciding whether the authorization actually names this checkout -- sets no context of its own, so an unauthorized development tree refused with: refusing to publish: directory / (must be root-owned and not group- or world-writable) failed validation naming a directory that had just passed, and pointing the reader at a filesystem problem that is not there. Seen on a worker VM running an unauthorized checkout, where / is 0555 root-owned. The hint line underneath was already right, which is what kept this from being worse. Restore the context before the comparison so the first line agrees with it, and assert both halves: that the refusal names the authorization, and that it does not blame the root directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki --- bin/omarchy-plymouth-set | 8 ++++++++ test/shell.d/plymouth-set-test.sh | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index f4c9b792..ff35b449 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -146,6 +146,14 @@ run_root_transaction() { failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree" validate_trusted_configuration_file "$omarchy_conf" + # validate_trusted_configuration_file walks /etc up to / and leaves its own + # subject behind in failure_context. Without restoring ours, a checkout + # that simply is not the authorized one refuses with "directory / must be + # root-owned and not group- or world-writable" -- naming a directory that + # passed, and sending the reader after a filesystem problem that is not + # there. + failure_context="the dev-link authorization in $omarchy_conf, which must name $source_root" + quoted_source_root=$source_root quoted_source_root=${quoted_source_root//\\/\\\\} quoted_source_root=${quoted_source_root//\"/\\\"} diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index a2a7935a..c88f49fb 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -504,6 +504,13 @@ status=$? (( status != 0 )) || fail "a stale dev-link authorization is rejected" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged" +# The refusal has to name the authorization. Validating /etc/omarchy.conf walks +# its parents and leaves that walk's subject in failure_context, so without +# restoring ours this refuses with "directory / must be root-owned and not +# group- or world-writable" -- accusing a directory that passed and pointing the +# reader at a filesystem problem that does not exist. +[[ $output == *"$omarchy_conf"* ]] || fail "a stale dev-link refusal names the authorization it rejected" "$output" +[[ $output != *"directory / "* ]] || fail "a stale dev-link refusal does not blame the root directory" "$output" assert_no_temporary_files "$fake_root" setup_run From 2002fb35ed4d6abd9cd38353b8b1da32470ad2a4 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 15:27:16 -0400 Subject: [PATCH 71/73] Test installed CUPS state directly --- test/acceptance.d/cups-test.sh | 51 +++ test/acceptance.d/system-test.sh | 44 +-- .../cups-browsed-removal-migration-test.sh | 358 ------------------ 3 files changed, 53 insertions(+), 400 deletions(-) create mode 100644 test/acceptance.d/cups-test.sh delete mode 100644 test/shell.d/cups-browsed-removal-migration-test.sh diff --git a/test/acceptance.d/cups-test.sh b/test/acceptance.d/cups-test.sh new file mode 100644 index 00000000..b2c82fe7 --- /dev/null +++ b/test/acceptance.d/cups-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +for package in cups cups-filters system-config-printer cups-pk-helper; do + pacman -Q "$package" >/dev/null 2>&1 || fail "printing packages are installed" "$package is missing" +done +pass "printing packages are installed" + +! pacman -Q cups-pdf >/dev/null 2>&1 || fail "the root CUPS-PDF backend is absent" +pass "the root CUPS-PDF backend is absent" + +! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is absent" +! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not enabled" +! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not running" +! pgrep -x cups-browsed >/dev/null 2>&1 || fail "no cups-browsed process exists" +pass "automatic printer discovery is not installed or running" + +for path in \ + /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ + /etc/cups/cups-browsed.conf.pacnew \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no package files" "$path still exists" +done +pass "automatic printer discovery leaves no package files" + +systemctl is-enabled --quiet cups.service || fail "CUPS is enabled" +systemctl is-active --quiet cups.service || fail "CUPS is running" +timeout 10 lpstat -r >/dev/null 2>&1 || fail "the CUPS scheduler answers" +pass "CUPS is enabled, running, and answering" + +policy_metadata=$(stat -c '%U:%G %a' /etc/cups/cups-files.conf) +[[ $policy_metadata == "root:cups 640" ]] || + fail "the CUPS authorization policy is protected" "$policy_metadata" +pass "the CUPS authorization policy is protected" + +if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" +elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" +fi +pass "CUPS denies unauthenticated desktop administration" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index ab3affa7..610fcb4b 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -47,7 +47,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service docker.socket \ + avahi-daemon.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -64,46 +64,6 @@ verify_services() { pass "user audio services are running" } -verify_printing_security() { - local lpinfo_output path - - ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" - pass "the root CUPS-PDF backend is not installed" - - # Automatic discovery is temporarily out of the default install: a daemon that - # turns anything advertising itself on the network into a print queue is more - # exposure than the convenience is worth while it is reworked. CUPS itself - # stays, so what a stock machine proves here is that printing runs and that - # the desktop user still cannot administer it without authenticating. - ! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is not installed" - ! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || - fail "no discovery service is enabled" - ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || - fail "no discovery service is running" - - for path in \ - /etc/cups/cups-browsed.conf \ - /etc/cups/cups-browsed.conf.pacsave \ - /usr/bin/cups-browsed \ - /usr/lib/cups/backend/implicitclass \ - /usr/lib/systemd/system/cups-browsed.service \ - /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do - [[ ! -e $path && ! -L $path ]] || - fail "automatic printer discovery leaves no installed package files" "$path still exists" - done - pass "automatic printer discovery is absent from a stock install" - - systemctl is-active --quiet cups.service || fail "CUPS is running" - - if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then - fail "the desktop user cannot administer CUPS without authentication" - elif [[ $lpinfo_output != *"Forbidden"* ]]; then - fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" - fi - - pass "CUPS runs with passwordless desktop administration still denied" -} - verify_runtime_tools() { # Docker access is intentionally NOT granted to the desktop user: the docker # group is root-equivalent, so a rogue process running as the user could @@ -147,7 +107,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_printing_security verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh deleted file mode 100644 index a311e6e7..00000000 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ /dev/null @@ -1,358 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "$0")" && pwd)/base-test.sh" - -migration="$ROOT/migrations/1788009111.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" - -# Every privileged step is stubbed: a real run here would take printer -# discovery off the developer's own machine. -cat >"$mock_bin/omarchy-pkg-present" <<'SH' -#!/bin/bash -[[ " $BROWSED_INSTALLED " == *" $1 "* ]] -SH - -cat >"$mock_bin/omarchy-pkg-drop" <<'SH' -#!/bin/bash -printf 'omarchy-pkg-drop\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_DROP_FAILS ]] -SH - -cat >"$mock_bin/pacman" <<'SH' -#!/bin/bash -printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ $* == *--print* ]]; then - [[ -z $BROWSED_REMOVAL_BLOCKED ]] -fi -SH - -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash -printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" -unit=$3 -case $1 in - is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; - is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; - *) : ;; -esac -SH - -cat >"$mock_bin/sudo" <<'SH' -#!/bin/bash -printf 'sudo\t%s\n' "$*" >>"$BROWSED_LOG" -exec "$@" -SH - -cat >"$mock_bin/install" <<'SH' -#!/bin/bash -printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" -exec /usr/bin/install "$@" -SH - -# lpstat reports only package-generated queues on the implicitclass backend. -# Manual ipp:// and usb:// printers must survive. -cat >"$mock_bin/lpstat" <<'SH' -#!/bin/bash -printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" -case $1 in - -v) - if [[ -n $BROWSED_LPSTAT_FAILS ]]; then - echo "lpstat: Scheduler is not responding." >&2 - exit 1 - elif [[ -z $BROWSED_QUEUES ]]; then - echo "lpstat: No destinations added." >&2 - exit 1 - elif [[ $LC_ALL == "C" ]]; then - printf '%s\n' "$BROWSED_QUEUES" - else - printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' - fi - ;; - -p) - [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] - ;; - -o) - if [[ -n $BROWSED_LPSTAT_O_FAILS ]]; then - echo "lpstat: Scheduler is not responding." >&2 - exit 1 - fi - if [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]]; then - printf '%s-7 alice 1024\n' "$2" - fi - ;; -esac -SH - -cat >"$mock_bin/lpadmin" <<'SH' -#!/bin/bash -printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ -n $BROWSED_LPADMIN_FAILS ]]; then - echo "lpadmin: Printer does not exist." >&2 - exit 1 -fi -SH - -cat >"$mock_bin/cupsreject" <<'SH' -#!/bin/bash -printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ -n $BROWSED_REJECT_FAILS ]]; then - echo "cupsreject: Unable to reject jobs." >&2 - exit 1 -fi -SH - -chmod +x "$mock_bin"/* - -log="$test_tmp/actions.log" -output="$test_tmp/migration.out" -errors="$test_tmp/migration.err" -status=0 - -start_case() { - installed="cups-browsed" - enabled="cups-browsed.service" - active="cups-browsed.service cups.service" - blocked="" - drop_fails="" - queues="" - busy="" - lpstat_fails="" - lpstat_o_fails="" - lpadmin_fails="" - reject_fails="" - gone_queues="" - use_marker="$test_tmp/var/lib/omarchy/migrations/$1" - rm -f "$use_marker" -} - -run_migration() { - : >"$log" - : >"$output" - : >"$errors" - - if env -u LC_ALL -u LANGUAGE \ - BROWSED_LOG="$log" \ - BROWSED_INSTALLED="$installed" \ - BROWSED_ENABLED="$enabled" \ - BROWSED_ACTIVE="$active" \ - BROWSED_REMOVAL_BLOCKED="$blocked" \ - BROWSED_DROP_FAILS="$drop_fails" \ - BROWSED_QUEUES="$queues" \ - BROWSED_BUSY_QUEUES="$busy" \ - BROWSED_LPSTAT_FAILS="$lpstat_fails" \ - BROWSED_LPSTAT_O_FAILS="$lpstat_o_fails" \ - BROWSED_LPADMIN_FAILS="$lpadmin_fails" \ - BROWSED_REJECT_FAILS="$reject_fails" \ - BROWSED_GONE_QUEUES="$gone_queues" \ - PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="$use_marker" \ - bash -euo pipefail "$migration" >"$output" 2>"$errors"; then - status=0 - else - status=$? - fi -} - -assert_description_only() { - [[ $(<"$output") == "Temporarily remove automatic printer discovery" ]] || - fail "the migration prints only its description" "$(cat "$output")" -} - -assert_quiet_success() { - (( status == 0 )) || fail "the migration succeeds" "$(cat "$errors")" - assert_description_only - [[ ! -s $errors ]] || fail "a successful migration is quiet" "$(cat "$errors")" -} - -# ------------------------------------------------ a machine that has printers - -start_case printers -queues=$'device for Office: implicitclass://Office/\ndevice for Front_Desk: implicitclass://Front_Desk/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' -run_migration -assert_quiet_success - -grep -qxF $'pacman\t-Rs --print cups-browsed' "$log" || - fail "the migration preflights the package removal" "$(cat "$log")" -grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "the migration disables and stops discovery" "$(cat "$log")" -for queue in Office Front_Desk all; do - grep -qxF "sudo lpadmin -x $queue" "$log" || - fail "the migration removes generated queue $queue" "$(cat "$log")" -done -grep -q 'lpadmin -x Desk' "$log" && - fail "the migration leaves a manually-added IPP printer alone" "$(cat "$log")" -grep -q 'lpadmin -x Attic' "$log" && - fail "the migration leaves a manually-added USB printer alone" "$(cat "$log")" -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "the migration uses the standard package removal helper" "$(cat "$log")" -[[ -f $use_marker ]] || fail "the migration records machine-wide completion" - -disable_line=$(grep -n 'disable --now' "$log" | head -1 | cut -d: -f1) -cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1) -removal_line=$(grep -n 'omarchy-pkg-drop' "$log" | head -1 | cut -d: -f1) -(( disable_line < cleanup_line && cleanup_line < removal_line )) || - fail "the service and queues are handled before package removal" "$(cat "$log")" -pass "generated queues are removed, manual printers survive, and normal output is quiet" - -# ------------------------------------ a second user, after a deliberate reinstall - -run_migration -assert_quiet_success -[[ ! -s $log ]] || fail "a completed machine-wide removal is left alone" "$(cat "$log")" -pass "a second user does not undo a deliberate reinstall" - -# ----------------------------------------- a machine that never had discovery - -start_case no-package -installed="" -run_migration -assert_quiet_success -[[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a machine without discovery gets no marker" -pass "a machine without cups-browsed does no privileged work" - -# --------------------------------------------- a machine that has no printers - -start_case no-printers -run_migration -assert_quiet_success -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "no printers does not prevent package removal" "$(cat "$log")" -grep -qE 'cupsreject|lpadmin' "$log" && - fail "no printers requires no queue administration" "$(cat "$log")" -[[ -f $use_marker ]] || fail "the no-printer migration records completion" -pass "CUPS reporting no destinations is a successful empty migration" - -# ------------------------------------------------- a blocked package removal - -start_case blocked -blocked="1" -run_migration -(( status != 0 )) || fail "a blocked package removal remains pending" -assert_description_only -grep -q 'disable --now' "$log" && - fail "a blocked removal does not stop discovery" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a blocked removal does not invoke package removal" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a blocked removal gets no marker" -pass "a package dependency prevents any partial migration" - -# --------------------------------------------------- a queue that has jobs - -start_case busy -queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' -busy="Office" -run_migration -assert_quiet_success -grep -q 'lpadmin -x Office' "$log" && - fail "a queue with jobs is not deleted" "$(cat "$log")" -grep -qxF $'sudo\tlpadmin -x Spare' "$log" || - fail "an idle generated queue is deleted" "$(cat "$log")" -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "a busy queue does not retain the discovery package" "$(cat "$log")" - -reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1) -probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1) -delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1) -(( reject_line < probe_line && probe_line < delete_line )) || - fail "a queue is closed before it is checked and deleted" "$(cat "$log")" -pass "busy queues survive while idle discovery queues are removed" - -# ------------------------------------------------- a job query that fails - -start_case job-query-fails -queues=$'device for Office: implicitclass://Office/' -lpstat_o_fails="1" -run_migration -(( status != 0 )) || fail "a failed job query keeps the migration pending" -assert_description_only -grep -q 'lpadmin -x' "$log" && - fail "a queue with unknown job state is not deleted" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a failed job query retains the package" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a failed job query gets no marker" -pass "a failed job query is retryable instead of falsely completing" - -# ------------------------------------------------------ CUPS out of reach - -start_case no-cups -queues=$'device for Office: implicitclass://Office/' -lpstat_fails="1" -run_migration -(( status != 0 )) || fail "an unavailable CUPS server keeps the migration pending" -assert_description_only -grep -qxF "lpstat: Scheduler is not responding." "$errors" || - fail "the underlying CUPS failure is preserved" "$(cat "$errors")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "an unavailable CUPS server retains the package" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "an unavailable CUPS server gets no marker" -pass "an actual CUPS failure remains pending without custom telemetry" - -# ------------------------------------------------ a queue that will not go - -start_case stuck-queue -queues=$'device for Office: implicitclass://Office/' -lpadmin_fails="1" -run_migration -(( status != 0 )) || fail "a failed queue deletion keeps the migration pending" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a persistent generated queue retains the backend" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a failed queue deletion gets no marker" -pass "a persistent generated queue prevents partial completion" - -# --------------------------------------------- a queue removed concurrently - -start_case vanished-queue -queues=$'device for Office: implicitclass://Office/' -lpadmin_fails="1" -gone_queues="Office" -run_migration -assert_quiet_success -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "a concurrently removed queue does not block package removal" "$(cat "$log")" -pass "a queue removed concurrently counts as removed" - -# ------------------------------------------- a queue that cannot be closed - -start_case open-queue -queues=$'device for Office: implicitclass://Office/' -reject_fails="1" -run_migration -(( status != 0 )) || fail "a queue that cannot be closed keeps the migration pending" -grep -q 'lpadmin -x' "$log" && - fail "a queue still accepting jobs is not deleted" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a queue still accepting jobs retains the backend" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a queue still accepting jobs gets no marker" -pass "a queue that cannot be closed is retried later" - -# -------------------------------------------------- a masked but running daemon - -start_case masked -enabled="" -active="cups-browsed.service" -run_migration -assert_quiet_success -grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || - fail "a masked running daemon is stopped" "$(cat "$log")" -grep -q 'disable --now' "$log" && - fail "a masked unit is not disabled" "$(cat "$log")" -pass "a masked running daemon is stopped without noisy output" - -# ------------------------------------------------ a package removal that fails - -start_case drop-fails -drop_fails="1" -run_migration -(( status != 0 )) || fail "a failed package removal keeps the migration pending" -assert_description_only -[[ ! -e $use_marker ]] || fail "a failed package removal gets no marker" -pass "a package removal failure cannot be recorded as complete" From 70e79c40ae52ac44e8a8399d835e9e0ccf0ed6f2 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 18:19:51 -0400 Subject: [PATCH 72/73] Harden Plymouth and SDDM reset publication --- bin/omarchy-plymouth-reset | 6 +- bin/omarchy-plymouth-set | 115 ++++++--- bin/omarchy-refresh-sddm | 5 +- test/shell.d/plymouth-set-test.sh | 387 ++++++++++++++++++++++++++---- 4 files changed, 428 insertions(+), 85 deletions(-) diff --git a/bin/omarchy-plymouth-reset b/bin/omarchy-plymouth-reset index 797d3cb0..fa3a3696 100755 --- a/bin/omarchy-plymouth-reset +++ b/bin/omarchy-plymouth-reset @@ -3,5 +3,7 @@ # omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen # omarchy:requires-sudo=true -omarchy-refresh-plymouth -omarchy-refresh-sddm +set -euo pipefail + +"$OMARCHY_PATH/bin/omarchy-refresh-plymouth" +"$OMARCHY_PATH/bin/omarchy-refresh-sddm" diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index ff35b449..44c32b75 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -11,21 +11,39 @@ set -euo pipefail # fixed destination atomically. The caller opens the selected logo before sudo, # so the privileged process never resolves a user-controlled input path. -refresh_default=false -if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then - refresh_default=true -elif (( $# != 3 )); then +usage() { echo "Usage: omarchy-plymouth-set " >&2 exit 1 +} + +if (( $# == 3 )); then + mode=set +elif (( $# == 1 )); then + case "$1" in + --refresh-default) + mode=refresh-plymouth + ;; + --refresh-sddm-default) + mode=refresh-sddm + ;; + *) + usage + ;; + esac +else + usage +fi + +if (( EUID == 0 )); then + echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2 + exit 1 fi logo_fd= -if $refresh_default; then - mode=refresh +if [[ $mode != "set" ]]; then bg_hex= text_hex= else - mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -67,6 +85,7 @@ run_root_transaction() { set -eEuo pipefail PATH=/usr/bin:/bin export PATH + umask 077 # Every check below is a bare assertion that aborts under set -e. Name the # subject of each one so a refusal reaches the user instead of exiting mute. @@ -89,7 +108,7 @@ run_root_transaction() { max_asset_size=$5 failure_context="the arguments of the privileged transaction" - [[ $mode == "set" || $mode == "refresh" ]] + [[ $mode == "set" || $mode == "refresh-plymouth" || $mode == "refresh-sddm" ]] [[ $source_root == /* ]] [[ $max_asset_size =~ ^[0-9]+$ ]] (( max_asset_size > 0 )) @@ -187,6 +206,22 @@ run_root_transaction() { ) plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) + sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf) + + plymouth_assets=() + sddm_assets=() + case "$mode" in + set) + plymouth_assets=("${plymouth_theme_assets[@]}") + sddm_assets=("${sddm_theme_assets[@]}") + ;; + refresh-plymouth) + plymouth_assets=("${plymouth_default_assets[@]}") + ;; + refresh-sddm) + sddm_assets=("${sddm_default_assets[@]}") + ;; + esac validate_trusted_file() { local source=$1 canonical uid file_mode size @@ -211,9 +246,7 @@ run_root_transaction() { local source=$1 destination=$2 validate_trusted_file "$source" - cp --reflink=never -- "$source" "$destination" - chown 0:0 -- "$destination" - chmod 0600 -- "$destination" + install -o 0 -g 0 -m 0600 -- "$source" "$destination" } staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) @@ -230,13 +263,7 @@ run_root_transaction() { sddm_stage=$staging_dir/sddm mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" - if [[ $mode == "refresh" ]]; then - assets_to_stage=("${plymouth_default_assets[@]}") - else - assets_to_stage=("${plymouth_theme_assets[@]}") - fi - - for asset in "${assets_to_stage[@]}"; do + for asset in "${plymouth_assets[@]}"; do copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" done @@ -280,6 +307,20 @@ run_root_transaction() { done chown -R 0:0 -- "$staging_dir" find "$staging_dir" -type f -exec chmod 0600 -- {} + + elif (( ${#sddm_assets[@]} )); then + for asset in "${sddm_assets[@]}"; do + copy_trusted_file "$source_root/default/sddm/omarchy/$asset" "$sddm_stage/$asset" + done + fi + + if (( ${#plymouth_assets[@]} )); then + validate_trusted_directory "$theme_dir" + if [[ $mode == "refresh-plymouth" ]]; then + validate_trusted_directory "$theme_dir/logos" + fi + fi + if (( ${#sddm_assets[@]} )); then + validate_trusted_directory "$sddm_dir" fi publish_asset() { @@ -298,23 +339,23 @@ run_root_transaction() { validate_trusted_directory "$parent" temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cp --reflink=never -- "$source" "$temporary" + install -o 0 -g 0 -m 0644 -- "$source" "$temporary" copied_size=$(stat -c %s -- "$temporary") (( copied_size == source_size )) cmp -s -- "$source" "$temporary" - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" sync -f -- "$temporary" mv --no-copy -fT -- "$temporary" "$destination" temporary= } - for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" - done + if (( ${#plymouth_assets[@]} )); then + for asset in "${plymouth_assets[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + fi - if [[ $mode == "set" ]]; then - for asset in "${sddm_theme_assets[@]}"; do + if (( ${#sddm_assets[@]} )); then + for asset in "${sddm_assets[@]}"; do publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" done validate_trusted_directory "$sddm_dir" @@ -323,16 +364,18 @@ run_root_transaction() { ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } -if $refresh_default; then - run_root_transaction /dev/null; then + output=$(unshare --user --map-root-user env OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$secret" 2>&1) + status=$? + (( status != 0 )) || fail "omarchy-plymouth-set refuses to run as root" + [[ $output == *"as your user"* && $output == *"not under sudo"* ]] || + fail "the root refusal explains how to invoke the publisher safely" "$output" +else + grep -A2 -Eq '^if \(\( EUID == 0 \)\); then$' "$ROOT/bin/omarchy-plymouth-set" || + fail "omarchy-plymouth-set retains its root-invocation guard" +fi +pass "the logo descriptor can only be opened by an unprivileged caller" + # Style > Unlock picks a theme by name and hands the answer to # omarchy-launch-floating-terminal-with-presentation, which joins its arguments # into a script and runs that with `bash -c`. So the name is shell source @@ -84,16 +110,26 @@ cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB' printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME" STUB -# Stands in for the real wrapper, which is a shell-string API: it interpolates -# "$*" into a script and hands that to `bash -c`. The grep below is what keeps -# this stub honest if the wrapper ever stops working that way. -cat >"$stub_dir/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +# Run the real presentation wrapper while replacing only its terminal launcher. +# The launcher stub executes the final `bash -c` locally instead of opening a +# terminal window. +ln -s "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" "$stub_dir/omarchy-launch-floating-terminal-with-presentation" + +cat >"$stub_dir/omarchy-restart-gum" <<'STUB' #!/bin/bash -exec bash -c "omarchy-show-logo; $*; omarchy-show-done" +: STUB -grep -Fq 'bash -c "$presentation_script"' "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" || - fail "the presentation wrapper still runs its argument as a shell string, as the stub above assumes" +cat >"$stub_dir/setsid" <<'STUB' +#!/bin/bash +while (( $# >= 3 )); do + if [[ $1 == "bash" && $2 == "-c" ]]; then + exec bash -c "$3" + fi + shift +done +exit 97 +STUB # Records what actually arrived, so a name that survived as data is told apart # from one that arrived split or partly eaten. @@ -203,7 +239,8 @@ cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} if [[ ${1:-} == -c && ${2:-} == %u ]]; then - if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + if [[ (-n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"*) || + (-n ${TEST_UNTRUSTED_CONFIGURATION:-} && $last == "$TEST_UNTRUSTED_CONFIGURATION"*) ]]; then printf '1000\n' exit 0 fi @@ -224,6 +261,35 @@ last=${!#} exit 0 SH +cat >"$root_tools/install" <<'SH' +#!/bin/bash +mode= +while (( $# )); do + case "$1" in + -o | -g) + shift 2 + ;; + -m) + mode=$2 + shift 2 + ;; + --) + shift + break + ;; + *) + exit 96 + ;; + esac +done + +(( $# == 2 )) || exit 96 +[[ $mode == "0600" || $mode == "0644" ]] || exit 96 +destination=$2 +[[ $destination == "$TEST_FAKE_ROOT"* || $destination == /tmp/omarchy-plymouth.* ]] || exit 93 +exec /usr/bin/install -m "$mode" -- "$1" "$destination" +SH + cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 @@ -267,7 +333,7 @@ setup_run() { printf 'old plymouth %s\n' "$asset" >"$destination" chmod 0600 "$destination" done - for asset in "${sddm_theme_assets[@]}" metadata.desktop theme.conf; do + for asset in "${sddm_default_assets[@]}"; do destination="$sddm/$asset" printf 'old sddm %s\n' "$asset" >"$destination" chmod 0600 "$destination" @@ -287,9 +353,26 @@ setup_run() { ln -s "$legacy_victim" "$sddm/logo.svg" } -run_set_colors() { - local requested_umask="$1" background="$2" text="$3" - shift 3 +setup_fresh_run() { + local asset destination + + setup_run + for asset in "${plymouth_default_assets[@]}"; do + destination="$theme/$asset" + rm -f -- "$destination" + /usr/bin/install -m 0644 -- "$ROOT/default/plymouth/$asset" "$destination" + done + for asset in "${sddm_default_assets[@]}"; do + destination="$sddm/$asset" + rm -f -- "$destination" + /usr/bin/install -m 0644 -- "$ROOT/default/sddm/omarchy/$asset" "$destination" + done + rm -f -- "$sddm/logo.svg" +} + +run_in_fake_root() { + local requested_umask="$1" + shift ( umask "$requested_umask" PATH="$fake_bin:$ROOT/bin:$PATH" \ @@ -301,25 +384,56 @@ run_set_colors() { TEST_OMARCHY_CONF="$omarchy_conf" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - "$@" \ - /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" + "$@" ) } +run_set_colors() { + local requested_umask="$1" background="$2" text="$3" + shift 3 + run_in_fake_root "$requested_umask" "$@" \ + /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" +} + run_set() { local requested_umask="$1" shift run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@" } +run_refresh_plymouth() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" +} + +run_refresh_sddm() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-sddm" +} + +run_reset() { + run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-plymouth-reset" +} + assert_no_temporary_files() { local directory="$1" leftovers leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print) [[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers" } +assert_packaged_assets() { + local context=$1 source_dir=$2 destination_dir=$3 + shift 3 + + local asset destination + for asset in "$@"; do + destination="$destination_dir/$asset" + cmp -s "$source_dir/$asset" "$destination" || fail "$context publishes the packaged $asset bytes" + [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || + fail "$context publishes $asset as a regular mode-0644 file" + done +} + for requested_umask in 022 027 077; do - setup_run + setup_fresh_run output=$(run_set "$requested_umask" env 2>&1) status=$? (( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output" @@ -342,20 +456,35 @@ for requested_umask in 022 027 077; do grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask" grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask" - [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "Plymouth destination symlink never changes its victim" - [[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "Main.qml destination symlink never changes its victim" - [[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "legacy logo.svg removal never changes its victim" - [[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "legacy logo.svg is removed" - - [[ $(cat "$theme/logos/oma.png") == 'old plymouth logos/oma.png' && $(stat -c %a "$theme/logos/oma.png") == 600 ]] || fail "normal theme set does not broaden into the refresh-only nested asset" - [[ $(cat "$sddm/metadata.desktop") == 'old sddm metadata.desktop' ]] || fail "normal theme set leaves SDDM metadata unchanged" - [[ $(cat "$sddm/theme.conf") == 'old sddm theme.conf' ]] || fail "normal theme set leaves SDDM theme.conf unchanged" + cmp -s "$ROOT/default/plymouth/logos/oma.png" "$theme/logos/oma.png" || fail "theme set leaves the packaged nested logo unchanged" + cmp -s "$ROOT/default/sddm/omarchy/metadata.desktop" "$sddm/metadata.desktop" || fail "theme set leaves packaged SDDM metadata unchanged" + cmp -s "$ROOT/default/sddm/omarchy/theme.conf" "$sddm/theme.conf" || fail "theme set leaves packaged SDDM configuration unchanged" [[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")" [[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask" + grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "theme set activates the published Plymouth theme" + grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "theme set rebuilds the initramfs" assert_no_temporary_files "$fake_root" done -pass "every Plymouth and SDDM destination is atomically replaced with mode 0644 across restrictive umasks" +pass "a fresh installation receives complete mode-0644 Plymouth and SDDM theme files across restrictive umasks" + +# An upgraded machine may already contain restrictive modes, destination +# symlinks, and the legacy SDDM logo. Setting a theme must replace only the +# destination entries and must never write through those symlinks. +setup_run +output=$(run_set 022 env 2>&1) +status=$? + +(( status == 0 )) || fail "theme set repairs migrated Plymouth and SDDM destinations" "$output" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "theme set replaces a migrated Plymouth destination symlink" +[[ -f $sddm/Main.qml && ! -L $sddm/Main.qml ]] || fail "theme set replaces a migrated SDDM destination symlink" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "theme set never changes a Plymouth symlink victim" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "theme set never changes an SDDM symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "theme set never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "theme set removes the legacy SDDM logo" +assert_no_temporary_files "$fake_root" + +pass "theme set repairs migrated destinations without following existing symlinks" # White uses #ffffff behind #000000 text. A direct two-expression sed first # writes the white background and then consumes it as if it were the template's @@ -483,6 +612,29 @@ assert_no_temporary_files "$fake_root" pass "root rejects packaged assets that a desktop process could rewrite" +# A packaged filename may not redirect root to some other readable file. Use +# the explicitly authorized development-source path so its ordinary file-mode +# checks are intentionally skipped and only the leaf symlink/canonical-file +# checks can decide this case. +setup_run +symlink_source_root=$(mktemp -d "$test_tmp/symlink-source.XXXXXXXX") +symlink_source_root=$(realpath -e -- "$symlink_source_root") +mkdir -p "$symlink_source_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$symlink_source_root/default/" +rm -f "$symlink_source_root/default/plymouth/bullet.png" +ln -s "$secret" "$symlink_source_root/default/plymouth/bullet.png" +printf 'export OMARCHY_PATH="%s"\n' "$symlink_source_root" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env OMARCHY_PATH="$symlink_source_root" TEST_UNTRUSTED_SOURCE="$symlink_source_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a symlinked packaged asset is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a packaged source symlink leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source symlink says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root never follows a packaged asset symlink" + # A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical # checkout recorded by root in /etc/omarchy.conf is the supported dev-link # exception; an unrelated or stale authorization must not weaken the check. @@ -523,6 +675,28 @@ status=$? [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged" assert_no_temporary_files "$fake_root" +setup_run +authorization_target="$run_dir/authorization-target" +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$authorization_target" +chmod 0644 "$authorization_target" +ln -s "$authorization_target" "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "a symlinked dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + +setup_run +printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" +chmod 0644 "$omarchy_conf" +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" TEST_UNTRUSTED_CONFIGURATION="$omarchy_conf" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned dev-link authorization is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned dev-link authorization leaves the live theme unchanged" +assert_no_temporary_files "$fake_root" + setup_run printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf" chmod 0644 "$omarchy_conf" @@ -532,7 +706,7 @@ status=$? (( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output" cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets" -pass "only the checkout explicitly authorized by omarchy dev link may be user-owned" +pass "only a regular root-owned authorization may name the exact development checkout" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -603,6 +777,25 @@ assert_no_temporary_files "$fake_root" pass "root checks every packaged asset, not only its directory" +# Keep every file root-owned and mode 0644 while making only its containing +# directory writable. Per-file checks cannot close the rename race in that +# state; the packaged source parent-chain walk must reject it. +setup_run +writable_directory_root=$(mktemp -d "$test_tmp/writable-directory-source.XXXXXXXX") +writable_directory_root=$(realpath -e -- "$writable_directory_root") +mkdir -p "$writable_directory_root/default" +cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_directory_root/default/" +chmod 0777 "$writable_directory_root/default/plymouth" +output=$(run_set 022 env OMARCHY_PATH="$writable_directory_root" 2>&1) +status=$? + +(( status != 0 )) || fail "a writable packaged source directory is rejected" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged directory leaves the live theme unchanged" +[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged directory says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "root validates the packaged source parent chain before copying" + # Ownership is not the only way a packaged asset stays rewritable: a group- or # world-writable mode does it too. Stage a tree the shim reports as root-owned # so only the real mode can decide, then loosen one asset. @@ -638,34 +831,138 @@ assert_no_temporary_files "$fake_root" pass "an empty logo cannot be published" +# Bound the descriptor read as well as the final destination. A sparse file +# makes the real 64 MiB + 1 byte boundary deterministic without storing a +# large fixture in the repository. +setup_run +cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep" +truncate -s "$((64 * 1024 * 1024 + 1))" "$test_tmp/logo.png" +output=$(run_set 022 env 2>&1) +status=$? +mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png" + +(( status != 0 )) || fail "an oversized logo is rejected" "$output" +[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an oversized logo leaves the live logo unchanged" +assert_no_temporary_files "$fake_root" + +pass "a logo larger than the publication bound cannot be published" + # Refresh uses the same publisher but its explicit contract includes the # packaged nested logos/oma.png asset. It must not touch the SDDM theme. setup_run -output=$( - PATH="$fake_bin:$ROOT/bin:$PATH" \ - TMPDIR="$stages" \ - OMARCHY_PATH="$ROOT" \ - TEST_FAKE_ROOT="$fake_root" \ - TEST_STAGES="$stages" \ - TEST_ROOT_TOOLS="$root_tools" \ - TEST_OMARCHY_CONF="$omarchy_conf" \ - TEST_SUDO_LOG="$sudo_log" \ - TEST_LEAK_LOG="$leak_log" \ - /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 -) +output=$(run_refresh_plymouth 2>&1) status=$? (( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" -for asset in "${plymouth_default_assets[@]}"; do - destination="$theme/$asset" - cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" - [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" -done +assert_packaged_assets "Plymouth refresh" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets" [[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" - -grep -Fq 'sudo /bin/bash -c' "$ROOT/bin/omarchy-plymouth-set" || fail "publisher invokes Bash by its trusted absolute path" -grep -Fq 'PATH=/usr/bin:/bin' "$ROOT/bin/omarchy-plymouth-set" || fail "root helper resets PATH before resolving utilities" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "Plymouth refresh activates the restored theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "Plymouth refresh rebuilds the initramfs" pass "refresh safely publishes its complete fixed asset set, including logos/oma.png" + +# SDDM refresh has the same fixed-file contract but must leave Plymouth and the +# boot image alone. It also replaces legacy destination symlinks without +# changing their victims. +setup_run +output=$(run_refresh_sddm 2>&1) +status=$? +(( status == 0 )) || fail "SDDM refresh succeeds through the safe publisher" "$output" + +assert_packaged_assets "SDDM refresh" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "SDDM refresh leaves Plymouth unchanged" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "SDDM refresh never changes a destination symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "SDDM refresh never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "SDDM refresh removes the legacy logo.svg" +! grep -Fq 'command plymouth-set-default-theme' "$sudo_log" || fail "SDDM refresh does not activate Plymouth" +! grep -Fq 'command mkinitcpio' "$sudo_log" || fail "SDDM refresh does not rebuild the initramfs" +[[ ! -s $leak_log ]] || fail "SDDM refresh never gives root a user-writable source pathname" "$(cat "$leak_log")" + +pass "SDDM refresh safely restores its complete packaged asset set without rebuilding Plymouth" + +# A fresh package installation already contains the complete default file set. +# Reset must be safe and idempotent in that ordinary state. +setup_fresh_run +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "reset succeeds on a fresh installation" "$output" + +assert_packaged_assets "fresh reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "fresh reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "fresh reset activates the packaged Plymouth theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "fresh reset rebuilds the initramfs" +assert_no_temporary_files "$fake_root" + +pass "reset is safe and idempotent on a fresh package installation" + +# Exercise an existing hostile state: destination symlinks stand in for an +# upgraded machine that may already contain artifacts planted through the old +# paths. Each refresh must replace its own entries without following them. +setup_run +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "combined Plymouth and SDDM reset succeeds" "$output" + +assert_packaged_assets "migrated reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "migrated reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "reset never changes a Plymouth destination symlink victim" +[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "reset never changes an SDDM destination symlink victim" +[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "reset never changes the legacy logo victim" +[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "reset removes the legacy logo.svg" +grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "reset activates the restored Plymouth theme" +grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "reset rebuilds the initramfs" +[[ ! -s $leak_log ]] || fail "reset never gives root a user-writable source pathname" "$(cat "$leak_log")" + +pass "reset safely repairs a migrated Plymouth and SDDM installation" + +# A damaged installation may retain its package-owned directories while some +# destination files are missing. Reset must recreate every allowlisted leaf. +setup_run +for asset in "${plymouth_default_assets[@]}"; do + rm -f -- "$theme/$asset" +done +for asset in "${sddm_default_assets[@]}"; do + rm -f -- "$sddm/$asset" +done +rm -f -- "$sddm/logo.svg" +output=$(run_reset 2>&1) +status=$? +(( status == 0 )) || fail "reset repairs missing Plymouth and SDDM destinations" "$output" +assert_packaged_assets "missing-file reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +assert_packaged_assets "missing-file reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}" + +pass "reset recreates missing files in package-owned destination trees" + +# The two refreshes are independently hardened. If Plymouth succeeds and SDDM +# then refuses its unsafe destination, the completed Plymouth refresh remains +# valid while SDDM and its symlink victims remain unchanged. +setup_run +chmod 0777 "$sddm" +output=$(run_reset 2>&1) +status=$? + +(( status != 0 )) || fail "reset rejects an unsafe SDDM destination" "$output" +assert_packaged_assets "Plymouth before SDDM refusal" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}" +[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "the successful Plymouth refresh never changes its old symlink victim" +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "a rejected reset leaves SDDM unchanged" +[[ $output == *"refusing to publish"* ]] || fail "an unsafe reset destination says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +pass "an SDDM refusal cannot make either refresh follow an unsafe destination" + +# A packaged source that fails the root trust checks must stop the combined +# reset; it cannot fall through into a second legacy SDDM copy. +setup_run +output=$(run_reset env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1) +status=$? + +(( status != 0 )) || fail "reset rejects an untrusted packaged source" "$output" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted reset source leaves Plymouth unchanged" +[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "an untrusted reset source leaves SDDM unchanged" +[[ $output == *"refusing to publish"* ]] || fail "an untrusted reset source says why it refused" "$output" +[[ $(grep -c '^root transaction$' "$sudo_log") == 1 ]] || fail "reset stops before SDDM when Plymouth refuses" +assert_no_temporary_files "$fake_root" + +pass "a reset refusal cannot fall through to an unhardened SDDM copy" From e711066522f051e6226327b3fcd4d64c9da60bc1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 07:46:48 +0200 Subject: [PATCH 73/73] Add CODEOWNERS requiring org owner review --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..4e9460dd --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Merges to protected branches need sign-off from an org owner. +* @dhh @ryanrhughes