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 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-agent b/bin/omarchy-agent index 05f1e1a1..bc65ab9a 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -99,8 +99,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/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 81202b87..c9cce2ff 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/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 ee1ed82d..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 @@ -71,11 +76,29 @@ 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; a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # 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 # 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 omarchy-crash-mute can + # lift one without reading, rewriting and re-parsing the rest. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue 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 diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index f71c7c98..4593bc72 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -6,9 +6,10 @@ 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_dir "$1" } announce_browser_installed() { @@ -23,13 +24,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 +34,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 +43,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 +52,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 +61,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 +70,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 +79,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 +87,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-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 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 1fd70d50..44c32b75 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -5,88 +5,377 @@ # 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 +# 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. + +usage() { echo "Usage: omarchy-plymouth-set " >&2 exit 1 -fi +} -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 -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 - -find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + -cp "$logo_path" "$staging_dir/logo.png" - -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" -done - -sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/" -sudo plymouth-set-default-theme omarchy - -if omarchy-cmd-present limine-mkinitcpio; then - sudo limine-mkinitcpio +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 - sudo mkinitcpio -P + usage 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" +if (( EUID == 0 )); then + echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2 + exit 1 +fi -sed \ - -e "s/#1a1b26/#$bg_hex/g" \ - -e "s/#ffffff/#$text_hex/g" \ - "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null +logo_fd= +if [[ $mode != "set" ]]; then + bg_hex= + text_hex= +else + bg_hex="${1#\#}" + text_hex="${2#\#}" + logo_path="$3" -sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" -for asset in bullet.png entry.png lock.png; do - sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" -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" -done -sudo rm -f "$sddm_dir/logo.svg" + 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 + + 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 + +run_root_transaction() { + sudo /bin/bash -c ' + 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. + 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 + if [[ -n ${failure_hint:-} ]]; then + printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2 + fi + } + 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-plymouth" || $mode == "refresh-sddm" ]] + [[ $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" ]] + + 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 + 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" + + # 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//\"/\\\"} + 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 + [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] + [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] + 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) + 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 + + failure_context="packaged source file $source" + [[ -f $source && ! -L $source ]] + canonical=$(realpath -e -- "$source") + [[ $canonical == "$source" ]] + file_mode=$(stat -c %a -- "$source") + size=$(stat -c %s -- "$source") + (( 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() { + local source=$1 destination=$2 + + validate_trusted_file "$source" + install -o 0 -g 0 -m 0600 -- "$source" "$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" + + for asset in "${plymouth_assets[@]}"; 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. + 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 )) + 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 -- {} + + 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() { + 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") + (( 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") + install -o 0 -g 0 -m 0644 -- "$source" "$temporary" + copied_size=$(stat -c %s -- "$temporary") + (( copied_size == source_size )) + cmp -s -- "$source" "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + temporary= + } + + if (( ${#plymouth_assets[@]} )); then + for asset in "${plymouth_assets[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + fi + + if (( ${#sddm_assets[@]} )); then + for asset in "${sddm_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 [[ $mode == "set" ]]; then + run_root_transaction <&"$logo_fd" +else + run_root_transaction /etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel + + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir || -L $dir ]] || continue + browser_policy_setup_dir "$dir" + done } install_authorized_keys() { diff --git a/bin/omarchy-refresh-plymouth b/bin/omarchy-refresh-plymouth index 58042dba..fa47ffff 100755 --- a/bin/omarchy-refresh-plymouth +++ b/bin/omarchy-refresh-plymouth @@ -3,11 +3,6 @@ # 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. +exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default diff --git a/bin/omarchy-refresh-sddm b/bin/omarchy-refresh-sddm index d03ddd33..233570a0 100755 --- a/bin/omarchy-refresh-sddm +++ b/bin/omarchy-refresh-sddm @@ -3,5 +3,6 @@ # omarchy:summary=Refresh the SDDM theme from default # omarchy:requires-sudo=true -sudo rm -rf /usr/share/sddm/themes/omarchy -sudo cp -r "$OMARCHY_PATH/default/sddm/omarchy" /usr/share/sddm/themes/omarchy +# Reuse the fixed-file publisher so root never resolves an untrusted source or +# follows a destination symlink while restoring the packaged SDDM theme. +exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-sddm-default diff --git a/bin/omarchy-theme-install b/bin/omarchy-theme-install index 4bc1d11e..b324fe55 100755 --- a/bin/omarchy-theme-install +++ b/bin/omarchy-theme-install @@ -23,16 +23,26 @@ 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" -# 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`. +# 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/bin/omarchy-theme-set-browser b/bin/omarchy-theme-set-browser index 4dd7592f..612e586f 100755 --- a/bin/omarchy-theme-set-browser +++ b/bin/omarchy-theme-set-browser @@ -3,23 +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 -set_browser_policy() { - local policy_dir="$1" - - [[ -d $policy_dir ]] || return - echo "{\"BrowserThemeColor\": \"$THEME_HEX_COLOR\", \"BrowserColorScheme\": \"device\"}" | tee "$policy_dir/color.json" >/dev/null -} - refresh_running_browser() { local process="$1" local command="$2" @@ -30,17 +22,15 @@ refresh_running_browser() { fi } -set_browser_policy /etc/chromium/policies/managed +failed=0 +omarchy-theme-set-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1 + refresh_running_browser chromium chromium - -set_browser_policy /etc/opt/chrome/policies/managed refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome - -set_browser_policy /etc/opt/edge/policies/managed refresh_running_browser msedge microsoft-edge-stable - -set_browser_policy /etc/brave/policies/managed 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-theme-set-browser-policy b/bin/omarchy-theme-set-browser-policy new file mode 100755 index 00000000..ccd07722 --- /dev/null +++ b/bin/omarchy-theme-set-browser-policy @@ -0,0 +1,123 @@ +#!/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="" +# 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() { + if [[ -n $staged ]]; then + rm -f "$staged" + fi +} +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 e28da697..296433d8 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1312,9 +1312,23 @@ 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 \ + bash -euo pipefail -c ' + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + browser_policy_setup_dir /etc/chromium/policies/managed + for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ $dir == "/etc/chromium/policies/managed" ]] && continue + [[ -d $dir || -L $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}}}' | \ + 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 @@ -1350,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 @@ -2306,6 +2319,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/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..e07354ba 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 @@ -42,6 +54,34 @@ 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). +normalize_webapp_url() { + local url=$1 + if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then + url="https://$url" + fi + printf '%s' "$url" +} + +# Raw whitespace must be percent-encoded in a URL. Refuse it before serializing +# the desktop entry; before Exec argument quoting, it also split browser flags +# and additional URLs into separate arguments. Schemes are case-insensitive. +require_http_url() { + local url=$1 + + 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 +} + fetch_site_icon() { local site_url="$1" dest="$2" local origin page icon_url @@ -65,13 +105,44 @@ 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. + # + # 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() { + # 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") + 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" - fi + 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" @@ -88,10 +159,8 @@ 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") + require_http_url "$APP_URL" ICON_REF="$3" CUSTOM_EXEC="$4" # Optional custom exec command MIME_TYPES="$5" # Optional mime types @@ -104,6 +173,8 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +require_plain_name "$APP_NAME" + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" @@ -128,28 +199,39 @@ 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")" +DESKTOP_DIR="$HOME/.local/share/applications" +DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" +mkdir -p "$DESKTOP_DIR" + +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" diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index 9cf72c38..b3244637 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -9,14 +9,31 @@ ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps" OLD_ICON_DIR="$HOME/.local/share/applications/icons" DESKTOP_DIR="$HOME/.local/share/applications/" -if (( $# == 0 )); then - # Find all web apps - while IFS= read -r -d '' file; do - if grep -q '^Exec=.*\(omarchy-launch-webapp\|omarchy-webapp-handler\).*' "$file"; then - WEB_APPS+=("$(basename "${file%.desktop}")") - fi - done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0) +# Always index the launchers, so removal deletes the file that was found rather +# than a path rebuilt from the displayed name. Installs predating the name +# validation could nest the launcher inside directories, and those are exactly +# the ones a reconstructed path cannot reach. +WEB_APP_PATHS=() +while IFS= read -r -d '' file; do + if grep -q '^Exec=.*\(omarchy-launch-webapp\|omarchy-webapp-handler\).*' "$file"; then + WEB_APPS+=("$(basename "${file%.desktop}")") + WEB_APP_PATHS+=("$file") + fi +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). +path_for_web_app() { + local wanted="$1" i + for i in "${!WEB_APPS[@]}"; do + if [[ ${WEB_APPS[$i]} == "$wanted" ]]; then + printf '%s\n' "${WEB_APP_PATHS[$i]}" + return 0 + fi + done +} + +if (( $# == 0 )); then if ((${#WEB_APPS[@]})); then mapfile -t SORTED_WEB_APPS < <(printf '%s\n' "${WEB_APPS[@]}" | sort) APP_NAME=$(omarchy-menu-select "Select web app to remove" "${SORTED_WEB_APPS[@]}" -- --width 520 --maxheight 520) @@ -34,7 +51,8 @@ if [[ -z $APP_NAME ]]; then fi icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//') -rm -f "$DESKTOP_DIR/$APP_NAME.desktop" +desktop_file=$(path_for_web_app "$APP_NAME") +rm -f "${desktop_file:-$DESKTOP_DIR/$APP_NAME.desktop}" rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png" if [[ ${OMARCHY_REMOVE_NOTIFY:-true} != "false" ]]; then diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 1487cb21..f1582e2d 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 @@ -67,9 +86,18 @@ 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 + # 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_security_migration && mounts_ready >/dev/null 2>&1 + } + }; then + with_vm_lock "__priv_$action" "$@" + return + fi fi local target target=$(priv_target) || { @@ -81,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$ ]]; } @@ -88,16 +138,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,35 +153,531 @@ valid_priv_action() { # --- privileged actions (run as root via pkexec, or directly when sudoless) --- -# 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 +# 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 - 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" ;; - STORAGE) storage="$value" ;; - SHARED) shared="$value" ;; - esac + if ((EUID == 0)); then + [[ ${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 + } + 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 + # opening and pinning the familiar data entries below 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 + + # 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() { + # 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_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 runtime_parent + 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 + fi + + 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 + 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 + } +} + +# 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 + } + # 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 +} + +# 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 + } + 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 + 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 - 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" - valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } - valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; } + 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() { + 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}<&- +} + +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" +} + +mount_layer_count() { + local path="$1" + awk -v path="$path" '$5 == path { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +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 @@ -150,17 +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//\$/\$\$} - 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" </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). -# valid_path kept a ':' out of the stored path, so splitting on it is safe. get_mount_source() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 } -# Refuse to bring the VM up if a bind-mount source is a symlink, or reached -# through one. valid_path keeps a traversal string like /./ out of the compose, -# but a symlink planted at ~/.windows or ~/Windows would redirect the privileged -# mount just the same — docker follows it — and a string check cannot see that. -# So verify the real directories here, as root, immediately before the mount. A -# source that does not exist is fine: docker creates it as a plain directory. -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 +# 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 + 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() { + 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 +} + +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" || { + rm -f "$tmp" 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 + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || { rm -f "$tmp"; return 1; } +} + +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 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 + return 1 + } + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + + [[ $(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 - 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 + mounts_prepared=1 + needs_rewrite=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 + } + + 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. + 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 + } } __priv_up() { assert_mounts_safe && dc up -d; } @@ -273,19 +925,114 @@ __priv_up_wait() { __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_remove() { - 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" - rmdir "$RUNTIME_DIR" 2>/dev/null || true + 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" 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 } @@ -304,12 +1051,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" } @@ -340,27 +1094,26 @@ 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 + 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 echo "Could not migrate the existing configuration automatically." >&2 echo "Re-run: omarchy-windows-vm install" >&2 return 1 fi - write_credentials "$username" "$password" rm -f "$LEGACY_COMPOSE_FILE" } @@ -385,7 +1138,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" @@ -400,11 +1156,11 @@ 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 - mkdir -p "$HOME/.windows" mkdir -p "$HOME/.local/share/applications" cat </dev/null @@ -457,7 +1213,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 @@ -540,19 +1299,21 @@ 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 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" "$HOME/.windows" "$HOME/Windows" || { + "$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..." @@ -597,9 +1358,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" @@ -789,7 +1558,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/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..ea773007 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,38 @@ 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 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 '' # silence it +omarchy-crash-mute '' off # let it speak again +omarchy-crash-mute # list what is muted +``` + +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. + +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 diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index 6f6abda3..734d2b8f 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -3,9 +3,12 @@ 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", }) 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 }) +-- 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 }) 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/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 08f9bf4b..30ee45fc 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/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/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/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/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/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..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..fd802c66 --- /dev/null +++ b/install/config/browser-policy.sh @@ -0,0 +1,2 @@ +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +browser_policy_setup_dir /etc/chromium/policies/managed 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/config/theme-system.sh b/install/config/theme-system.sh index 2902e1cd..1527e17e 100644 --- a/install/config/theme-system.sh +++ b/install/config/theme-system.sh @@ -6,11 +6,8 @@ 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 +# 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 '{"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 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..c2c93d8e --- /dev/null +++ b/install/helpers/browser-policy.sh @@ -0,0 +1,168 @@ +# 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_MANAGED_DIRS=( + /etc/chromium/policies/managed + /etc/opt/chrome/policies/managed + /etc/opt/edge/policies/managed + /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_purge_dir() { + local dir=$1 + + as_root find "$dir" -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} + +} + +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_dir_hardened() { + browser_policy_parent_hardened "$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" + browser_policy_setup_parent "$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_install_color() { + local policy_dir=$1 + local hex=$2 + local dest=$policy_dir/color.json + local tmp + + [[ -d $policy_dir && ! -L $policy_dir ]] || return 0 + [[ $hex =~ ^#[0-9a-f]{6}$ ]] || return 1 + + tmp=$(mktemp) || return 1 + printf '{"BrowserThemeColor": "%s", "BrowserColorScheme": "device"}\n' "$hex" >"$tmp" + + if [[ -L $dest || -d $dest ]]; then + if ! rm -rf -- "$dest" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + fi + + if install -m 0644 -T "$tmp" "$dest" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + + rm -f "$tmp" + 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 && ! -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" +} + +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} + + browser_policy_setup_parent "$distribution_dir" + browser_policy_purge_dir "$distribution_dir" + browser_policy_install_firefox_policies "$distribution_dir" "$policies" +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 4097b645..c5013ba6 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -17,9 +17,8 @@ chromium clang cliamp cups -cups-browsed cups-filters -cups-pdf +cups-pk-helper ddcutil docker docker-buildx diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 23580b3c..da84975b 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,11 +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 -# 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 - cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew +# 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 source "$OMARCHY_INSTALL/hardware/pacman.sh" 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/manual/17-ai.md b/manual/17-ai.md index f6212146..ca6ce8af 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -40,6 +40,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 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 The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, and Hermes Desktop. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 9da7786f..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) @@ -26,9 +26,15 @@ 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`. -The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. +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. 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/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/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/1787515927.sh b/migrations/1787515927.sh new file mode 100644 index 00000000..9adae003 --- /dev/null +++ b/migrations/1787515927.sh @@ -0,0 +1,30 @@ +echo "Stop world-writable Chromium and Firefox policy directories" + +source "$OMARCHY_PATH/install/helpers/browser-policy.sh" + +repaired=0 +for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do + [[ -d $dir || -L $dir ]] || continue + browser_policy_setup_dir "$dir" + 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 || true +fi + +for dir in "${BROWSER_POLICY_FIREFOX_DIRS[@]}"; do + [[ -d $dir || -L $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 + browser_policy_install_firefox_policies "$dir" + fi +done 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 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 diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh new file mode 100644 index 00000000..b3f9282a --- /dev/null +++ b/migrations/1787815267.sh @@ -0,0 +1,57 @@ +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 + +# 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 + +# 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 + +# Stop the root-running daemon before changing the authorization it relies on. +if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service +fi + +if omarchy-pkg-present cups; then + sudo systemctl daemon-reload + sudo systemctl try-reload-or-restart cups.service +fi + +# 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 + +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh new file mode 100644 index 00000000..676fd5d8 --- /dev/null +++ b/migrations/1788009111.sh @@ -0,0 +1,72 @@ +echo "Temporarily remove automatic printer discovery" + +machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" + +[[ ! -e $machine_marker ]] || exit 0 +omarchy-pkg-present cups-browsed || exit 0 + +# Check the full removal transaction before changing the service or queues. +pacman -Rs --print cups-browsed >/dev/null + +# 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 >/dev/null +elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service >/dev/null +fi + +# 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. +# +# 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') + +while IFS= read -r queue; do + [[ -n $queue ]] || continue + + 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 + # The queue disappeared after the initial snapshot, which is already the + # desired state. + continue + fi + + 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 + printf '%s\n' "$delete_error" >&2 + exit 1 + fi + fi +done <<<"$generated_queues" + +omarchy-pkg-drop cups-browsed >/dev/null +sudo install -Dm644 /dev/null "$machine_marker" 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..b5a6f3ea 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -5,8 +5,92 @@ 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 `<`, 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 = /^<[^A-Za-z0-9]*([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. +// +// Work in whole tags, never in substrings of one. A `<` opens a tag that runs +// 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 +// +// g src="http://a/beacon.png"> +// +// 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 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 +} + +// 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 = String(body || "").replace(/]*>/gi, "") + var text = stripImageTags(String(body || "")) if (!isChromiumDerived(app, appIcon)) return text return text @@ -366,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 cf88f23e..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) @@ -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..515b43ce 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 @@ -524,6 +530,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.foreground opacity: 0.6 font.family: root.fontFamily @@ -531,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/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..d1e41149 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 @@ -1946,6 +1954,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -1953,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 871ee2f6..b1c34da2 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 @@ -517,6 +520,7 @@ Panel { } component InfoLabel: Text { + textFormat: Text.PlainText color: root.bar.foreground opacity: 0.6 font.family: root.bar.fontFamily @@ -524,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/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/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 53b2c1d7..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 cups-browsed.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" 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..18b65cd0 --- /dev/null +++ b/test/shell.d/brightness-display-apple-cache-test.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +# 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, +# 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" + +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 + 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 /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="" +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 ------ +# 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 + 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" + 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" +fi 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..0d66d216 --- /dev/null +++ b/test/shell.d/browser-policy-dir-test.sh @@ -0,0 +1,330 @@ +#!/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 + +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_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 == "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_install_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_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" +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_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_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 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" +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" +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 755 "$owned" +if browser_policy_dir_hardened "$owned"; then + fail "a user-owned 0755 directory is not treated as hardened" +fi +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 755 "$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" + +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" ]] || + 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 stock 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" +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 +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" +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" +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" + +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" + +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" + "$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 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_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 || + 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" 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/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh index f8cf3497..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 -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 diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index d4119cd8..1b7e93d0 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,334 @@ 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" +} + +# 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" PATH="$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null +} + +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" + +# 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 + 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 + +# 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 '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') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh new file mode 100644 index 00000000..a8aefb94 --- /dev/null +++ b/test/shell.d/cups-hardening-test.sh @@ -0,0 +1,246 @@ +#!/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" +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" + +# 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" + +# Automatic discovery is temporarily out of the default install while it is +# 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" +! 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" + +# 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' "$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 "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" +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 '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" + +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/var/lib/omarchy/migrations" + +passwd_db="$test_tmp/passwd" +group_db="$test_tmp/group" +touch "$passwd_db" "$group_db" + +cat >"$mock_bin/getent" <<'SH' +#!/bin/bash +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 +[[ $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"/* + +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" + +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_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" + +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_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 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" + +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" + +# 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" +[[ $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_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 masked or disabled unit is deliberately left alone. +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_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" diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index fbd60f31..f421fabb 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -459,7 +459,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" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index aa07659d..8151c250 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,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: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: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 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" +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" || @@ -217,6 +227,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/notifications-test.sh b/test/shell.d/notifications-test.sh index 58a4e32a..f06e0481 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -18,6 +18,151 @@ assertEqual( 'notifications strip inline image tags' ) +// 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 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 + 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) + // 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 + } + 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.styledBody(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' +) + +// 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' +) + +// 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' +) + +// 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 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/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 2cbc95be..cc80067b 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -1,43 +1,968 @@ #!/bin/bash +set -uo pipefail + source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT +[[ -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 -source_dir="$test_tmp/source" -theme_dir="$test_tmp/theme" +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) +sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf) -mkdir -m 0700 "$source_dir" -mkdir -m 0755 "$theme_dir" -touch "$source_dir/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" -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" +# SDDM reset is also a fixed-file publication contract. New packaged files +# must fail this test until their trust and reset behavior are reviewed. +packaged_sddm_assets=$(find "$ROOT/default/sddm/omarchy" -type f -printf '%P\n' | LC_ALL=C sort) +allowlisted_sddm_assets=$(printf '%s\n' "${sddm_default_assets[@]}" | LC_ALL=C sort) +[[ $packaged_sddm_assets == "$allowlisted_sddm_assets" ]] || + fail "SDDM refresh allowlist differs from the packaged asset set" "$packaged_sddm_assets" +pass "SDDM refresh allowlist covers every packaged asset" # 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. -secret="$test_tmp/secret" +# ~/.config/omarchy/themes. Both installed copies are world-readable, so a +# symlink there must not republish whatever it points at. 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" [[ $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" + +# The descriptor must be opened before elevation. Invoking the whole command +# as root would make the pre-open race privileged again, so refuse that shape +# before sudo or any publication can begin. User namespaces let this run +# without real privilege; retain a structural assertion on hosts that disable +# them so the invariant never becomes an untested skip. +if unshare --user --map-root-user true 2>/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 +# 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 + +# 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 +: +STUB + +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. +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" + +fake_bin="$test_tmp/bin" +root_tools="$test_tmp/root-tools" +stages="$test_tmp/stages" +mkdir -p "$fake_bin" "$root_tools" "$stages" + +cat >"$fake_bin/sudo" <<'SH' +#!/bin/bash +set -u + +for argument in "$@"; do + if [[ $argument == *"$TEST_STAGES"* ]]; then + printf '%s\n' "$argument" >>"$TEST_LEAK_LOG" + fi +done + +case "$1" in +/bin/bash) + [[ ${2:-} == -c && $# == 9 ]] || exit 90 + code=$3 + shell_name=$4 + shift 4 + printf 'root transaction\n' >>"$TEST_SUDO_LOG" + + # The production helper intentionally resets PATH. For this unprivileged + # 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 + + PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ + /bin/bash -c "$code" "$shell_name" "$@" + ;; +plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 92 + ;; +esac +SH + +cat >"$root_tools/stat" <<'SH' +#!/bin/bash +last=${!#} +if [[ ${1:-} == -c && ${2:-} == %u ]]; 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 + 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"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93 +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 +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"/* "$root_tools"/* + +printf 'caller-selected logo\n' >"$test_tmp/logo.png" + +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" + omarchy_conf="$run_dir/omarchy.conf" + 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_default_assets[@]}"; 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" +} + +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" \ + 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" \ + "$@" + ) +} + +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_fresh_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" + 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" + 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" + + 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 "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 +# 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 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" +printf 'ROOT ONLY\n' >"$secret" +chmod 000 "$secret" +cat >"$preopen_hook" <<'SH' +if [[ $0 == */bin/omarchy-plymouth-set ]]; then + set -T + trap ' + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && + ! -e $TEST_PREOPEN_MARKER ]]; then + mv -T -- "$logo_path" "$logo_path.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$logo_path" + 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" +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 '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" + +# 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 +# in its own root-owned stage. +setup_run +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 "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 "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" +[[ $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" + +# 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. +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 == *"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" + +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" +# 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 +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 +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" +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 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. +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" +[[ $output == *"refusing to publish"* ]] || fail "a rejected symlinked parent says why it refused" "$output" +assert_no_temporary_files "$fake_root" + +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" +[[ $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" + +# 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" + +# 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" + +# 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. +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" + +# 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=$(run_refresh_plymouth 2>&1) +status=$? +(( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output" + +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 '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" diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index d0eac226..5a5fc516 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -27,16 +27,41 @@ 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 must not be recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" +[[ ! -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. @@ -45,17 +70,22 @@ 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" 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 -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 but never docker" +pass "existing install user gets input but never docker or browser-policy" 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 new file mode 100755 index 00000000..4d987ce4 --- /dev/null +++ b/test/shell.d/qml-text-format-test.sh @@ -0,0 +1,276 @@ +#!/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. +# +# 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 + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command python3 + +SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py" + +violations=$(python3 "$SCAN" "$ROOT") + +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" + +# 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" 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" diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index bc29338e..6c6044d0 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -92,6 +92,83 @@ 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" + +# 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" + +# 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")" diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index bf94605f..60e8abff 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -67,6 +67,24 @@ 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" +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 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|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 root" + 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 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" diff --git a/test/shell.d/webapp-install-test.sh b/test/shell.d/webapp-install-test.sh new file mode 100644 index 00000000..2db32353 --- /dev/null +++ b/test/shell.d/webapp-install-test.sh @@ -0,0 +1,124 @@ +#!/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" + +# Raw whitespace is not valid URL data, and before Exec argument quoting it +# split browser flags or additional URLs into separate arguments. +for url in \ + " javascript:alert(1)" \ + " file:///etc/passwd" \ + "https://example.com data:text/html,hi" \ + "https://example.com/ --user-agent=INJECTION_PROOF_MARKER_12345"; 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" diff --git a/test/shell.d/webapp-name-test.sh b/test/shell.d/webapp-name-test.sh new file mode 100644 index 00000000..903f851a --- /dev/null +++ b/test/shell.d/webapp-name-test.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" "$tmp_dir/home" + +for stub in gtk-update-icon-cache update-desktop-database omarchy-notification-send; do + printf '#!/bin/bash\n:\n' >"$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" +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. 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" +[[ $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" ]] || + 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" + +# 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" diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 0aa353f2..189536f4 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -1,72 +1,119 @@ #!/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" -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' \ +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 /home/alice/.windows /home/alice/Windows +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 -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path" -grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" -pass "writer emits a pinned compose with 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" +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" +[[ $(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 /a /b 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" +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 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 /h/.windows /h/Windows -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" +write 8G 4 64G bob "$tricky" UTC +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" -export HOME="$TMPDIR/home" -mkdir -p "$HOME/.config/windows" +# 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' @@ -83,42 +130,364 @@ 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" -grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume" -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" +resolve_caller +[[ -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 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" -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" +# 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 "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 +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, unprotected, and writable compose inputs" -# --- 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" +# 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" + +# 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" + +# 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" + +# 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 -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" +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" -# --- 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" +# 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..34f36cc2 --- /dev/null +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -0,0 +1,198 @@ +#!/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" + +# 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" +[[ $(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" +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" +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" diff --git a/test/shell.d/windows-vm-test.sh b/test/shell.d/windows-vm-test.sh index 2e887672..e2a04dae 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,15 @@ 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" + +# 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" +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"