From 24c18df5b7edbfdc32a88e52aebb893b93fa2ecd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 15:48:28 +0200 Subject: [PATCH 1/5] Temporarily remove automatic printer discovery cups-browsed is the daemon that watches the network and creates print queues by itself. Hardening it took a root daemon with a predictable cache down to a confined service account, but a daemon that turns anything advertising itself on the network into a print queue is a lot of exposure for a convenience, so it comes out of the default install while that is reworked. Only the discovery half: CUPS itself stays and printing keeps working, with each printer added by hand in Print Settings. The migration disables the unit before removing the package because that is the only order that works: pacman deletes the unit file but not the enable symlink, and once the unit is gone systemd can no longer resolve it by name to clean that up. It then removes the queues discovery generated. cups-browsed keeps those when it stops, since KeepGeneratedQueuesOnShutdown defaults to Yes, and they route through its own implicitclass backend, which goes with the package, so they cannot print again. Idle ones go. A queue with jobs on it is left alone and named: implicitclass only needs cups-browsed to choose a destination, so a job already past that point finishes on its own, and deleting the queue would abort it. One printer's job does not hold up the removal. A printer added by hand has an ipp:// or usb:// device and is left where it is. A queue whose jobs cannot be asked about is left alone rather than assumed idle, including one named so that lpstat would misread it -- "all" is its word for every destination, and a leading dash or a comma reads as another option or a list. Where CUPS does not answer at all, or a queue will not delete, discovery is still stopped but the package stays and no marker is written. omarchy-migrate records a migration for the user as soon as it exits zero, so that is where the machine stays until someone removes the package by hand, and the message says so rather than implying a retry. The queue list is read under LC_ALL=C because lpstat translates "device for", and captured rather than piped, so a cupsd it cannot reach is reported instead of reading like a machine with nothing to clean up. It removes with plain pacman -R rather than omarchy-pkg-drop, which passes -n and would discard /etc/cups/cups-browsed.conf instead of keeping it as a .pacsave. A removal meant to be temporary should not delete the machine's copy of its own configuration. Without -s either, so it only ever removes the package it names: sweeping newly unneeded dependencies is nothing today, but it is not a promise a rolling dependency graph can keep. Queue names come off the network, since cups-browsed names its queues after what the printer advertised. CUPS allows every printable character but space, tab, / and #, and lpstat and lpadmin take a destination as an option value, so a name with a leading dash or a comma is reported rather than passed to them and guessed at. Migration state is per user, so a machine-wide marker records the one removal. Without it, an account whose first migration run came after someone deliberately reinstalled discovery would quietly take it back out again. The install-time override for cups-browsed.conf now waits for cups-browsed rather than for CUPS. Guarding it on a file CUPS still ships would write a configuration file for a package nothing installed, and pacman would later land the package's own copy beside it as a .pacnew. The hardened configuration stays in the tree. omarchy-settings still ships the cups-browsed.conf override, the sysusers account and the service drop-in, so they are what discovery returns onto. Co-Authored-By: Codex XHigh --- bin/omarchy-upgrade-to-quattro | 1 - install/config/enable-services.sh | 8 +- install/omarchy-base.packages | 1 - install/post-install/pacman.sh | 19 +- manual/46-faq.md | 8 +- migrations/1788009111.sh | 131 +++++++ test/acceptance.d/system-test.sh | 67 +--- .../cups-browsed-removal-migration-test.sh | 364 ++++++++++++++++++ test/shell.d/cups-hardening-test.sh | 31 +- 9 files changed, 563 insertions(+), 67 deletions(-) create mode 100644 migrations/1788009111.sh create mode 100644 test/shell.d/cups-browsed-removal-migration-test.sh diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 32ea6465..bf103dc0 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1364,7 +1364,6 @@ EOF as_root systemctl disable docker.service >/dev/null 2>&1 || true enable_system_service cups.service - enable_system_service cups-browsed.service enable_system_service avahi-daemon.service enable_system_service linux-modules-cleanup.service enable_system_service docker.socket diff --git a/install/config/enable-services.sh b/install/config/enable-services.sh index b7e2ba8b..964f0867 100644 --- a/install/config/enable-services.sh +++ b/install/config/enable-services.sh @@ -1,16 +1,14 @@ # Enable services only. Installs are followed by reboot, so don't start/reload # daemons mid-install. UFW and hardware-gated services stay in their own scripts. systemctl enable cups.service -systemctl enable cups-browsed.service systemctl enable avahi-daemon.service systemctl enable linux-modules-cleanup.service systemctl enable docker.socket systemctl enable systemd-resolved.service systemctl enable NetworkManager.service -# Don't let network-online.target (pulled in by cups-browsed) hold up -# graphical.target waiting for DHCP/Wi-Fi association. Nothing in the session -# needs to block on the network. Mirrors the systemd-networkd-wait-online mask -# in install/hardware/network.sh. +# Don't let network-online.target hold up graphical.target waiting for +# DHCP/Wi-Fi association. Nothing in the session needs to block on the network. +# Mirrors the systemd-networkd-wait-online mask in install/hardware/network.sh. systemctl mask NetworkManager-wait-online.service systemctl enable power-profiles-daemon.service systemctl enable sddm.service diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index f4c5e2ef..c5013ba6 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -17,7 +17,6 @@ chromium clang cliamp cups -cups-browsed cups-filters cups-pk-helper ddcutil diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 327b812f..22422820 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,13 +3,22 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# omarchy-settings skips these overrides until CUPS is actually present to -# avoid pacman creating .pacnew files during ISO package installation. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-files.conf ]]; then +# Each override waits for the package that owns the file it replaces, the way +# omarchy-settings does, so pacman does not turn it into a .pacnew during ISO +# package installation. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then + install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf + rm -f /etc/cups/cups-files.conf.pacnew +fi + +# cups-browsed is no longer part of the default install, so this waits for a +# machine that adds discovery back by hand. Writing the override before then +# would leave a configuration file for a package nothing installed, and pacman +# would later land the package's own copy beside it as a .pacnew. +if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf ]]; then systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf - rm -f /etc/cups/cups-browsed.conf.pacnew /etc/cups/cups-files.conf.pacnew + rm -f /etc/cups/cups-browsed.conf.pacnew fi source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/manual/46-faq.md b/manual/46-faq.md index 65e1f2e7..6c625407 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -44,7 +44,13 @@ The plain open source Chromium build doesn't ship with the OAuth credentials tha ### How do I add a printer? -Printing is set up and running out of the box, so a printer on your network is usually already discovered. Launch _Print Settings_ from the app launcher (`Super + Space`) to see what's there, add one by hand, or set the default. Printing to a PDF file works without any printer at all. +Printing is set up and running out of the box, and you add each printer yourself from _Print Settings_ in the app launcher (`Super + Space`). + +Choose _Add_, and give it a moment to look: a printer plugged in over USB, and most network printers, are found for you. If yours isn't in the list, pick _Network Printer > Internet Printing Protocol (ipp)_ and enter its address — the printer's own display or its web page will tell you what that is, usually something like `192.168.1.50` with a queue of `ipp/print`. _Forward_ then offers a driver, where a modern printer works best on the driverless _IPP Everywhere_ profile and an older one wants the model's own driver. + +Right-click a printer and choose _Set as Default_ to pick which one your apps reach for first, and _Properties_ to set paper size, duplex and quality. + +Automatic discovery, where printers on the network appear without being added, is temporarily switched off while it's reworked, which is why the first step above is yours rather than automatic. Printing to a PDF file works without any printer at all. ### How do I change where screenshots or screenrecordings are saved? diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh new file mode 100644 index 00000000..1f0d67f5 --- /dev/null +++ b/migrations/1788009111.sh @@ -0,0 +1,131 @@ +echo "Temporarily remove automatic printer discovery" + +# cups-browsed is the daemon that watches the network and creates print queues +# by itself. Hardening it (1787815267) took a root daemon with a predictable +# cache down to a confined service account, but a daemon that turns anything +# advertising itself on the network into a print queue is a lot of exposure for +# a convenience, so it comes out of the default install while that is reworked. +# Temporarily, and only the discovery half: CUPS itself stays, printing keeps +# working, and a printer is added by hand in Print Settings instead of +# appearing on its own. +machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" + +[[ ! -e $machine_marker ]] || exit 0 + +# Nothing to do on a machine that never had it. Checked before any sudo so those +# runs never prompt for a password, and before the marker so no machine pays a +# password prompt for a removal it does not need. +omarchy-pkg-present cups-browsed || exit 0 + +# Ask pacman whether the removal is possible before touching the service. If +# something here depends on cups-browsed, the alternative is a machine whose +# discovery daemon has been stopped and whose package removal then failed -- +# broken rather than removed. +if ! pacman -R --print cups-browsed >/dev/null 2>&1; then + echo " Something else on this machine still depends on cups-browsed, so it is staying installed." + exit 0 +fi + +# Disable before removing, and this is the only window in which it works. +# pacman deletes the unit file but not the enable symlink systemd wrote under +# /etc, and once the unit is gone `systemctl disable` refuses it by name and +# leaves the symlink dangling with nothing left that can clean it. Stopping is +# part of the same step: a daemon whose executable has been unlinked keeps +# running until it is told not to. A masked or already-disabled unit reports +# not-enabled and is left alone. Doing it before the queue list is read also +# means the list cannot grow a new entry while it is being acted on. +if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl disable --now cups-browsed.service +elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + sudo systemctl stop cups-browsed.service +fi + +# cups-browsed keeps the queues it generated when it stops -- +# KeepGeneratedQueuesOnShutdown defaults to Yes and nothing here overrides it -- +# and those queues route through its own implicitclass backend, which goes with +# the package, so none of them can print again. The idle ones are removed rather +# than left in Print Settings looking like printers; the ones with jobs on them +# are handled below. Only queues on that backend: a printer added by hand has an +# ipp:// or usb:// device and is left alone. +# +# LC_ALL=C because lpstat translates "device for", and on a German or French +# machine the untranslated pattern would match nothing and read exactly like a +# machine that had no queues to clean up. Captured rather than piped so that +# failing to reach cupsd is distinguishable from finding nothing. The name is +# matched greedily because CUPS allows a colon in a queue name but never a +# space, so the last ": implicitclass://" is the separator and an earlier colon +# belongs to the name. +if ! queue_report=$(LC_ALL=C lpstat -v 2>/dev/null); then + echo " Could not ask CUPS which queues discovery had created." + echo " Discovery is off, but cups-browsed stays installed; remove it by hand once CUPS answers." + exit 0 +fi + +generated_queues=$(printf '%s\n' "$queue_report" | + sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') + +unremoved=0 + +while IFS= read -r queue; do + [[ -n $queue ]] || continue + + # A queue name is whatever the printer advertised, put through cups-browsed's + # own sanitizer. `lpstat -o` takes its destination as an optional argument, so + # a leading dash reads as the next option and a comma as a list separator, and + # "all" is its word for every destination. The jobs on such a queue cannot be + # asked about, so it is left alone and named. Never a reason to keep the + # package, though: that would hand a printer that picked its own name a veto + # over the removal. + if [[ $queue == -* || $queue == *,* || $queue == "all" ]]; then + echo " Cannot safely ask about jobs on the queue named '$queue'; remove it in Print Settings." + continue + fi + + # Removing a queue aborts what is printing on it. The implicitclass backend + # only needs cups-browsed to pick a destination, so a job already past that + # point finishes on its own even though the daemon has stopped -- and a job + # that has not is one this cannot route anyway. Either way the queue is left + # for the person whose job it is, and named so they know to remove it. + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then + if [[ -n $job_report ]]; then + echo " $queue still has jobs, so it is being left alone." + echo " Once they finish or are cancelled, remove it in Print Settings; it cannot print again." + continue + fi + else + echo " Could not check for jobs on $queue, so it is being left alone." + continue + fi + + if ! sudo lpadmin -x "$queue"; then + echo " Could not remove the queue $queue." + unremoved=1 + fi +done <<<"$generated_queues" + +# A queue that would not delete is a CUPS that is not answering as expected, so +# the package stays rather than deleting the backend out from under it. Discovery +# is stopped either way, which is the half that mattered. omarchy-migrate records +# this migration for the user as soon as it exits zero, so this is where the +# machine stays until someone removes the package by hand -- said plainly rather +# than dressed up as a retry. +if ((unremoved)); then + echo " Leaving cups-browsed installed. Discovery is off; remove the package by hand once those queues are gone." + exit 0 +fi + +# Raw pacman rather than omarchy-pkg-drop, which passes -n: that discards the +# files pacman has marked as backups instead of renaming them .pacsave, and +# /etc/cups/cups-browsed.conf is one of them. A removal meant to be temporary +# should leave the machine's copy of its own configuration behind. Plain -R +# rather than -Rs, so this only ever removes the one package it names: -s also +# sweeps dependencies that have become unneeded, which is nothing today but is +# a promise the dependency graph of a rolling distribution cannot keep. +# pacman's systemd hook reloads the system manager once the unit file goes. +sudo pacman -R --noconfirm cups-browsed + +# Migration state is per user, so every account on this machine runs every +# migration. Without machine-wide state, an account whose first run comes after +# someone deliberately reinstalled cups-browsed would quietly take it back out +# again. This records that the machine has had its one removal. +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 899cc6e7..deb42f4e 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -47,7 +47,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service cups-browsed.service docker.socket \ + avahi-daemon.service cups.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -65,29 +65,24 @@ verify_services() { } verify_printing_security() { - local cups_browsed_pid lpinfo_output printer_name printer_process printer_tmp + local lpinfo_output ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" pass "the root CUPS-PDF backend is not installed" - getent passwd cups-browsed >/dev/null || fail "the cups-browsed service account exists" - [[ $(systemctl show -P User cups-browsed.service) == "cups-browsed" ]] || - fail "cups-browsed runs as its service account" - [[ $(systemctl show -P Group cups-browsed.service) == "cups-browsed" ]] || - fail "cups-browsed runs as its service group" - systemctl is-active --quiet cups-browsed.service || fail "cups-browsed is running" + # Automatic discovery is temporarily out of the default install: a daemon that + # turns anything advertising itself on the network into a print queue is more + # exposure than the convenience is worth while it is reworked. CUPS itself + # stays, so what a stock machine proves here is that printing runs and that + # the desktop user still cannot administer it without authenticating. + ! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is not installed" + ! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "no discovery service is enabled" + ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "no discovery service is running" + pass "automatic printer discovery is absent from a stock install" - cups_browsed_pid=$(systemctl show -P MainPID cups-browsed.service) - [[ -r /proc/$cups_browsed_pid/status ]] || fail "cups-browsed has a readable process status" - [[ $(awk '/^Uid:/{print $2}' "/proc/$cups_browsed_pid/status") != 0 ]] || - fail "cups-browsed does not run with root UID" - [[ $(awk '/^CapEff:/{print $2}' "/proc/$cups_browsed_pid/status") == "0000000000000000" ]] || - fail "cups-browsed has no effective Linux capabilities" - - [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || - fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" - [[ " $(id -nG cups-browsed) " != *" cups "* ]] || - fail "cups-browsed is separate from the print-filter group" + systemctl is-active --quiet cups.service || fail "CUPS is running" if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then fail "the desktop user cannot administer CUPS without authentication" @@ -95,39 +90,7 @@ verify_printing_security() { fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" fi - pass "CUPS discovery is isolated from root, filters, and passwordless desktop administration" - - # A live driverless printer proves the non-root daemon can still discover and - # create queues without the CAP_NET_BIND_SERVICE Ubuntu carries downstream. - printer_name="OmarchyAcceptancePrinter" - printer_tmp=$(mktemp -d) - printf '#!/bin/bash\nexit 0\n' >"$printer_tmp/command" - chmod 0700 "$printer_tmp/command" - mkdir -m 0700 "$printer_tmp/spool" - - ippeveprinter -p 18631 -d "$printer_tmp/spool" -c "$printer_tmp/command" "$printer_name" \ - >"$printer_tmp/ippeveprinter.log" 2>&1 & - printer_process=$! - - printing_test_cleanup() { - kill "$printer_process" >/dev/null 2>&1 || true - wait "$printer_process" >/dev/null 2>&1 || true - rm -rf "$printer_tmp" - } - trap printing_test_cleanup EXIT - - for _ in {1..30}; do - lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" && break - sleep 1 - done - - lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" || - fail "non-root cups-browsed discovers a driverless IPP printer" "$(<"$printer_tmp/ippeveprinter.log")" - - printing_test_cleanup - trap - EXIT - - pass "non-root cups-browsed still creates driverless IPP queues without capabilities" + pass "CUPS runs with passwordless desktop administration still denied" } verify_runtime_tools() { diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh new file mode 100644 index 00000000..82797b4d --- /dev/null +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -0,0 +1,364 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1788009111.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" + +# Every privileged step is stubbed: a real run here would take printer discovery +# off the developer's own machine. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ " $BROWSED_INSTALLED " == *" $1 "* ]] +SH + +# pacman answers the removal preflight from the state each case sets up, and +# logs the removal itself so the flags it is called with are visible. +cat >"$mock_bin/pacman" <<'SH' +#!/bin/bash +printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" +[[ $* == *--print* ]] || exit 0 +[[ -z $BROWSED_REMOVAL_BLOCKED ]] +SH + +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" +unit=${*: -1} +case $1 in + is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; + is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; + *) : ;; +esac +SH + +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\t%s\n' "$*" >>"$BROWSED_LOG" +exec "$@" +SH + +cat >"$mock_bin/install" <<'SH' +#!/bin/bash +printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" +exec /usr/bin/install "$@" +SH + +# lpstat reports the queues cups-browsed generated. Only those route through its +# implicitclass backend; the ipp:// and usb:// entries are printers a person +# added and must survive. +cat >"$mock_bin/lpstat" <<'SH' +#!/bin/bash +printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" +case $1 in + -v) + [[ -z $BROWSED_LPSTAT_FAILS ]] || exit 1 + # lpstat translates "device for". Only a caller that pinned the locale gets + # the string the migration parses. + if [[ ${LC_ALL:-} == "C" ]]; then + printf '%s\n' "$BROWSED_QUEUES" + else + printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' + fi + ;; + -o) + [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 + # Jobs are listed per queue: "- ". + [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]] && printf '%s-7 alice 1024\n' "$2" + ;; +esac +exit 0 +SH + +cat >"$mock_bin/lpadmin" <<'SH' +#!/bin/bash +printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_LPADMIN_FAILS ]] +SH + +chmod +x "$mock_bin"/* + +log="$test_tmp/actions.log" +output="$test_tmp/migration.out" +marker="$test_tmp/var/lib/omarchy/migrations/1788009111" + +run_migration() { + : >"$log" + : >"$output" + + # No LC_ALL in the environment, so the mock above sees "C" only when the + # migration pinned it for the call itself -- which is what a non-English + # desktop depends on. + env -u LC_ALL -u LANGUAGE \ + BROWSED_LOG="$log" \ + BROWSED_INSTALLED="${installed:-}" \ + BROWSED_ENABLED="${enabled:-}" \ + BROWSED_ACTIVE="${active:-}" \ + BROWSED_REMOVAL_BLOCKED="${blocked:-}" \ + BROWSED_QUEUES="${queues:-}" \ + BROWSED_BUSY_QUEUES="${busy:-}" \ + BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ + BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ + BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ + PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ + bash -euo pipefail "$migration" >"$output" +} + +# ------------------------------------------------ a machine that has discovery + +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +# CUPS allows a colon in a destination name but never a space, so "Front:Desk" +# is a legal queue and the separator is the last ": implicitclass://". +queues=$'device for Office: implicitclass://Office/\ndevice for Front:Desk: implicitclass://Front:Desk/\ndevice for -p: implicitclass://-p/\ndevice for a,b: implicitclass://a,b/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' +rm -f "$marker" +run_migration + +grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || + fail "the removal disables and stops the discovery service" "$(cat "$log")" +pass "the removal disables and stops the discovery service" + +# -Rns would discard /etc/cups/cups-browsed.conf rather than keep it as a +# .pacsave, and a removal meant to be temporary must not delete configuration. +grep -qxF $'pacman\t-R --noconfirm cups-browsed' "$log" || + fail "the removal keeps configuration pacman marked as a backup" "$(cat "$log")" +if grep -qE -- '-Rns|-Rn ' "$log"; then + fail "the removal never passes -n" "$(cat "$log")" +fi +# -s would also sweep dependencies that became unneeded, which is a promise a +# rolling dependency graph cannot keep. +if grep -q -- '-Rs --noconfirm' "$log"; then + fail "the removal names only the package it means to remove" "$(cat "$log")" +fi +pass "the removal keeps the machine's own cups-browsed.conf and touches nothing else" + +# pacman deletes the unit file but not the enable symlink, and once the unit is +# gone systemd cannot resolve it by name to clean that up. +disable_line=$(grep -n 'disable --now' "$log" | tail -1 | cut -d: -f1 || true) +removal_line=$(grep -n $'^pacman\t-R --noconfirm' "$log" | head -1 | cut -d: -f1 || true) +[[ -n $disable_line && -n $removal_line ]] || + fail "the removal both disables the unit and removes the package" "$(cat "$log")" +(( disable_line < removal_line )) || + fail "the removal disables before the unit file goes" "$(cat "$log")" +pass "the removal disables before the unit file goes" + +# cups-browsed keeps its generated queues on shutdown, and they print through +# the implicitclass backend that goes with the package. +grep -qxF $'sudo\tlpadmin -x Office' "$log" || + fail "the queues discovery generated are removed with it" "$(cat "$log")" +grep -q 'lpadmin -x Desk' "$log" && + fail "a printer added by hand over ipp survives" "$(cat "$log")" +grep -q 'lpadmin -x Attic' "$log" && + fail "a printer added by hand over usb survives" "$(cat "$log")" +grep -qxF $'sudo\tlpadmin -x Front:Desk' "$log" || + fail "a queue whose name contains a colon is still matched" "$(cat "$log")" + +# cups-browsed names queues after what the printer advertised, so the name came +# off the network. lpstat and lpadmin take a destination as an option value: a +# leading dash reads as another option and a comma separates a list. +if grep -qF -- $'lpstat\t-o -p' "$log"; then + fail "a queue named like an option is never passed to lpstat" "$(cat "$log")" +fi +if grep -q -- 'lpadmin -x -p' "$log"; then + fail "a queue named like an option is never passed to lpadmin" "$(cat "$log")" +fi +if grep -q 'lpadmin -x a,b' "$log"; then + fail "a queue name holding a comma is never passed as a destination list" "$(cat "$log")" +fi +if grep -qF -- $'lpstat\t-o all' "$log"; then + fail "a queue named all is never asked about, since lpstat reads it as every destination" "$(cat "$log")" +fi +grep -qF -- "Cannot safely ask about jobs on the queue named '-p'" "$output" || + fail "a queue that cannot be asked about safely is reported" "$(cat "$output")" +pass "generated queues go, hand-added printers stay, unaddressable names are reported" + +# The queues have to go while cups-browsed's backend is still installed. +cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1 || true) +[[ -n $cleanup_line ]] || fail "the removal cleans up generated queues" "$(cat "$log")" +(( cleanup_line < removal_line )) || + fail "generated queues go before the backend that serves them" "$(cat "$log")" +pass "generated queues go before the backend that serves them" + +[[ -f $marker ]] || fail "the removal records machine-wide completion" +pass "the removal records machine-wide completion" + +# ------------------------------------ a second user, after a deliberate reinstall + +# Migration state is per user. The account that runs this after someone put +# discovery back on purpose must not quietly take it away again. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +run_migration + +[[ ! -s $log ]] || fail "a machine that already had its removal is left alone" "$(cat "$log")" +pass "a second user does not undo a deliberate reinstall" + +# ----------------------------------------- a machine that never had discovery + +installed="" +enabled="" +active="cups.service" +use_marker="$test_tmp/var/lib/omarchy/migrations/never-had-it" +run_migration + +[[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" +[[ ! -e $use_marker ]] || + fail "a machine with nothing to remove is not made to pay for a marker" "$(cat "$log")" +pass "a machine without discovery does no privileged work and gets no password prompt" + +# ------------------------------------------------- a blocked package removal + +# Something depending on cups-browsed makes pacman refuse. Stopping the service +# first and only then discovering that would leave discovery broken rather than +# removed. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/blocked" +run_migration + +grep -q 'disable --now' "$log" && + fail "a refused removal never stops the service" "$(cat "$log")" +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "a refused removal does not go on to remove anything" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a refused removal is not recorded as done" +# The preflight has to ask about the same command the removal will run. +grep -qxF $'pacman\t-R --print cups-browsed' "$log" || + fail "the preflight asks pacman about the removal it will actually run" "$(cat "$log")" +pass "a removal pacman would refuse changes nothing at all" + +# --------------------------------------------------- a queue that is printing + +# lpadmin -x cancels the jobs on the queue it removes. A stale queue can be +# deleted whenever someone notices it; an aborted print cannot come back. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' +busy="Office" +use_marker="$test_tmp/var/lib/omarchy/migrations/printing" +run_migration + +# The implicitclass backend only needs cups-browsed to choose a destination, so +# a job already past that point finishes even though the daemon has stopped. +# Deleting the queue would abort it. +if grep -q 'lpadmin -x Office' "$log"; then + fail "a queue with jobs on it is left for them to finish" "$(cat "$log")" +fi +grep -qxF $'sudo\tlpadmin -x Spare' "$log" || + fail "an idle generated queue is still removed" "$(cat "$log")" +grep -q 'Office still has jobs' "$output" || + fail "a queue left alone is named so it can be removed later" "$(cat "$output")" +# One printer's job must not keep discovery on the machine. +grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || + fail "a busy queue does not hold up the removal" "$(cat "$log")" +pass "a queue with jobs is left for them to finish, and does not hold up the removal" + +# ------------------------------------------------- a job query that fails + +# Treating a failed query as an idle queue would delete it and abort whatever +# was on it, which is the one outcome this is trying to avoid. +lpstat_o_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/nojobs" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "a queue whose jobs could not be checked is left alone" "$(cat "$log")" +fi +grep -q 'Could not check for jobs' "$output" || + fail "a job query that failed is said out loud" "$(cat "$output")" +pass "a queue whose jobs cannot be checked is left alone, not assumed idle" + +lpstat_o_fails="" + +# ------------------------------------------------------ CUPS out of reach + +# Failing to reach cupsd must not read like a machine with no queues to clean. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service" +busy="" +lpstat_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/nocups" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "nothing is removed when the queue list could not be read" "$(cat "$log")" +fi +# Removing the backend without having read the queue list would strand every +# generated queue permanently, and the marker would stop anyone retrying. +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits until the queue list can be read" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || + fail "an unread queue list is not recorded as a finished removal" +grep -qi 'could not ask cups' "$output" || + fail "a queue list that could not be read is said out loud" "$(cat "$output")" +# Stopping discovery is the half that mattered, and it is idempotent. +grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || + fail "discovery is still stopped when the queue list cannot be read" "$(cat "$log")" +pass "an unreadable queue list stops discovery but finalizes nothing" + +lpstat_fails="" + +# ------------------------------------------------ a queue that will not go + +# A queue left behind would route through a backend the removal is about to +# delete, so the package waits rather than stranding it for good. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/' +busy="" +lpstat_fails="" +lpadmin_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/stuck" +run_migration + +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits while a generated queue is still there" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a half-done cleanup is not recorded as finished" +grep -q 'Could not remove the queue Office' "$output" || + fail "a queue that would not go is named" "$(cat "$output")" +pass "a queue that will not go keeps the package and the marker back" + +lpadmin_fails="" + +# -------------------------------------------------- a masked but running daemon + +installed="cups-browsed" +enabled="" +active="cups-browsed.service" +blocked="" +queues="" +use_marker="$test_tmp/var/lib/omarchy/migrations/masked" +run_migration + +grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || + fail "a running unit is stopped even when it is not enabled" "$(cat "$log")" +grep -q 'disable --now' "$log" && + fail "a masked unit is not disabled" "$(cat "$log")" +pass "a masked but running daemon is stopped without being disabled" + +# A machine whose discovery never created a queue has nothing to clean up, and +# no reason to reach for CUPS administration. +grep -q 'lpadmin' "$log" && + fail "no queues means no CUPS administration" "$(cat "$log")" +pass "a machine with no generated queues does not touch CUPS administration" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index 96110fff..d60e156f 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -10,11 +10,38 @@ cups_files_conf="$ROOT/etc/cups/cups-files.conf" sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" -grep -qxF cups-browsed "$packages" || fail "cups-browsed remains in the base package set" +# Only discovery goes. Everything else printing needs stays, or this stops +# being a removal of one daemon and becomes a removal of printing. +grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set" +grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set" +grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set" grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" ! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" -pass "the base install keeps discovery and replaces CUPS-PDF with Polkit administration" +# Automatic discovery is temporarily out of the default install while it is +# reworked. The hardened configuration below still ships: it is what a +# hand-installed cups-browsed gets, and what discovery comes back onto. +! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" +! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || + fail "a fresh install does not enable a discovery service it no longer installs" +! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" || + fail "the Quattro upgrade does not enable a discovery service it no longer installs" + +pass "the base install keeps CUPS and Polkit administration, without automatic discovery" + +# The install-time override for a file has to wait for the package that owns +# it. CUPS still ships /etc/cups/cups-files.conf, so a guard on that file no +# longer says anything about cups-browsed: writing its override on a machine +# without the package leaves a configuration file for a package nothing +# installed, and pacman lands its own copy beside it as a .pacnew later. +post_install_pacman="$ROOT/install/post-install/pacman.sh" + +grep -q 'cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf' "$post_install_pacman" || + fail "the discovery override waits for the package that owns the file it replaces" +grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || + fail "the CUPS authorization override waits for the file it replaces" + +pass "install-time overrides wait for the packages that own their files" grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || fail "cups-browsed keeps state outside the print-filter cache" From 1dbc7d5bce67943a191fb6624c3a8568a659777e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 29 Aug 2026 17:44:31 +0200 Subject: [PATCH 2/5] Close each discovered queue before inspecting or removing it Checking a queue for jobs and then deleting it leaves a window in between, and the sudo that does the deleting can sit at a password prompt for as long as someone takes to type. A job submitted in that window was cancelled by a deletion that had already decided the queue was empty. cupsreject closes the queue to new work first, which also stops more jobs piling onto one that is being left behind and can no longer route them. It comes from the cups package, which stays. What a queue with jobs on it means is now said rather than implied: whatever is already at the printer finishes, whatever is still waiting cannot be routed with the daemon gone, and the person who owns them has to cancel what is left. A queue that another administrator removed while this was running is the outcome wanted, not a failure to keep the package installed for, so a deletion that fails is checked against whether the destination is still there. Co-Authored-By: Codex XHigh --- migrations/1788009111.sh | 36 +++++++--- .../cups-browsed-removal-migration-test.sh | 72 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh index 1f0d67f5..fc2d262f 100644 --- a/migrations/1788009111.sh +++ b/migrations/1788009111.sh @@ -81,15 +81,28 @@ while IFS= read -r queue; do continue fi - # Removing a queue aborts what is printing on it. The implicitclass backend - # only needs cups-browsed to pick a destination, so a job already past that - # point finishes on its own even though the daemon has stopped -- and a job - # that has not is one this cannot route anyway. Either way the queue is left - # for the person whose job it is, and named so they know to remove it. + # Close the queue to new jobs before looking at what is on it. Otherwise a job + # submitted between the check and the removal -- the sudo below can sit at a + # password prompt for as long as someone takes to type it -- is cancelled by a + # deletion that decided the queue was empty. It also stops more jobs piling + # onto a queue that is being left behind and can no longer route them. + if ! sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue"; then + echo " Could not stop $queue accepting new jobs, so it is being left alone." + unremoved=1 + continue + fi + + # Removing a queue cancels the jobs on it. implicitclass needs cups-browsed + # only to pick a destination, so a job already past that point finishes on its + # own; one still waiting cannot, because the daemon that would route it has + # stopped. Neither is this migration's to throw away, so the queue is left for + # whoever owns them, and what will and will not happen is said rather than + # implied. if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then if [[ -n $job_report ]]; then - echo " $queue still has jobs, so it is being left alone." - echo " Once they finish or are cancelled, remove it in Print Settings; it cannot print again." + echo " $queue still has jobs and is no longer taking new ones, so it is being left alone." + echo " Anything already sent to the printer finishes; anything still waiting cannot be routed now." + echo " Cancel what is left and remove the queue in Print Settings." continue fi else @@ -97,9 +110,14 @@ while IFS= read -r queue; do continue fi + # A queue another administrator removed while this was running is a queue that + # is gone, which is the outcome wanted -- not a failure worth keeping the + # package for. if ! sudo lpadmin -x "$queue"; then - echo " Could not remove the queue $queue." - unremoved=1 + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + echo " Could not remove the queue $queue." + unremoved=1 + fi fi done <<<"$generated_queues" diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh index 82797b4d..63f6a053 100644 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -68,6 +68,11 @@ case $1 in printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' fi ;; + -p) + # A destination that no longer exists is unknown to lpstat. + [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] + exit $? + ;; -o) [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 # Jobs are listed per queue: "- ". @@ -83,6 +88,12 @@ printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" [[ -z $BROWSED_LPADMIN_FAILS ]] SH +cat >"$mock_bin/cupsreject" <<'SH' +#!/bin/bash +printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_REJECT_FAILS ]] +SH + chmod +x "$mock_bin"/* log="$test_tmp/actions.log" @@ -107,6 +118,8 @@ run_migration() { BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ + BROWSED_REJECT_FAILS="${reject_fails:-}" \ + BROWSED_GONE_QUEUES="${gone_queues:-}" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ @@ -262,6 +275,19 @@ if grep -q 'lpadmin -x Office' "$log"; then fi grep -qxF $'sudo\tlpadmin -x Spare' "$log" || fail "an idle generated queue is still removed" "$(cat "$log")" + +# A job submitted between the check and the deletion -- the sudo in between can +# sit at a password prompt -- would be cancelled by a deletion that had decided +# the queue was empty. +reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1 || true) +probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1 || true) +delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1 || true) +[[ -n $reject_line && -n $probe_line && -n $delete_line ]] || + fail "a queue is closed, inspected and removed in that order" "$(cat "$log")" +(( reject_line < probe_line && probe_line < delete_line )) || + fail "a queue stops taking new jobs before it is inspected or removed" "$(cat "$log")" +pass "a queue stops taking new jobs before it is inspected or removed" + grep -q 'Office still has jobs' "$output" || fail "a queue left alone is named so it can be removed later" "$(cat "$output")" # One printer's job must not keep discovery on the machine. @@ -341,6 +367,52 @@ pass "a queue that will not go keeps the package and the marker back" lpadmin_fails="" +# --------------------------------------------- a queue removed by someone else + +# Another administrator deleting the queue mid-run is the outcome wanted, not a +# failure worth keeping the package installed for. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +blocked="" +queues=$'device for Office: implicitclass://Office/' +busy="" +lpstat_fails="" +lpstat_o_fails="" +lpadmin_fails="1" +gone_queues="Office" +use_marker="$test_tmp/var/lib/omarchy/migrations/vanished" +run_migration + +grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || + fail "a queue that is already gone does not hold up the removal" "$(cat "$log")" +[[ -f $use_marker ]] || fail "a queue that is already gone still finishes the migration" +pass "a queue someone else removed counts as removed" + +lpadmin_fails="" +gone_queues="" + +# ------------------------------------------- a queue that will not stop taking jobs + +# Deleting a queue that is still accepting work races whatever arrives next. +installed="cups-browsed" +enabled="cups-browsed.service" +active="cups-browsed.service cups.service" +reject_fails="1" +use_marker="$test_tmp/var/lib/omarchy/migrations/openqueue" +run_migration + +if grep -q 'lpadmin -x' "$log"; then + fail "a queue still accepting jobs is not deleted" "$(cat "$log")" +fi +if grep -q $'^pacman\t-R --noconfirm' "$log"; then + fail "the package waits while a queue is still accepting jobs" "$(cat "$log")" +fi +[[ ! -e $use_marker ]] || fail "a queue still taking jobs is not recorded as done" +pass "a queue that will not stop taking jobs is neither inspected nor deleted" + +reject_fails="" + # -------------------------------------------------- a masked but running daemon installed="cups-browsed" From bb5b178e5f5d26fa2059c552ec2aac38a1e25fe5 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 14:24:05 -0400 Subject: [PATCH 3/5] Remove unused cups-browsed install override --- install/post-install/pacman.sh | 15 ++------------- test/acceptance.d/system-test.sh | 12 +++++++++++- test/shell.d/cups-hardening-test.sh | 18 ++++++++---------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 22422820..da84975b 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -3,22 +3,11 @@ cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist -# Each override waits for the package that owns the file it replaces, the way -# omarchy-settings does, so pacman does not turn it into a .pacnew during ISO -# package installation. +# Wait for CUPS to own the file, the way omarchy-settings does, so pacman does +# not turn the override into a .pacnew during ISO package installation. if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then install -m 0640 -o root -g cups "$OMARCHY_PATH/etc-overrides/cups-cups-files.conf" /etc/cups/cups-files.conf rm -f /etc/cups/cups-files.conf.pacnew fi -# cups-browsed is no longer part of the default install, so this waits for a -# machine that adds discovery back by hand. Writing the override before then -# would leave a configuration file for a package nothing installed, and pacman -# would later land the package's own copy beside it as a .pacnew. -if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf ]]; then - systemd-sysusers /etc/sysusers.d/omarchy-cups-browsed.conf - cp -f "$OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf" /etc/cups/cups-browsed.conf - rm -f /etc/cups/cups-browsed.conf.pacnew -fi - source "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index deb42f4e..75e9ac81 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -65,7 +65,7 @@ verify_services() { } verify_printing_security() { - local lpinfo_output + local lpinfo_output path ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" pass "the root CUPS-PDF backend is not installed" @@ -80,6 +80,16 @@ verify_printing_security() { fail "no discovery service is enabled" ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || fail "no discovery service is running" + + for path in \ + /etc/cups/cups-browsed.conf \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no installed package files" "$path still exists" + done pass "automatic printer discovery is absent from a stock install" systemctl is-active --quiet cups.service || fail "CUPS is running" diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index d60e156f..a8aefb94 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -19,8 +19,8 @@ grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is i ! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" # Automatic discovery is temporarily out of the default install while it is -# reworked. The hardened configuration below still ships: it is what a -# hand-installed cups-browsed gets, and what discovery comes back onto. +# reworked. The hardened configuration below stays as the baseline discovery +# comes back onto. ! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" ! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || fail "a fresh install does not enable a discovery service it no longer installs" @@ -29,19 +29,17 @@ grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is i pass "the base install keeps CUPS and Polkit administration, without automatic discovery" -# The install-time override for a file has to wait for the package that owns -# it. CUPS still ships /etc/cups/cups-files.conf, so a guard on that file no -# longer says anything about cups-browsed: writing its override on a machine -# without the package leaves a configuration file for a package nothing -# installed, and pacman lands its own copy beside it as a .pacnew later. +# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is +# applied after the ISO installs that package. cups-browsed is absent, so the +# installer must not write any of its package-owned configuration. post_install_pacman="$ROOT/install/post-install/pacman.sh" -grep -q 'cups-cups-browsed.conf && -f /etc/cups/cups-browsed.conf' "$post_install_pacman" || - fail "the discovery override waits for the package that owns the file it replaces" +! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" || + fail "a fresh install does not write configuration for absent printer discovery" grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || fail "the CUPS authorization override waits for the file it replaces" -pass "install-time overrides wait for the packages that own their files" +pass "the fresh install applies CUPS hardening without writing discovery configuration" grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || fail "cups-browsed keeps state outside the print-filter cache" From 96d5682460eb5935c4b7de689facb32a45d01d4b Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 14:58:33 -0400 Subject: [PATCH 4/5] Fix cups-browsed removal migration --- migrations/1788009111.sh | 155 ++---- test/acceptance.d/system-test.sh | 1 + .../cups-browsed-removal-migration-test.sh | 482 ++++++++---------- 3 files changed, 242 insertions(+), 396 deletions(-) diff --git a/migrations/1788009111.sh b/migrations/1788009111.sh index fc2d262f..676fd5d8 100644 --- a/migrations/1788009111.sh +++ b/migrations/1788009111.sh @@ -1,149 +1,72 @@ echo "Temporarily remove automatic printer discovery" -# cups-browsed is the daemon that watches the network and creates print queues -# by itself. Hardening it (1787815267) took a root daemon with a predictable -# cache down to a confined service account, but a daemon that turns anything -# advertising itself on the network into a print queue is a lot of exposure for -# a convenience, so it comes out of the default install while that is reworked. -# Temporarily, and only the discovery half: CUPS itself stays, printing keeps -# working, and a printer is added by hand in Print Settings instead of -# appearing on its own. machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}" [[ ! -e $machine_marker ]] || exit 0 - -# Nothing to do on a machine that never had it. Checked before any sudo so those -# runs never prompt for a password, and before the marker so no machine pays a -# password prompt for a removal it does not need. omarchy-pkg-present cups-browsed || exit 0 -# Ask pacman whether the removal is possible before touching the service. If -# something here depends on cups-browsed, the alternative is a machine whose -# discovery daemon has been stopped and whose package removal then failed -- -# broken rather than removed. -if ! pacman -R --print cups-browsed >/dev/null 2>&1; then - echo " Something else on this machine still depends on cups-browsed, so it is staying installed." - exit 0 -fi +# Check the full removal transaction before changing the service or queues. +pacman -Rs --print cups-browsed >/dev/null -# Disable before removing, and this is the only window in which it works. -# pacman deletes the unit file but not the enable symlink systemd wrote under -# /etc, and once the unit is gone `systemctl disable` refuses it by name and -# leaves the symlink dangling with nothing left that can clean it. Stopping is -# part of the same step: a daemon whose executable has been unlinked keeps -# running until it is told not to. A masked or already-disabled unit reports -# not-enabled and is left alone. Doing it before the queue list is read also -# means the list cannot grow a new entry while it is being acted on. +# Disable the unit while its package still owns the unit file so systemd can +# remove the enable symlink cleanly. if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl disable --now cups-browsed.service + sudo systemctl disable --now cups-browsed.service >/dev/null elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl stop cups-browsed.service + sudo systemctl stop cups-browsed.service >/dev/null fi -# cups-browsed keeps the queues it generated when it stops -- -# KeepGeneratedQueuesOnShutdown defaults to Yes and nothing here overrides it -- -# and those queues route through its own implicitclass backend, which goes with -# the package, so none of them can print again. The idle ones are removed rather -# than left in Print Settings looking like printers; the ones with jobs on them -# are handled below. Only queues on that backend: a printer added by hand has an -# ipp:// or usb:// device and is left alone. +# cups-browsed leaves its implicitclass queues behind when stopped. Remove idle +# discovery queues before removing the backend they require, but leave queues +# with jobs for the user to resolve. # -# LC_ALL=C because lpstat translates "device for", and on a German or French -# machine the untranslated pattern would match nothing and read exactly like a -# machine that had no queues to clean up. Captured rather than piped so that -# failing to reach cupsd is distinguishable from finding nothing. The name is -# matched greedily because CUPS allows a colon in a queue name but never a -# space, so the last ": implicitclass://" is the separator and an earlier colon -# belongs to the name. -if ! queue_report=$(LC_ALL=C lpstat -v 2>/dev/null); then - echo " Could not ask CUPS which queues discovery had created." - echo " Discovery is off, but cups-browsed stays installed; remove it by hand once CUPS answers." - exit 0 +# A healthy CUPS server with no configured printers reports this condition on +# stderr and exits 1. Treat that as an empty queue list; every other failure +# keeps the migration pending so it can be retried. +if queue_report=$(LC_ALL=C lpstat -v 2>&1); then + : +elif [[ $queue_report == "lpstat: No destinations added." ]]; then + queue_report="" +else + printf '%s\n' "$queue_report" >&2 + exit 1 fi generated_queues=$(printf '%s\n' "$queue_report" | sed -n 's|^device for \(.*\): implicitclass://.*|\1|p') -unremoved=0 - while IFS= read -r queue; do [[ -n $queue ]] || continue - # A queue name is whatever the printer advertised, put through cups-browsed's - # own sanitizer. `lpstat -o` takes its destination as an optional argument, so - # a leading dash reads as the next option and a comma as a list separator, and - # "all" is its word for every destination. The jobs on such a queue cannot be - # asked about, so it is left alone and named. Never a reason to keep the - # package, though: that would hand a printer that picked its own name a veto - # over the removal. - if [[ $queue == -* || $queue == *,* || $queue == "all" ]]; then - echo " Cannot safely ask about jobs on the queue named '$queue'; remove it in Print Settings." - continue - fi - - # Close the queue to new jobs before looking at what is on it. Otherwise a job - # submitted between the check and the removal -- the sudo below can sit at a - # password prompt for as long as someone takes to type it -- is cancelled by a - # deletion that decided the queue was empty. It also stops more jobs piling - # onto a queue that is being left behind and can no longer route them. - if ! sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue"; then - echo " Could not stop $queue accepting new jobs, so it is being left alone." - unremoved=1 - continue - fi - - # Removing a queue cancels the jobs on it. implicitclass needs cups-browsed - # only to pick a destination, so a job already past that point finishes on its - # own; one still waiting cannot, because the daemon that would route it has - # stopped. Neither is this migration's to throw away, so the queue is left for - # whoever owns them, and what will and will not happen is said rather than - # implied. - if job_report=$(LC_ALL=C lpstat -o "$queue" 2>/dev/null); then - if [[ -n $job_report ]]; then - echo " $queue still has jobs and is no longer taking new ones, so it is being left alone." - echo " Anything already sent to the printer finishes; anything still waiting cannot be routed now." - echo " Cancel what is left and remove the queue in Print Settings." + if ! reject_error=$(sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue" 2>&1); then + if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$reject_error" >&2 + exit 1 + else continue fi + fi + + if job_report=$(LC_ALL=C lpstat -o "$queue" 2>&1); then + [[ -z $job_report ]] || continue + elif LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then + printf '%s\n' "$job_report" >&2 + exit 1 else - echo " Could not check for jobs on $queue, so it is being left alone." + # The queue disappeared after the initial snapshot, which is already the + # desired state. continue fi - # A queue another administrator removed while this was running is a queue that - # is gone, which is the outcome wanted -- not a failure worth keeping the - # package for. - if ! sudo lpadmin -x "$queue"; then + if ! delete_error=$(sudo lpadmin -x "$queue" 2>&1); then + # Treat a concurrent disappearance as success. A queue that still exists + # means CUPS did not complete the deletion, so retry the migration later. if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then - echo " Could not remove the queue $queue." - unremoved=1 + printf '%s\n' "$delete_error" >&2 + exit 1 fi fi done <<<"$generated_queues" -# A queue that would not delete is a CUPS that is not answering as expected, so -# the package stays rather than deleting the backend out from under it. Discovery -# is stopped either way, which is the half that mattered. omarchy-migrate records -# this migration for the user as soon as it exits zero, so this is where the -# machine stays until someone removes the package by hand -- said plainly rather -# than dressed up as a retry. -if ((unremoved)); then - echo " Leaving cups-browsed installed. Discovery is off; remove the package by hand once those queues are gone." - exit 0 -fi - -# Raw pacman rather than omarchy-pkg-drop, which passes -n: that discards the -# files pacman has marked as backups instead of renaming them .pacsave, and -# /etc/cups/cups-browsed.conf is one of them. A removal meant to be temporary -# should leave the machine's copy of its own configuration behind. Plain -R -# rather than -Rs, so this only ever removes the one package it names: -s also -# sweeps dependencies that have become unneeded, which is nothing today but is -# a promise the dependency graph of a rolling distribution cannot keep. -# pacman's systemd hook reloads the system manager once the unit file goes. -sudo pacman -R --noconfirm cups-browsed - -# Migration state is per user, so every account on this machine runs every -# migration. Without machine-wide state, an account whose first run comes after -# someone deliberately reinstalled cups-browsed would quietly take it back out -# again. This records that the machine has had its one removal. +omarchy-pkg-drop cups-browsed >/dev/null sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 75e9ac81..ab3affa7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -83,6 +83,7 @@ verify_printing_security() { for path in \ /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ /usr/bin/cups-browsed \ /usr/lib/cups/backend/implicitclass \ /usr/lib/systemd/system/cups-browsed.service \ diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh index 63f6a053..a311e6e7 100644 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ b/test/shell.d/cups-browsed-removal-migration-test.sh @@ -2,7 +2,7 @@ set -euo pipefail -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$(cd -- "$(dirname -- "$0")" && pwd)/base-test.sh" migration="$ROOT/migrations/1788009111.sh" @@ -12,26 +12,31 @@ trap 'rm -rf "$test_tmp"' EXIT mock_bin="$test_tmp/bin" mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" -# Every privileged step is stubbed: a real run here would take printer discovery -# off the developer's own machine. +# Every privileged step is stubbed: a real run here would take printer +# discovery off the developer's own machine. cat >"$mock_bin/omarchy-pkg-present" <<'SH' #!/bin/bash [[ " $BROWSED_INSTALLED " == *" $1 "* ]] SH -# pacman answers the removal preflight from the state each case sets up, and -# logs the removal itself so the flags it is called with are visible. +cat >"$mock_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf 'omarchy-pkg-drop\t%s\n' "$*" >>"$BROWSED_LOG" +[[ -z $BROWSED_DROP_FAILS ]] +SH + cat >"$mock_bin/pacman" <<'SH' #!/bin/bash printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" -[[ $* == *--print* ]] || exit 0 -[[ -z $BROWSED_REMOVAL_BLOCKED ]] +if [[ $* == *--print* ]]; then + [[ -z $BROWSED_REMOVAL_BLOCKED ]] +fi SH cat >"$mock_bin/systemctl" <<'SH' #!/bin/bash printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" -unit=${*: -1} +unit=$3 case $1 in is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; @@ -51,386 +56,303 @@ printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" exec /usr/bin/install "$@" SH -# lpstat reports the queues cups-browsed generated. Only those route through its -# implicitclass backend; the ipp:// and usb:// entries are printers a person -# added and must survive. +# lpstat reports only package-generated queues on the implicitclass backend. +# Manual ipp:// and usb:// printers must survive. cat >"$mock_bin/lpstat" <<'SH' #!/bin/bash printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" case $1 in -v) - [[ -z $BROWSED_LPSTAT_FAILS ]] || exit 1 - # lpstat translates "device for". Only a caller that pinned the locale gets - # the string the migration parses. - if [[ ${LC_ALL:-} == "C" ]]; then + if [[ -n $BROWSED_LPSTAT_FAILS ]]; then + echo "lpstat: Scheduler is not responding." >&2 + exit 1 + elif [[ -z $BROWSED_QUEUES ]]; then + echo "lpstat: No destinations added." >&2 + exit 1 + elif [[ $LC_ALL == "C" ]]; then printf '%s\n' "$BROWSED_QUEUES" else printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' fi ;; -p) - # A destination that no longer exists is unknown to lpstat. [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] - exit $? ;; -o) - [[ -z $BROWSED_LPSTAT_O_FAILS ]] || exit 1 - # Jobs are listed per queue: "- ". - [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]] && printf '%s-7 alice 1024\n' "$2" + if [[ -n $BROWSED_LPSTAT_O_FAILS ]]; then + echo "lpstat: Scheduler is not responding." >&2 + exit 1 + fi + if [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]]; then + printf '%s-7 alice 1024\n' "$2" + fi ;; esac -exit 0 SH cat >"$mock_bin/lpadmin" <<'SH' #!/bin/bash printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_LPADMIN_FAILS ]] +if [[ -n $BROWSED_LPADMIN_FAILS ]]; then + echo "lpadmin: Printer does not exist." >&2 + exit 1 +fi SH cat >"$mock_bin/cupsreject" <<'SH' #!/bin/bash printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_REJECT_FAILS ]] +if [[ -n $BROWSED_REJECT_FAILS ]]; then + echo "cupsreject: Unable to reject jobs." >&2 + exit 1 +fi SH chmod +x "$mock_bin"/* log="$test_tmp/actions.log" output="$test_tmp/migration.out" -marker="$test_tmp/var/lib/omarchy/migrations/1788009111" +errors="$test_tmp/migration.err" +status=0 + +start_case() { + installed="cups-browsed" + enabled="cups-browsed.service" + active="cups-browsed.service cups.service" + blocked="" + drop_fails="" + queues="" + busy="" + lpstat_fails="" + lpstat_o_fails="" + lpadmin_fails="" + reject_fails="" + gone_queues="" + use_marker="$test_tmp/var/lib/omarchy/migrations/$1" + rm -f "$use_marker" +} run_migration() { : >"$log" : >"$output" + : >"$errors" - # No LC_ALL in the environment, so the mock above sees "C" only when the - # migration pinned it for the call itself -- which is what a non-English - # desktop depends on. - env -u LC_ALL -u LANGUAGE \ + if env -u LC_ALL -u LANGUAGE \ BROWSED_LOG="$log" \ - BROWSED_INSTALLED="${installed:-}" \ - BROWSED_ENABLED="${enabled:-}" \ - BROWSED_ACTIVE="${active:-}" \ - BROWSED_REMOVAL_BLOCKED="${blocked:-}" \ - BROWSED_QUEUES="${queues:-}" \ - BROWSED_BUSY_QUEUES="${busy:-}" \ - BROWSED_LPSTAT_FAILS="${lpstat_fails:-}" \ - BROWSED_LPADMIN_FAILS="${lpadmin_fails:-}" \ - BROWSED_LPSTAT_O_FAILS="${lpstat_o_fails:-}" \ - BROWSED_REJECT_FAILS="${reject_fails:-}" \ - BROWSED_GONE_QUEUES="${gone_queues:-}" \ + BROWSED_INSTALLED="$installed" \ + BROWSED_ENABLED="$enabled" \ + BROWSED_ACTIVE="$active" \ + BROWSED_REMOVAL_BLOCKED="$blocked" \ + BROWSED_DROP_FAILS="$drop_fails" \ + BROWSED_QUEUES="$queues" \ + BROWSED_BUSY_QUEUES="$busy" \ + BROWSED_LPSTAT_FAILS="$lpstat_fails" \ + BROWSED_LPSTAT_O_FAILS="$lpstat_o_fails" \ + BROWSED_LPADMIN_FAILS="$lpadmin_fails" \ + BROWSED_REJECT_FAILS="$reject_fails" \ + BROWSED_GONE_QUEUES="$gone_queues" \ PATH="$mock_bin:$PATH" \ OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="${use_marker:-$marker}" \ - bash -euo pipefail "$migration" >"$output" + OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="$use_marker" \ + bash -euo pipefail "$migration" >"$output" 2>"$errors"; then + status=0 + else + status=$? + fi } -# ------------------------------------------------ a machine that has discovery +assert_description_only() { + [[ $(<"$output") == "Temporarily remove automatic printer discovery" ]] || + fail "the migration prints only its description" "$(cat "$output")" +} -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" -# CUPS allows a colon in a destination name but never a space, so "Front:Desk" -# is a legal queue and the separator is the last ": implicitclass://". -queues=$'device for Office: implicitclass://Office/\ndevice for Front:Desk: implicitclass://Front:Desk/\ndevice for -p: implicitclass://-p/\ndevice for a,b: implicitclass://a,b/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' -rm -f "$marker" +assert_quiet_success() { + (( status == 0 )) || fail "the migration succeeds" "$(cat "$errors")" + assert_description_only + [[ ! -s $errors ]] || fail "a successful migration is quiet" "$(cat "$errors")" +} + +# ------------------------------------------------ a machine that has printers + +start_case printers +queues=$'device for Office: implicitclass://Office/\ndevice for Front_Desk: implicitclass://Front_Desk/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' run_migration +assert_quiet_success +grep -qxF $'pacman\t-Rs --print cups-browsed' "$log" || + fail "the migration preflights the package removal" "$(cat "$log")" grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "the removal disables and stops the discovery service" "$(cat "$log")" -pass "the removal disables and stops the discovery service" - -# -Rns would discard /etc/cups/cups-browsed.conf rather than keep it as a -# .pacsave, and a removal meant to be temporary must not delete configuration. -grep -qxF $'pacman\t-R --noconfirm cups-browsed' "$log" || - fail "the removal keeps configuration pacman marked as a backup" "$(cat "$log")" -if grep -qE -- '-Rns|-Rn ' "$log"; then - fail "the removal never passes -n" "$(cat "$log")" -fi -# -s would also sweep dependencies that became unneeded, which is a promise a -# rolling dependency graph cannot keep. -if grep -q -- '-Rs --noconfirm' "$log"; then - fail "the removal names only the package it means to remove" "$(cat "$log")" -fi -pass "the removal keeps the machine's own cups-browsed.conf and touches nothing else" - -# pacman deletes the unit file but not the enable symlink, and once the unit is -# gone systemd cannot resolve it by name to clean that up. -disable_line=$(grep -n 'disable --now' "$log" | tail -1 | cut -d: -f1 || true) -removal_line=$(grep -n $'^pacman\t-R --noconfirm' "$log" | head -1 | cut -d: -f1 || true) -[[ -n $disable_line && -n $removal_line ]] || - fail "the removal both disables the unit and removes the package" "$(cat "$log")" -(( disable_line < removal_line )) || - fail "the removal disables before the unit file goes" "$(cat "$log")" -pass "the removal disables before the unit file goes" - -# cups-browsed keeps its generated queues on shutdown, and they print through -# the implicitclass backend that goes with the package. -grep -qxF $'sudo\tlpadmin -x Office' "$log" || - fail "the queues discovery generated are removed with it" "$(cat "$log")" + fail "the migration disables and stops discovery" "$(cat "$log")" +for queue in Office Front_Desk all; do + grep -qxF "sudo lpadmin -x $queue" "$log" || + fail "the migration removes generated queue $queue" "$(cat "$log")" +done grep -q 'lpadmin -x Desk' "$log" && - fail "a printer added by hand over ipp survives" "$(cat "$log")" + fail "the migration leaves a manually-added IPP printer alone" "$(cat "$log")" grep -q 'lpadmin -x Attic' "$log" && - fail "a printer added by hand over usb survives" "$(cat "$log")" -grep -qxF $'sudo\tlpadmin -x Front:Desk' "$log" || - fail "a queue whose name contains a colon is still matched" "$(cat "$log")" + fail "the migration leaves a manually-added USB printer alone" "$(cat "$log")" +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "the migration uses the standard package removal helper" "$(cat "$log")" +[[ -f $use_marker ]] || fail "the migration records machine-wide completion" -# cups-browsed names queues after what the printer advertised, so the name came -# off the network. lpstat and lpadmin take a destination as an option value: a -# leading dash reads as another option and a comma separates a list. -if grep -qF -- $'lpstat\t-o -p' "$log"; then - fail "a queue named like an option is never passed to lpstat" "$(cat "$log")" -fi -if grep -q -- 'lpadmin -x -p' "$log"; then - fail "a queue named like an option is never passed to lpadmin" "$(cat "$log")" -fi -if grep -q 'lpadmin -x a,b' "$log"; then - fail "a queue name holding a comma is never passed as a destination list" "$(cat "$log")" -fi -if grep -qF -- $'lpstat\t-o all' "$log"; then - fail "a queue named all is never asked about, since lpstat reads it as every destination" "$(cat "$log")" -fi -grep -qF -- "Cannot safely ask about jobs on the queue named '-p'" "$output" || - fail "a queue that cannot be asked about safely is reported" "$(cat "$output")" -pass "generated queues go, hand-added printers stay, unaddressable names are reported" - -# The queues have to go while cups-browsed's backend is still installed. -cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1 || true) -[[ -n $cleanup_line ]] || fail "the removal cleans up generated queues" "$(cat "$log")" -(( cleanup_line < removal_line )) || - fail "generated queues go before the backend that serves them" "$(cat "$log")" -pass "generated queues go before the backend that serves them" - -[[ -f $marker ]] || fail "the removal records machine-wide completion" -pass "the removal records machine-wide completion" +disable_line=$(grep -n 'disable --now' "$log" | head -1 | cut -d: -f1) +cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1) +removal_line=$(grep -n 'omarchy-pkg-drop' "$log" | head -1 | cut -d: -f1) +(( disable_line < cleanup_line && cleanup_line < removal_line )) || + fail "the service and queues are handled before package removal" "$(cat "$log")" +pass "generated queues are removed, manual printers survive, and normal output is quiet" # ------------------------------------ a second user, after a deliberate reinstall -# Migration state is per user. The account that runs this after someone put -# discovery back on purpose must not quietly take it away again. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" run_migration - -[[ ! -s $log ]] || fail "a machine that already had its removal is left alone" "$(cat "$log")" +assert_quiet_success +[[ ! -s $log ]] || fail "a completed machine-wide removal is left alone" "$(cat "$log")" pass "a second user does not undo a deliberate reinstall" # ----------------------------------------- a machine that never had discovery +start_case no-package installed="" -enabled="" -active="cups.service" -use_marker="$test_tmp/var/lib/omarchy/migrations/never-had-it" run_migration - +assert_quiet_success [[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" -[[ ! -e $use_marker ]] || - fail "a machine with nothing to remove is not made to pay for a marker" "$(cat "$log")" -pass "a machine without discovery does no privileged work and gets no password prompt" +[[ ! -e $use_marker ]] || fail "a machine without discovery gets no marker" +pass "a machine without cups-browsed does no privileged work" + +# --------------------------------------------- a machine that has no printers + +start_case no-printers +run_migration +assert_quiet_success +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "no printers does not prevent package removal" "$(cat "$log")" +grep -qE 'cupsreject|lpadmin' "$log" && + fail "no printers requires no queue administration" "$(cat "$log")" +[[ -f $use_marker ]] || fail "the no-printer migration records completion" +pass "CUPS reporting no destinations is a successful empty migration" # ------------------------------------------------- a blocked package removal -# Something depending on cups-browsed makes pacman refuse. Stopping the service -# first and only then discovering that would leave discovery broken rather than -# removed. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" +start_case blocked blocked="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/blocked" run_migration - +(( status != 0 )) || fail "a blocked package removal remains pending" +assert_description_only grep -q 'disable --now' "$log" && - fail "a refused removal never stops the service" "$(cat "$log")" -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "a refused removal does not go on to remove anything" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a refused removal is not recorded as done" -# The preflight has to ask about the same command the removal will run. -grep -qxF $'pacman\t-R --print cups-browsed' "$log" || - fail "the preflight asks pacman about the removal it will actually run" "$(cat "$log")" -pass "a removal pacman would refuse changes nothing at all" + fail "a blocked removal does not stop discovery" "$(cat "$log")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a blocked removal does not invoke package removal" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a blocked removal gets no marker" +pass "a package dependency prevents any partial migration" -# --------------------------------------------------- a queue that is printing +# --------------------------------------------------- a queue that has jobs -# lpadmin -x cancels the jobs on the queue it removes. A stale queue can be -# deleted whenever someone notices it; an aborted print cannot come back. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case busy queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' busy="Office" -use_marker="$test_tmp/var/lib/omarchy/migrations/printing" run_migration - -# The implicitclass backend only needs cups-browsed to choose a destination, so -# a job already past that point finishes even though the daemon has stopped. -# Deleting the queue would abort it. -if grep -q 'lpadmin -x Office' "$log"; then - fail "a queue with jobs on it is left for them to finish" "$(cat "$log")" -fi +assert_quiet_success +grep -q 'lpadmin -x Office' "$log" && + fail "a queue with jobs is not deleted" "$(cat "$log")" grep -qxF $'sudo\tlpadmin -x Spare' "$log" || - fail "an idle generated queue is still removed" "$(cat "$log")" + fail "an idle generated queue is deleted" "$(cat "$log")" +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "a busy queue does not retain the discovery package" "$(cat "$log")" -# A job submitted between the check and the deletion -- the sudo in between can -# sit at a password prompt -- would be cancelled by a deletion that had decided -# the queue was empty. -reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1 || true) -probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1 || true) -delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1 || true) -[[ -n $reject_line && -n $probe_line && -n $delete_line ]] || - fail "a queue is closed, inspected and removed in that order" "$(cat "$log")" +reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1) +probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1) +delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1) (( reject_line < probe_line && probe_line < delete_line )) || - fail "a queue stops taking new jobs before it is inspected or removed" "$(cat "$log")" -pass "a queue stops taking new jobs before it is inspected or removed" - -grep -q 'Office still has jobs' "$output" || - fail "a queue left alone is named so it can be removed later" "$(cat "$output")" -# One printer's job must not keep discovery on the machine. -grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || - fail "a busy queue does not hold up the removal" "$(cat "$log")" -pass "a queue with jobs is left for them to finish, and does not hold up the removal" + fail "a queue is closed before it is checked and deleted" "$(cat "$log")" +pass "busy queues survive while idle discovery queues are removed" # ------------------------------------------------- a job query that fails -# Treating a failed query as an idle queue would delete it and abort whatever -# was on it, which is the one outcome this is trying to avoid. +start_case job-query-fails +queues=$'device for Office: implicitclass://Office/' lpstat_o_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/nojobs" run_migration - -if grep -q 'lpadmin -x' "$log"; then - fail "a queue whose jobs could not be checked is left alone" "$(cat "$log")" -fi -grep -q 'Could not check for jobs' "$output" || - fail "a job query that failed is said out loud" "$(cat "$output")" -pass "a queue whose jobs cannot be checked is left alone, not assumed idle" - -lpstat_o_fails="" +(( status != 0 )) || fail "a failed job query keeps the migration pending" +assert_description_only +grep -q 'lpadmin -x' "$log" && + fail "a queue with unknown job state is not deleted" "$(cat "$log")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a failed job query retains the package" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a failed job query gets no marker" +pass "a failed job query is retryable instead of falsely completing" # ------------------------------------------------------ CUPS out of reach -# Failing to reach cupsd must not read like a machine with no queues to clean. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service" -busy="" +start_case no-cups +queues=$'device for Office: implicitclass://Office/' lpstat_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/nocups" run_migration - -if grep -q 'lpadmin -x' "$log"; then - fail "nothing is removed when the queue list could not be read" "$(cat "$log")" -fi -# Removing the backend without having read the queue list would strand every -# generated queue permanently, and the marker would stop anyone retrying. -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits until the queue list can be read" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || - fail "an unread queue list is not recorded as a finished removal" -grep -qi 'could not ask cups' "$output" || - fail "a queue list that could not be read is said out loud" "$(cat "$output")" -# Stopping discovery is the half that mattered, and it is idempotent. -grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "discovery is still stopped when the queue list cannot be read" "$(cat "$log")" -pass "an unreadable queue list stops discovery but finalizes nothing" - -lpstat_fails="" +(( status != 0 )) || fail "an unavailable CUPS server keeps the migration pending" +assert_description_only +grep -qxF "lpstat: Scheduler is not responding." "$errors" || + fail "the underlying CUPS failure is preserved" "$(cat "$errors")" +grep -q 'omarchy-pkg-drop' "$log" && + fail "an unavailable CUPS server retains the package" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "an unavailable CUPS server gets no marker" +pass "an actual CUPS failure remains pending without custom telemetry" # ------------------------------------------------ a queue that will not go -# A queue left behind would route through a backend the removal is about to -# delete, so the package waits rather than stranding it for good. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case stuck-queue queues=$'device for Office: implicitclass://Office/' -busy="" -lpstat_fails="" lpadmin_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/stuck" run_migration +(( status != 0 )) || fail "a failed queue deletion keeps the migration pending" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a persistent generated queue retains the backend" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a failed queue deletion gets no marker" +pass "a persistent generated queue prevents partial completion" -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits while a generated queue is still there" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a half-done cleanup is not recorded as finished" -grep -q 'Could not remove the queue Office' "$output" || - fail "a queue that would not go is named" "$(cat "$output")" -pass "a queue that will not go keeps the package and the marker back" +# --------------------------------------------- a queue removed concurrently -lpadmin_fails="" - -# --------------------------------------------- a queue removed by someone else - -# Another administrator deleting the queue mid-run is the outcome wanted, not a -# failure worth keeping the package installed for. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" -blocked="" +start_case vanished-queue queues=$'device for Office: implicitclass://Office/' -busy="" -lpstat_fails="" -lpstat_o_fails="" lpadmin_fails="1" gone_queues="Office" -use_marker="$test_tmp/var/lib/omarchy/migrations/vanished" run_migration +assert_quiet_success +grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || + fail "a concurrently removed queue does not block package removal" "$(cat "$log")" +pass "a queue removed concurrently counts as removed" -grep -qxF $'sudo\tpacman -R --noconfirm cups-browsed' "$log" || - fail "a queue that is already gone does not hold up the removal" "$(cat "$log")" -[[ -f $use_marker ]] || fail "a queue that is already gone still finishes the migration" -pass "a queue someone else removed counts as removed" +# ------------------------------------------- a queue that cannot be closed -lpadmin_fails="" -gone_queues="" - -# ------------------------------------------- a queue that will not stop taking jobs - -# Deleting a queue that is still accepting work races whatever arrives next. -installed="cups-browsed" -enabled="cups-browsed.service" -active="cups-browsed.service cups.service" +start_case open-queue +queues=$'device for Office: implicitclass://Office/' reject_fails="1" -use_marker="$test_tmp/var/lib/omarchy/migrations/openqueue" run_migration - -if grep -q 'lpadmin -x' "$log"; then +(( status != 0 )) || fail "a queue that cannot be closed keeps the migration pending" +grep -q 'lpadmin -x' "$log" && fail "a queue still accepting jobs is not deleted" "$(cat "$log")" -fi -if grep -q $'^pacman\t-R --noconfirm' "$log"; then - fail "the package waits while a queue is still accepting jobs" "$(cat "$log")" -fi -[[ ! -e $use_marker ]] || fail "a queue still taking jobs is not recorded as done" -pass "a queue that will not stop taking jobs is neither inspected nor deleted" - -reject_fails="" +grep -q 'omarchy-pkg-drop' "$log" && + fail "a queue still accepting jobs retains the backend" "$(cat "$log")" +[[ ! -e $use_marker ]] || fail "a queue still accepting jobs gets no marker" +pass "a queue that cannot be closed is retried later" # -------------------------------------------------- a masked but running daemon -installed="cups-browsed" +start_case masked enabled="" active="cups-browsed.service" -blocked="" -queues="" -use_marker="$test_tmp/var/lib/omarchy/migrations/masked" run_migration - +assert_quiet_success grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || - fail "a running unit is stopped even when it is not enabled" "$(cat "$log")" + fail "a masked running daemon is stopped" "$(cat "$log")" grep -q 'disable --now' "$log" && fail "a masked unit is not disabled" "$(cat "$log")" -pass "a masked but running daemon is stopped without being disabled" +pass "a masked running daemon is stopped without noisy output" -# A machine whose discovery never created a queue has nothing to clean up, and -# no reason to reach for CUPS administration. -grep -q 'lpadmin' "$log" && - fail "no queues means no CUPS administration" "$(cat "$log")" -pass "a machine with no generated queues does not touch CUPS administration" +# ------------------------------------------------ a package removal that fails + +start_case drop-fails +drop_fails="1" +run_migration +(( status != 0 )) || fail "a failed package removal keeps the migration pending" +assert_description_only +[[ ! -e $use_marker ]] || fail "a failed package removal gets no marker" +pass "a package removal failure cannot be recorded as complete" From 2002fb35ed4d6abd9cd38353b8b1da32470ad2a4 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 29 Aug 2026 15:27:16 -0400 Subject: [PATCH 5/5] Test installed CUPS state directly --- test/acceptance.d/cups-test.sh | 51 +++ test/acceptance.d/system-test.sh | 44 +-- .../cups-browsed-removal-migration-test.sh | 358 ------------------ 3 files changed, 53 insertions(+), 400 deletions(-) create mode 100644 test/acceptance.d/cups-test.sh delete mode 100644 test/shell.d/cups-browsed-removal-migration-test.sh diff --git a/test/acceptance.d/cups-test.sh b/test/acceptance.d/cups-test.sh new file mode 100644 index 00000000..b2c82fe7 --- /dev/null +++ b/test/acceptance.d/cups-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +for package in cups cups-filters system-config-printer cups-pk-helper; do + pacman -Q "$package" >/dev/null 2>&1 || fail "printing packages are installed" "$package is missing" +done +pass "printing packages are installed" + +! pacman -Q cups-pdf >/dev/null 2>&1 || fail "the root CUPS-PDF backend is absent" +pass "the root CUPS-PDF backend is absent" + +! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is absent" +! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not enabled" +! systemctl is-active --quiet cups-browsed.service 2>/dev/null || + fail "automatic printer discovery is not running" +! pgrep -x cups-browsed >/dev/null 2>&1 || fail "no cups-browsed process exists" +pass "automatic printer discovery is not installed or running" + +for path in \ + /etc/cups/cups-browsed.conf \ + /etc/cups/cups-browsed.conf.pacsave \ + /etc/cups/cups-browsed.conf.pacnew \ + /usr/bin/cups-browsed \ + /usr/lib/cups/backend/implicitclass \ + /usr/lib/systemd/system/cups-browsed.service \ + /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do + [[ ! -e $path && ! -L $path ]] || + fail "automatic printer discovery leaves no package files" "$path still exists" +done +pass "automatic printer discovery leaves no package files" + +systemctl is-enabled --quiet cups.service || fail "CUPS is enabled" +systemctl is-active --quiet cups.service || fail "CUPS is running" +timeout 10 lpstat -r >/dev/null 2>&1 || fail "the CUPS scheduler answers" +pass "CUPS is enabled, running, and answering" + +policy_metadata=$(stat -c '%U:%G %a' /etc/cups/cups-files.conf) +[[ $policy_metadata == "root:cups 640" ]] || + fail "the CUPS authorization policy is protected" "$policy_metadata" +pass "the CUPS authorization policy is protected" + +if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" +elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" +fi +pass "CUPS denies unauthenticated desktop administration" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index ab3affa7..610fcb4b 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -47,7 +47,7 @@ verify_services() { local unit for unit in \ - avahi-daemon.service cups.service docker.socket \ + avahi-daemon.service docker.socket \ NetworkManager.service power-profiles-daemon.service sddm.service \ systemd-resolved.service ufw.service; do systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" @@ -64,46 +64,6 @@ verify_services() { pass "user audio services are running" } -verify_printing_security() { - local lpinfo_output path - - ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" - pass "the root CUPS-PDF backend is not installed" - - # Automatic discovery is temporarily out of the default install: a daemon that - # turns anything advertising itself on the network into a print queue is more - # exposure than the convenience is worth while it is reworked. CUPS itself - # stays, so what a stock machine proves here is that printing runs and that - # the desktop user still cannot administer it without authenticating. - ! pacman -Q cups-browsed >/dev/null 2>&1 || fail "automatic printer discovery is not installed" - ! systemctl is-enabled --quiet cups-browsed.service 2>/dev/null || - fail "no discovery service is enabled" - ! systemctl is-active --quiet cups-browsed.service 2>/dev/null || - fail "no discovery service is running" - - for path in \ - /etc/cups/cups-browsed.conf \ - /etc/cups/cups-browsed.conf.pacsave \ - /usr/bin/cups-browsed \ - /usr/lib/cups/backend/implicitclass \ - /usr/lib/systemd/system/cups-browsed.service \ - /etc/systemd/system/multi-user.target.wants/cups-browsed.service; do - [[ ! -e $path && ! -L $path ]] || - fail "automatic printer discovery leaves no installed package files" "$path still exists" - done - pass "automatic printer discovery is absent from a stock install" - - systemctl is-active --quiet cups.service || fail "CUPS is running" - - if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then - fail "the desktop user cannot administer CUPS without authentication" - elif [[ $lpinfo_output != *"Forbidden"* ]]; then - fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" - fi - - pass "CUPS runs with passwordless desktop administration still denied" -} - verify_runtime_tools() { # Docker access is intentionally NOT granted to the desktop user: the docker # group is root-equivalent, so a rogue process running as the user could @@ -147,7 +107,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_printing_security verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/cups-browsed-removal-migration-test.sh b/test/shell.d/cups-browsed-removal-migration-test.sh deleted file mode 100644 index a311e6e7..00000000 --- a/test/shell.d/cups-browsed-removal-migration-test.sh +++ /dev/null @@ -1,358 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "$0")" && pwd)/base-test.sh" - -migration="$ROOT/migrations/1788009111.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" - -# Every privileged step is stubbed: a real run here would take printer -# discovery off the developer's own machine. -cat >"$mock_bin/omarchy-pkg-present" <<'SH' -#!/bin/bash -[[ " $BROWSED_INSTALLED " == *" $1 "* ]] -SH - -cat >"$mock_bin/omarchy-pkg-drop" <<'SH' -#!/bin/bash -printf 'omarchy-pkg-drop\t%s\n' "$*" >>"$BROWSED_LOG" -[[ -z $BROWSED_DROP_FAILS ]] -SH - -cat >"$mock_bin/pacman" <<'SH' -#!/bin/bash -printf 'pacman\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ $* == *--print* ]]; then - [[ -z $BROWSED_REMOVAL_BLOCKED ]] -fi -SH - -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash -printf 'systemctl\t%s\n' "$*" >>"$BROWSED_LOG" -unit=$3 -case $1 in - is-enabled) [[ " $BROWSED_ENABLED " == *" $unit "* ]] ;; - is-active) [[ " $BROWSED_ACTIVE " == *" $unit "* ]] ;; - *) : ;; -esac -SH - -cat >"$mock_bin/sudo" <<'SH' -#!/bin/bash -printf 'sudo\t%s\n' "$*" >>"$BROWSED_LOG" -exec "$@" -SH - -cat >"$mock_bin/install" <<'SH' -#!/bin/bash -printf 'install\t%s\n' "$*" >>"$BROWSED_LOG" -exec /usr/bin/install "$@" -SH - -# lpstat reports only package-generated queues on the implicitclass backend. -# Manual ipp:// and usb:// printers must survive. -cat >"$mock_bin/lpstat" <<'SH' -#!/bin/bash -printf 'lpstat\t%s\n' "$*" >>"$BROWSED_LOG" -case $1 in - -v) - if [[ -n $BROWSED_LPSTAT_FAILS ]]; then - echo "lpstat: Scheduler is not responding." >&2 - exit 1 - elif [[ -z $BROWSED_QUEUES ]]; then - echo "lpstat: No destinations added." >&2 - exit 1 - elif [[ $LC_ALL == "C" ]]; then - printf '%s\n' "$BROWSED_QUEUES" - else - printf '%s\n' "$BROWSED_QUEUES" | sed 's|^device for |Gerät für |' - fi - ;; - -p) - [[ " $BROWSED_GONE_QUEUES " != *" $2 "* ]] - ;; - -o) - if [[ -n $BROWSED_LPSTAT_O_FAILS ]]; then - echo "lpstat: Scheduler is not responding." >&2 - exit 1 - fi - if [[ " $BROWSED_BUSY_QUEUES " == *" $2 "* ]]; then - printf '%s-7 alice 1024\n' "$2" - fi - ;; -esac -SH - -cat >"$mock_bin/lpadmin" <<'SH' -#!/bin/bash -printf 'lpadmin\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ -n $BROWSED_LPADMIN_FAILS ]]; then - echo "lpadmin: Printer does not exist." >&2 - exit 1 -fi -SH - -cat >"$mock_bin/cupsreject" <<'SH' -#!/bin/bash -printf 'cupsreject\t%s\n' "$*" >>"$BROWSED_LOG" -if [[ -n $BROWSED_REJECT_FAILS ]]; then - echo "cupsreject: Unable to reject jobs." >&2 - exit 1 -fi -SH - -chmod +x "$mock_bin"/* - -log="$test_tmp/actions.log" -output="$test_tmp/migration.out" -errors="$test_tmp/migration.err" -status=0 - -start_case() { - installed="cups-browsed" - enabled="cups-browsed.service" - active="cups-browsed.service cups.service" - blocked="" - drop_fails="" - queues="" - busy="" - lpstat_fails="" - lpstat_o_fails="" - lpadmin_fails="" - reject_fails="" - gone_queues="" - use_marker="$test_tmp/var/lib/omarchy/migrations/$1" - rm -f "$use_marker" -} - -run_migration() { - : >"$log" - : >"$output" - : >"$errors" - - if env -u LC_ALL -u LANGUAGE \ - BROWSED_LOG="$log" \ - BROWSED_INSTALLED="$installed" \ - BROWSED_ENABLED="$enabled" \ - BROWSED_ACTIVE="$active" \ - BROWSED_REMOVAL_BLOCKED="$blocked" \ - BROWSED_DROP_FAILS="$drop_fails" \ - BROWSED_QUEUES="$queues" \ - BROWSED_BUSY_QUEUES="$busy" \ - BROWSED_LPSTAT_FAILS="$lpstat_fails" \ - BROWSED_LPSTAT_O_FAILS="$lpstat_o_fails" \ - BROWSED_LPADMIN_FAILS="$lpadmin_fails" \ - BROWSED_REJECT_FAILS="$reject_fails" \ - BROWSED_GONE_QUEUES="$gone_queues" \ - PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_BROWSED_REMOVAL_MARKER="$use_marker" \ - bash -euo pipefail "$migration" >"$output" 2>"$errors"; then - status=0 - else - status=$? - fi -} - -assert_description_only() { - [[ $(<"$output") == "Temporarily remove automatic printer discovery" ]] || - fail "the migration prints only its description" "$(cat "$output")" -} - -assert_quiet_success() { - (( status == 0 )) || fail "the migration succeeds" "$(cat "$errors")" - assert_description_only - [[ ! -s $errors ]] || fail "a successful migration is quiet" "$(cat "$errors")" -} - -# ------------------------------------------------ a machine that has printers - -start_case printers -queues=$'device for Office: implicitclass://Office/\ndevice for Front_Desk: implicitclass://Front_Desk/\ndevice for all: implicitclass://all/\ndevice for Desk: ipp://192.168.1.9/ipp/print\ndevice for Attic: usb://HP/LaserJet%20P1102' -run_migration -assert_quiet_success - -grep -qxF $'pacman\t-Rs --print cups-browsed' "$log" || - fail "the migration preflights the package removal" "$(cat "$log")" -grep -qxF $'sudo\tsystemctl disable --now cups-browsed.service' "$log" || - fail "the migration disables and stops discovery" "$(cat "$log")" -for queue in Office Front_Desk all; do - grep -qxF "sudo lpadmin -x $queue" "$log" || - fail "the migration removes generated queue $queue" "$(cat "$log")" -done -grep -q 'lpadmin -x Desk' "$log" && - fail "the migration leaves a manually-added IPP printer alone" "$(cat "$log")" -grep -q 'lpadmin -x Attic' "$log" && - fail "the migration leaves a manually-added USB printer alone" "$(cat "$log")" -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "the migration uses the standard package removal helper" "$(cat "$log")" -[[ -f $use_marker ]] || fail "the migration records machine-wide completion" - -disable_line=$(grep -n 'disable --now' "$log" | head -1 | cut -d: -f1) -cleanup_line=$(grep -n 'lpadmin -x' "$log" | tail -1 | cut -d: -f1) -removal_line=$(grep -n 'omarchy-pkg-drop' "$log" | head -1 | cut -d: -f1) -(( disable_line < cleanup_line && cleanup_line < removal_line )) || - fail "the service and queues are handled before package removal" "$(cat "$log")" -pass "generated queues are removed, manual printers survive, and normal output is quiet" - -# ------------------------------------ a second user, after a deliberate reinstall - -run_migration -assert_quiet_success -[[ ! -s $log ]] || fail "a completed machine-wide removal is left alone" "$(cat "$log")" -pass "a second user does not undo a deliberate reinstall" - -# ----------------------------------------- a machine that never had discovery - -start_case no-package -installed="" -run_migration -assert_quiet_success -[[ ! -s $log ]] || fail "a machine without discovery is left alone" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a machine without discovery gets no marker" -pass "a machine without cups-browsed does no privileged work" - -# --------------------------------------------- a machine that has no printers - -start_case no-printers -run_migration -assert_quiet_success -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "no printers does not prevent package removal" "$(cat "$log")" -grep -qE 'cupsreject|lpadmin' "$log" && - fail "no printers requires no queue administration" "$(cat "$log")" -[[ -f $use_marker ]] || fail "the no-printer migration records completion" -pass "CUPS reporting no destinations is a successful empty migration" - -# ------------------------------------------------- a blocked package removal - -start_case blocked -blocked="1" -run_migration -(( status != 0 )) || fail "a blocked package removal remains pending" -assert_description_only -grep -q 'disable --now' "$log" && - fail "a blocked removal does not stop discovery" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a blocked removal does not invoke package removal" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a blocked removal gets no marker" -pass "a package dependency prevents any partial migration" - -# --------------------------------------------------- a queue that has jobs - -start_case busy -queues=$'device for Office: implicitclass://Office/\ndevice for Spare: implicitclass://Spare/' -busy="Office" -run_migration -assert_quiet_success -grep -q 'lpadmin -x Office' "$log" && - fail "a queue with jobs is not deleted" "$(cat "$log")" -grep -qxF $'sudo\tlpadmin -x Spare' "$log" || - fail "an idle generated queue is deleted" "$(cat "$log")" -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "a busy queue does not retain the discovery package" "$(cat "$log")" - -reject_line=$(grep -n 'cupsreject.*Spare' "$log" | head -1 | cut -d: -f1) -probe_line=$(grep -n $'^lpstat\t-o Spare' "$log" | head -1 | cut -d: -f1) -delete_line=$(grep -n 'lpadmin -x Spare' "$log" | head -1 | cut -d: -f1) -(( reject_line < probe_line && probe_line < delete_line )) || - fail "a queue is closed before it is checked and deleted" "$(cat "$log")" -pass "busy queues survive while idle discovery queues are removed" - -# ------------------------------------------------- a job query that fails - -start_case job-query-fails -queues=$'device for Office: implicitclass://Office/' -lpstat_o_fails="1" -run_migration -(( status != 0 )) || fail "a failed job query keeps the migration pending" -assert_description_only -grep -q 'lpadmin -x' "$log" && - fail "a queue with unknown job state is not deleted" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a failed job query retains the package" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a failed job query gets no marker" -pass "a failed job query is retryable instead of falsely completing" - -# ------------------------------------------------------ CUPS out of reach - -start_case no-cups -queues=$'device for Office: implicitclass://Office/' -lpstat_fails="1" -run_migration -(( status != 0 )) || fail "an unavailable CUPS server keeps the migration pending" -assert_description_only -grep -qxF "lpstat: Scheduler is not responding." "$errors" || - fail "the underlying CUPS failure is preserved" "$(cat "$errors")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "an unavailable CUPS server retains the package" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "an unavailable CUPS server gets no marker" -pass "an actual CUPS failure remains pending without custom telemetry" - -# ------------------------------------------------ a queue that will not go - -start_case stuck-queue -queues=$'device for Office: implicitclass://Office/' -lpadmin_fails="1" -run_migration -(( status != 0 )) || fail "a failed queue deletion keeps the migration pending" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a persistent generated queue retains the backend" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a failed queue deletion gets no marker" -pass "a persistent generated queue prevents partial completion" - -# --------------------------------------------- a queue removed concurrently - -start_case vanished-queue -queues=$'device for Office: implicitclass://Office/' -lpadmin_fails="1" -gone_queues="Office" -run_migration -assert_quiet_success -grep -qxF $'omarchy-pkg-drop\tcups-browsed' "$log" || - fail "a concurrently removed queue does not block package removal" "$(cat "$log")" -pass "a queue removed concurrently counts as removed" - -# ------------------------------------------- a queue that cannot be closed - -start_case open-queue -queues=$'device for Office: implicitclass://Office/' -reject_fails="1" -run_migration -(( status != 0 )) || fail "a queue that cannot be closed keeps the migration pending" -grep -q 'lpadmin -x' "$log" && - fail "a queue still accepting jobs is not deleted" "$(cat "$log")" -grep -q 'omarchy-pkg-drop' "$log" && - fail "a queue still accepting jobs retains the backend" "$(cat "$log")" -[[ ! -e $use_marker ]] || fail "a queue still accepting jobs gets no marker" -pass "a queue that cannot be closed is retried later" - -# -------------------------------------------------- a masked but running daemon - -start_case masked -enabled="" -active="cups-browsed.service" -run_migration -assert_quiet_success -grep -qxF $'sudo\tsystemctl stop cups-browsed.service' "$log" || - fail "a masked running daemon is stopped" "$(cat "$log")" -grep -q 'disable --now' "$log" && - fail "a masked unit is not disabled" "$(cat "$log")" -pass "a masked running daemon is stopped without noisy output" - -# ------------------------------------------------ a package removal that fails - -start_case drop-fails -drop_fails="1" -run_migration -(( status != 0 )) || fail "a failed package removal keeps the migration pending" -assert_description_only -[[ ! -e $use_marker ]] || fail "a failed package removal gets no marker" -pass "a package removal failure cannot be recorded as complete"