101 Commits
Author SHA1 Message Date
Ryan HughesandGitHub b686ed892d Merge pull request #9267 from omacom/fix/close-password-only-sshd
Disable sshd entirely when no usable key is authorized
2026-08-30 18:46:35 -04:00
Ryan HughesandClaude Fable 5 5c03dc8c09 Disable sshd entirely when no usable key is authorized
The old setup command enabled sshd before importing a key, so an aborted
run left a password-only server exposed. Skipping that machine kept the
hole Omarchy opened; close it instead by disabling sshd. Omarchy is a
desktop distro, so the console remains, and the warning explains how to
set up key-based access or deliberately re-enable password logins.

With the stakes flipped from skip to disable, "no usable key" must not
false-positive: follow an authorized_keys symlink to its key (dotfiles
setups have working key auth), and treat an unreadable file as
unverifiable rather than keyless.

Amends the unreleased 1788124236 migration in place; no released install
has run it, so every machine still gets the new behavior in one pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:45:05 -04:00
Ryan HughesandGitHub 3c2a24b248 Merge pull request #9255 from omacom/security/migrate-existing-sshd-hardening-v4-0-2
Harden existing key-based SSH setups
2026-08-30 18:38:00 -04:00
Ryan HughesandClaude Fable 5 986962bb64 Keep the sshd hardening migration from locking users out
Validate authorized_keys line by line with the question sshd actually
asks: ssh-keygen -lf on the whole file also fingerprints a private key
copied there by mistake, which sshd cannot use, so the migration would
have disabled the only working login path.

Tighten ~/.ssh and authorized_keys the way omarchy-setup-security-sshd
does, and back off from a group-writable home directory: StrictModes
makes sshd ignore the key either way, with the same lockout.

Complete with a notice instead of failing on conditions the migration
cannot repair (a broken or pre-Include sshd_config, an overriding admin
rule, a failed reload of a valid config), so those machines keep passwords
as they were without blocking every migration queued behind this one.
Only missing privileges stay pending, since a terminal rerun fixes that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:36:26 -04:00
Ryan HughesandGitHub a93ee6a433 Merge pull request #9263 from omacom/fix/shell-test-host-assumptions
Fix test failures caused by host assumptions
2026-08-30 18:27:24 -04:00
acrogenesisandRyan Hughes ca4f596a14 Harden existing key-based SSH setups 2026-08-30 18:07:59 -04:00
Ryan Hughes 279f4d6b95 Give the sshd acceptance exercise a terminal for sudo
Without a terminal sudo keys its cached credential on the parent
process of each call, so the timestamp validated by sudo -S -v in the
test shell never reached omarchy-setup-security-sshd's own sudo calls
when omarchy-iso-test drove the suite over ssh with no pty, and the
exercise died with 'a terminal is required'. Run it under script(1)
and validate the password on that pseudo-terminal first, so every sudo
underneath shares the terminal-keyed credential.
2026-08-30 18:06:33 -04:00
Ryan Hughes 243fe1c9d9 Shadow the git URL checker instead of thinning PATH
The missing-checker case dropped $ROOT/bin from PATH to make
omarchy-git-url-check unfindable, but installed machines carry the
packaged checker in /usr/bin, so it was always found and the test
failed on every 4.x machine. Shadow it with a stub that reports
command-not-found so the scenario holds regardless of the host.
2026-08-30 17:48:34 -04:00
Ryan Hughes bae189861f Copy the Windows VM helper before the test hides /home
The mount-boundary test tmpfs-mounts over /home before sourcing
$ROOT/bin/omarchy-windows-vm, so a checkout living under /home vanished
mid-test and set -e aborted with no output. Take a mount-safe copy of
the helper into the test tmpdir before the mounts land.
2026-08-30 17:48:34 -04:00
Ryan HughesandGitHub 981274b20a Merge pull request #9249 from omacom/fix/acceptance-installed-tree-default
Default the acceptance suite's OMARCHY_PATH to the installed tree alone
2026-08-30 16:58:09 -04:00
Ryan Hughes fe1325202b Default the acceptance suite's OMARCHY_PATH to the installed tree alone
The suite verifies the finished product: VM runs never use a dev-linked
tree, so the session-environment lookup and own-checkout fallback were
needless indirection. /usr/share/omarchy is the default; a caller testing
a different tree passes OMARCHY_PATH itself.
2026-08-30 16:58:04 -04:00
Ryan HughesandGitHub 432b5e3e24 Merge pull request #9240 from omacom/fix/acceptance-security-coverage
Fix the bar visibility toggle and make the acceptance suite cover 4.0.2 security hardening
2026-08-30 16:40:42 -04:00
Ryan Hughes 99ec17acfa Cover the 4.0.2 security hardening in the acceptance suite
Assert the closed session-to-root paths on an installed system — no blanket
input-group membership, no shipped asdcontrol sudoers grant — and exercise
omarchy-setup-security-sshd unattended end to end: sshd up, key authorized,
password and keyboard-interactive authentication off in the effective
config, SSH port rate limited in the firewall.

The sshd section mutates the machine, so it requires the explicit
OMARCHY_ACCEPTANCE_SUDO_PASSWORD opt-in that omarchy-iso-test passes for
its throwaway VMs; elsewhere it skips.
2026-08-30 16:30:51 -04:00
Ryan Hughes 9ca8f90e91 Capture OCR screenshots at 2x scale
Tesseract routinely drops small caption text at native resolution — the
weather panel's detail labels fail the WIND assertion with the text plainly
on screen. Let the compositor upscale the capture instead.
2026-08-30 16:30:51 -04:00
Ryan Hughes d3a5e69162 Fail the package audit when the manifest is missing
Reading a nonexistent manifest produced an empty package list, so the audit
reported every package installed after checking none of them.
2026-08-30 16:30:51 -04:00
Ryan Hughes d6130394fa Default the suite's OMARCHY_PATH to the running session's tree
Run over SSH with no OMARCHY_PATH, the acceptance runner defaulted it to
its own root — wrong in both sync modes omarchy-iso-test uses. With only
test/ synced, the root has no shell or install manifests: omarchy-shell
refuses every call and the package audit passes vacuously against an empty
manifest. With a full tree synced, the path disagrees with the config path
the session shell was started from, and since qs matches instances by that
path, every omarchy-shell call reads as "not running".

The suite acts on the running session, so ask the user manager for the
session's own OMARCHY_PATH first, then fall back to this checkout, then to
the installed tree.
2026-08-30 16:30:45 -04:00
Ryan HughesandGitHub 7aceb388e7 Merge pull request #9226 from omacom/security/add-security-policy
Add security policy
2026-08-30 16:15:41 -04:00
Ryan Hughes e1fc502286 Nudge the bar over IPC when toggling visibility
The shell notices the bar-off flag through a FileView watch on the toggles
directory, and that watch can permanently stop delivering events after flag
changes land in quick succession — the bar then stays parked off screen
until the shell restarts. Have omarchy-toggle-bar nudge the bar's probe
over IPC after flipping the flag, so the toggle no longer depends on the
watch staying alive. The watch remains for other writers of the flag.
2026-08-30 16:10:57 -04:00
Ryan HughesandGitHub e68994680a Merge pull request #9232 from omacom/fix/menu-acceptance-style-navigation
Fix Style submenu navigation in the menu acceptance test
2026-08-30 15:32:49 -04:00
Ryan Hughes 55a3906f4c Step past the restored Unlock entry to reach Menu Bar in the menu test
The Style submenu grew its Unlock entry back (d411c90a) the same day the
menu acceptance test was written, so the blind Down-key walk landed on
Font and picked a font instead of opening the Menu Bar submenu — the bar
position assertion then timed out on every run.
2026-08-30 15:31:13 -04:00
acrogenesis f8d7fae7a8 Match website security guidance 2026-08-30 13:26:18 -06:00
acrogenesis 3def390764 Add security policy 2026-08-30 13:19:11 -06:00
Ryan HughesandGitHub a24064c720 Merge pull request #9225 from omacom/fix/sshd-hardening-verification-case
Match sshd -T keywords case-insensitively when verifying SSH hardening
2026-08-30 15:13:52 -04:00
Ryan Hughes 71d7ac81ae Match sshd -T keywords case-insensitively when verifying hardening
OpenSSH 10.x prints configuration keywords in CamelCase in its sshd -T
dump, where 9.x printed them lowercase. The case-sensitive grep in
omarchy-setup-security-sshd therefore never matched on OpenSSH 10.x, so
the hardening drop-in was always judged ineffective and removed, leaving
password authentication enabled.
2026-08-30 15:03:06 -04:00
Ryan HughesandGitHub 4271b880c3 Merge pull request #9214 from omacom/rc-channel-pacman
Point the rc channel at the rc package repository
2026-08-30 13:54:07 -04:00
Ryan Hughes 884ca49340 Point the rc channel at the rc package repository
pacman-rc.conf shipped with [omarchy] on pkgs.omarchy.org/edge — a
leftover from when release candidates published there. Candidates now
publish to a dedicated rc channel, so a machine switched to rc with
omarchy-refresh-pacman was pairing the rc Arch mirror with edge omarchy
packages, and omarchy-version-channel could not name the rc repository
at all (an rc install reported 'rc / unknown').

Point the conf at pkgs.omarchy.org/rc, teach omarchy-version-channel
the rc repository, and repoint existing rc-channel machines with a
migration. The migration only rewrites the shipped pairing (rc mirror +
edge [omarchy]); an administrator's deliberate combination is kept.
2026-08-30 13:52:06 -04:00
Ryan HughesandGitHub 21b27c5aed Merge pull request #9200 from omacom/security/v4-0-2-input-asdcontrol-sshd
[4.0.2] Close unprivileged input and SSH escalation paths
2026-08-30 13:03:09 -04:00
Ryan HughesandDavid Heinemeier Hansson df819a6f98 Close three paths from an unprivileged session to root
Apply the Omabot patch on Quattro, verify effective SSH hardening, prevent stored provisioning state from restoring the blanket input-group grant, and stop Omarchy from shipping asdcontrol authorization that belongs to the package.

Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-08-30 12:54:08 -04:00
Ryan HughesandGitHub 943d2fcbe9 Merge pull request #9002 from acrogenesis/remove-legacy-installer-privileged-files
Repair legacy paths and privileged files left by retired installers
2026-08-30 12:07:18 -04:00
Ryan Hughes 58c399de30 Revert "Drop unrelated privileged heredoc scanner"
This reverts commit 4c23077f80.
2026-08-30 11:54:31 -04:00
Ryan Hughes 15f26cbe1b Remove redundant migration command preflight 2026-08-30 11:49:54 -04:00
Ryan Hughes 4c23077f80 Drop unrelated privileged heredoc scanner 2026-08-30 11:49:50 -04:00
Ryan Hughes 40d0c9bbdf Require Quattro migrations to complete 2026-08-30 11:36:22 -04:00
Ryan Hughes 8add7b49de Repair legacy XCompose and vulnerable power paths 2026-08-30 11:36:22 -04:00
625c4a1603 Quote install-app and install-font names like install-and-launch (#7843)
* Quote install-app and install-font names like install-and-launch

* Quote the package list too, not just the display name

The display name was quoted but omarchy-pkg-add's own arguments were still interpolated into the bash -c string raw, so `omarchy install app Vim 'vim; id'` ran id. The list has to reach the helper as several words, so it cannot be quoted whole: it is split the way the unquoted expansion split it and each word is quoted on its own. Reading with -d '' keeps a newline-separated list intact instead of dropping every package after the first, which plain read -a would. install-font's package is singular and is quoted whole, and install-and-launch carried the same flaw.

Reported by acrogenesis in review of #7843.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Test that install-font skips font-set when pkg-add fails

The hostile-package case was asserting the family still got set, which only held because the mock always exits 0. pacman would reject that name and the && chain would skip font-set.

* Keep the installers working when errexit is inherited

read -d '' always ends at EOF rather than on its delimiter, so it reports failure on every input. Under an inherited errexit the installers exited there and built no command at all.

Reported by Codex XHigh in review of #7843.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-30 14:22:58 +02:00
e3b566bae8 Remove the last first-run sudoers grant the installer wrote
install/post-install/first-run-mode.sh shipped on quattro between 53e26115 and 75cb4f71, and its final body writes `Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run`. The predicate's case listed only the two `/bin/rm` spellings, so that line fell through to the user-spec test, failed it, and the whole file read as hand-written. The migration then left it alone and wrote its machine marker, which is permanent: on an offline install from that window the account keeps passwordless `/usr/bin/systemctl` for good, and nothing looks at the file again.

Adding the string is the whole fix. The test now carries all nine bodies the installer wrote across both locations rather than the eight from install/preflight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <codex@openai.com>
2026-08-30 09:06:22 +02:00
David Heinemeier HanssonandClaude Opus 5 96404be37b Catch a privileged heredoc redirected with >|
`>|` is a plain redirect with noclobber overridden, not a redirect followed by a pipe. command_destinations detached `>` from its target before looking at the bar, so the target read as `|` and the privileged path behind it was never examined: `cat <<EOF >| /etc/udev/rules.d/99-x.rules` with `$HOME` in the body produced no finding at all, while the same write through `>` produced one.

Normalizing `>|` to `>` alongside the existing `>>` handling closes it. The fixture fails without the normalization.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 08:58:12 +02:00
David Heinemeier HanssonandClaude Opus 5 844f320bbe Stop the legacy udev migration tripping the 4.0 upgrade guard
test/shell.d/config-test.sh greps every file under migrations/ for `upgrade-to-quattro` and fails the suite when one matches, because pre-4 layout work belongs in the upgrade command rather than in a migration. The comment explaining why this particular cleanup is the exception named that command literally, so it matched the guard and config-test.sh failed on this branch while passing on quattro.

The comment now names the Omarchy 4 upgrade command without spelling the file, which leaves the guard able to catch a migration that actually reaches for it. agents/skills/migrations.md still names `bin/omarchy-upgrade-to-quattro` in full, and it is not under migrations/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 08:58:07 +02:00
acrogenesis 8e41961c7d Close privileged cleanup review gaps 2026-08-29 23:55:42 -06:00
David Heinemeier HanssonandGitHub 002c70a544 Merge pull request #9090 from omacom/add-codeowners
Add CODEOWNERS requiring org owner review
2026-08-30 07:54:37 +02:00
David Heinemeier Hansson e711066522 Add CODEOWNERS requiring org owner review 2026-08-30 07:46:48 +02:00
acrogenesis f91d2e5453 Restore strict migration ordering 2026-08-29 22:42:33 -06:00
acrogenesis 4d697a063c Harden privileged cleanup review fixes 2026-08-29 21:08:33 -06:00
acrogenesis 4996941513 Address privileged cleanup review findings 2026-08-29 20:03:46 -06:00
acrogenesis 7ed761fb31 Stop the heredoc check writing its own exemption
The failure advice printed a ready-to-paste annotation with the scan's
verdict already filled in, so the shortest way past the check was to copy
back what it had just concluded. That is worst exactly where the scan is
weakest: a path it cannot follow through a variable reads as an ordinary
value, and the annotation it offers for that case is paths=none.

Print the annotation with the path list left blank and say why the author
has to fill it in. The scan's own reading stays in the report above it, so
nothing diagnostic is lost.
2026-08-29 19:23:02 -06:00
acrogenesis 80e7c25b37 Fail the sudoers cleanup when it cannot elevate to look
Running the migration on a real machine with no cached sudo credentials
printed sudo's "a terminal is required to read the password" and still
exited 0. bin/omarchy-migrate writes the completion marker on a zero exit,
so the cleanup would have been recorded as done on every install that runs
migrations without a terminal, and never tried again.

Probe for elevation before the combined existence check and exit non-zero
when it fails, so the marker stays unwritten and the next run retries. The
probe is skipped when the directory is readable as-is, which is the case
when migrations run as root.
2026-08-29 19:23:02 -06:00
acrogenesis 394c1371c9 Model what each parser does with an empty and a dangling directive
Review of the previous commits turned up four places where the predicates
and their tests disagreed with the tools they are modelling, each checked
against udevadm verify, systemd-analyze verify and visudo -cf rather than
against reading of the sources.

An empty ExecStop= resets the list, so a unit an administrator neutralised
that way runs nothing at shutdown and is no longer ours to remove; the
predicate now tracks the last state instead of returning on the first home
path it sees. A file whose last line ends in a backslash still carries a
live directive for systemd, so the pending logical line is emitted at EOF;
udev ignores such a line and sudo rejects the file outright, so this costs
those two nothing. The scanner's taint pass now reads += appends, which its
own comment already promised: the value of an append is no use, but a name
that reaches a user root through one has to be judged on it.

Two regression guards passed against the implementations they were written
for. The udev continuation fixture put the whole RUN+= below the comment, so
it matched whether or not the pending half was carried across; the split now
falls inside the RUN+= value. The sudoers one kept its file on the strength
of a spec above the comment, so it could not fail either; the hand-written
spec now sits below. Both fail against a mutant that discards the pending
line. The comment above the second also claimed a continued comment stays a
comment, which visudo contradicts.
2026-08-29 19:23:02 -06:00
acrogenesis d593847728 Fail the suite on privileged writes through unquoted heredocs
An installer that writes a root-owned file through a heredoc with an
unquoted delimiter (<<EOF rather than <<'EOF') has the installing user's
shell expand the body first, so a user-controlled value is baked in as a
literal. Send that into /etc and root later reads or executes a path the
unprivileged user picked: a udev rule carrying
$HOME/.local/share/omarchy/bin/... resolves through a symlink that user
owns, so replacing the symlink gets their code run as root.

Add the static check. A heredoc is flagged when its delimiter is
unquoted, its body contains an install-time expansion (escaped \$VAR does
not count, since that is left for a root daemon to expand at runtime),
and its output reaches /etc, /usr, /opt, /srv, /boot or /var/lib via sudo
tee, sudo dd, a redirect, or an install/cp/mv of the generated scratch
file. Destinations written as variables are resolved from the file's own
assignments.

Sites that genuinely need install-time expansion declare it inline:

  # omarchy:heredoc-expands paths=none -- $servers is a validated IP list

paths= is machine-checked against the expansions the scanner finds to be
path-shaped, so this cannot become a rubber stamp: adding a $HOME/... to
an already-annotated heredoc makes the declaration false and trips the
check again. Path expansions anchored under a root-owned prefix, as in
"/etc/systemd/system/$unit", are correctly not path-shaped.

Annotate the sites the scan reports, each of which expands a scalar: DNS
addresses in omarchy-dns, a literal PAM line in
omarchy-setup-security-fingerprint, kernel cmdline parameters and
usernames in omarchy-upgrade-to-quattro. omarchy-provision-owner expanded
a unit name that was already a constant, so its delimiter is now quoted
and the name hardcoded; the generated unit file is byte-identical.
omarchy-windows-vm declares paths=storage,shared, the only site that
interpolates a user-chosen path.

Fixtures prove non-vacuity in both directions: the write routes other
than a pipe into sudo tee, the shapes that must stay quiet, udev rules
and a shutdown unit taken verbatim from this repository's history, and
the rubber-stamp case where a paths=none annotation on a baked $HOME path
still fails.
2026-08-29 19:23:02 -06:00
acrogenesis 96ed473ce1 Remove privileged files left behind by retired Omarchy installers
Three installers that no longer exist each left a root-owned file on
disk, and nothing in Omarchy has ever removed any of them.

/etc/sudoers.d/first-run granted the installing account passwordless
sudo for the rest of the first boot, unrestricted /usr/bin/systemctl
included from 2025-10-14 on. omarchy-first-run clears its first-run.mode
guard before eight set -e steps and only deletes the grant after them,
so any failure in between strands it with nothing left to retry.

/etc/sudoers.d/tsui named whatever $(which tsui) resolved to for the
installing user, normally a binary under their own home that the vendor
script had just written without sudo.

/etc/systemd/system/omarchy-plymouth-shutdown.service ran an ExecStop
under the installing user's home as uid 0 on every shutdown.

Each file is judged against what the installer that wrote it actually
produced. The first-run grant was rewritten eight times and only the
last four carry both Cmnd_Alias lines, so rather than key on those, every
active line must be one the installer emitted and one of them must be
its own self-cleanup. The shutdown unit is disabled but never stopped:
stopping it is what would run the ExecStop being taken away.

Generalize the migrations.md exception, which framed itself around pre-4
layout transitions and so did not cover installers retired on their own.
2026-08-29 19:22:44 -06:00
acrogenesis cd519283fe Remove Omarchy 3 power udev rules that run a command out of a user home
Omarchy 3 wrote 99-power-profile.rules and 99-wifi-powersave.rules with
an unquoted heredoc, baking the installing user's home into a rule udev
runs as root. That path resolves through ~/.local/share/omarchy, a
symlink the unprivileged user owns, so replacing it and provoking a
power_supply event runs their code as root. HEAD points the rules at
/usr/bin under new names, but the one-shot cleanup for the old
filenames was dropped, leaving the file on every install that came up
through the 3.x line.

Remove a legacy file only when an active RUN+= really does run that
filename's binary out of a home directory, so a rule of the same name a
user wrote themselves stays, comments and all.
2026-08-29 19:22:44 -06:00
acrogenesis a75551de4e Allow a migration to clear a pre-4 layout that is a security defect
The upgrade command only runs on a machine still crossing 3 to 4, so a
vulnerable file an old installer wrote never gets swept on an install
that crossed already. It ends by running omarchy-migrate, so a single
migration reaches both populations.
2026-08-29 19:22:44 -06:00
Ryan HughesandGitHub 158e8cfb3a Merge pull request #8419 from AFOliveira/security/windows-vm-mount-boundary
[codex] Secure Windows VM host mounts
2026-08-29 19:31:39 -04:00
Ryan HughesandGitHub e229927671 Merge pull request #8934 from ErikMelton/security/plymouth-publication-race
Secure Plymouth and SDDM asset publication
2026-08-29 18:45:37 -04:00
Ryan Hughes 70e79c40ae Harden Plymouth and SDDM reset publication 2026-08-29 18:19:51 -04:00
Ryan HughesandGitHub c720f0b981 Merge pull request #8951 from omacom/cups-browsed-temporarily-removed
Temporarily remove automatic printer discovery
2026-08-29 15:38:23 -04:00
David Heinemeier HanssonandGitHub 5236f4426c Merge pull request #8203 from hjanuschka/fix-chromium-first-run-eula
Skip Chromium's new first-run EULA
2026-08-29 21:28:42 +02:00
Ryan Hughes 2002fb35ed Test installed CUPS state directly 2026-08-29 15:27:16 -04:00
David Heinemeier HanssonandClaude Opus 5 e47784be11 Name the dev-link authorization when it is the thing that failed
Validating /etc/omarchy.conf walks its parent chain to /, and that walk leaves
its own subject behind in the global failure_context. The comparison that
follows -- the one deciding whether the authorization actually names this
checkout -- sets no context of its own, so an unauthorized development tree
refused with:

  refusing to publish: directory / (must be root-owned and not group- or
  world-writable) failed validation

naming a directory that had just passed, and pointing the reader at a
filesystem problem that is not there. Seen on a worker VM running an
unauthorized checkout, where / is 0555 root-owned.

The hint line underneath was already right, which is what kept this from being
worse. Restore the context before the comparison so the first line agrees with
it, and assert both halves: that the refusal names the authorization, and that
it does not blame the root directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki
2026-08-29 21:03:02 +02:00
Ryan Hughes 96d5682460 Fix cups-browsed removal migration 2026-08-29 14:58:33 -04:00
David Heinemeier Hansson bf20c94ea0 Merge quattro into the Chromium first-run EULA branch
Quattro stopped making the Chromium managed-policy directory world-writable while this branch was open, and the block it deleted from the theme install leaf sat directly above the comment this branch rewrites, so the two edits landed in one hunk. The resolution keeps the hardening — the policy directory is set up through install/config/browser-policy.sh now — along with the first-run seed and the comment that names both things the seed does.
2026-08-29 20:55:09 +02:00
Ryan Hughes bb5b178e5f Remove unused cups-browsed install override 2026-08-29 14:24:05 -04:00
David Heinemeier HanssonandClaude Opus 5 1b92b7f5be Correct the dev-link help now that Plymouth follows the link
omarchy-dev-link told people /usr/share/plymouth was not covered by a link
and to reach for omarchy-dev-pkg-test. Since the publisher authorizes a
dev-linked checkout out of the root-owned /etc/omarchy.conf, the Plymouth and
SDDM themes are republished from the checkout like every other
$OMARCHY_PATH-resolved tree, so the help was left describing the old
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 18:03:57 +02:00
Erik Melton aac1b009e4 Cover non-regular Plymouth logo descriptors 2026-08-29 17:47:42 +02:00
David Heinemeier HanssonandCodex XHigh 1dbc7d5bce 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 <noreply@openai.com>
2026-08-29 17:44:31 +02:00
David Heinemeier HanssonandClaude Opus 5 d67a7c00bc Cover the ownership and mode checks the publisher rests on
Mutating each root-side control one at a time showed three that no test could
see: a destination directory root does not own, a single user-owned asset
inside an otherwise root-owned packaged directory, and an asset left group- or
world-writable by its own mode. Deleting any of the three left the suite green,
because the existing cases mark a whole tree untrusted and are caught by the
directory check before the per-file one is reached.

The harness already had the hook for the ownership pair: TEST_UNTRUSTED_SOURCE
makes the stat shim report a chosen prefix as uid 1000, so those two only need
it pointed at a destination directory and at a single file rather than at a
whole tree. A mode has to be real, so that case stages a copy of the packaged
tree the shim reports as root-owned and loosens one asset in it.

The empty logo is refused by the destination size bound rather than the
caller-side one, so that case pins the behaviour without isolating the check;
the two bounds are exactly redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115LngksSpXLD9NSXBEP3ki
2026-08-29 17:39:33 +02:00
David Heinemeier HanssonandCodex XHigh 24c18df5b7 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 <noreply@openai.com>
2026-08-29 17:13:11 +02:00
David Heinemeier HanssonandCodex XHigh e9ba17e52e Keep the explanation for the Chromium color scheme defaults
The comment above the seed was the only thing recording that color_scheme and color_scheme2 are both zero in order to follow system appearance rather than force dark. Generalizing it to "first-run defaults" left two magic numbers with nothing to explain them, so the next person touching an unrelated first-run setting has no way to tell that changing them regresses theme following. Name both things the seed does.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-29 17:13:06 +02:00
David Heinemeier HanssonandCodex XHigh eb76684c60 Retrofit the Chromium first-run EULA opt-out onto existing installs
Chromium 151 flipped MasterPrefs::eula_required from false to true, so a first run with no seed now stops on a blank terms-of-service dialog before the browser opens. The opt-out is written in two places that each run exactly once: the install leaf that runs during ISO finalization, and the one-time 3.x upgrade. A machine already on Quattro runs neither again, so it keeps the old seed and still meets the dialog the first time anyone launches Chromium, including from every user account created after the install.

The migration writes the same seed those two paths write. It compares before writing so the second user on a shared machine no-ops rather than repeating a machine-wide repair, and the literal is duplicated rather than sourced because a migration repairs the state of its own moment and must not drift when the seed later changes.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-29 17:13:06 +02:00
David Heinemeier HanssonandGitHub 56fbaf4689 Merge pull request #8162 from jmwall/fix/windows-vm-opacity
Keep the Windows VM display fully opaque
2026-08-29 17:10:14 +02:00
Erik Melton 363db1f569 Keep Plymouth publishing working in dev mode 2026-08-29 16:19:31 +02:00
Erik MeltonandAfonso Oliveira 11fa6b9809 Race Windows VM mount sources concurrently 2026-08-29 15:15:51 +01:00
Erik MeltonandAfonso Oliveira bf10b75150 Protect the Windows VM web console 2026-08-29 15:15:51 +01:00
079d116511 Cover the Plymouth parent-chain walk and guard its test harness
Deleting the whole ancestor walk from validate_trusted_directory and checking
only the immediate parent left the suite green, so the invariant the design
rests on had no coverage: a writable ancestor lets an attacker swap a validated
directory out from under the leaf. Reject a run whose destination grandparent
is world-writable while the destination itself is pristine.

The harness also rewrites the root script's fixed /usr/share paths by string
substitution. A drifted string silently no-ops that rewrite and would point the
simulation at the real system tree, so assert each substitution landed. Assert
the scratch directory exists too: a failed mktemp left it empty and the suite
carried on building paths from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-29 16:01:01 +02:00
David Heinemeier HanssonandClaude Opus 5 d3b7810a76 Say why the privileged Plymouth transaction refused
Every check inside the root shell is a bare [[ ]] or (( )) assertion that
aborts under set -e, so a refusal exited with status 1 and no output at all.
The floating-terminal wrapper then printed its green "Done!" for any status
but 130, so a failed boot-theme change read as a success.

The refusal a working machine actually hits is omarchy dev link, which points
OMARCHY_PATH at a checkout the desktop user owns. Name that case outright and
point at omarchy dev unlink; report every other rejection through an ERR trap
that names what failed validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 16:00:50 +02:00
Erik Melton 05fb36d3cc Build Plymouth assets in a root-owned stage 2026-08-29 15:05:13 +02:00
Erik Melton a465dfa681 Use the runtime path for Plymouth refresh 2026-08-29 14:45:17 +02:00
20a23b8c16 [Security] Complete Plymouth publication coverage
Keep SDDM color substitution collision-free for White themes, based on the fix proposed in #8469.

Co-authored-by: itz4blitz <itz4blitz@users.noreply.github.com>
2026-08-29 14:45:17 +02:00
Afonso OliveiraandErik Melton 0f15e2330f [Security] Harden Plymouth asset publication 2026-08-29 14:45:17 +02:00
Afonso OliveiraandErik Melton 2b91fdc0d3 [Security] Pin Plymouth asset reads before elevation 2026-08-29 14:44:37 +02:00
Ryan Hughes 0b3f1b7ead Merge pull request #8496 from Chessing234/security/webapp-http-only 2026-08-29 03:32:08 -04:00
Ryan Hughes f20bf0a21b Merge pull request #8473 from bastidotnet/fix/webapp-desktop-value-escaping 2026-08-29 03:27:39 -04:00
Ryan HughesandGitHub 9da8824098 Merge pull request #8416 from mdisec/theme-name-shell-syntax
Refuse a theme name that is shell syntax, and quote the one the unlock picker returns
2026-08-29 03:19:45 -04:00
Ryan HughesandGitHub 169ad00a84 Merge pull request #8627 from mdisec/security/harden-cups-browsed
Harden CUPS printer discovery
2026-08-29 02:19:53 -04:00
Ryan Hughes 74997fd523 Ship CUPS authorization through settings package 2026-08-29 02:18:44 -04:00
Afonso Oliveira 4fc14173b7 [Security] Add Windows VM boundary race regressions 2026-08-28 19:37:04 +01:00
Afonso Oliveira a165185a3f [Security] Complete Windows VM mount hardening 2026-08-27 23:53:44 +01:00
9382410026 Validate the web app URL before anything fetches it
The scheme check ran after the interactive branch, which had already handed the URL to curl: a refused `file://` or `ftp://` URL was dereferenced first, the whole string went out to Google's favicon endpoint in a `domain=` query, and a fetch that happened to succeed left an orphan icon and a rebuilt icon cache behind. Validating immediately after normalization puts the refusal ahead of every dereference in both branches.

A leading space also kept the URL out of the scheme test entirely. `normalize_webapp_url` saw no scheme, prefixed it, and ` file:///etc/passwd` became `https:// file:///etc/passwd`, which passes `^https?://`. The desktop `Exec` field is unquoted, so it splits back into two arguments, and `omarchy-launch-webapp` forwards everything after the first to the browser, where a bare argument is another URL to open. Refusing whitespace closes that without touching desktop-entry escaping.

Scheme comparison is case-insensitive because schemes are: `HTTPS://example.com` installed before this check existed and has no reason to stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 20:10:15 +02:00
b07374f03c Reserve the cups-browsed account name at install
The username prompt already refuses the service accounts a desktop user must not claim, cups and lp among them. A user who took cups-browsed would get a primary group of that name, and the CUPS authorization written here puts that group in SystemGroup, handing that desktop user the passwordless administration the rest of this change removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 19:55:36 +02:00
68fc0cf6e6 Match the CUPS directives being rewritten the way cupsd reads them
cupsd compares directive names with _cups_strcasecmp, so a hand-edited "systemgroup sys root wheel" is live configuration, but matching $1 against the canonical spelling skipped it and appended a second directive at the end of the file. parse_groups accumulates the groups of every SystemGroup directive it reads rather than replacing them, so both lines took effect and wheel kept the passwordless administration this is meant to remove, with the migration reporting success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 19:55:36 +02:00
David Heinemeier HanssonandCodex XHigh 2b923cf5bd Pin the theme name check to C, and read a colon before any slash as scp-style
A bracket range is collated, not ASCII, so `[a-z]` admits `é` under
en_US.UTF-8 and refuses it under C: the same theme URL installed on one
desktop and was refused on the next, and the manual's stated set was only
true in the C locale. Pinning the comparison makes the written set the
shipped one everywhere.

git reads a URL as scp-style when a colon appears before any slash, so the
path after it need not contain one. Requiring a slash left the prefix on
`git@host:omarchy-blue-theme.git` and derived the name
`git@host:omarchy-blue`, which the allowlist then refuses -- a repo that
clones fine could no longer be installed at all.

Co-Authored-By: Codex XHigh <codex@openai.com>
2026-08-27 19:52:37 +02:00
521f1ae9ac Resume cups-browsed on whether it is enabled, not on whether it was running
The migration recomputed whether cups-browsed was active at the start of every run, after an earlier run may already have stopped it. A run interrupted between that stop and the completion marker left the service down, and the retry that followed read it as inactive, skipped the restart, and wrote the marker anyway: printer discovery stayed off until the next reboot with no migration left to bring it back. Keying the restart to whether the unit is enabled survives the interruption, and reports not-enabled for a unit the user masked or disabled, which restarting would fail on and abort the migration short of its marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 19:47:10 +02:00
c2587dff08 Escape .desktop values with parameter expansion, not sed
GNU sed's N auto-prints the pattern space and exits at end of input, so the
`:a;N;$!ba` slurp skipped every following s/// for a value with no newline in
it. That is every value except the injection attempt the escaping exists to
stop, so the Exec quoting's own backslashes were written to the file unescaped
and GLib refused to load the entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex GPT-5.6-sol (xhigh) <noreply@openai.com>
2026-08-27 19:45:45 +02:00
Mehmet InceandDaybreak Blue 5c336885d2 Harden CUPS printer discovery
Run cups-browsed as a locked service account with a dedicated cache and a focused systemd sandbox. Restrict automatic queues to driverless IPP printers, remove wheel from passwordless CUPS administration, replace cups-pdf with Polkit-backed setup, and migrate existing systems safely.

Reported-By: Erik Hunstad (Bad Sector Labs)

Co-Authored-By: Daybreak Blue <noreply@openai.com>
2026-08-27 18:00:12 +01:00
Taksh 877f1e96ef Keep web app launchers on http(s)
Chromium --app= will run javascript:, file:, and data: URLs. Prefix
schemeless input with https as before, then refuse anything else.
2026-08-27 07:17:44 +05:30
Mehmet InceandClaude Opus 5 3b0e899029 Let a theme name hold a plus or lead with an underscore, and document the set
The name a theme installs under is derived from its repo URL, and holding it to
an allowlist made that allowlist a naming convention nobody had written down. It
was also tighter than the harm it exists to stop: `+` is not shell syntax and a
leading `_` is neither the `..` climb nor the dash basename reads as an option,
so `omarchy-c++-theme` was refused for nothing.

Widen the set to those two and say what it is where a theme author is already
picking a name. The leading character stays out of `.` and `-`, which is the
part that does the work.

Reported-by: Luis Alvarez (lalvarezt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd3RCHxwjEbMXoSYB9Aiso
2026-08-26 23:37:44 +01:00
bastidotnet 187c268d68 Escape webapp .desktop values per freedesktop spec
- omarchy-webapp-install wrote all substituted values raw into the
  generated .desktop (Name/Comment/Icon/MimeType/Exec), with no escaping.
- Adds two spec-level escapers: Desktop Entry string escaping on every
  field (a raw newline could inject a second key line / second Exec=),
  and Exec-argument quoting for the default Exec's URL (spaces, %, reserved
  chars). $CUSTOM_EXEC stays file-syntax-only — it is a full command line
  by design, not a single value.
- No known exploit path: untrusted input reaches these values today only
  via Omarchy literals, interactive gum, or direct CLI. This is
  defense-in-depth for a latent sink.
- Verified end-to-end: generated .desktop has one escaped key per field;
  gio launch passes the URL to omarchy-launch-webapp as a single unchanged
  argument (Sunshine literal and normal gum path both byte-identical).
2026-08-26 23:59:38 +02:00
Mehmet InceandClaude Opus 5 75e51f0b95 Refuse a theme name that is shell syntax, and quote the one the unlock picker returns
A theme installed from a git repo is named after the repo URL, and that name
becomes its directory name under ~/.config/omarchy/themes. Style > Unlock built
a command line out of the name the picker returned and handed it to
omarchy-launch-floating-terminal-with-presentation, which runs its argument as a
shell string -- so a theme directory called `a';id;'b` ran `id`. Themes are
already held to contributing colour and nothing that executes, which is why
omarchy-theme-set stages no .lua, terminal config, or vscode.json from one.

Hold the derived name to the characters a theme name needs, which stops it from
being dangerous at every place it lands rather than at the one found, and quote
it with printf %q on the way into the action for the names already on disk.

omarchy-theme-remove keeps its existing path-climb guard: its name reaches only
a quoted rm, and the same charset would strand a theme installed before this.

Reported-by: Luis Alvarez (lalvarezt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WFcUm5HWFyxaVYdwAeWPP
2026-08-26 16:22:17 +01:00
Afonso Oliveira c34d20ca14 [Security] Pin Windows VM mounts behind a root boundary 2026-08-25 18:39:00 +01:00
Helmut Januschka b91180a808 Skip Chromium EULA on first run 2026-08-25 14:48:44 +02:00
David Heinemeier HanssonandClaude Opus 5 12c350e404 Match the Windows VM title regardless of the launcher's shell quoting
The new assertion pinned /title:"Windows VM - Omarchy" with the quote sitting
immediately after the colon. That quote is incidental shell syntax, not the
title the Hyprland rule matches on. Open PR #7902 moves the RDP arguments into
an array, where the same flag reads "/title:Windows VM - Omarchy", so the
assertion would fail for whichever of the two pull requests merged second.

Allow an optional quote after the colon. The assertion still fails if the title
itself drifts, in either direction, which is the coupling it exists to protect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 08:45:50 +02:00
Jason Wall 8bee78bc63 Keep the Windows VM display fully opaque 2026-08-24 22:11:16 -07:00
106 changed files with 8166 additions and 450 deletions
+2
View File
@@ -0,0 +1,2 @@
# Merges to protected branches need sign-off from an org owner.
* @dhh @ryanrhughes
+47
View File
@@ -0,0 +1,47 @@
# Security at Omarchy
## Report a vulnerability
If you believe youve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public.
[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report)
Please dont report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before theyve been resolved.
## What is a vulnerability?
We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didnt already have.
Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes.
Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity.
## What to include
Give us enough information to understand and reproduce the issue:
- The affected component and Omarchy version.
- An explanation of what an attacker can do before and after exploitation.
- Steps to reproduce the issue and any proof of concept.
- Your preferred contact details for follow-up.
## Responsible disclosure
Please act in good faith while investigating and reporting vulnerabilities:
- Only test systems and accounts you own or have explicit permission to test.
- Avoid privacy violations, disruption, data destruction, and service degradation.
- Dont exploit a vulnerability beyond what is needed to demonstrate it.
- Give us a reasonable opportunity to investigate and address the issue before publishing details.
Well review your report and keep you informed as were able while we work toward a resolution.
## Credits
Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that dont cross a security boundary may still be credited in our release notes.
Credits link to each reporters X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit.
## Regular bugs and support
For anything that isnt a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues).
+3
View File
@@ -126,6 +126,7 @@ New migration format:
- Start with an `echo` describing what the migration does. - Start with an `echo` describing what the migration does.
- Use `$OMARCHY_PATH` to reference the Omarchy directory. - Use `$OMARCHY_PATH` to reference the Omarchy directory.
- Be idempotent. Check existing state before changing it. - Be idempotent. Check existing state before changing it.
- Migrations are strictly ordered and synchronous. A migration that cannot finish must exit non-zero, remain pending, and stop the queue; never mark later migrations complete against state an earlier migration has not established.
- Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`,
`omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and
`omarchy-pkg-missing` when appropriate. `omarchy-pkg-missing` when appropriate.
@@ -165,3 +166,5 @@ omarchy-migrate
Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the
normal migration runner. Do not add compatibility migrations for old installer normal migration runner. Do not add compatibility migrations for old installer
layouts; put pre-4 package-layout transition work in the upgrade command instead. layouts; put pre-4 package-layout transition work in the upgrade command instead.
Clearing a privileged file that a retired installer left on disk is the exception, and belongs in a migration whether or not that installer was part of a package layout transition. The upgrade command only runs on a machine still making the 3 to 4 crossing, so anything put there never reaches an install that crossed already, and it never runs at all for an installer that was retired on its own — while the file the installer wrote is still sitting on those machines. The upgrade command finishes by running `omarchy-migrate` (`run_post_upgrade_migrations`), so one migration reaches every population; a copy in the upgrade command would only be a second copy of the same predicate to keep correct. Such a migration must name the defect it clears and match what the old installer actually produced before deleting it. Leave safe administrator-authored files alone; if one still contains the vulnerable privileged action, preserve it under an inactive name rather than discarding custom content or leaving the action executable. A user config that depends on the same retired compatibility path may be repaired in that migration when doing so eliminates an overlapping migration, but only by matching and replacing the exact legacy path while preserving the rest of the file.
+6 -2
View File
@@ -34,8 +34,12 @@ systemd, shell, or app-launcher environment; reboot to make every layer agree.
Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/, Affects only \$OMARCHY_PATH-resolved trees: bin/, default/, shell/,
themes/, applications/, config/. Files installed at fixed system paths themes/, applications/, config/. Files installed at fixed system paths
(/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user (/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user
creation, /usr/share/plymouth) are NOT covered — for those, use creation) are NOT covered — for those, use omarchy-dev-pkg-test to build
omarchy-dev-pkg-test to build and install the package from the checkout. and install the package from the checkout.
The Plymouth and SDDM themes under /usr/share are the exception: omarchy
plymouth set and omarchy refresh plymouth republish them from the checkout,
reading this link's authorization out of the root-owned /etc/omarchy.conf.
Also writes $sudoers_file so sudo resolves omarchy-* Also writes $sudoers_file so sudo resolves omarchy-*
from the checkout instead of the packaged copies. That part takes effect from the checkout instead of the packaged copies. That part takes effect
+6
View File
@@ -149,6 +149,9 @@ write_networkmanager_dns() {
local servers="$1" local servers="$1"
install -d -m 0755 "$(dirname "$NM_DNS_CONF")" install -d -m 0755 "$(dirname "$NM_DNS_CONF")"
# omarchy:heredoc-expands paths=none -- $servers is a normalized, single-line
# DNS server list written as data, not a path or command; nothing user-writable
# is resolved or executed from the root-owned drop-in.
cat >"$NM_DNS_CONF" <<EOF cat >"$NM_DNS_CONF" <<EOF
# Managed by omarchy-dns. Remove this file or run omarchy dns DHCP to use DHCP DNS again. # Managed by omarchy-dns. Remove this file or run omarchy dns DHCP to use DHCP DNS again.
[global-dns] [global-dns]
@@ -302,6 +305,9 @@ Custom)
split_dns_servers "$dns_servers" split_dns_servers "$dns_servers"
write_networkmanager_dns "$dns_servers" write_networkmanager_dns "$dns_servers"
set_connection_dns "$ipv4_dns" "$ipv6_dns" set_connection_dns "$ipv4_dns" "$ipv6_dns"
# omarchy:heredoc-expands paths=none -- $dns_servers is a normalized,
# single-line DNS server list; the //,/ turns its comma separators into the
# spaces resolved.conf wants. It is written as data, not a path or command.
tee /etc/systemd/resolved.conf >/dev/null <<EOF tee /etc/systemd/resolved.conf >/dev/null <<EOF
[Resolve] [Resolve]
DNS=${dns_servers//,/ } DNS=${dns_servers//,/ }
+7 -1
View File
@@ -16,6 +16,12 @@ fi
printf -v install_message '%q' "Installing ${name}..." printf -v install_message '%q' "Installing ${name}..."
printf -v desktop_id_arg '%q' "$desktop_id" printf -v desktop_id_arg '%q' "$desktop_id"
# The list has to reach omarchy-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
# The subshell keeps & from backgrounding the package installation too. # The subshell keeps & from backgrounding the package installation too.
exec omarchy-launch-floating-terminal-with-presentation \ exec omarchy-launch-floating-terminal-with-presentation \
"echo ${install_message}; omarchy-pkg-add ${packages} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)" "echo ${install_message}; omarchy-pkg-add ${packages_arg} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
+10 -1
View File
@@ -12,4 +12,13 @@ if [[ -z $name || -z $packages ]]; then
exit 1 exit 1
fi fi
exec omarchy-launch-floating-terminal-with-presentation "echo 'Installing ${name}...'; omarchy-pkg-add ${packages}" printf -v install_message '%q' "Installing ${name}..."
# The list has to reach omarchy-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
exec omarchy-launch-floating-terminal-with-presentation \
"echo ${install_message}; omarchy-pkg-add ${packages_arg}"
+5 -1
View File
@@ -13,5 +13,9 @@ if [[ -z $name || -z $package || -z $family ]]; then
exit 1 exit 1
fi fi
printf -v install_message '%q' "Installing ${name}..."
printf -v package_arg '%q' "$package"
printf -v family_arg '%q' "$family"
exec omarchy-launch-floating-terminal-with-presentation \ exec omarchy-launch-floating-terminal-with-presentation \
"echo 'Installing ${name}...'; omarchy-pkg-add ${package} && sleep 2 && omarchy-font-set '${family}'" "echo ${install_message}; omarchy-pkg-add ${package_arg} && sleep 2 && omarchy-font-set ${family_arg}"
+3 -3
View File
@@ -85,16 +85,16 @@ wait_for_pacman_transaction
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
[[ -d $MIGRATIONS_DIR ]] || exit 0 [[ -d $MIGRATIONS_DIR ]] || exit 0
while IFS=$'\t' read -r name file marker; do while IFS=$'\t' read -r name file marker <&3; do
[[ -n $name ]] || continue [[ -n $name ]] || continue
if [[ ! -f $marker ]]; then if [[ ! -f $marker ]]; then
echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m" echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m"
OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" 3<&-
mkdir -p "$(dirname "$marker")" mkdir -p "$(dirname "$marker")"
touch "$marker" touch "$marker"
fi fi
done < <(migration_entries) done 3< <(migration_entries)
# Clear a login-time notification the user left sitting there and then resolved # Clear a login-time notification the user left sitting there and then resolved
# by running migrations some other way. The substring matches both the current # by running migrations some other way. The substring matches both the current
+4 -2
View File
@@ -3,5 +3,7 @@
# omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen # omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
omarchy-refresh-plymouth set -euo pipefail
omarchy-refresh-sddm
"$OMARCHY_PATH/bin/omarchy-refresh-plymouth"
"$OMARCHY_PATH/bin/omarchy-refresh-sddm"
+365 -76
View File
@@ -5,88 +5,377 @@
# omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png # omarchy:examples=omarchy plymouth set '#1d2021' '#ebdbb2' ~/.local/state/omarchy/current/theme/plymouth/logo.png
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
# Configure the Plymouth boot theme with a custom background color, text color, and logo. set -euo pipefail
# Stages the change in a temp dir, then commits the staged files to /usr/share and
# rebuilds the initramfs. Also syncs the SDDM login screen (the post-logout
# screen) with the same colors and logo so boot/login stay visually unified.
if (( $# != 3 )); then # Build the authoritative theme in a root-owned directory, then publish each
# fixed destination atomically. The caller opens the selected logo before sudo,
# so the privileged process never resolves a user-controlled input path.
usage() {
echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2 echo "Usage: omarchy-plymouth-set <background-hex> <text-hex> <path-to-logo.png>" >&2
exit 1 exit 1
fi }
bg_hex="${1#\#}" if (( $# == 3 )); then
text_hex="${2#\#}" mode=set
logo_path="$3" elif (( $# == 1 )); then
case "$1" in
if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then --refresh-default)
echo "Invalid background color: $1 (expected #RRGGBB)" >&2 mode=refresh-plymouth
exit 1 ;;
fi --refresh-sddm-default)
mode=refresh-sddm
if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then ;;
echo "Invalid text color: $2 (expected #RRGGBB)" >&2 *)
exit 1 usage
fi ;;
esac
if [[ ! -f $logo_path ]]; then
echo "Logo file not found: $logo_path" >&2
exit 1
fi
# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from
# ~/.config/omarchy/themes, where an installed theme can make it a symlink to
# anything. The copies below land in world-readable /usr/share, so following one
# would republish whatever it points at.
if [[ -L $logo_path ]]; then
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
exit 1
fi
bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}')
bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}')
bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}')
theme_dir="/usr/share/plymouth/themes/omarchy"
staging_dir=$(mktemp -d)
trap 'rm -rf "$staging_dir"' EXIT
find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} +
cp "$logo_path" "$staging_dir/logo.png"
sed -i \
-e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \
-e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \
"$staging_dir/omarchy.script"
for asset in bullet.png entry.png lock.png progress_bar.png; do
magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset"
done
sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else else
sudo mkinitcpio -P usage
fi fi
# Sync the SDDM login screen with the same colors and logo. if (( EUID == 0 )); then
sddm_dir="/usr/share/sddm/themes/omarchy" echo "Error: run omarchy-plymouth-set as your user, not under sudo." >&2
sddm_template="$OMARCHY_PATH/default/sddm/omarchy/Main.qml" exit 1
fi
sed \ logo_fd=
-e "s/#1a1b26/#$bg_hex/g" \ if [[ $mode != "set" ]]; then
-e "s/#ffffff/#$text_hex/g" \ bg_hex=
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null text_hex=
else
bg_hex="${1#\#}"
text_hex="${2#\#}"
logo_path="$3"
sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" if ! [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
for asset in bullet.png entry.png lock.png; do echo "Invalid background color: $1 (expected #RRGGBB)" >&2
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" exit 1
done fi
for asset in entry lock; do
magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" if ! [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]; then
sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" echo "Invalid text color: $2 (expected #RRGGBB)" >&2
done exit 1
sudo rm -f "$sddm_dir/logo.svg" fi
if [[ ! -f $logo_path ]]; then
echo "Logo file not found: $logo_path" >&2
exit 1
fi
if [[ -L $logo_path ]]; then
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
exit 1
fi
# Open the logo while still unprivileged. A replacement symlink to a root-only
# file therefore fails here instead of being followed after sudo starts.
if ! exec {logo_fd}<"$logo_path"; then
echo "Unable to open logo file as the current user: $logo_path" >&2
exit 1
fi
if [[ ! -f /proc/$$/fd/$logo_fd ]]; then
echo "Logo input is no longer a regular file: $logo_path" >&2
exit 1
fi
fi
run_root_transaction() {
sudo /bin/bash -c '
set -eEuo pipefail
PATH=/usr/bin:/bin
export PATH
umask 077
# Every check below is a bare assertion that aborts under set -e. Name the
# subject of each one so a refusal reaches the user instead of exiting mute.
failure_context="the privileged Plymouth transaction"
failure_reported=
report_failure() {
[[ -z $failure_reported ]] || return 0
failure_reported=1
printf "omarchy-plymouth-set: refusing to publish: %s failed validation\n" "$failure_context" >&2
if [[ -n ${failure_hint:-} ]]; then
printf "omarchy-plymouth-set: %s\n" "$failure_hint" >&2
fi
}
trap report_failure ERR
mode=$1
source_root=$2
bg_hex=$3
text_hex=$4
max_asset_size=$5
failure_context="the arguments of the privileged transaction"
[[ $mode == "set" || $mode == "refresh-plymouth" || $mode == "refresh-sddm" ]]
[[ $source_root == /* ]]
[[ $max_asset_size =~ ^[0-9]+$ ]]
(( max_asset_size > 0 ))
failure_context="the Omarchy source tree $source_root"
canonical_source_root=$(realpath -e -- "$source_root")
[[ $canonical_source_root == "$source_root" ]]
validate_trusted_directory() {
local directory=$1 canonical uid directory_mode
failure_context="directory $directory"
canonical=$(realpath -e -- "$directory")
[[ $canonical == "$directory" && -d $directory && ! -L $directory ]]
while :; do
failure_context="directory $directory (must be root-owned and not group- or world-writable)"
uid=$(stat -c %u -- "$directory")
directory_mode=$(stat -c %a -- "$directory")
(( uid == 0 ))
(( (8#$directory_mode & 0022) == 0 ))
[[ $directory == "/" ]] && break
directory=${directory%/*}
[[ -n $directory ]] || directory=/
done
}
validate_trusted_configuration_file() {
local configuration=$1 canonical uid configuration_mode size
failure_context="root configuration $configuration"
[[ -f $configuration && ! -L $configuration ]]
canonical=$(realpath -e -- "$configuration")
[[ $canonical == "$configuration" ]]
validate_trusted_directory "${configuration%/*}"
uid=$(stat -c %u -- "$configuration")
configuration_mode=$(stat -c %a -- "$configuration")
size=$(stat -c %s -- "$configuration")
(( uid == 0 ))
(( (8#$configuration_mode & 0022) == 0 ))
(( size > 0 && size <= 4096 ))
}
# A packaged tree must be root-owned. A development checkout is the one
# deliberate exception: omarchy dev link records its canonical path in a
# root-owned /etc/omarchy.conf. That is already an explicit decision to run
# privileged Omarchy commands from user-editable code in the checkout, so
# reading its packaged assets does not widen the development trust boundary.
development_source=false
source_root_uid=$(stat -c %u -- "$source_root")
if (( source_root_uid != 0 )); then
omarchy_conf=/etc/omarchy.conf
failure_context="$source_root is user-owned and $omarchy_conf must contain its trusted dev-link authorization; run omarchy dev link to authorize it"
failure_hint="$source_root is user-owned; run omarchy dev link to authorize this development checkout, or omarchy dev unlink to use the packaged tree"
validate_trusted_configuration_file "$omarchy_conf"
# validate_trusted_configuration_file walks /etc up to / and leaves its own
# subject behind in failure_context. Without restoring ours, a checkout
# that simply is not the authorized one refuses with "directory / must be
# root-owned and not group- or world-writable" -- naming a directory that
# passed, and sending the reader after a filesystem problem that is not
# there.
failure_context="the dev-link authorization in $omarchy_conf, which must name $source_root"
quoted_source_root=$source_root
quoted_source_root=${quoted_source_root//\\/\\\\}
quoted_source_root=${quoted_source_root//\"/\\\"}
quoted_source_root=${quoted_source_root//\$/\\\$}
quoted_source_root=${quoted_source_root//\`/\\\`}
expected_config_line="export OMARCHY_PATH=\"$quoted_source_root\""
mapfile -t omarchy_config_lines <"$omarchy_conf"
(( ${#omarchy_config_lines[@]} == 1 ))
[[ ${omarchy_config_lines[0]} == "$expected_config_line" ]]
development_source=true
failure_hint=
fi
if [[ $mode == "set" ]]; then
[[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]]
[[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]]
fi
theme_dir=/usr/share/plymouth/themes/omarchy
sddm_dir=/usr/share/sddm/themes/omarchy
plymouth_theme_assets=(
bullet.png
entry.png
lock.png
logo.png
omarchy.plymouth
omarchy.script
preview-unlock.png
progress_bar.png
progress_box.png
)
plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png)
sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png)
sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf)
plymouth_assets=()
sddm_assets=()
case "$mode" in
set)
plymouth_assets=("${plymouth_theme_assets[@]}")
sddm_assets=("${sddm_theme_assets[@]}")
;;
refresh-plymouth)
plymouth_assets=("${plymouth_default_assets[@]}")
;;
refresh-sddm)
sddm_assets=("${sddm_default_assets[@]}")
;;
esac
validate_trusted_file() {
local source=$1 canonical uid file_mode size
failure_context="packaged source file $source"
[[ -f $source && ! -L $source ]]
canonical=$(realpath -e -- "$source")
[[ $canonical == "$source" ]]
file_mode=$(stat -c %a -- "$source")
size=$(stat -c %s -- "$source")
(( size > 0 && size <= max_asset_size ))
if ! $development_source; then
validate_trusted_directory "${source%/*}"
uid=$(stat -c %u -- "$source")
(( uid == 0 ))
(( (8#$file_mode & 0022) == 0 ))
fi
}
copy_trusted_file() {
local source=$1 destination=$2
validate_trusted_file "$source"
install -o 0 -g 0 -m 0600 -- "$source" "$destination"
}
staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX)
temporary=
cleanup() {
[[ -z $temporary ]] || rm -f -- "$temporary"
rm -rf -- "$staging_dir"
}
trap cleanup EXIT HUP INT TERM
chown 0:0 -- "$staging_dir"
chmod 0700 -- "$staging_dir"
plymouth_stage=$staging_dir/plymouth
sddm_stage=$staging_dir/sddm
mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage"
for asset in "${plymouth_assets[@]}"; do
copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset"
done
if [[ $mode == "set" ]]; then
# stdin was opened by the unprivileged caller. Read no more than the
# documented limit into the root-owned stage before doing other work.
failure_context="the selected logo (expected 1 to $max_asset_size bytes)"
head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png"
logo_size=$(stat -c %s -- "$plymouth_stage/logo.png")
(( logo_size > 0 && logo_size <= max_asset_size ))
chown 0:0 -- "$plymouth_stage/logo.png"
chmod 0600 -- "$plymouth_stage/logo.png"
cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png"
bg_r=$(awk -v n=$((16#${bg_hex:0:2})) "BEGIN{printf \"%.3f\", n/255}")
bg_g=$(awk -v n=$((16#${bg_hex:2:2})) "BEGIN{printf \"%.3f\", n/255}")
bg_b=$(awk -v n=$((16#${bg_hex:4:2})) "BEGIN{printf \"%.3f\", n/255}")
sed -i \
-e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \
-e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \
"$plymouth_stage/omarchy.script"
for asset in bullet.png entry.png lock.png progress_bar.png; do
magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset"
done
copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml"
sed -i \
-e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \
-e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \
-e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \
-e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \
"$sddm_stage/Main.qml"
for asset in bullet.png entry.png lock.png; do
cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset"
done
for asset in entry lock; do
magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png"
done
chown -R 0:0 -- "$staging_dir"
find "$staging_dir" -type f -exec chmod 0600 -- {} +
elif (( ${#sddm_assets[@]} )); then
for asset in "${sddm_assets[@]}"; do
copy_trusted_file "$source_root/default/sddm/omarchy/$asset" "$sddm_stage/$asset"
done
fi
if (( ${#plymouth_assets[@]} )); then
validate_trusted_directory "$theme_dir"
if [[ $mode == "refresh-plymouth" ]]; then
validate_trusted_directory "$theme_dir/logos"
fi
fi
if (( ${#sddm_assets[@]} )); then
validate_trusted_directory "$sddm_dir"
fi
publish_asset() {
local source=$1 destination=$2 parent filename source_size copied_size
failure_context="destination $destination"
[[ -f $source && ! -L $source ]]
(( $(stat -c %u -- "$source") == 0 ))
source_size=$(stat -c %s -- "$source")
(( source_size > 0 && source_size <= max_asset_size ))
[[ $destination == /* && $destination != */ && $destination != *"/../"* ]]
parent=${destination%/*}
filename=${destination##*/}
[[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]]
validate_trusted_directory "$parent"
temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX")
install -o 0 -g 0 -m 0644 -- "$source" "$temporary"
copied_size=$(stat -c %s -- "$temporary")
(( copied_size == source_size ))
cmp -s -- "$source" "$temporary"
sync -f -- "$temporary"
mv --no-copy -fT -- "$temporary" "$destination"
temporary=
}
if (( ${#plymouth_assets[@]} )); then
for asset in "${plymouth_assets[@]}"; do
publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset"
done
fi
if (( ${#sddm_assets[@]} )); then
for asset in "${sddm_assets[@]}"; do
publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset"
done
validate_trusted_directory "$sddm_dir"
rm -f -- "$sddm_dir/logo.svg"
fi
' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))"
}
if [[ $mode == "set" ]]; then
run_root_transaction <&"$logo_fd"
else
run_root_transaction </dev/null
fi
if [[ $mode != "refresh-sddm" ]]; then
sudo plymouth-set-default-theme omarchy
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else
sudo mkinitcpio -P
fi
fi
+10 -6
View File
@@ -677,11 +677,15 @@ user_groups() {
if [[ -f $PROVISIONING_DIR/groups ]]; then if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do while IFS= read -r group; do
[[ -n $group ]] || continue [[ -n $group ]] || continue
# Never grant docker at first boot, even if an older install recorded it # Never replay old privileged group defaults. Docker is always opt-in.
# (or a factory snapshot predating the opt-in default carries it): the # Input is only retained when the factory image has one of the features
# docker group is root-equivalent. It is opt-in via # whose installer deliberately grants access to raw input devices.
# omarchy-setup-security-sudoless-docker.
[[ $group == "docker" ]] && continue [[ $group == "docker" ]] && continue
if [[ $group == "input" ]] &&
! pacman -Qq xpadneo-dkms &>/dev/null &&
! pacman -Qq ydotool &>/dev/null; then
continue
fi
getent group "$group" >/dev/null || continue getent group "$group" >/dev/null || continue
[[ ",$groups," == *",$group,"* ]] || groups+=",$group" [[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups" done <"$PROVISIONING_DIR/groups"
@@ -788,7 +792,7 @@ configure_login() {
# After=) is what makes it deterministic — no sleep/race against SDDM's startup. # After=) is what makes it deterministic — no sleep/race against SDDM's startup.
install_autologin_once_cleanup() { install_autologin_once_cleanup() {
local unit=omarchy-provision-autologin-once.service local unit=omarchy-provision-autologin-once.service
cat >"/etc/systemd/system/$unit" <<UNIT sed "s|@UNIT@|$unit|g" >"/etc/systemd/system/$unit" <<'UNIT'
[Unit] [Unit]
Description=Drop the first-boot autologin before the next login Description=Drop the first-boot autologin before the next login
Before=display-manager.service Before=display-manager.service
@@ -797,7 +801,7 @@ ConditionPathExists=/etc/sddm.conf.d/autologin.conf
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf
ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/$unit /etc/systemd/system/$unit ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/@UNIT@ /etc/systemd/system/@UNIT@
[Install] [Install]
WantedBy=graphical.target WantedBy=graphical.target
+3 -8
View File
@@ -3,11 +3,6 @@
# omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it. # omarchy:summary=Overwrite the user config for the Plymouth drive decryption and boot sequence with the Omarchy default and rebuild it.
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
sudo cp -r "$OMARCHY_PATH/default/plymouth/." /usr/share/plymouth/themes/omarchy/ # Reuse the fixed-file publisher so root never resolves the source checkout or
sudo plymouth-set-default-theme omarchy # follows a destination symlink while restoring the packaged assets.
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-default
if omarchy-cmd-present limine-mkinitcpio; then
sudo limine-mkinitcpio
else
sudo mkinitcpio -P
fi
+3 -2
View File
@@ -3,5 +3,6 @@
# omarchy:summary=Refresh the SDDM theme from default # omarchy:summary=Refresh the SDDM theme from default
# omarchy:requires-sudo=true # omarchy:requires-sudo=true
sudo rm -rf /usr/share/sddm/themes/omarchy # Reuse the fixed-file publisher so root never resolves an untrusted source or
sudo cp -r "$OMARCHY_PATH/default/sddm/omarchy" /usr/share/sddm/themes/omarchy # follows a destination symlink while restoring the packaged SDDM theme.
exec "$OMARCHY_PATH/bin/omarchy-plymouth-set" --refresh-sddm-default
+3
View File
@@ -41,6 +41,9 @@ setup_pam_config() {
fi fi
else else
echo "Creating polkit configuration with fingerprint authentication..." echo "Creating polkit configuration with fingerprint authentication..."
# omarchy:heredoc-expands paths=none -- $fprintd_gate is the literal PAM
# line defined above, shared with the two sed insertions so the gate cannot
# drift between files. The only path in it is the fixed /usr/bin one.
sudo tee /etc/pam.d/polkit-1 >/dev/null <<EOF sudo tee /etc/pam.d/polkit-1 >/dev/null <<EOF
$fprintd_gate $fprintd_gate
auth sufficient pam_fprintd.so auth sufficient pam_fprintd.so
+47
View File
@@ -143,6 +143,50 @@ authorize_pasted_key() {
authorize_key "$key" || exit 1 authorize_key "$key" || exit 1
} }
# Only called after a key is authorized. Disabling password authentication
# before then could lock the owner out of the machine.
disable_password_auth() {
local config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf
local effective_config
if [[ ! -s $AUTHORIZED_KEYS ]]; then
echo -e "\e[31mCannot disable SSH password authentication without an authorized key.\e[0m" >&2
return 1
fi
echo "Disabling SSH password authentication, now that a key is authorized..."
sudo install -Dm644 /dev/stdin "$config" <<'CONF'
# Written by omarchy-setup-security-sshd once an SSH key was authorized.
# Delete this file and reload sshd to allow password logins again.
PasswordAuthentication no
KbdInteractiveAuthentication no
CONF
# Validate before reloading: a config sshd rejects would otherwise take the
# service down on its next restart, potentially stranding a remote owner.
if ! sudo sshd -t; then
echo -e "\e[31msshd rejected the hardening config; removing it and leaving passwords on.\e[0m" >&2
sudo rm -f "$config"
return 1
fi
# Syntax alone is insufficient because sshd uses the first value it reads for
# these settings. An earlier administrator rule could leave passwords enabled.
# Match keywords case-insensitively: OpenSSH 9.x dumps them lowercase, 10.x
# in CamelCase.
if ! effective_config=$(sudo sshd -T) ||
! grep -qixF "passwordauthentication no" <<<"$effective_config" ||
! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then
echo -e "\e[31msshd did not apply the password-authentication restrictions; removing the ineffective config.\e[0m" >&2
sudo rm -f "$config"
return 1
fi
# Reload rather than restart so an administrator already connected keeps
# their session.
sudo systemctl reload sshd.service
}
echo -e "\e[32mSetting up SSH server access with key-based authentication.\n\e[0m" echo -e "\e[32mSetting up SSH server access with key-based authentication.\n\e[0m"
setup_sshd setup_sshd
@@ -161,5 +205,8 @@ else
esac esac
fi fi
disable_password_auth
echo -e "\e[32m\nPerfect! The SSH server is running and your key is authorized.\e[0m" echo -e "\e[32m\nPerfect! The SSH server is running and your key is authorized.\e[0m"
echo "Password logins are off; this machine now accepts authorized keys only."
echo "You can now connect with: ssh $USER@$(hostname)" echo "You can now connect with: ssh $USER@$(hostname)"
+16 -6
View File
@@ -23,16 +23,26 @@ omarchy-git-url-check "$REPO_URL" || exit 1
THEMES_DIR="$HOME/.config/omarchy/themes" THEMES_DIR="$HOME/.config/omarchy/themes"
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path # Strip user@host: prefix from scp-style SSH URLs so basename sees just the path.
# git reads a URL as scp-style when a colon appears before any slash, so the path
# after it need not hold one: `git@host:omarchy-blue-theme.git` is a repo in that
# user's home, and leaving its prefix on names the theme after the whole URL.
REPO_PATH="$REPO_URL" REPO_PATH="$REPO_URL"
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}" [[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}"
THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]') THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
THEME_PATH="$THEMES_DIR/$THEME_NAME" THEME_PATH="$THEMES_DIR/$THEME_NAME"
# The name comes from the URL and is joined into a path that is about to be # The name comes from the URL, is joined into a path that is about to be
# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading # removed, and then names a directory the rest of Omarchy passes around by
# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`. # name: Style > Unlock builds a command line out of the one the picker
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then # returned. So it is held to the characters a theme name needs rather than
# screened for the harm of the day -- a repo called `..` would take
# ~/.config/omarchy with it, and one called `a';'id` would carry its own
# command into that picker. The leading character is kept out of `.` and `-`,
# which also covers `host:-s/foo.git` leaving basename with `.git`.
# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in
# `é` under en_US.UTF-8. Pin the locale so the set is the one written here.
if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); then
echo "Error: '$REPO_URL' does not give a usable theme name." echo "Error: '$REPO_URL' does not give a usable theme name."
exit 1 exit 1
fi fi
+6
View File
@@ -5,3 +5,9 @@
# omarchy:examples=omarchy toggle bar | omarchy toggle bar off | omarchy toggle bar on # omarchy:examples=omarchy toggle bar | omarchy toggle bar off | omarchy toggle bar on
omarchy-toggle bar-off "${1:-toggle}" omarchy-toggle bar-off "${1:-toggle}"
# The shell's watch on the toggles directory can miss flag changes that land in
# quick succession, stranding the bar off screen until the shell restarts.
# Nudge the bar to re-read the flag; quiet best-effort so the toggle still
# works when the shell is not up.
omarchy-shell -q omarchy.bar syncHidden
+20 -5
View File
@@ -597,6 +597,9 @@ preserve_kernel_cmdline_root() {
fi fi
log "Preserving the kernel cmdline root parameters in $default_conf" log "Preserving the kernel cmdline root parameters in $default_conf"
# omarchy:heredoc-expands paths=none -- ${boot_params[*]} is the kernel
# cmdline fragment assembled above from findmnt output (root=, rootflags=),
# not a filesystem path this script controls.
as_root tee -a "$default_conf" >/dev/null <<EOF as_root tee -a "$default_conf" >/dev/null <<EOF
# Written by omarchy-upgrade-to-quattro. The += drop-ins in # Written by omarchy-upgrade-to-quattro. The += drop-ins in
# /etc/limine-entry-tool.d/ stop limine-entry-tool from reading # /etc/limine-entry-tool.d/ stop limine-entry-tool from reading
@@ -1187,11 +1190,19 @@ ensure_sleep_lock_service() {
} }
run_post_upgrade_migrations() { run_post_upgrade_migrations() {
PATH="$package_path" command -v omarchy-migrate >/dev/null 2>&1 || return 0 local pending_status
log "Running Omarchy migrations" log "Running Omarchy migrations"
if ! run_as_user_omarchy OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 omarchy-migrate; then if ! run_as_user_omarchy OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 omarchy-migrate; then
warn "Could not run Omarchy migrations; the user may be prompted to run them after login." fail "Omarchy migrations did not complete. Fix the error above and rerun the upgrade before rebooting."
fi
if run_as_user_omarchy omarchy-migrate --pending >/dev/null; then
fail "Omarchy migrations are still pending. Rerun the upgrade before rebooting."
else
pending_status=$?
if (( pending_status != 1 )); then
fail "Could not verify that Omarchy migrations completed. Rerun the upgrade before rebooting."
fi
fi fi
} }
@@ -1328,7 +1339,7 @@ apply_system_transition() {
' '
fi fi
as_root install -d -m 0755 /usr/lib/chromium as_root install -d -m 0755 /usr/lib/chromium
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ printf '%s\n' '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
as_root tee /usr/lib/chromium/initial_preferences >/dev/null as_root tee /usr/lib/chromium/initial_preferences >/dev/null
# Deliberately do NOT add the user to the docker group. That group is # Deliberately do NOT add the user to the docker group. That group is
@@ -1364,7 +1375,6 @@ EOF
as_root systemctl disable docker.service >/dev/null 2>&1 || true as_root systemctl disable docker.service >/dev/null 2>&1 || true
enable_system_service cups.service enable_system_service cups.service
enable_system_service cups-browsed.service
enable_system_service avahi-daemon.service enable_system_service avahi-daemon.service
enable_system_service linux-modules-cleanup.service enable_system_service linux-modules-cleanup.service
enable_system_service docker.socket enable_system_service docker.socket
@@ -1396,6 +1406,9 @@ EOF
autologin_user=$(as_root awk -F= '/^User=/ { print $2; exit }' /etc/sddm.conf.d/autologin.conf 2>/dev/null || true) autologin_user=$(as_root awk -F= '/^User=/ { print $2; exit }' /etc/sddm.conf.d/autologin.conf 2>/dev/null || true)
fi fi
[[ -n ${autologin_user:-} ]] || autologin_user="$target_user" [[ -n ${autologin_user:-} ]] || autologin_user="$target_user"
# omarchy:heredoc-expands paths=none -- $autologin_user is a username, read
# back from the root-owned drop-in or falling back to $target_user. Same
# mechanism as the old getty override: a name expands, no path does.
cat <<EOF | as_root tee /etc/sddm.conf.d/autologin.conf >/dev/null cat <<EOF | as_root tee /etc/sddm.conf.d/autologin.conf >/dev/null
[Autologin] [Autologin]
User=$autologin_user User=$autologin_user
@@ -1406,6 +1419,8 @@ EOF
fi fi
as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm as_root install -d -m 0755 -o sddm -g sddm /var/lib/sddm 2>/dev/null || as_root install -d -m 0755 /var/lib/sddm
# omarchy:heredoc-expands paths=none -- $target_user is a username, not a
# path; SDDM's state file records who logged in last.
cat <<EOF | as_root tee /var/lib/sddm/state.conf >/dev/null cat <<EOF | as_root tee /var/lib/sddm/state.conf >/dev/null
[Last] [Last]
Session=omarchy.desktop Session=omarchy.desktop
@@ -2359,8 +2374,8 @@ run_as_user_omarchy omarchy-bar defaults ||
cleanup_retired_services cleanup_retired_services
ensure_sleep_lock_service ensure_sleep_lock_service
remove_retired_default_packages remove_retired_default_packages
run_post_upgrade_migrations
run_final_system_package_upgrade run_final_system_package_upgrade
run_post_upgrade_migrations
run_post_upgrade_update_steps run_post_upgrade_update_steps
refresh_current_theme_after_upgrade refresh_current_theme_after_upgrade
# Do not force-reload Hyprland in the live upgraded session. The legacy # Do not force-reload Hyprland in the live upgraded session. The legacy
+2
View File
@@ -14,6 +14,8 @@ fi
if grep -q "https://pkgs.omarchy.org/stable/" /etc/pacman.conf; then if grep -q "https://pkgs.omarchy.org/stable/" /etc/pacman.conf; then
pkgs="stable" pkgs="stable"
elif grep -q "https://pkgs.omarchy.org/rc/" /etc/pacman.conf; then
pkgs="rc"
elif grep -q "https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then elif grep -q "https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then
pkgs="edge" pkgs="edge"
else else
+80 -14
View File
@@ -54,6 +54,34 @@ download_icon() {
[[ -s $2 && $(file -b --mime-type "$2") == image/* ]] [[ -s $2 && $(file -b --mime-type "$2") == image/* ]]
} }
# Chromium --app= treats javascript:, file:, and data: as a document to
# run. Prefix schemeless input with https as before, then refuse anything
# that is not http(s).
normalize_webapp_url() {
local url=$1
if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then
url="https://$url"
fi
printf '%s' "$url"
}
# Raw whitespace must be percent-encoded in a URL. Refuse it before serializing
# the desktop entry; before Exec argument quoting, it also split browser flags
# and additional URLs into separate arguments. Schemes are case-insensitive.
require_http_url() {
local url=$1
if [[ $url =~ [[:space:]] ]]; then
echo "Error: web app URL must not contain whitespace." >&2
exit 1
fi
if [[ ! ${url,,} =~ ^https?:// ]]; then
echo "Error: web app URL must be http or https." >&2
exit 1
fi
}
fetch_site_icon() { fetch_site_icon() {
local site_url="$1" dest="$2" local site_url="$1" dest="$2"
local origin page icon_url local origin page icon_url
@@ -77,14 +105,44 @@ fetch_site_icon() {
download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest" download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest"
} }
desktop_string_escape() {
# Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"):
# a raw newline would start a new key line and let a value inject a second
# Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value
# written into the .desktop file passes through here.
#
# Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space
# and exits at end of input, so a `:a;N;$!ba` slurp skips every following s///
# for a value with no newline in it - which is every value except the injection
# attempt this exists to stop.
local value="$1"
value=${value//\\/\\\\}
value=${value//$'\t'/\\t}
value=${value//$'\r'/\\r}
value=${value//$'\n'/\\n}
[[ $value == " "* ]] && value="\\s${value# }"
printf '%s' "$value"
}
desktop_exec_arg() {
# One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes
# " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's
# URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only).
local escaped
escaped=$(printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g')
printf '"%s"' "$escaped"
}
if (( $# < 3 )); then if (( $# < 3 )); then
echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m"
APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app")
require_plain_name "$APP_NAME" require_plain_name "$APP_NAME"
APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com")
if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then APP_URL=$(normalize_webapp_url "$APP_URL")
APP_URL="https://$APP_URL" require_http_url "$APP_URL"
fi
# Try to fetch the site's icon automatically first. # Try to fetch the site's icon automatically first.
mkdir -p "$ICON_DIR" mkdir -p "$ICON_DIR"
@@ -101,10 +159,8 @@ if (( $# < 3 )); then
INTERACTIVE_MODE=true INTERACTIVE_MODE=true
else else
APP_NAME="$1" APP_NAME="$1"
APP_URL="$2" APP_URL=$(normalize_webapp_url "$2")
if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then require_http_url "$APP_URL"
APP_URL="https://$APP_URL"
fi
ICON_REF="$3" ICON_REF="$3"
CUSTOM_EXEC="$4" # Optional custom exec command CUSTOM_EXEC="$4" # Optional custom exec command
MIME_TYPES="$5" # Optional mime types MIME_TYPES="$5" # Optional mime types
@@ -143,29 +199,39 @@ else
ICON_VALUE=$(icon_name_from_ref "$ICON_REF") ICON_VALUE=$(icon_name_from_ref "$ICON_REF")
fi fi
# Use custom exec if provided, otherwise default behavior # Default Exec quotes the URL as one Exec-spec argument; the whole line then gets
EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" # the file-syntax escaping below (unescaped first at read time per spec, so the
# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only.
if [[ -n $CUSTOM_EXEC ]]; then
EXEC_COMMAND=$CUSTOM_EXEC
else
EXEC_COMMAND="omarchy-launch-webapp $(desktop_exec_arg "$APP_URL")"
fi
# Create application .desktop file # Create application .desktop file
DESKTOP_DIR="$HOME/.local/share/applications" DESKTOP_DIR="$HOME/.local/share/applications"
DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop"
mkdir -p "$DESKTOP_DIR" mkdir -p "$DESKTOP_DIR"
name_field=$(desktop_string_escape "$APP_NAME")
exec_field=$(desktop_string_escape "$EXEC_COMMAND")
icon_field=$(desktop_string_escape "$ICON_VALUE")
cat >"$DESKTOP_FILE" <<EOF cat >"$DESKTOP_FILE" <<EOF
[Desktop Entry] [Desktop Entry]
Version=1.0 Version=1.0
Name=$APP_NAME Name=$name_field
Comment=$APP_NAME Comment=$name_field
Exec=$EXEC_COMMAND Exec=$exec_field
Terminal=false Terminal=false
Type=Application Type=Application
Icon=$ICON_VALUE Icon=$icon_field
StartupNotify=true StartupNotify=true
EOF EOF
# Add mime types if provided # Add mime types if provided
if [[ -n $MIME_TYPES ]]; then if [[ -n $MIME_TYPES ]]; then
echo "MimeType=$MIME_TYPES" >>"$DESKTOP_FILE" printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE"
fi fi
chmod +x "$DESKTOP_FILE" chmod +x "$DESKTOP_FILE"
+898 -125
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
-- Keep the Windows VM display opaque instead of applying the default window opacity.
o.window({ class = "^xfreerdp$", title = "^Windows VM - Omarchy$" }, {
tag = "-default-opacity",
opacity = "1 1",
})
+1 -1
View File
@@ -103,7 +103,7 @@
// Style // Style
"style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""},
"style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""}, "style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""},
"style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"}, "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme $(printf %q \"$unlock\")\"; fi"},
"style.font": {"icon":"","label":"Font","provider":"fonts"}, "style.font": {"icon":"","label":"Font","provider":"fonts"},
"style.bar": {"icon":"󰍜","label":"Menu Bar"}, "style.bar": {"icon":"󰍜","label":"Menu Bar"},
"style.bar.position": {"icon":"","label":"Position"}, "style.bar.position": {"icon":"","label":"Position"},
+1 -1
View File
@@ -26,4 +26,4 @@ Include = /etc/pacman.d/mirrorlist
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
[omarchy] [omarchy]
Server = https://pkgs.omarchy.org/edge/$arch Server = https://pkgs.omarchy.org/rc/$arch
+8 -4
View File
@@ -1,4 +1,8 @@
# Omarchy override of cups-browsed's shipped config. The only behavioural # Keep state away from /var/cache/cups, which is writable by the account CUPS
# change vs the upstream default (all-commented) is enabling auto-registration # uses for print filters. cups-browsed is the only writer to this directory.
# of remote IPP printers discovered via Avahi/mDNS. CacheDir /var/cache/cups-browsed
CreateRemotePrinters Yes
# Auto-create queues only for modern driverless IPP printers. Remote queues
# exported by another CUPS server can still be added manually when needed.
CreateIPPPrinterQueues Driverless
CreateRemoteCUPSPrinterQueues No
+90
View File
@@ -0,0 +1,90 @@
#
# File/directory/user/group configuration file for the CUPS scheduler.
# See "man cups-files.conf" for a complete description of this file.
#
# List of events that are considered fatal errors for the scheduler...
#FatalErrors config
# Strip domain in local username?
#StripUserDomain No
# Do we call fsync() after writing configuration or status files?
#SyncOnClose No
# Default user and group for filters/backends/helper programs; this cannot be
# any user or group that resolves to ID 0 for security reasons...
User 209
Group 209
# Administrator user group, used to match @SYSTEM in cupsd.conf policy rules...
# This cannot contain the Group value for security reasons...
SystemGroup cups-browsed sys root
# Are Unix domain socket peer credentials used for authorization?
PeerCred on
# User that is substituted for unauthenticated (remote) root accesses...
#RemoteRoot remroot
# Do we allow file: device URIs other than to /dev/null?
#FileDevice No
# Permissions for configuration and log files...
#ConfigFilePerm 0640
#LogFilePerm 0644
# Location of the file logging all access to the scheduler; may be the name
# "syslog". If not an absolute path, the value of ServerRoot is used as the
# root directory. Also see the "AccessLogLevel" directive in cupsd.conf.
AccessLog /var/log/cups/access_log
# Location of cache files used by the scheduler...
#CacheDir /var/cache/cups
# Location of data files used by the scheduler...
#DataDir /usr/share/cups
# Location of the static web content served by the scheduler...
#DocumentRoot /usr/share/cups/doc
# Location of the file logging all messages produced by the scheduler and any
# helper programs; may be the name "syslog". If not an absolute path, the value
# of ServerRoot is used as the root directory. Also see the "LogLevel"
# directive in cupsd.conf.
ErrorLog /var/log/cups/error_log
# Location of the file logging all pages printed by the scheduler and any
# helper programs; may be the name "syslog". If not an absolute path, the value
# of ServerRoot is used as the root directory. Also see the "PageLogFormat"
# directive in cupsd.conf.
PageLog /var/log/cups/page_log
# Location of the file listing all of the local printers...
#Printcap /etc/printcap
# Format of the Printcap file...
#PrintcapFormat bsd
#PrintcapFormat plist
#PrintcapFormat solaris
# Location of all spool files...
#RequestRoot /var/spool/cups
# Location of helper programs...
#ServerBin /usr/lib/cups
# SSL/TLS keychain for the scheduler...
#ServerKeychain ssl
# Location of other configuration files...
#ServerRoot /etc/cups
# Location of scheduler state files...
#StateDir /run/cups
# Location of scheduler/helper temporary files. This directory is emptied on
# scheduler startup and cannot be one of the standard (public) temporary
# directory locations for security reasons...
#TempDir /var/spool/cups/tmp
-1
View File
@@ -1 +0,0 @@
%wheel ALL=(ALL) NOPASSWD: /usr/bin/asdcontrol
@@ -0,0 +1,11 @@
[Service]
User=cups-browsed
Group=cups-browsed
CacheDirectory=cups-browsed
CacheDirectoryMode=0750
UMask=0027
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
+1
View File
@@ -0,0 +1 @@
u cups-browsed - "CUPS printer discovery" / -
+3 -5
View File
@@ -1,16 +1,14 @@
# Enable services only. Installs are followed by reboot, so don't start/reload # 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. # daemons mid-install. UFW and hardware-gated services stay in their own scripts.
systemctl enable cups.service systemctl enable cups.service
systemctl enable cups-browsed.service
systemctl enable avahi-daemon.service systemctl enable avahi-daemon.service
systemctl enable linux-modules-cleanup.service systemctl enable linux-modules-cleanup.service
systemctl enable docker.socket systemctl enable docker.socket
systemctl enable systemd-resolved.service systemctl enable systemd-resolved.service
systemctl enable NetworkManager.service systemctl enable NetworkManager.service
# Don't let network-online.target (pulled in by cups-browsed) hold up # Don't let network-online.target hold up graphical.target waiting for
# graphical.target waiting for DHCP/Wi-Fi association. Nothing in the session # DHCP/Wi-Fi association. Nothing in the session needs to block on the network.
# needs to block on the network. Mirrors the systemd-networkd-wait-online mask # Mirrors the systemd-networkd-wait-online mask in install/hardware/network.sh.
# in install/hardware/network.sh.
systemctl mask NetworkManager-wait-online.service systemctl mask NetworkManager-wait-online.service
systemctl enable power-profiles-daemon.service systemctl enable power-profiles-daemon.service
systemctl enable sddm.service systemctl enable sddm.service
+3 -2
View File
@@ -6,7 +6,8 @@ ln -snf /usr/share/icons/Adwaita/symbolic/actions/go-next-symbolic.svg \
/usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg /usr/share/icons/Yaru/scalable/actions/go-next-symbolic.svg
gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true gtk-update-icon-cache /usr/share/icons/Yaru &>/dev/null || true
# Default Chromium to follow system appearance ("device") instead of dark # Seed Chromium's first run: follow system appearance ("device") instead of dark,
# and skip the terms-of-service dialog Chromium 151 turned on by default.
mkdir -p /usr/lib/chromium mkdir -p /usr/lib/chromium
echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \ echo '{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' > \
/usr/lib/chromium/initial_preferences /usr/lib/chromium/initial_preferences
-1
View File
@@ -4,7 +4,6 @@ run_logged "$OMARCHY_INSTALL/hardware/dell-xps-touchpad-haptics.sh"
run_logged "$OMARCHY_INSTALL/hardware/surface.sh" run_logged "$OMARCHY_INSTALL/hardware/surface.sh"
run_logged "$OMARCHY_INSTALL/hardware/network.sh" run_logged "$OMARCHY_INSTALL/hardware/network.sh"
run_logged "$OMARCHY_INSTALL/hardware/input-group.sh"
run_logged "$OMARCHY_INSTALL/hardware/set-wireless-regdom.sh" run_logged "$OMARCHY_INSTALL/hardware/set-wireless-regdom.sh"
run_logged "$OMARCHY_INSTALL/hardware/fix-fkeys.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-fkeys.sh"
run_logged "$OMARCHY_INSTALL/hardware/fix-synaptic-touchpad.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-synaptic-touchpad.sh"
-11
View File
@@ -1,11 +0,0 @@
# Give this user privileged input access for dictation tools + xbox controllers to work.
# Recorded for provisioning first-boot user creation and factory reset, granted directly
# when the install user already exists (deferred-provisioning installs create the user at
# first boot instead).
provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}"
mkdir -p "$provisioning_dir"
grep -qxF input "$provisioning_dir/groups" 2>/dev/null || echo input >>"$provisioning_dir/groups"
if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then
usermod -aG input "$OMARCHY_INSTALL_USER"
fi
+1 -2
View File
@@ -17,9 +17,8 @@ chromium
clang clang
cliamp cliamp
cups cups
cups-browsed
cups-filters cups-filters
cups-pdf cups-pk-helper
ddcutil ddcutil
docker docker
docker-buildx docker-buildx
+5 -5
View File
@@ -3,11 +3,11 @@
cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf 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 cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist
# omarchy-settings skips this override until cups-browsed is actually present # Wait for CUPS to own the file, the way omarchy-settings does, so pacman does
# to avoid pacman creating cups-browsed.conf.pacnew during ISO package install. # not turn the override into a .pacnew during ISO package installation.
if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-browsed.conf && -d /etc/cups ]]; then if [[ -f $OMARCHY_PATH/etc-overrides/cups-cups-files.conf && -f /etc/cups/cups-files.conf ]]; then
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 rm -f /etc/cups/cups-files.conf.pacnew
fi fi
source "$OMARCHY_INSTALL/hardware/pacman.sh" source "$OMARCHY_INSTALL/hardware/pacman.sh"
+1 -1
View File
@@ -79,7 +79,7 @@ Turkish|trq
Ukrainian|ua' Ukrainian|ua'
OMARCHY_USERNAME_PATTERN='^[a-z_][a-z0-9_-]*[$]?$' OMARCHY_USERNAME_PATTERN='^[a-z_][a-z0-9_-]*[$]?$'
OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$' OMARCHY_RESERVED_USERNAMES='^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|cups-browsed|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$'
OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' OMARCHY_HOSTNAME_PATTERN='^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$'
OMARCHY_HOSTNAME_DEFAULT='omarchy' OMARCHY_HOSTNAME_DEFAULT='omarchy'
+9 -3
View File
@@ -4,7 +4,7 @@ Omarchy offers an easy way to run Windows through a Docker VM. You can install i
Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself. Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself.
The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The browser prompts for the same username and password before opening the console.
![windows-vm](images/windows-vm.webp) ![windows-vm](images/windows-vm.webp)
@@ -26,9 +26,15 @@ omarchy windows vm launch # start and connect
## Sharing files ## Sharing files
The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk lives in `~/.windows`. The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`.
The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. Those familiar home paths stay on their own filesystems. They can also be symlinks to directories you own, which is useful when the virtual disk lives on a larger drive. The installer measures free space on the filesystem that actually contains `~/.windows`, not necessarily the filesystem containing your home directory.
Keep the disk and shared paths as separate, non-overlapping directories. Removal deliberately empties the disk directory but preserves the shared directory. Immediately before deletion, Omarchy performs a bounded containment check and refuses to remove anything if that check times out or cannot prove the two trees are separate.
Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents.
The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The web console also requires the configured Windows username and password, preventing another local account from driving the VM through port 8006.
## Limits and licensing ## Limits and licensing
+2
View File
@@ -38,6 +38,8 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro
If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation. If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation.
That leftover `[themename]` becomes the theme's directory name, so it has to be one Omarchy can hand around safely: it must start with a letter, a digit, or an underscore, and the rest may hold letters, digits, `.`, `_`, `+`, and `-`. Capitals are lowercased for you, but anything else — a space, a quote, a non-English character — is refused at install time rather than turned into a directory name. So `omarchy-tokyo-night-theme`, `omarchy-flexoki_light-theme`, and `omarchy-c++-theme` all install fine.
Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those. Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those.
You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site). You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site).
+7 -1
View File
@@ -44,7 +44,13 @@ The plain open source Chromium build doesn't ship with the OAuth credentials tha
### How do I add a printer? ### 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? ### How do I change where screenshots or screenrecordings are saved?
+18
View File
@@ -0,0 +1,18 @@
echo "Skip Chromium's new first-run EULA on machines already on Quattro"
# Chromium 151 flipped MasterPrefs::eula_required from false to true, so an
# unconfigured first run now stops on a blank terms-of-service dialog before the
# browser opens. Omarchy answers that in the seed it writes next to the Chromium
# binary, but that seed is only laid down by a fresh install and by the one-time
# 3.x upgrade, so machines already on Quattro never receive it. Retrofit it here.
#
# The literal is deliberately duplicated rather than sourced: a migration repairs
# the state of its own moment, and must not drift when the seed later changes.
chromium_prefs="/usr/lib/chromium/initial_preferences"
chromium_seed='{"distribution":{"require_eula":false},"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}'
if [[ $(cat "$chromium_prefs" 2>/dev/null) != "$chromium_seed" ]]; then
sudo mkdir -p "$(dirname "$chromium_prefs")"
echo "$chromium_seed" | sudo tee "$chromium_prefs" >/dev/null
fi
+57
View File
@@ -0,0 +1,57 @@
echo "Separate printer discovery from root and print-filter access"
machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}"
[[ ! -e $machine_marker ]] || exit 0
# Existing releases allowed a desktop user or shared group named cups-browsed,
# which systemd-sysusers would silently reuse for passwordless CUPS access.
if omarchy-pkg-present cups; then
cups_browsed_account=$(getent passwd cups-browsed || true)
cups_browsed_group=$(getent group cups-browsed || true)
if [[ -n $cups_browsed_account || -n $cups_browsed_group ]]; then
IFS=: read -r _ _ cups_browsed_uid cups_browsed_gid cups_browsed_description cups_browsed_home cups_browsed_shell <<<"$cups_browsed_account"
IFS=: read -r _ _ cups_browsed_group_gid cups_browsed_group_members <<<"$cups_browsed_group"
other_primary_user=$(getent passwd | awk -F: -v gid="$cups_browsed_gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }')
if [[ ! $cups_browsed_uid =~ ^[0-9]+$ || ! $cups_browsed_group_gid =~ ^[0-9]+$ ]] ||
((cups_browsed_uid <= 0 || cups_browsed_uid >= 1000)) ||
[[ $cups_browsed_gid != $cups_browsed_group_gid ]] ||
[[ $cups_browsed_description != "CUPS printer discovery" || $cups_browsed_home != "/" || $cups_browsed_shell != "/usr/bin/nologin" ]] ||
[[ -n $cups_browsed_group_members || -n $other_primary_user ]]; then
echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2
false
fi
fi
fi
# CUPS-PDF accepts a job-controlled post-processing command in a backend that
# CUPS launches as root. Native application print-to-file support replaces it.
omarchy-pkg-drop cups-pdf
# system-config-printer uses this helper to request printer administration
# through Polkit now that the desktop user's wheel group is no longer @SYSTEM.
if omarchy-pkg-present cups; then
omarchy-pkg-add cups-pk-helper
fi
# Stop the root-running daemon before changing the authorization it relies on.
if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then
sudo systemctl stop cups-browsed.service
fi
if omarchy-pkg-present cups; then
sudo systemctl daemon-reload
sudo systemctl try-reload-or-restart cups.service
fi
# Resume on whether the unit is enabled, not on whether it was running when this
# run started: an interrupted earlier run leaves it stopped, and a retry that
# recomputed that would skip the restart and still write the marker below. A
# masked or disabled unit reports not-enabled and is left alone.
if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then
sudo systemctl restart cups-browsed.service
fi
sudo install -Dm644 /dev/null "$machine_marker"
+18
View File
@@ -0,0 +1,18 @@
echo "Drop the default input group grant, which allowed unprivileged keylogging"
# Membership of `input` gives raw read/write access to /dev/input/event*: any
# process running as the user can capture keystrokes and synthesize input. The
# blanket grant is unnecessary: the Xbox-controller and ydotool installers add
# the group themselves when those features are deliberately installed.
#
# Preserve membership where one of those opt-in features is present; removing
# it there would break the feature the user chose to install.
if id -nG "$USER" | grep -qw input; then
if pacman -Qq xpadneo-dkms &>/dev/null || pacman -Qq ydotool &>/dev/null; then
echo "Keeping $USER in the input group: controller or ydotool support is installed."
else
sudo gpasswd -d "$USER" input >/dev/null
echo "Removed $USER from the input group. Log out and back in to apply."
omarchy-state set reboot-required
fi
fi
+72
View File
@@ -0,0 +1,72 @@
echo "Temporarily remove automatic printer discovery"
machine_marker="${OMARCHY_CUPS_BROWSED_REMOVAL_MARKER:-/var/lib/omarchy/migrations/1788009111}"
[[ ! -e $machine_marker ]] || exit 0
omarchy-pkg-present cups-browsed || exit 0
# Check the full removal transaction before changing the service or queues.
pacman -Rs --print cups-browsed >/dev/null
# Disable the unit while its package still owns the unit file so systemd can
# remove the enable symlink cleanly.
if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then
sudo systemctl disable --now cups-browsed.service >/dev/null
elif systemctl is-active --quiet cups-browsed.service 2>/dev/null; then
sudo systemctl stop cups-browsed.service >/dev/null
fi
# cups-browsed leaves its implicitclass queues behind when stopped. Remove idle
# discovery queues before removing the backend they require, but leave queues
# with jobs for the user to resolve.
#
# A healthy CUPS server with no configured printers reports this condition on
# stderr and exits 1. Treat that as an empty queue list; every other failure
# keeps the migration pending so it can be retried.
if queue_report=$(LC_ALL=C lpstat -v 2>&1); then
:
elif [[ $queue_report == "lpstat: No destinations added." ]]; then
queue_report=""
else
printf '%s\n' "$queue_report" >&2
exit 1
fi
generated_queues=$(printf '%s\n' "$queue_report" |
sed -n 's|^device for \(.*\): implicitclass://.*|\1|p')
while IFS= read -r queue; do
[[ -n $queue ]] || continue
if ! reject_error=$(sudo cupsreject -r "Printer discovery has been removed from Omarchy" "$queue" 2>&1); then
if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then
printf '%s\n' "$reject_error" >&2
exit 1
else
continue
fi
fi
if job_report=$(LC_ALL=C lpstat -o "$queue" 2>&1); then
[[ -z $job_report ]] || continue
elif LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then
printf '%s\n' "$job_report" >&2
exit 1
else
# The queue disappeared after the initial snapshot, which is already the
# desired state.
continue
fi
if ! delete_error=$(sudo lpadmin -x "$queue" 2>&1); then
# Treat a concurrent disappearance as success. A queue that still exists
# means CUPS did not complete the deletion, so retry the migration later.
if LC_ALL=C lpstat -p "$queue" >/dev/null 2>&1; then
printf '%s\n' "$delete_error" >&2
exit 1
fi
fi
done <<<"$generated_queues"
omarchy-pkg-drop cups-browsed >/dev/null
sudo install -Dm644 /dev/null "$machine_marker"
+339
View File
@@ -0,0 +1,339 @@
echo "Remove privileged files left behind by retired Omarchy installers"
sudoers_dir="${OMARCHY_SUDOERS_DIR:-/etc/sudoers.d}"
systemd_dir="${OMARCHY_SYSTEMD_SYSTEM_DIR:-/etc/systemd/system}"
machine_marker="${OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER:-/var/lib/omarchy/migrations/1788025225}"
reload_needed_marker="$machine_marker.daemon-reload"
[[ ! -e $machine_marker ]] || exit 0
as_root() {
if (( EUID == 0 )); then
"$@"
else
sudo "$@"
fi
}
# Three installers that no longer exist each left a root-owned file behind, and
# nothing in Omarchy has ever removed any of them. Each is judged against what
# the installer that wrote it actually produced, so a file of the same name that
# an administrator wrote themselves is left alone.
#
# Emit the lines a parser would act on: comments and blanks dropped, backslash
# continuations joined, and runs of whitespace collapsed so a reformatted copy
# still compares equal. Reads the body on stdin, because /etc/sudoers.d is 0750
# root:root and the caller has to hand us an elevated read.
#
# Comments are tested before continuations are joined, which is the order every
# consumer here uses: udev's parse_file discards a '#' line without looking at a
# trailing backslash (`udevadm verify` on "# disabled \" plus a bogus key reports
# the error on line 2), sudo's toke.l comment rule consumes to the newline and
# clears its continuation flag, and systemd's config_parse tests the comment
# characters before appending to a continuation. Joining first would let a
# comment ending in a backslash swallow the live line beneath it.
#
# FORMAT is sudoers or systemd. systemd takes ';' as well as '#'. sudo does not
# treat every '#' as a comment: toke.l has INITIAL rules for ^#include and
# ^#includedir, and its comment pattern excludes '#' followed by a digit or
# -digit so those reach the ID token as a numeric uid user spec. Those lines are
# active directives, and a file carrying one must not read as though it held only
# generated lines.
active_lines() {
local format="$1"
local comments='#'
local line logical=""
[[ $format == "systemd" ]] && comments='#;'
while IFS= read -r line || [[ -n $line ]]; do
if [[ $line =~ ^[[:space:]]*[$comments] ]] &&
! { [[ $format == "sudoers" ]] && sudoers_hash_is_active "$line"; }; then
# The two consumers part company here. sudo ends the logical line at a
# comment and keeps what came before it, so `visudo -cf` reads a spec
# ending in a backslash, then a comment, then a second spec as two live
# specs; dropping the pending half would hide an administrator's grant and
# let this file read as though the installer had written all of it. systemd
# resumes the continuation instead: `systemd-analyze verify` on "ExecStop=\"
# + "; c" + a path resolves that path, so the pending half has to stay.
if [[ $format == "sudoers" ]]; then
emit_logical "$logical"
logical=""
fi
continue
fi
if [[ $line == *\\ ]]; then
logical+="${line%\\} "
continue
fi
emit_logical "$logical$line"
logical=""
done
# A file whose last line ends in a backslash still carries a live directive for
# systemd: `systemd-analyze verify` resolves an ExecStop= written that way.
# udev ignores the dangling line and sudo rejects the file outright, so emitting
# it costs those two nothing.
emit_logical "$logical"
}
# One logical line, whitespace collapsed so a reformatted copy still compares
# equal, and nothing at all for a line that held only whitespace.
emit_logical() {
local -a parts
read -ra parts <<<"$1"
if (( ${#parts[@]} )); then
printf '%s\n' "${parts[*]}"
fi
}
sudoers_hash_is_active() {
local line="$1"
[[ $line =~ ^[[:space:]]*#include[[:blank:]] ]] && return 0
[[ $line =~ ^[[:space:]]*#includedir[[:blank:]] ]] && return 0
[[ $line =~ ^[[:space:]]*#-?[0-9] ]] && return 0
return 1
}
# install/preflight/first-run-mode.sh (2025-08-25 to 2026-05-25) granted the
# installing account passwordless sudo for the rest of the first boot, including
# an unrestricted /usr/bin/systemctl from 2025-10-14 on -- enough to link and
# start a unit of the user's own, which is root. bin/omarchy-first-run was meant
# to delete the grant, but it clears its first-run.mode guard as the very first
# statement and only reaches the removal after eight set -e steps, two of which
# touch the network. Any failure in between leaves the grant on the machine with
# nothing left to retry it.
#
# The installer rewrote this file nine times across two locations -- the last two
# bodies came from install/post-install/first-run-mode.sh, whose cleanup alias
# names /usr/bin/rm as well as /bin/rm -- and only the later ones carry both
# Cmnd_Alias lines, so keying on those would walk past the earlier ones. Instead
# require every active line to be one the installer itself emitted, plus at least
# one line that is unmistakably this grant: its own self-cleanup. One
# hand-written line anywhere in the file and it is not ours to delete.
first_run_sudoers_is_generated() {
local spec_pattern='^([^[:space:]]+) ALL=\(ALL\) NOPASSWD: (.+)$'
local marker_pattern='^/bin/rm -f /home/([^/]+)/\.local/state/omarchy/first-run\.mode$'
local line user command marker_user generated_user=""
local seen_any=0 seen_marker=0 seen_spec=0
while IFS= read -r line; do
seen_any=1
case "$line" in
"Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf")
continue
;;
"Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run" | \
"Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/99-omarchy-installer-reboot" | \
"Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run")
seen_marker=1
continue
;;
esac
# Everything else the installer wrote is a user spec naming the installing
# account, whose name cannot be assumed here: it may since have been renamed
# or removed, and a second account runs this migration too.
if [[ ! $line =~ $spec_pattern ]]; then
return 1
fi
user=${BASH_REMATCH[1]}
command=${BASH_REMATCH[2]}
if [[ -n $generated_user && $user != "$generated_user" ]]; then
return 1
fi
generated_user=$user
seen_spec=1
case "$command" in
"/usr/bin/systemctl" | "/usr/bin/ufw" | "/usr/bin/ufw-docker" | \
"/usr/bin/gtk-update-icon-cache" | "/usr/bin/udevadm" | \
"/usr/bin/tee /etc/udev/rules.d/*" | "SYMLINK_RESOLVED")
continue
;;
"FIRST_RUN_CLEANUP" | "/bin/rm -f /etc/sudoers.d/first-run")
seen_marker=1
continue
;;
esac
if [[ $command =~ $marker_pattern ]]; then
marker_user=${BASH_REMATCH[1]}
[[ $marker_user == "$generated_user" ]] || return 1
seen_marker=1
continue
fi
return 1
done < <(active_lines sudoers)
(( seen_any && seen_marker && seen_spec ))
}
# bin/omarchy-install-tailscale (2025-08-22 to 2026-02-02) ran
# "echo \"\$USER ALL=(ALL) NOPASSWD: \$(which tsui)\" | sudo tee
# /etc/sudoers.d/tsui" one line after installing tsui by piping a vendor script
# to bash with no sudo at all, so the path it resolved was usually the user's own
# ~/.local/bin. Overwrite that file, run sudo tsui, and you are root. The grant
# goes whatever the path turned out to be: the feature was dropped from Omarchy,
# and unrestricted NOPASSWD on a TUI that can shell out is an escalation from a
# root-owned path too.
tsui_sudoers_is_generated() {
local spec_pattern='^[^[:space:]]+ ALL=\(ALL\) NOPASSWD: ([^[:space:]]+)$'
local line command="" count=0
while IFS= read -r line; do
count=$(( count + 1 ))
if (( count > 1 )); then
return 1
fi
if [[ ! $line =~ $spec_pattern ]]; then
return 1
fi
command=${BASH_REMATCH[1]}
done < <(active_lines sudoers)
if (( count == 1 )) && [[ ${command##*/} == "tsui" ]]; then
return 0
fi
return 1
}
# install/plymouth.sh wrote this unit for two days (2025-07-05 to 2025-07-07)
# with an unquoted heredoc, so ExecStop names the installing user's home. The
# unit is enabled WantedBy=multi-user.target, so systemd runs that path as uid 0
# on every shutdown, with no hardware event needed to reach it.
plymouth_unit_runs_from_home() {
local binary="omarchy-plymouth-shutdown-sync"
local exec_stop_pattern='^ExecStop[[:space:]]*=[[:space:]]*(.*)$'
local home_pattern="^/.+/\\.local/share/omarchy/bin/$binary\$"
local line word
local -a words
local matched=1
while IFS= read -r line; do
if [[ ! $line =~ $exec_stop_pattern ]]; then
continue
fi
read -ra words <<<"${BASH_REMATCH[1]}"
if (( ! ${#words[@]} )); then
# An empty assignment resets the list, so nothing named before it still
# runs. `systemd-analyze verify` reports the missing command for a unit
# with one ExecStop=, and reports nothing once a bare ExecStop= follows it.
# An administrator who neutralised the unit this way is left alone.
matched=1
continue
fi
# systemd reads -, @, +, ! and : ahead of the command as flags, not as part
# of the path it runs.
word=${words[0]}
while [[ $word == [-@+!:]* ]]; do
word=${word:1}
done
if [[ $word =~ $home_pattern ]]; then
# Non-empty ExecStop= assignments append to the command list. Once a
# vulnerable command is present it stays live until an empty assignment
# explicitly resets the list; a later packaged command does not replace it.
matched=0
fi
done < <(active_lines systemd)
return $matched
}
# /etc/sudoers.d is 0750 root:root as shipped, and omarchy-migrate runs as the
# logged-in user, so an unelevated [[ -f ]] on a file in there is false whether or
# not the file exists and an unelevated read returns nothing. Both tests and both
# reads have to be elevated or this migration reports success having done nothing.
first_run_sudoers="$sudoers_dir/first-run"
tsui_sudoers="$sudoers_dir/tsui"
fail_privileged_repair() {
echo "Cannot complete the privileged installer-artifact repair. An administrator must run omarchy-migrate to repair this machine." >&2
exit 1
}
# This is a machine-wide repair with per-user migration markers. A root-owned,
# readable marker lets later non-sudo users finish their own migration run after
# one privileged account has inspected and repaired the machine. Until then,
# the migration fails loudly and remains pending. After an administrator repairs
# the machine, this marker lets every other account complete without using sudo.
if ! as_root true 2>/dev/null; then
fail_privileged_repair
fi
# Removing a unit and reloading systemd are one repair. Persist the second half
# before removing the file so a failed daemon-reload cannot be forgotten on a
# retry that now sees no unit on disk.
if [[ -e $reload_needed_marker ]]; then
if ! as_root systemctl daemon-reload >/dev/null 2>&1; then
fail_privileged_repair
fi
if ! as_root rm -f "$reload_needed_marker"; then
fail_privileged_repair
fi
fi
inspect_sudoers_file() {
local file="$1" predicate="$2" kind content
# Emit an explicit state from the elevated process. A bare `sudo test -f` in
# an if-condition makes "file missing" indistinguishable from "sudo failed",
# which could mark a live grant repaired without ever reading it.
if ! kind=$(as_root bash -c 'if [[ -f $1 ]]; then printf file; elif [[ -e $1 ]]; then printf other; else printf missing; fi' bash "$file"); then
fail_privileged_repair
fi
[[ $kind == "file" ]] || return 0
if ! content=$(as_root cat "$file"); then
fail_privileged_repair
fi
if "$predicate" <<<"$content"; then
if ! as_root rm -f "$file"; then
fail_privileged_repair
fi
fi
}
inspect_sudoers_file "$first_run_sudoers" first_run_sudoers_is_generated
inspect_sudoers_file "$tsui_sudoers" tsui_sudoers_is_generated
# /etc/systemd/system is 0755, so this one needs no elevation to look at.
plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service"
if [[ -f $plymouth_unit ]] && plymouth_unit_runs_from_home <"$plymouth_unit"; then
# Disable, never stop. Stopping the unit is precisely what runs ExecStop, and
# ExecStop is the path this migration exists to keep root away from; disabling
# only drops the multi-user.target symlink.
if ! as_root install -Dm644 /dev/null "$reload_needed_marker"; then
fail_privileged_repair
fi
if ! as_root systemctl disable omarchy-plymouth-shutdown.service >/dev/null 2>&1; then
fail_privileged_repair
fi
if ! as_root rm -f "$plymouth_unit"; then
fail_privileged_repair
fi
# systemd keeps serving the copy it already loaded until it rereads the
# directory, so without this the unit is still there to run at shutdown.
if ! as_root systemctl daemon-reload >/dev/null 2>&1; then
fail_privileged_repair
fi
if ! as_root rm -f "$reload_needed_marker"; then
fail_privileged_repair
fi
fi
if ! as_root install -Dm644 /dev/null "$machine_marker"; then
fail_privileged_repair
fi
+257
View File
@@ -0,0 +1,257 @@
echo "Repair legacy XCompose and remove vulnerable Omarchy 3 power udev rules"
xcompose="$HOME/.XCompose"
packaged_xcompose="$OMARCHY_PATH/default/xcompose"
legacy_xcompose_pattern='^[[:space:]]*include[[:space:]]+"[^"]*/\.local/share/omarchy/default/xcompose"[[:space:]]*$'
# Omarchy 3 pointed the user's compose file through the checkout compatibility
# link. Preserve their own sequences while moving that include to the packaged
# tree. A failed live restart is harmless: the next graphical login reads the
# repaired file.
if [[ -f $xcompose ]] && grep -Eq "$legacy_xcompose_pattern" "$xcompose"; then
xcompose_replacement=${packaged_xcompose//\\/\\\\}
xcompose_replacement=${xcompose_replacement//&/\\&}
xcompose_replacement=${xcompose_replacement//|/\\|}
sed -i -E "s|^([[:space:]]*include[[:space:]]+\")[^\"]*/\\.local/share/omarchy/default/xcompose\"[[:space:]]*$|\\1$xcompose_replacement\"|" "$xcompose"
omarchy-restart-xcompose >/dev/null 2>&1 || true
fi
rules_dir=/etc/udev/rules.d
reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed
udev_control=/run/udev/control
install_command=/usr/bin/install
mv_command=/usr/bin/mv
rm_command=/usr/bin/rm
udevadm_command=/usr/bin/udevadm
as_root() {
if (( EUID == 0 )); then
"$@"
else
sudo "$@"
fi
}
# Omarchy 3 generated these two rules with an unquoted heredoc, so the installing
# user's $HOME was expanded and the file on disk names that absolute home path.
# udev runs RUN+= as root, and
# ~/.local/share/omarchy is a symlink that same unprivileged user owns: replacing
# it with a tree of their own and provoking a power_supply event runs their code
# as root. Quattro ships the rules as 99-omarchy-*.rules under /usr/bin, but the
# one-shot migration that swept the old filenames was itself dropped, so an
# install that came up through the 3.x line keeps the old file until this
# migration removes it.
#
# Pre-4 layout work normally belongs in the Omarchy 4 upgrade command, but that
# command only runs on a machine still making the crossing, so an install that
# crossed already would never see it. The upgrade command ends by running
# omarchy-migrate, so this covers the installs still to upgrade as well.
#
# Only remove an exact two-line body that one of the retired installers wrote.
# A same-named file with no active vulnerable RUN survives; one that still has
# the vulnerable command but also contains administrator changes is quarantined
# under a non-.rules suffix for review.
rule_runs_from_home() {
local file="$1" binary="$2"
local run_pattern='RUN([[:space:]]*\{[^}]*\})?[[:space:]]*(\+|:)?=[[:space:]]*(e)?"([^"]*)"'
local line logical="" rest command
while IFS= read -r line || [[ -n $line ]]; do
# udev tests for a comment before it joins continuations, and skipping one
# does not end a continuation already under way. Both halves verified with
# `udevadm verify`: "# disabled \" followed by a bogus key reports the error
# on line 2, so a comment's own trailing backslash swallows nothing, while
# 'SUBSYSTEM=="power_supply" \' + "# c" + ', RUN+="..."' reports its style
# warning on line 1, so the rule spans the comment. Testing the comment after
# the join would hide a live rule; clearing the pending line here would hide
# one just as well.
if [[ $line =~ ^[[:space:]]*# ]]; then
continue
fi
# A trailing backslash continues the rule on the next line.
if [[ $line == *\\ ]]; then
logical+=${line%\\}
continue
fi
rest=$logical$line
logical=""
while [[ $rest =~ $run_pattern ]]; do
command=${BASH_REMATCH[4]}
rest=${rest#*"${BASH_REMATCH[0]}"}
# This is only the fail-closed detector; wholesale deletion still requires
# an exact historical body below. Match spacing edits and wrapper arguments
# conservatively so a modified active rule is never mistaken for a safe one.
if [[ $command == *"/.local/share/omarchy/bin/$binary"* ]]; then
return 0
fi
done
done <"$file"
return 1
}
mark_reload_needed() {
if ! as_root "$install_command" -Dm644 /dev/null "$reload_needed_marker"; then
echo "Administrator privileges are required to repair the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
}
# The retired installers overwrote each file with one of five known two-line
# bodies. Only those exact bodies are safe to delete wholesale. If a vulnerable
# rule has since been edited or extended, preserve it under an inactive name
# rather than taking unrelated rules with it.
rule_is_exact_generated() {
local file="$1" binary="$2"
local prefix suffix home expected
local -a lines
mapfile -t lines <"$file"
(( ${#lines[@]} == 2 )) || return 1
case "$binary" in
omarchy-powerprofiles-set)
# Initial userland helper: separate AC/battery units and arguments.
prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-battery --property=After=power-profiles-daemon.service '
suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set battery"'
if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then
home=${lines[0]#"$prefix"}
home=${home%"$suffix"}
expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set ac"'
[[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0
fi
# USB-C support: one fixed transient-unit name and no profile argument.
prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service '
suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set"'
if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then
home=${lines[0]#"$prefix"}
home=${home%"$suffix"}
expected='SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set"'
[[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0
fi
# The final Omarchy 3 revision dropped the fixed transient-unit name.
prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service '
suffix='/.local/share/omarchy/bin/omarchy-powerprofiles-set"'
if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then
home=${lines[0]#"$prefix"}
home=${home%"$suffix"}
expected='SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service '"$home"'/.local/share/omarchy/bin/omarchy-powerprofiles-set"'
[[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0
fi
;;
omarchy-wifi-powersave)
# Initial Wi-Fi helper: invoke the userland command directly.
prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="'
suffix='/.local/share/omarchy/bin/omarchy-wifi-powersave on"'
if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then
home=${lines[0]#"$prefix"}
home=${home%"$suffix"}
expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="'"$home"'/.local/share/omarchy/bin/omarchy-wifi-powersave off"'
[[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0
fi
# Later revision deferred each change through its own transient unit.
prefix='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on '
suffix='/.local/share/omarchy/bin/omarchy-wifi-powersave on"'
if [[ ${lines[0]} == "$prefix"*"$suffix" ]]; then
home=${lines[0]#"$prefix"}
home=${home%"$suffix"}
expected='SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off '"$home"'/.local/share/omarchy/bin/omarchy-wifi-powersave off"'
[[ $home == /* && $home != *'"'* && ${lines[1]} == "$expected" ]] && return 0
fi
;;
esac
return 1
}
finish_pending_reload() {
# With no control socket there is no running udevd holding the deleted rule;
# the next daemon start reads the directory from disk. If a daemon is running,
# a failed reload must keep this migration pending so the in-memory root rule
# cannot outlive the per-user completion marker.
if [[ -e $udev_control ]] && ! as_root "$udevadm_command" control --reload 2>/dev/null; then
echo "Could not reload udev after removing a vulnerable legacy rule. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
if ! as_root "$rm_command" -f "$reload_needed_marker"; then
echo "Could not finish the legacy udev-rule repair. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
}
quarantine_rule() {
local rule_file="$1"
local backup="$rule_file.omarchy-disabled"
local suffix=0
# udev only loads files ending in .rules. Preserve an administrator-modified
# file byte-for-byte under a suffix udev ignores instead of either deleting
# their additions or leaving its user-controlled command active as root.
while [[ -e $backup || -L $backup ]]; do
((++suffix))
backup="$rule_file.omarchy-disabled.$suffix"
done
mark_reload_needed
if ! as_root "$mv_command" --no-clobber -- "$rule_file" "$backup"; then
echo "Administrator privileges are required to quarantine the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
if [[ -e $rule_file || -L $rule_file ]]; then
echo "Could not quarantine the vulnerable legacy udev rule at $rule_file. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
finish_pending_reload
echo "Quarantined the modified legacy rule as $backup so udev cannot execute it. Review the preserved file before restoring any safe custom actions." >&2
}
if [[ -d $rules_dir && ! -x $rules_dir ]]; then
echo "Could not inspect legacy udev rules under $rules_dir. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
for legacy_rule in "99-power-profile.rules:omarchy-powerprofiles-set" "99-wifi-powersave.rules:omarchy-wifi-powersave"; do
rule_name="${legacy_rule%%:*}"
rule_file="$rules_dir/$rule_name"
binary="${legacy_rule##*:}"
reload_needed_marker="$reload_marker_prefix-$rule_name"
# The marker is written before removal so a crash cannot lose the need to
# reload. Only consume it early when the corresponding active file is already
# gone; another user's concurrent run may still be between those two steps.
if [[ -e $reload_needed_marker && ! -e $rule_file && ! -L $rule_file ]]; then
finish_pending_reload
fi
if [[ -f $rule_file && ! -r $rule_file ]]; then
echo "Could not inspect the legacy udev rule at $rule_file. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
if [[ -f $rule_file ]] && rule_is_exact_generated "$rule_file" "$binary"; then
mark_reload_needed
if ! as_root "$rm_command" -f "$rule_file"; then
echo "Administrator privileges are required to remove the vulnerable legacy udev rule. Ask an administrator to run omarchy-migrate." >&2
exit 1
fi
finish_pending_reload
elif [[ -f $rule_file ]] && rule_runs_from_home "$rule_file" "$binary"; then
quarantine_rule "$rule_file"
fi
# If an interrupted removal was followed by an administrator installing a
# safe replacement, reload that replacement before clearing the old marker.
if [[ -e $reload_needed_marker ]]; then
finish_pending_reload
fi
done
+12
View File
@@ -0,0 +1,12 @@
echo "Point rc-channel installs at the rc package repository"
# pacman-rc.conf shipped with [omarchy] pointing at the edge repository, a
# leftover from when release candidates published there. Candidates now publish
# to the dedicated rc channel, so a machine on the rc mirror was taking its
# omarchy packages from edge. Repoint only a conf that still carries the
# shipped pairing: an administrator who chose another combination keeps it.
if grep -q "https://rc-mirror.omarchy.org/" /etc/pacman.d/mirrorlist &&
grep -q "^Server = https://pkgs.omarchy.org/edge/" /etc/pacman.conf; then
sudo sed -i "s|^Server = https://pkgs.omarchy.org/edge/|Server = https://pkgs.omarchy.org/rc/|" /etc/pacman.conf
echo "Switched the [omarchy] repository to the rc channel to match this machine's rc mirror."
fi
+133
View File
@@ -0,0 +1,133 @@
echo "Disable SSH password authentication, or sshd itself when no key is authorized"
config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf
authorized_keys="$HOME/.ssh/authorized_keys"
as_root() {
if (( EUID == 0 )); then
"$@"
else
sudo "$@"
fi
}
# Passwords staying enabled is the state the machine has been living with, so a
# condition this migration cannot repair completes with a notice instead of
# failing and holding up every migration queued behind it. Only missing
# privileges stay pending below, because rerunning from a terminal fixes that.
skip() {
echo "$1 SSH password authentication remains enabled; run omarchy-setup-security-sshd to harden manually."
exit 0
}
# The fixed setup command writes this file itself. Its presence is also the
# machine-wide completion state, so migrations run by another account no-op.
if [[ -e $config || -L $config ]]; then
exit 0
fi
# Earlier versions enabled sshd before importing the key, but did not leave a
# marker saying that Omarchy configured it. Limit the repair to a daemon that is
# enabled or currently exposed and a user who already has a usable authorized
# key. A machine that never set SSH up exits without prompting for privileges.
if ! systemctl is-enabled --quiet sshd.service 2>/dev/null &&
! systemctl is-active --quiet sshd.service 2>/dev/null; then
exit 0
fi
# sshd reads authorized_keys one entry per line, while ssh-keygen -lf
# fingerprints whole files in formats sshd does not accept there — a private
# key copied in by mistake passes the file-level check even though sshd finds
# no usable entry in it. Ask sshd's question instead: does any single line
# parse as a public key?
has_usable_key() {
local line
while IFS= read -r line || [[ -n $line ]]; do
if [[ $line =~ ^[[:space:]]*(#|$) ]]; then
continue
fi
if ssh-keygen -lf /dev/stdin <<<"$line" >/dev/null 2>&1; then
return 0
fi
done <"$authorized_keys"
return 1
}
# A file that exists but cannot be read leaves the key question unanswered; do
# not treat it as proof the machine is password-only. [[ -f ]] and the read
# both follow symlinks on purpose: a dotfiles-managed authorized_keys link with
# a working key must not count as keyless.
if [[ -f $authorized_keys && ! -r $authorized_keys ]]; then
skip "Could not read $authorized_keys to check for a usable key."
fi
# The old setup command enabled sshd before importing a key, so an aborted run
# left a password-only server exposed. Without a usable key there is nothing to
# harden: close the hole Omarchy opened by disabling the server. Omarchy is a
# desktop distro, so the console remains; re-enabling password SSH afterwards
# is an intentional, informed choice the warning explains how to make.
if [[ ! -f $authorized_keys ]] || ! has_usable_key; then
if ! as_root systemctl disable --now sshd.service; then
echo "Administrator privileges are required to close the password-only SSH server. Run omarchy-migrate again from a terminal." >&2
exit 1
fi
echo "No usable SSH key is authorized, so sshd only accepted password logins. The SSH server has been disabled: run omarchy-setup-security-sshd to set it up with key-based authentication, or re-enable sshd to accept password logins anyway."
exit 0
fi
# Under StrictModes, sshd's default, a group- or world-writable home directory,
# ~/.ssh, or authorized_keys makes sshd ignore the key that just validated, and
# passwords would then be the only way in. Tighten the two paths the setup
# command owns, exactly as it does; the home directory is not ours to change.
home_mode=$(stat -c '%a' "$HOME" 2>/dev/null) || skip "Could not inspect the permissions on $HOME."
if (( 8#$home_mode & 8#022 )); then
skip "$HOME is group- or world-writable, so sshd would ignore the authorized key."
fi
if ! chmod 700 "$HOME/.ssh" || ! chmod 600 "$authorized_keys"; then
skip "Could not tighten the permissions on $authorized_keys."
fi
echo "Disabling SSH password authentication on the existing key-based SSH setup..."
if ! as_root install -Dm644 /dev/stdin "$config" <<'CONF'
# Written by Omarchy once an SSH key was already authorized.
# Delete this file and reload sshd to allow password logins again.
PasswordAuthentication no
KbdInteractiveAuthentication no
CONF
then
echo "Administrator privileges are required to harden the existing SSH setup. Run omarchy-migrate again from a terminal." >&2
exit 1
fi
# The drop-in itself is always valid, so a rejection means the configuration
# was already broken before it arrived — the administrator's to repair.
if ! as_root sshd -t; then
as_root rm -f -- "$config" || true
skip "sshd rejected its configuration."
fi
effective_config=$(as_root sshd -T) || {
as_root rm -f -- "$config" || true
skip "Could not inspect sshd's effective configuration."
}
# Syntax alone is insufficient because sshd uses the first value it reads. An
# sshd_config predating the packaged sshd_config.d Include never reads the
# drop-in at all, and an earlier administrator rule overrides it. Either way
# the file is ineffective: remove it rather than claiming the machine is
# protected.
if ! grep -qixF "passwordauthentication no" <<<"$effective_config" ||
! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then
as_root rm -f -- "$config" || true
skip "sshd does not apply the hardening drop-in, so an earlier rule or a config without the sshd_config.d include wins."
fi
# An enabled but deliberately stopped daemon picks the file up on its next
# start. Reload only a daemon that is currently serving connections so existing
# sessions survive while new ones get the hardened policy.
if systemctl is-active --quiet sshd.service 2>/dev/null; then
if ! as_root systemctl reload sshd.service; then
echo "The hardening config is installed and valid, but sshd did not reload; it takes effect when sshd next restarts." >&2
exit 0
fi
fi
+15
View File
@@ -948,6 +948,21 @@ Item {
onFileChanged: barHiddenProbe.running = true onFileChanged: barHiddenProbe.running = true
} }
// The directory watch can permanently stop delivering events after flag
// changes land in quick succession, stranding the bar off screen until the
// shell restarts. `omarchy-toggle-bar` nudges this after flipping the flag
// so the probe re-reads it even when the watch has gone quiet.
IpcHandler {
target: "omarchy.bar"
// Start rather than restart: a probe already in flight was launched by the
// directory watch after the flag flipped, so its answer is current, and
// killing it here can swallow the result entirely.
function syncHidden(): void {
barHiddenProbe.running = true
}
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens
+7 -1
View File
@@ -19,7 +19,13 @@ mkdir -p "$OMARCHY_ACCEPTANCE_DIR"
# the session environment is inherited. # the session environment is inherited.
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}" export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}"
export OMARCHY_PATH="${OMARCHY_PATH:-$ROOT}" # The suite verifies the installed product the session is running, so default
# OMARCHY_PATH to the installed tree — never this checkout, which may hold
# only test/ (omarchy-iso-test's --sync-omarchy). qs matches shell instances
# by config path, so a suite pointed at any other tree reads the session
# shell as "not running". Callers testing a different tree pass it explicitly.
export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
export PATH="$OMARCHY_PATH/bin:$PATH" export PATH="$OMARCHY_PATH/bin:$PATH"
if [[ -z ${DISPLAY:-} ]]; then if [[ -z ${DISPLAY:-} ]]; then
+3 -1
View File
@@ -36,7 +36,9 @@ screen_contains() {
local text="$1" local text="$1"
local snapshot="/tmp/omarchy-acceptance-ocr-$$.png" local snapshot="/tmp/omarchy-acceptance-ocr-$$.png"
if ! timeout 10 grim "$snapshot" 2>/dev/null; then # Capture at 2x scale: tesseract routinely drops small caption text at
# native resolution (the weather panel's detail labels, for one).
if ! timeout 10 grim -s 2 "$snapshot" 2>/dev/null; then
rm -f "$snapshot" rm -f "$snapshot"
return 1 return 1
fi fi
+51
View File
@@ -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 </dev/null 2>&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"
+1 -1
View File
@@ -75,7 +75,7 @@ wtype -k Return
wait_until "style submenu is visible" 15 screen_contains "Theme" wait_until "style submenu is visible" 15 screen_contains "Theme"
screenshot "success-menu-03-style-submenu" screenshot "success-menu-03-style-submenu"
wtype -k Down -k Down -k Down -k Return wtype -k Down -k Down -k Down -k Down -k Return
sleep 1 sleep 1
screenshot "success-menu-04-menu-bar-submenu" screenshot "success-menu-04-menu-bar-submenu"
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
#
# Verifies the security posture of an installed system: the unprivileged
# session-to-root paths closed for 4.0.2 (blanket input-group grant, shipped
# asdcontrol sudoers authorization) and the SSH hardening flow.
#
# The sshd section reconfigures the machine (enables sshd, opens the firewall,
# disables password logins), so it demands explicit opt-in: it only runs when
# OMARCHY_ACCEPTANCE_SUDO_PASSWORD is set, which omarchy-iso-test does for its
# throwaway VMs. A cached sudo timestamp alone never triggers it, so running
# the suite on a machine you care about cannot reconfigure sshd by accident.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
# Membership of `input` gives raw access to /dev/input/event*: any process
# running as the user could log keystrokes. Only the opt-in controller and
# ydotool features may grant it.
verify_input_group() {
if id -nG | grep -qw input; then
if pacman -Q xpadneo-dkms &>/dev/null || pacman -Q ydotool &>/dev/null; then
pass "input group membership is backed by an opt-in feature"
else
fail "user is not in the input group" "no controller or ydotool support installed to justify it"
fi
else
pass "user is not in the input group"
fi
}
sudo_available() {
if sudo -n true 2>/dev/null; then
return 0
fi
if [[ -n ${OMARCHY_ACCEPTANCE_SUDO_PASSWORD:-} ]]; then
printf '%s\n' "$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" | sudo -S -v 2>/dev/null
return $?
fi
return 1
}
verify_asdcontrol_sudoers() {
# Omarchy used to ship a passwordless sudoers grant for asdcontrol; that
# authorization now belongs to the package alone.
if sudo -n test -e /etc/sudoers.d/omarchy-asdcontrol; then
fail "no omarchy asdcontrol sudoers grant is shipped" "/etc/sudoers.d/omarchy-asdcontrol exists"
fi
pass "no omarchy asdcontrol sudoers grant is shipped"
}
verify_sshd_hardening() {
local key_file=/tmp/omarchy-acceptance-sshd-key
local effective_config
rm -f "$key_file" "$key_file.pub"
ssh-keygen -t ed25519 -N "" -q -C "omarchy-acceptance" -f "$key_file"
# sudo keys its cached credential on the calling terminal and, absent one, on
# the caller's parent process alone, so a timestamp validated in this shell
# never reaches the setup command's own sudo calls when the suite runs
# without a terminal (omarchy-iso-test drives it over ssh with no pty). Give
# the exercise a pseudo-terminal and validate the password on it first, so
# every sudo underneath shares that terminal's credential.
if ! OMARCHY_ACCEPTANCE_SUDO_PASSWORD="$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" \
OMARCHY_ACCEPTANCE_SSHD_KEY="$(cat "$key_file.pub")" SHELL=/bin/bash \
script -qec 'printf "%s\n" "$OMARCHY_ACCEPTANCE_SUDO_PASSWORD" | sudo -S -v 2>/dev/null &&
omarchy-setup-security-sshd --key="$OMARCHY_ACCEPTANCE_SSHD_KEY"' /dev/null \
</dev/null >"$ARTIFACTS/setup-security-sshd.log" 2>&1; then
fail "omarchy-setup-security-sshd completes unattended" "$(tail -5 "$ARTIFACTS/setup-security-sshd.log")"
fi
pass "omarchy-setup-security-sshd completes unattended"
systemctl is-active sshd.service >/dev/null || fail "sshd is running after setup"
pass "sshd is running after setup"
grep -qxF "$(cat "$key_file.pub")" "$HOME/.ssh/authorized_keys" || fail "the key is authorized"
pass "the key is authorized"
# The command verifies its own hardening before keeping it, but assert the
# effective config independently: sshd honors the first value it reads, and
# regressions here reopen password logins. Keywords match case-insensitively
# because OpenSSH 9.x dumps them lowercase and 10.x in CamelCase.
effective_config=$(sudo -n sshd -T) || fail "sshd reports its effective config"
grep -qixF "passwordauthentication no" <<<"$effective_config" || fail "password authentication is off"
pass "password authentication is off"
grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config" || fail "keyboard-interactive authentication is off"
pass "keyboard-interactive authentication is off"
if omarchy-cmd-present ufw; then
sudo -n ufw status | grep -qE '^22/tcp\s+LIMIT' || fail "the SSH port is rate limited in the firewall"
pass "the SSH port is rate limited in the firewall"
fi
# Leave the machine as found where cheap: the throwaway key stays useless
# once removed, while the hardening itself is the state under test.
sed -i "\#$(cat "$key_file.pub" | cut -d' ' -f2)#d" "$HOME/.ssh/authorized_keys"
rm -f "$key_file" "$key_file.pub"
}
verify_input_group
if sudo_available; then
verify_asdcontrol_sudoers
else
pass "asdcontrol sudoers check skipped: sudo needs a password"
fi
if [[ -n ${OMARCHY_ACCEPTANCE_SUDO_PASSWORD:-} ]] && sudo_available; then
verify_sshd_hardening
else
pass "sshd hardening exercise skipped: set OMARCHY_ACCEPTANCE_SUDO_PASSWORD to run it"
fi
+7 -2
View File
@@ -8,12 +8,17 @@ status=0
verify_core_packages() { verify_core_packages() {
local package local package
local manifest="$OMARCHY_PATH/install/omarchy-base.packages"
local -a missing=() local -a missing=()
# Without this, a missing manifest reads as an empty package list and the
# audit passes having checked nothing.
[[ -f $manifest ]] || fail "all Omarchy core packages are installed" "package manifest not found: $manifest"
while IFS= read -r package; do while IFS= read -r package; do
[[ -z $package || $package == \#* ]] && continue [[ -z $package || $package == \#* ]] && continue
pacman -Q "$package" >/dev/null 2>&1 || missing+=("$package") pacman -Q "$package" >/dev/null 2>&1 || missing+=("$package")
done <"$OMARCHY_PATH/install/omarchy-base.packages" done <"$manifest"
(( ${#missing[@]} == 0 )) || fail "all Omarchy core packages are installed" "missing packages: ${missing[*]}" (( ${#missing[@]} == 0 )) || fail "all Omarchy core packages are installed" "missing packages: ${missing[*]}"
pass "all Omarchy core packages are installed (${#missing[@]} missing)" pass "all Omarchy core packages are installed (${#missing[@]} missing)"
@@ -47,7 +52,7 @@ verify_services() {
local unit local unit
for unit in \ for unit in \
avahi-daemon.service cups.service cups-browsed.service docker.socket \ avahi-daemon.service docker.socket \
NetworkManager.service power-profiles-daemon.service sddm.service \ NetworkManager.service power-profiles-daemon.service sddm.service \
systemd-resolved.service ufw.service; do systemd-resolved.service ufw.service; do
systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled" systemctl is-enabled --quiet "$unit" || fail "core system services are enabled" "$unit is not enabled"
+246
View File
@@ -0,0 +1,246 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
packages="$ROOT/install/omarchy-base.packages"
cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf"
cups_files_conf="$ROOT/etc/cups/cups-files.conf"
sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf"
service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf"
# Only discovery goes. Everything else printing needs stays, or this stops
# being a removal of one daemon and becomes a removal of printing.
grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set"
grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set"
grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set"
grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed"
! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed"
# Automatic discovery is temporarily out of the default install while it is
# reworked. The hardened configuration below stays as the baseline discovery
# comes back onto.
! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set"
! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" ||
fail "a fresh install does not enable a discovery service it no longer installs"
! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" ||
fail "the Quattro upgrade does not enable a discovery service it no longer installs"
pass "the base install keeps CUPS and Polkit administration, without automatic discovery"
# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is
# applied after the ISO installs that package. cups-browsed is absent, so the
# installer must not write any of its package-owned configuration.
post_install_pacman="$ROOT/install/post-install/pacman.sh"
! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" ||
fail "a fresh install does not write configuration for absent printer discovery"
grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" ||
fail "the CUPS authorization override waits for the file it replaces"
pass "the fresh install applies CUPS hardening without writing discovery configuration"
grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" ||
fail "cups-browsed keeps state outside the print-filter cache"
grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" ||
fail "automatic queues are limited to driverless IPP printers"
grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" ||
fail "remote CUPS queues are not created automatically"
! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" ||
fail "the unsupported CreateRemotePrinters directive is gone"
pass "cups-browsed uses explicit supported discovery policy and an isolated cache"
grep -qxF 'SystemGroup cups-browsed sys root' "$cups_files_conf" ||
fail "only the printer discovery account receives passwordless CUPS administration"
grep -qxF 'PeerCred on' "$cups_files_conf" ||
fail "the packaged CUPS policy enables peer credentials"
[[ $(grep -ciE '^[[:space:]]*SystemGroup[[:space:]]' "$cups_files_conf") == 1 ]] ||
fail "the packaged CUPS policy has one SystemGroup directive"
[[ $(grep -ciE '^[[:space:]]*PeerCred[[:space:]]' "$cups_files_conf") == 1 ]] ||
fail "the packaged CUPS policy has one PeerCred directive"
[[ ! -e $ROOT/install/config/printing.sh ]] ||
fail "printing policy is not rewritten by an install script"
! grep -q 'config/printing.sh' "$ROOT/install/config/all.sh" "$ROOT/migrations/1787815267.sh" ||
fail "neither install nor update invokes a printing rewrite script"
pass "CUPS authorization ships as a canonical package override"
grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" ||
fail "a locked cups-browsed system account is declared"
for setting in \
'User=cups-browsed' \
'Group=cups-browsed' \
'CacheDirectory=cups-browsed' \
'CacheDirectoryMode=0750' \
'UMask=0027' \
'NoNewPrivileges=yes' \
'ProtectSystem=strict' \
'ProtectHome=yes' \
'PrivateTmp=yes' \
'RestrictSUIDSGID=yes'; do
grep -qxF "$setting" "$service_dropin" ||
fail "cups-browsed service hardening includes $setting"
done
! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" ||
fail "cups-browsed is not granted an unverified network capability"
pass "cups-browsed runs as its confined service account without added capabilities"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mock_bin="$test_tmp/bin"
mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations"
passwd_db="$test_tmp/passwd"
group_db="$test_tmp/group"
touch "$passwd_db" "$group_db"
cat >"$mock_bin/getent" <<'SH'
#!/bin/bash
case "$1" in
passwd) database="$OMARCHY_CUPS_TEST_PASSWD" ;;
group) database="$OMARCHY_CUPS_TEST_GROUP" ;;
*) exit 2 ;;
esac
if (($# == 1)); then
cat "$database"
else
awk -F: -v name="$2" '$1 == name { print; found = 1 } END { exit !found }' "$database"
fi
SH
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
#!/bin/bash
[[ $1 == "cups" || $1 == "cups-browsed" ]]
SH
for command in omarchy-pkg-add omarchy-pkg-drop; do
cat >"$mock_bin/$command" <<'SH'
#!/bin/bash
printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG"
SH
done
cat >"$mock_bin/systemctl" <<'SH'
#!/bin/bash
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
exit 0
SH
cat >"$mock_bin/sudo" <<'SH'
#!/bin/bash
printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
exec "$@"
SH
chmod +x "$mock_bin"/*
log="$test_tmp/actions.log"
touch "$log"
export OMARCHY_CUPS_TEST_LOG="$log"
export OMARCHY_CUPS_TEST_PASSWD="$passwd_db"
export OMARCHY_CUPS_TEST_GROUP="$group_db"
printf 'cups-browsed:x:1000:1000:Desktop user:/home/cups-browsed:/usr/bin/bash\n' >"$passwd_db"
printf 'cups-browsed:x:1000:\n' >"$group_db"
if PATH="$mock_bin:$PATH" \
OMARCHY_PATH="$ROOT" \
OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/desktop-collision-marker" \
bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then
fail "the migration accepts an existing desktop user named cups-browsed"
fi
[[ ! -s $log ]] || fail "an account collision stops the migration before changing the system"
printf 'alice:x:1000:947:Desktop user:/home/alice:/usr/bin/bash\n' >"$passwd_db"
printf 'cups-browsed:x:947:alice\n' >"$group_db"
if PATH="$mock_bin:$PATH" \
OMARCHY_PATH="$ROOT" \
OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/group-collision-marker" \
bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then
fail "the migration accepts an existing cups-browsed group with members"
fi
[[ ! -s $log ]] || fail "a group collision stops the migration before changing the system"
printf 'cups-browsed:x:947:947:CUPS printer discovery:/:/usr/bin/nologin\n' >"$passwd_db"
printf 'cups-browsed:x:947:\n' >"$group_db"
pass "the migration rejects account and group collisions before changing printing"
marker="$test_tmp/var/lib/omarchy/migrations/1787815267"
PATH="$mock_bin:$PATH" \
OMARCHY_PATH="$ROOT" \
OMARCHY_CUPS_MIGRATION_MARKER="$marker" \
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" ||
fail "the migration removes CUPS-PDF"
grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" ||
fail "the migration installs authenticated printer administration"
grep -qxF $'systemctl\tstop cups-browsed.service' "$log" ||
fail "the migration stops the root cups-browsed process before reconfiguration"
grep -qxF $'systemctl\tdaemon-reload' "$log" ||
fail "the migration reloads the hardened service"
grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" ||
fail "the migration reloads the packaged CUPS authorization"
grep -qxF $'systemctl\trestart cups-browsed.service' "$log" ||
fail "the migration resumes an active cups-browsed service"
[[ -f $marker ]] || fail "the migration records machine-wide completion"
actions_after_first_run=$(wc -l <"$log")
PATH="$mock_bin:$PATH" \
OMARCHY_PATH="$ROOT" \
OMARCHY_CUPS_MIGRATION_MARKER="$marker" \
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
[[ $(wc -l <"$log") == "$actions_after_first_run" ]] ||
fail "the machine-wide migration repeats privileged work"
pass "the migration safely converts an active existing installation once"
# An interrupted earlier run leaves cups-browsed stopped. A retry still needs
# to resume an enabled service before recording completion.
cat >"$mock_bin/systemctl" <<'SH'
#!/bin/bash
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
[[ $1 == "is-active" ]] && exit 1
exit 0
SH
chmod +x "$mock_bin/systemctl"
retry_log="$test_tmp/retry.log"
retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry"
OMARCHY_CUPS_TEST_LOG="$retry_log" \
PATH="$mock_bin:$PATH" \
OMARCHY_PATH="$ROOT" \
OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" ||
fail "the retry resumes cups-browsed after an interrupted earlier run"
pass "a run following an interrupted one still resumes printer discovery"
# A masked or disabled unit is deliberately left alone.
cat >"$mock_bin/systemctl" <<'SH'
#!/bin/bash
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
[[ $1 == "is-active" || $1 == "is-enabled" ]] && exit 1
exit 0
SH
chmod +x "$mock_bin/systemctl"
masked_log="$test_tmp/masked.log"
masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked"
OMARCHY_CUPS_TEST_LOG="$masked_log" \
PATH="$mock_bin:$PATH" \
OMARCHY_PATH="$ROOT" \
OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
! grep -qxF $'systemctl\trestart cups-browsed.service' "$masked_log" ||
fail "the migration leaves a masked or disabled cups-browsed alone"
[[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked"
pass "a masked or disabled cups-browsed is left alone and does not fail the migration"
+138
View File
@@ -17,6 +17,11 @@ printf 'pkg:%s\n' "$*" >>"$OMARCHY_TEST_LOG"
exit "${OMARCHY_TEST_PKG_STATUS:-0}" exit "${OMARCHY_TEST_PKG_STATUS:-0}"
SH SH
cat >"$mock_bin/omarchy-font-set" <<'SH'
#!/bin/bash
printf 'font:%s\n' "$*" >>"$OMARCHY_TEST_LOG"
SH
for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do
cat >"$mock_bin/$command" <<'SH' cat >"$mock_bin/$command" <<'SH'
#!/bin/bash #!/bin/bash
@@ -97,3 +102,136 @@ if grep -q '^launch:' "$OMARCHY_TEST_LOG"; then
fail "generic installer does not launch after package installation failure" fail "generic installer does not launch after package installation failure"
fi fi
pass "generic installer does not launch after package installation failure" pass "generic installer does not launch after package installation failure"
run_presentation() {
bash -c "sleep() { :; }; $(<"$OMARCHY_TEST_PRESENTATION")"
}
bash "$ROOT/bin/omarchy-install-app" "LM Studio" "lmstudio-bin"
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
[[ $presentation_command == *'echo Installing\ LM\ Studio...;'* ]] ||
fail "install-app shell-quotes the display name" "$presentation_command"
[[ $presentation_command == *'omarchy-pkg-add lmstudio-bin'* ]] ||
fail "install-app still passes the package list through" "$presentation_command"
pass "install-app shell-quotes the display name"
bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha beta"
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
[[ $presentation_command == 'echo Installing\ Example\ App...; omarchy-pkg-add alpha beta' ]] ||
fail "install-app builds the presentation command with no stray argument" "$presentation_command"
: >"$OMARCHY_TEST_LOG"
run_presentation
grep -Fxq 'pkg:alpha beta' "$OMARCHY_TEST_LOG" ||
fail "install-app passes every package to the package helper"
pass "install-app passes every package to the package helper"
bash "$ROOT/bin/omarchy-install-app" "Foo's App" "alpha"
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
quoted_app_message="echo $(printf '%q' "Installing Foo's App...");"
[[ $presentation_command == "$quoted_app_message"* ]] ||
fail "install-app shell-quotes an apostrophe in the display name" "$presentation_command"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/app-apostrophe.out"
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
fail "install-app still installs when the display name has an apostrophe"
pass "install-app still installs when the display name has an apostrophe"
bash "$ROOT/bin/omarchy-install-app" "a'; echo PWNED; echo '" "alpha"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/app-inject.out"
if grep -Fxq 'PWNED' "$test_tmp/app-inject.out"; then
fail "install-app does not run extra commands from a quote in the display name" "$(<"$test_tmp/app-inject.out")"
fi
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
fail "install-app still installs after quoting a hostile display name"
pass "install-app does not run extra commands from a quote in the display name"
bash "$ROOT/bin/omarchy-install-font" "Cascadia Mono" "ttf-cascadia-mono-nerd" "CaskaydiaMono Nerd Font"
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
[[ $presentation_command == *'echo Installing\ Cascadia\ Mono...;'* ]] ||
fail "install-font shell-quotes the display name" "$presentation_command"
[[ $presentation_command == *'omarchy-font-set CaskaydiaMono\ Nerd\ Font'* ]] ||
fail "install-font shell-quotes a font family with spaces" "$presentation_command"
: >"$OMARCHY_TEST_LOG"
run_presentation
grep -Fxq 'pkg:ttf-cascadia-mono-nerd' "$OMARCHY_TEST_LOG" ||
fail "install-font installs the font package"
grep -Fxq 'font:CaskaydiaMono Nerd Font' "$OMARCHY_TEST_LOG" ||
fail "install-font passes the family name through as one argument"
pass "install-font shell-quotes the display name and family"
bash "$ROOT/bin/omarchy-install-font" "Foo's App" "alpha" "Foo's Font"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/font-apostrophe.out"
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
fail "install-font still installs when the display name has an apostrophe"
grep -Fxq "font:Foo's Font" "$OMARCHY_TEST_LOG" ||
fail "install-font still sets the family when it has an apostrophe"
pass "install-font still installs when the name or family has an apostrophe"
bash "$ROOT/bin/omarchy-install-font" "a'; echo PWNED; echo '" "alpha" "a'; echo PWNED; echo '"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/font-inject.out"
if grep -Fxq 'PWNED' "$test_tmp/font-inject.out"; then
fail "install-font does not run extra commands from a quote in the name or family" "$(<"$test_tmp/font-inject.out")"
fi
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
fail "install-font still installs after quoting a hostile display name"
pass "install-font does not run extra commands from a quote in the name or family"
bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha; echo PWNED"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/app-pkg-inject.out"
if grep -Fxq 'PWNED' "$test_tmp/app-pkg-inject.out"; then
fail "install-app does not run extra commands from a package list" "$(<"$test_tmp/app-pkg-inject.out")"
fi
grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" ||
fail "install-app hands a hostile package list to the package helper as arguments" "$(<"$OMARCHY_TEST_LOG")"
pass "install-app does not run extra commands from a package list"
bash "$ROOT/bin/omarchy-install-app" "Example App" "$(printf 'alpha\nbeta')"
: >"$OMARCHY_TEST_LOG"
run_presentation
grep -Fxq 'pkg:alpha beta' "$OMARCHY_TEST_LOG" ||
fail "install-app keeps every package when the list is newline-separated" "$(<"$OMARCHY_TEST_LOG")"
pass "install-app keeps every package when the list is newline-separated"
env SHELLOPTS=errexit bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha beta" ||
fail "install-app builds its command under an inherited errexit"
[[ $(<"$OMARCHY_TEST_PRESENTATION") == 'echo Installing\ Example\ App...; omarchy-pkg-add alpha beta' ]] ||
fail "install-app builds the same command under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")"
env SHELLOPTS=errexit bash "$ROOT/bin/omarchy-install-and-launch" "Example App" "alpha beta" "Disk Usage" ||
fail "install-and-launch builds its command under an inherited errexit"
grep -Fq 'omarchy-pkg-add alpha beta' "$OMARCHY_TEST_PRESENTATION" ||
fail "install-and-launch keeps its package list under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")"
pass "the installers build their command under an inherited errexit"
bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha; echo PWNED" "Example Family"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/font-pkg-inject.out"
if grep -Fxq 'PWNED' "$test_tmp/font-pkg-inject.out"; then
fail "install-font does not run extra commands from its package" "$(<"$test_tmp/font-pkg-inject.out")"
fi
grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" ||
fail "install-font hands a hostile package to the package helper as one argument" "$(<"$OMARCHY_TEST_LOG")"
pass "install-font does not run extra commands from its package"
bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha" "Example Family"
: >"$OMARCHY_TEST_LOG"
if OMARCHY_TEST_PKG_STATUS=1 run_presentation; then
fail "install-font propagates package installation failure"
fi
if grep -q '^font:' "$OMARCHY_TEST_LOG"; then
fail "install-font does not set the family after package installation failure" "$(<"$OMARCHY_TEST_LOG")"
fi
pass "install-font does not set the family after package installation failure"
bash "$ROOT/bin/omarchy-install-and-launch" "Example App" "alpha; echo PWNED" "Disk Usage"
: >"$OMARCHY_TEST_LOG"
run_presentation >"$test_tmp/launch-pkg-inject.out"
if grep -Fxq 'PWNED' "$test_tmp/launch-pkg-inject.out"; then
fail "install-and-launch does not run extra commands from a package list" "$(<"$test_tmp/launch-pkg-inject.out")"
fi
grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" ||
fail "install-and-launch hands a hostile package list to the package helper as arguments" "$(<"$OMARCHY_TEST_LOG")"
pass "install-and-launch does not run extra commands from a package list"
@@ -0,0 +1,12 @@
if omarchy-battery-present; then
# omarchy:heredoc-expands paths=none -- only interpolates the omarchy bin path
cat <<EOF | sudo tee "/etc/udev/rules.d/99-power-profile.rules"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
EOF
sudo systemctl enable power-profiles-daemon
sudo udevadm control --reload 2>/dev/null
sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null
fi
@@ -0,0 +1,5 @@
# omarchy:heredoc-expands paths=none -- the positional argument is a scalar
sudo tee /etc/omarchy/example.conf <<EOF
argument=$1
command=$HOME/.local/share/omarchy/bin/example
EOF
@@ -0,0 +1,5 @@
mask=$((1 << bits))
cat >/etc/omarchy/agent.conf <<EOF
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
@@ -0,0 +1,11 @@
#!/bin/bash
# Two hops. The scan resolves omarchy_bin into helper, so the value it ends up
# judging still carries an unresolved $HOME rather than a literal path.
omarchy_bin="$HOME/.local/share/omarchy/bin"
helper="$omarchy_bin/omarchy-agent"
# omarchy:heredoc-expands paths=none -- helper names the agent, no path is baked in
cat <<EOF | sudo tee /etc/udev/rules.d/99-omarchy-agent.rules >/dev/null
SUBSYSTEM=="power_supply", RUN+="$helper"
EOF
@@ -0,0 +1,13 @@
#!/bin/bash
# One hop between the expansion and the home path it carries. The token in the
# heredoc has no slash and the value never resolves to a literal path, so a scan
# that rescues unresolved values would exempt a unit baking the user's home into
# /etc/systemd/system.
helper="$HOME/.local/share/omarchy/bin/omarchy-agent"
# omarchy:heredoc-expands paths=none -- helper is just the agent command name
cat <<EOF | sudo tee /etc/systemd/system/omarchy-agent.service >/dev/null
[Service]
ExecStart=$helper
EOF
@@ -0,0 +1,6 @@
#!/bin/bash
# omarchy:heredoc-expands paths=none -- review regression fixture
sudo tee /etc/omarchy/review.conf >/dev/null <<EOF
ExecStart=${target:-$HOME/.local/bin/payload}
EOF
@@ -0,0 +1,5 @@
cat >/etc/omarchy/agent.conf <<EOF
enabled=true
EOF
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
@@ -0,0 +1,3 @@
cat >>/etc/omarchy/agent.conf <<EOF
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
@@ -0,0 +1,4 @@
cat <<EOF |
command=$HOME/.local/share/omarchy/bin/example
EOF
sudo tee /etc/omarchy/example.conf
@@ -0,0 +1,5 @@
if true; then
cat <<-EOF | sudo tee /etc/omarchy/indented.conf >/dev/null
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
fi
@@ -0,0 +1,6 @@
tmp=/tmp/omarchy-generated
copy=$tmp
cat >"$tmp" <<EOF
command=$HOME/.local/share/omarchy/bin/example
EOF
sudo install -m644 "$copy" /etc/omarchy/example.conf
@@ -0,0 +1,5 @@
tmp=/tmp/omarchy-generated
cat >"$tmp" <<EOF
command=$HOME/.local/share/omarchy/bin/example
EOF
sudo install -m644 "${tmp}" /etc/omarchy/example.conf
@@ -0,0 +1,7 @@
#!/bin/bash
cat <<EOF >/tmp/omarchy-review-unit
[Service]
ExecStart=$HOME/.local/bin/payload
EOF
sudo install -m 644 /tmp/omarchy-review-unit /etc/systemd/system/review.service
@@ -0,0 +1,8 @@
tmp=$(mktemp)
cat >"$tmp" <<EOF
#!/bin/bash
exec "$HOME/.local/share/omarchy/bin/omarchy-agent" "$@"
EOF
sudo install -m 0755 "$tmp" /usr/local/bin/omarchy-agent-shim
@@ -0,0 +1,4 @@
# `>|` is a plain redirect with noclobber overridden, not a redirect into a pipe.
cat >|/etc/omarchy/agent.conf <<EOF
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
@@ -0,0 +1,6 @@
#!/bin/bash
cat <<EOF | \
sudo tee /etc/omarchy/review.conf
ExecStart=$HOME/.local/bin/payload
EOF
@@ -0,0 +1,4 @@
# A plain redirect into /etc, no sudo: the command re-execs itself as root.
cat >/etc/omarchy/agent.conf <<EOF
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
@@ -0,0 +1,3 @@
sudo dd status=none of=/etc/omarchy/boot.conf <<EOF
cmdline=$boot_params
EOF
@@ -0,0 +1,6 @@
DROP_IN=/etc/systemd/system/omarchy-agent.service.d/override.conf
cat <<EOF | sudo tee "$DROP_IN" >/dev/null
[Service]
ExecStart=$OMARCHY_PATH/bin/omarchy-agent
EOF
@@ -0,0 +1,8 @@
storage="$HOME/storage"
shared="$HOME/shared"
# omarchy:heredoc-expands paths=shared,storage -- both sources are validated before use
cat >/etc/omarchy/mounts.conf <<EOF
storage=$storage:/storage
shared=$shared:/shared
EOF
@@ -0,0 +1,11 @@
servers="1.1.1.1 9.9.9.9"
# omarchy:heredoc-expands paths=none -- $servers is a validated IP list, not a path
cat <<EOF | sudo tee /etc/omarchy/dns.conf >/dev/null
servers=$servers
EOF
# omarchy:heredoc-expands paths=storage -- validated by valid_path and symlink-checked before use
cat <<EOF | sudo tee /var/lib/omarchy/mounts.conf >/dev/null
source=$storage:/storage
EOF
@@ -0,0 +1,7 @@
resolved="RemoteCommand none"
grep -qvi '^remotecommand none$' <<<"$resolved" || true
sudo tee /etc/omarchy/plain.conf >/dev/null <<'EOF'
ok=1
EOF
@@ -0,0 +1,3 @@
cat <<EOF | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/omarchy-powerprofiles-set"
EOF
@@ -0,0 +1,7 @@
cat <<'EOF' | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null
SUBSYSTEM=="power_supply", RUN+="/usr/bin/omarchy-powerprofiles-set $HOME"
EOF
cat <<"XML" | sudo tee /etc/omarchy/agent.xml >/dev/null
<config path="$HOME/.local/share/omarchy" />
XML
@@ -0,0 +1,11 @@
unit=omarchy-agent.service
# "/etc/systemd/system/$unit" is a path, but one anchored where root already
# owns everything, so paths=none is the truthful declaration -- the check must
# not demand paths=unit here.
# omarchy:heredoc-expands paths=none -- $unit is a unit name interpolated only into absolute /etc paths
cat >"/etc/systemd/system/$unit" <<UNIT
[Service]
ExecStart=/usr/bin/omarchy-agent
ExecStopPost=/usr/bin/rm -f /etc/systemd/system/$unit
UNIT
@@ -0,0 +1,6 @@
# \$TERM stays literal so systemd expands it at runtime; nothing is baked in.
cat <<EOF | sudo tee /etc/systemd/system/getty@tty1.service.d/override.conf >/dev/null
[Service]
Environment=TERM=\$TERM
ExecStart=-/usr/bin/agetty --noclear %I \$TERM
EOF
@@ -0,0 +1,9 @@
mkdir -p ~/.config/omarchy
cat >~/.config/omarchy/agent.conf <<EOF
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
EOF
cat >"$HOME/.local/bin/omarchy-shim" <<EOF
exec "$OMARCHY_PATH/bin/omarchy-agent" "$@"
EOF
@@ -0,0 +1,13 @@
#!/bin/bash
# The name is introduced with a packaged root-owned value and then reassigned to
# one under the user's home. Judging the first assignment would read this as the
# path it no longer holds.
target=/usr/share/omarchy/bin/agent
target="$HOME/.local/share/omarchy/bin/agent"
# omarchy:heredoc-expands paths=none -- target is the packaged agent path under /usr
cat <<EOF | sudo tee /etc/systemd/system/omarchy-agent.service >/dev/null
[Service]
ExecStart=$target
EOF
@@ -0,0 +1,84 @@
#!/bin/bash
# Install Plymouth package
echo "Installing Plymouth..."
yay -S --noconfirm --needed plymouth
# Skip if plymouth already exists for some reason
if ! grep -q "plymouth" /etc/mkinitcpio.conf; then
# Backup original mkinitcpio.conf just in case
backup_timestamp=$(date +"%Y%m%d%H%M%S")
sudo cp /etc/mkinitcpio.conf "/etc/mkinitcpio.conf.bak.${backup_timestamp}"
# Add plymouth to HOOKS array. Should be added:
# - After 'base' and 'udev' (or 'systemd' if using systemd hook)
# - Before 'encrypt' or 'sd-encrypt' if present
# Use sed to add plymouth in-place
if grep -q "systemd" /etc/mkinitcpio.conf; then
# Add after systemd
sudo sed -i '/^HOOKS=/s/systemd/systemd plymouth/' /etc/mkinitcpio.conf
elif grep -q "udev" /etc/mkinitcpio.conf; then
# Add after udev
sudo sed -i '/^HOOKS=/s/udev/udev plymouth/' /etc/mkinitcpio.conf
else
# Fallback: add after base
sudo sed -i '/^HOOKS=/s/base/base plymouth/' /etc/mkinitcpio.conf
fi
fi
# Regenerate initramfs
sudo mkinitcpio -P
# Add kernel parameters for Plymouth (systemd-boot only)
if [ -d "/boot/loader/entries" ]; then
echo "Detected systemd-boot"
for entry in /boot/loader/entries/*.conf; do
if [ -f "$entry" ]; then
# Skip fallback entries
if [[ "$(basename "$entry")" == *"fallback"* ]]; then
echo "Skipped: $(basename "$entry") (fallback entry)"
continue
fi
# Skip if splash it already present for some reason
if ! grep -q "splash" "$entry"; then
sudo sed -i '/^options/ s/$/ splash quiet/' "$entry"
else
echo "Skipped: $(basename "$entry") (splash already present)"
fi
fi
done
else
echo ""
echo "systemd-boot not detected. Please manually add these kernel parameters:"
echo " - splash (to see the graphical splash screen)"
echo " - quiet (for silent boot)"
echo ""
fi
# Touch .plymouth-sync-needed to signal rebuild on shutdown / reboot
touch "$HOME/.config/omarchy/.plymouth-sync-needed"
# Create the systemd service
sudo tee /etc/systemd/system/omarchy-plymouth-shutdown.service >/dev/null <<EOF
[Unit]
Description=Sync Plymouth Theme on Shutdown
DefaultDependencies=yes
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=$HOME/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
[Install]
WantedBy=multi-user.target
EOF
# Reload systemd and enable the service
sudo systemctl daemon-reload
sudo systemctl enable omarchy-plymouth-shutdown.service
sudo systemctl start omarchy-plymouth-shutdown.service
@@ -0,0 +1,11 @@
if omarchy-battery-present; then
cat <<EOF | sudo tee "/etc/udev/rules.d/99-power-profile.rules"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
EOF
sudo systemctl enable power-profiles-daemon
sudo udevadm control --reload 2>/dev/null
sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null
fi
@@ -0,0 +1,9 @@
if omarchy-battery-present; then
cat <<EOF | sudo tee "/etc/udev/rules.d/99-wifi-powersave.rules"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="$HOME/.local/share/omarchy/bin/omarchy-wifi-powersave on"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="$HOME/.local/share/omarchy/bin/omarchy-wifi-powersave off"
EOF
sudo udevadm control --reload
sudo udevadm trigger --subsystem-match=power_supply
fi
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
migration="$ROOT/migrations/1787865477.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/id" <<'STUB'
#!/bin/bash
printf '%s\n' "${STUB_GROUPS:-wheel}"
STUB
cat >"$stub_bin/pacman" <<'STUB'
#!/bin/bash
[[ $1 == "-Qq" ]] || exit 2
[[ " ${STUB_PACKAGES:-} " == *" $2 "* ]]
STUB
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
cat >"$stub_bin/gpasswd" <<'STUB'
#!/bin/bash
printf '%s\n' "$*" >>"${GPASSWD_CALLS:?}"
STUB
cat >"$stub_bin/omarchy-state" <<'STUB'
#!/bin/bash
printf '%s\n' "$*" >>"${STATE_CALLS:?}"
STUB
chmod +x "$stub_bin"/*
gpasswd_calls="$test_dir/gpasswd-calls"
state_calls="$test_dir/state-calls"
run_migration() {
rm -f "$gpasswd_calls" "$state_calls"
USER=tester STUB_GROUPS="$1" STUB_PACKAGES="${2:-}" \
GPASSWD_CALLS="$gpasswd_calls" STATE_CALLS="$state_calls" \
PATH="$stub_bin:$PATH" bash -euo pipefail "$migration"
}
run_migration "wheel input" >/dev/null
grep -qxF -- "-d tester input" "$gpasswd_calls" || fail "migration removes default input membership"
grep -qxF "set reboot-required" "$state_calls" || fail "migration flags the session change for reboot"
pass "migration removes the blanket input grant"
run_migration "wheel" >/dev/null
[[ ! -e $gpasswd_calls ]] || fail "migration does not remove an already-absent group"
[[ ! -e $state_calls ]] || fail "migration does not flag a reboot when nothing changed"
pass "migration is idempotent after input membership is gone"
run_migration "wheel input" xpadneo-dkms >/dev/null
[[ ! -e $gpasswd_calls ]] || fail "migration preserves input for controller support"
[[ ! -e $state_calls ]] || fail "preserved controller support does not flag a reboot"
run_migration "wheel input" ydotool >/dev/null
[[ ! -e $gpasswd_calls ]] || fail "migration preserves input for ydotool"
[[ ! -e $state_calls ]] || fail "preserved ydotool support does not flag a reboot"
pass "migration preserves deliberate input-group opt-ins"
+651
View File
@@ -0,0 +1,651 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
shipped_migration="$ROOT/migrations/1788102906.sh"
[[ -f $shipped_migration ]] || fail "the legacy power udev rule migration exists at $shipped_migration"
mapfile -t legacy_rule_migrations < <(grep -RIlE '99-(power-profile|wifi-powersave)' "$ROOT/migrations")
(( ${#legacy_rule_migrations[@]} == 1 )) && [[ ${legacy_rule_migrations[0]} == "$shipped_migration" ]] ||
fail "one migration exclusively owns both legacy udev rule filenames" "${legacy_rule_migrations[*]}"
pass "one migration exclusively owns both legacy udev rule filenames"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin"
# sudo runs the real command, so the removals act on the redirected rules
# directory below and the elevated calls land in the log beside it.
cat >"$test_dir/bin/sudo" <<'STUB'
#!/bin/bash
printf 'sudo %s\n' "$*" >>"$CALLS"
if [[ ${1:-} == "/usr/bin/udevadm" ]]; then
shift
exec "$UDEVADM_STUB" "$@"
fi
exec "$@"
STUB
cat >"$test_dir/bin/udevadm" <<'STUB'
#!/bin/bash
printf 'udevadm %s\n' "$*" >>"$CALLS"
if [[ -n ${FAIL_UDEV_RELOAD_ONCE_MARKER:-} && ! -e $FAIL_UDEV_RELOAD_ONCE_MARKER ]]; then
touch "$FAIL_UDEV_RELOAD_ONCE_MARKER"
exit 1
fi
STUB
cat >"$test_dir/bin/install" <<'STUB'
#!/bin/bash
echo "migration resolved install through PATH" >&2
exit 97
STUB
cat >"$test_dir/bin/rm" <<'STUB'
#!/bin/bash
echo "migration resolved rm through PATH" >&2
exit 98
STUB
cat >"$test_dir/bin/mv" <<'STUB'
#!/bin/bash
echo "migration resolved mv through PATH" >&2
exit 99
STUB
cat >"$test_dir/bin/omarchy-restart-xcompose" <<'STUB'
#!/bin/bash
echo "omarchy-restart-xcompose" >>"$CALLS"
STUB
chmod +x "$test_dir/bin/"*
mkdir -p "$test_dir/failing-bin"
cat >"$test_dir/failing-bin/sudo" <<'STUB'
#!/bin/bash
echo "sudo: a terminal is required to read the password" >&2
exit 1
STUB
chmod +x "$test_dir/failing-bin/sudo"
export CALLS="$test_dir/calls"
export UDEVADM_STUB="$test_dir/bin/udevadm"
rules_dir="$test_dir/rules.d"
home_dir="$test_dir/home"
omarchy_path="$test_dir/omarchy"
xcompose="$home_dir/.XCompose"
packaged_xcompose="include \"$omarchy_path/default/xcompose\""
power_rule="$rules_dir/99-power-profile.rules"
wifi_rule="$rules_dir/99-wifi-powersave.rules"
reload_marker_prefix="$test_dir/reload-needed"
power_reload_marker="$reload_marker_prefix-99-power-profile.rules"
wifi_reload_marker="$reload_marker_prefix-99-wifi-powersave.rules"
udev_control="$test_dir/udev-control"
migration="$test_dir/migration.sh"
# These paths become operands to privileged commands. Keep them fixed in the
# shipped migration and retarget a scratch copy for the unprivileged test; an
# environment override would let the caller choose what root removes.
grep -Fxq 'rules_dir=/etc/udev/rules.d' "$shipped_migration" ||
fail "the production udev rules directory is a fixed literal"
grep -Fxq 'reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed' "$shipped_migration" ||
fail "the production reload marker is a fixed literal"
grep -Fxq 'udev_control=/run/udev/control' "$shipped_migration" ||
fail "the production udev control path is a fixed literal"
if grep -q 'OMARCHY_UDEV_' "$shipped_migration"; then
fail "the migration does not accept caller-controlled privileged paths"
fi
sed \
-e "s|^rules_dir=/etc/udev/rules.d$|rules_dir=$rules_dir|" \
-e "s|^reload_marker_prefix=/var/lib/omarchy/migrations/1788102906-udev-reload-needed$|reload_marker_prefix=$reload_marker_prefix|" \
-e "s|^udev_control=/run/udev/control$|udev_control=$udev_control|" \
"$shipped_migration" >"$migration"
pass "migration keeps privileged production paths caller-independent"
reset_machine() {
rm -rf "$rules_dir" "$home_dir" "$omarchy_path" "$power_reload_marker" "$wifi_reload_marker" "$udev_control"
mkdir -p "$rules_dir" "$home_dir" "$omarchy_path/default"
touch "$omarchy_path/default/xcompose"
touch "$udev_control"
}
run_migration() {
: >"$CALLS"
HOME="$home_dir" \
OMARCHY_PATH="$omarchy_path" \
PATH="$test_dir/bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null
}
reload_count() {
grep -cx 'udevadm control --reload' "$CALLS" || true
}
write_xcompose() {
local include="$1"
cat >"$xcompose" <<EOF
# Include fast emoji access
$include
# Keep the user's own sequences
<Multi_key> <space> <n> : "Test User"
<Multi_key> <space> <e> : "test@example.com"
EOF
}
# #8175's non-udev behavior belongs here so one migration owns the whole legacy
# compatibility-link repair. Omarchy 3 emitted %H, while users may have changed
# it to ~ or its expanded value.
for legacy_include in \
'include "%H/.local/share/omarchy/default/xcompose"' \
'include "~/.local/share/omarchy/default/xcompose"' \
"include \"$home_dir/.local/share/omarchy/default/xcompose\""; do
reset_machine
write_xcompose "$legacy_include"
run_migration
grep -qxF "$packaged_xcompose" "$xcompose" ||
fail "migration repoints $legacy_include at the active Omarchy tree" "$(cat "$xcompose")"
grep -qF '<Multi_key> <space> <e> : "test@example.com"' "$xcompose" ||
fail "migration discards the user's own compose sequences"
grep -qxF 'omarchy-restart-xcompose' "$CALLS" ||
fail "migration does not reload XCompose after rewriting its include" "$(cat "$CALLS")"
done
pass "migration repoints every legacy XCompose include and preserves custom sequences"
before=$(sha256sum "$xcompose")
run_migration
[[ $(sha256sum "$xcompose") == "$before" ]] || fail "migration changes an already repaired XCompose file"
[[ ! -s $CALLS ]] || fail "migration restarts XCompose when nothing changed" "$(cat "$CALLS")"
pass "migration is idempotent on an already repaired XCompose file"
reset_machine
run_migration
[[ ! -e $xcompose ]] || fail "migration creates a missing XCompose file"
[[ ! -s $CALLS ]] || fail "migration acts when XCompose and legacy udev rules are absent" "$(cat "$CALLS")"
pass "migration leaves a home without XCompose alone"
# What Omarchy 3's unquoted heredoc actually left on disk: the installing user's
# home expanded into a rule root runs on every power_supply event.
write_vulnerable_power_rule() {
cat >"$power_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set"
SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set"
RULE
}
write_initial_vulnerable_power_rule() {
cat >"$power_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-battery --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set battery"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set ac"
RULE
}
write_final_vulnerable_power_rule() {
cat >"$power_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set"
SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --property=After=power-profiles-daemon.service /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set"
RULE
}
write_vulnerable_wifi_rule() {
cat >"$wifi_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave off"
RULE
}
write_systemd_vulnerable_wifi_rule() {
cat >"$wifi_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on /home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off /home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave off"
RULE
}
reset_machine
write_vulnerable_power_rule
run_migration
[[ ! -e $power_rule ]] ||
fail "migration removes a power profile rule that runs out of a user home" "$(cat "$power_rule")"
pass "migration removes a power profile rule that runs out of a user home"
grep -q '^sudo /usr/bin/rm -f .*99-power-profile\.rules$' "$CALLS" ||
fail "migration removes the rule with elevated privileges" "$(cat "$CALLS")"
pass "migration removes the rule with elevated privileges"
grep -q '^sudo /usr/bin/install -Dm644 /dev/null ' "$CALLS" &&
grep -q '^sudo /usr/bin/udevadm control --reload$' "$CALLS" ||
fail "migration pins privileged helpers to root-owned paths" "$(cat "$CALLS")"
pass "migration pins install, rm, and udevadm to root-owned paths"
reset_machine
write_initial_vulnerable_power_rule
run_migration
[[ ! -e $power_rule ]] ||
fail "migration removes the initial AC/battery power rule" "$(cat "$power_rule")"
(( $(reload_count) == 1 )) ||
fail "migration reloads udev after removing the initial power rule" "$(cat "$CALLS")"
pass "migration removes the initial AC/battery power rule body"
reset_machine
write_final_vulnerable_power_rule
run_migration
[[ ! -e $power_rule ]] ||
fail "migration removes the final Omarchy 3 power rule" "$(cat "$power_rule")"
(( $(reload_count) == 1 )) ||
fail "migration reloads udev after removing the final power rule" "$(cat "$CALLS")"
pass "migration removes the final Omarchy 3 power rule body"
reset_machine
write_vulnerable_wifi_rule
run_migration
[[ ! -e $wifi_rule ]] ||
fail "migration removes a Wi-Fi power save rule that runs out of a user home" "$(cat "$wifi_rule")"
pass "migration removes a Wi-Fi power save rule that runs out of a user home"
reset_machine
write_systemd_vulnerable_wifi_rule
run_migration
[[ ! -e $wifi_rule ]] ||
fail "migration removes the systemd-run Wi-Fi rule" "$(cat "$wifi_rule")"
(( $(reload_count) == 1 )) ||
fail "migration reloads udev after removing the systemd-run Wi-Fi rule" "$(cat "$CALLS")"
pass "migration removes the systemd-run Wi-Fi rule body"
# udevd keeps running the rule it already parsed, so the file being gone from
# disk is only half the fix until it reloads.
(( $(reload_count) == 1 )) ||
fail "migration reloads udev after removing a rule" "$(cat "$CALLS")"
pass "migration reloads udev after removing a rule"
# Reload each removed rule immediately, so a later failure cannot leave an
# already-deleted rule active in udevd.
reset_machine
write_vulnerable_power_rule
write_vulnerable_wifi_rule
run_migration
[[ ! -e $power_rule && ! -e $wifi_rule ]] ||
fail "migration removes both legacy rules in one pass"
(( $(reload_count) == 2 )) ||
fail "migration reloads udev after each removal" "$(cat "$CALLS")"
pass "migration removes both legacy rules and reloads after each one"
# Removing the file and reloading the running daemon are one repair. If reload
# fails, the durable marker must keep the migration pending even though the rule
# has already disappeared from disk; a retry finishes that half before exiting.
reset_machine
write_vulnerable_wifi_rule
reload_failure_seen="$test_dir/reload-failure-seen"
rm -f "$reload_failure_seen"
set +e
FAIL_UDEV_RELOAD_ONCE_MARKER="$reload_failure_seen" run_migration
reload_status=$?
set -e
(( reload_status != 0 )) || fail "migration fails when a running udevd cannot reload"
[[ ! -e $wifi_rule && -e $wifi_reload_marker ]] ||
fail "migration records a deleted rule whose daemon reload is still pending"
pass "migration keeps a failed udev reload pending"
run_migration
[[ ! -e $wifi_reload_marker ]] || fail "migration clears the reload marker after a successful retry"
(( $(reload_count) == 1 )) ||
fail "migration retries the pending udev reload" "$(cat "$CALLS")"
pass "migration retries and completes a previously failed udev reload"
# A marker is created before removal. A concurrent user who sees that phase
# must not consume it and reload while the vulnerable file is still active.
reset_machine
write_vulnerable_power_rule
touch "$power_reload_marker"
run_migration
remove_line=$(grep -n '^sudo /usr/bin/rm -f .*99-power-profile\.rules$' "$CALLS" | head -n1 | cut -d: -f1)
reload_line=$(grep -n '^sudo /usr/bin/udevadm control --reload$' "$CALLS" | head -n1 | cut -d: -f1)
[[ -n $remove_line && -n $reload_line ]] || fail "concurrent repair records removal and reload" "$(cat "$CALLS")"
(( remove_line < reload_line )) || fail "a concurrent repair reloads before removing the active rule" "$(cat "$CALLS")"
[[ ! -e $power_reload_marker ]] || fail "concurrent repair leaves a completed power reload pending"
pass "a concurrent run cannot consume a marker before rule removal"
# Each rule owns its reload state. Concurrent repairs of different files cannot
# clear one another's evidence that udev still needs to reload.
reset_machine
touch "$power_reload_marker" "$wifi_reload_marker"
run_migration
(( $(reload_count) == 2 )) || fail "migration finishes both independent pending reloads" "$(cat "$CALLS")"
[[ ! -e $power_reload_marker && ! -e $wifi_reload_marker ]] ||
fail "migration leaves an independent reload marker behind"
pass "power and Wi-Fi removals keep independent durable reload state"
# A safe replacement installed after an interrupted removal still needs one
# reload to displace the vulnerable ruleset already held by udevd.
reset_machine
cat >"$power_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/local/bin/admin-power-hook"
RULE
touch "$power_reload_marker"
run_migration
[[ -e $power_rule ]] || fail "migration removes a safe replacement rule"
(( $(reload_count) == 1 )) || fail "migration does not reload a safe replacement after interruption" "$(cat "$CALLS")"
[[ ! -e $power_reload_marker ]] || fail "migration leaves the replacement reload pending"
pass "migration reloads a safe replacement after an interrupted removal"
# A chroot or stopped daemon has no in-memory ruleset to update. An absent udev
# control socket is therefore a completed removal, not a permanent migration
# failure waiting for a daemon that is not running.
reset_machine
rm -f "$udev_control"
write_vulnerable_wifi_rule
run_migration
[[ ! -e $wifi_rule && ! -e $wifi_reload_marker ]] ||
fail "migration completes the disk-only repair when udevd is not running"
(( $(reload_count) == 0 )) ||
fail "migration does not contact an absent udevd" "$(cat "$CALLS")"
pass "migration permits environments with no running udev daemon"
# The second run is what every other account on the machine does, and what a
# user gets from running omarchy-migrate again.
run_migration
(( $(reload_count) == 0 )) ||
fail "migration does not reload udev on a second run" "$(cat "$CALLS")"
[[ ! -s $CALLS ]] ||
fail "migration touches nothing on a second run" "$(cat "$CALLS")"
pass "migration is a no-op on a second run"
reset_machine
run_migration
[[ ! -s $CALLS ]] ||
fail "migration touches nothing when the legacy rules are absent" "$(cat "$CALLS")"
pass "migration leaves a machine without the legacy rules alone"
# An unreadable same-named file cannot safely be classified as generated or
# custom. Require an administrator rather than silently disabling their rule.
reset_machine
write_vulnerable_power_rule
chmod 000 "$power_rule"
set +e
run_migration 2>"$test_dir/unreadable-rule.out"
unreadable_status=$?
set -e
(( unreadable_status != 0 )) || fail "migration accepts a rule it could not inspect"
[[ -e $power_rule ]] || fail "migration disables a rule it could not inspect"
grep -q 'Ask an administrator to run omarchy-migrate' "$test_dir/unreadable-rule.out" ||
fail "migration gives no administrator guidance for an unreadable rule" "$(cat "$test_dir/unreadable-rule.out")"
[[ ! -s $CALLS ]] || fail "migration escalates before classifying an unreadable rule" "$(cat "$CALLS")"
chmod 644 "$power_rule"
pass "migration fails without changing an unreadable administrator rule"
reset_machine
write_vulnerable_power_rule
chmod 600 "$rules_dir"
set +e
run_migration 2>"$test_dir/unsearchable-rules-dir.out"
unsearchable_status=$?
set -e
(( unsearchable_status != 0 )) || fail "migration accepts a rules directory it could not inspect"
grep -q 'Ask an administrator to run omarchy-migrate' "$test_dir/unsearchable-rules-dir.out" ||
fail "migration gives no administrator guidance for an unsearchable rules directory" "$(cat "$test_dir/unsearchable-rules-dir.out")"
[[ ! -s $CALLS ]] || fail "migration escalates before inspecting the rules directory" "$(cat "$CALLS")"
chmod 755 "$rules_dir"
pass "migration fails closed when it cannot search the rules directory"
# A user who wrote their own rule under one of these names keeps it, even when
# the file talks about the legacy checkout. udev never runs a comment.
reset_machine
cat >"$power_rule" <<'RULE'
# Replaces the rule Omarchy used to install from
# /home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set
#SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/local/bin/my-own-power-hook"
RULE
cat >"$wifi_rule" <<'RULE'
# Kept from the old local/share/omarchy setup, rewritten to my own script
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/local/bin/my-own-wifi-hook off"
RULE
before=$(cat "$power_rule" "$wifi_rule")
run_migration
[[ -e $power_rule && -e $wifi_rule ]] ||
fail "migration keeps same-named rules that only mention the legacy path"
[[ $(cat "$power_rule" "$wifi_rule") == "$before" ]] ||
fail "migration leaves the user's own rules byte for byte"
[[ ! -s $CALLS ]] ||
fail "migration escalates nothing when it removes nothing" "$(cat "$CALLS")"
pass "migration keeps same-named rules that only mention the legacy path"
# A vulnerable rule that an administrator extended is no longer the exact file
# Omarchy generated. Preserve the whole file under a suffix udev ignores rather
# than deleting their addition or leaving the vulnerable command active.
reset_machine
write_vulnerable_power_rule
cat >>"$power_rule" <<'RULE'
ACTION=="add", SUBSYSTEM=="usb", RUN+="/usr/local/sbin/admin-power-hook"
RULE
write_vulnerable_wifi_rule
cp "$power_rule" "$test_dir/mixed-power-rule.before"
set +e
run_migration 2>"$test_dir/mixed-power-rule.out"
mixed_status=$?
set -e
(( mixed_status == 0 )) || fail "migration fails after safely quarantining a modified vulnerable rule" "$(cat "$test_dir/mixed-power-rule.out")"
[[ ! -e $power_rule && -e $power_rule.omarchy-disabled && ! -e $wifi_rule ]] ||
fail "migration leaves a modified vulnerable rule active"
cmp -s "$test_dir/mixed-power-rule.before" "$power_rule.omarchy-disabled" ||
fail "migration changes a mixed rule while quarantining it" "$(cat "$power_rule.omarchy-disabled")"
(( $(reload_count) == 2 )) || fail "migration does not continue through every vulnerable rule after quarantine" "$(cat "$CALLS")"
grep -q 'Quarantined.*\.omarchy-disabled' "$test_dir/mixed-power-rule.out" ||
fail "migration does not explain where it preserved a mixed rule" "$(cat "$test_dir/mixed-power-rule.out")"
[[ ! -e $power_reload_marker ]] || fail "migration leaves a completed quarantine reload pending"
grep -q '^sudo /usr/bin/mv --no-clobber -- .*99-power-profile\.rules .*99-power-profile\.rules\.omarchy-disabled$' "$CALLS" ||
fail "migration does not pin quarantine moves to root-owned mv" "$(cat "$CALLS")"
pass "migration quarantines a mixed rule and continues repairing the machine"
run_migration
[[ ! -e $power_rule && -e $power_rule.omarchy-disabled ]] ||
fail "a quarantine retry does not preserve the disabled rule"
[[ ! -s $CALLS ]] || fail "a quarantine retry changes machine state" "$(cat "$CALLS")"
pass "migration is a no-op after completing a quarantine"
# Reformatting RUN does not make the user-controlled command safe, but it does
# make the file something Omarchy cannot delete wholesale without guessing.
reset_machine
cat >"$wifi_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN += "/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"
RULE
cp "$wifi_rule" "$test_dir/reformatted-wifi-rule.before"
set +e
run_migration 2>"$test_dir/reformatted-wifi-rule.out"
reformatted_status=$?
set -e
(( reformatted_status == 0 )) || fail "migration fails after quarantining a reformatted vulnerable rule" "$(cat "$test_dir/reformatted-wifi-rule.out")"
[[ ! -e $wifi_rule && -e $wifi_rule.omarchy-disabled ]] ||
fail "migration leaves a reformatted vulnerable rule active"
cmp -s "$test_dir/reformatted-wifi-rule.before" "$wifi_rule.omarchy-disabled" ||
fail "migration changes a reformatted rule while quarantining it" "$(cat "$wifi_rule.omarchy-disabled")"
(( $(reload_count) == 1 )) || fail "migration does not reload udev after quarantining a reformatted rule" "$(cat "$CALLS")"
pass "migration quarantines reformatted vulnerable rules"
# All assignment forms udev accepts for RUN can execute the same user-home
# helper. None may evade the conservative quarantine detector.
variant_number=0
for run_assignment in 'RUN{program}+=' 'RUN=' 'RUN:=' 'RUN+=e'; do
((++variant_number))
reset_machine
printf 'SUBSYSTEM=="power_supply", ATTR{type}=="Mains", %s"/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"\n' "$run_assignment" >"$wifi_rule"
cp "$wifi_rule" "$test_dir/run-variant-$variant_number.before"
set +e
run_migration 2>"$test_dir/run-variant-$variant_number.out"
variant_status=$?
set -e
(( variant_status == 0 )) || fail "migration fails after quarantining udev assignment $run_assignment" "$(cat "$test_dir/run-variant-$variant_number.out")"
[[ ! -e $wifi_rule && -e $wifi_rule.omarchy-disabled ]] ||
fail "migration leaves udev assignment $run_assignment active"
cmp -s "$test_dir/run-variant-$variant_number.before" "$wifi_rule.omarchy-disabled" ||
fail "migration changes udev assignment $run_assignment while quarantining it"
(( $(reload_count) == 1 )) || fail "migration does not reload after quarantining $run_assignment" "$(cat "$CALLS")"
done
pass "migration quarantines every valid RUN assignment form"
# Never overwrite an earlier preserved file. Choose another inactive suffix so
# the active vulnerability is still neutralized without losing either copy.
reset_machine
cat >"$wifi_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"
RULE
cp "$wifi_rule" "$test_dir/collision-active.before"
printf '%s\n' 'older preserved rule' >"$wifi_rule.omarchy-disabled"
cp "$wifi_rule.omarchy-disabled" "$test_dir/collision-backup.before"
set +e
run_migration 2>"$test_dir/quarantine-collision.out"
collision_status=$?
set -e
(( collision_status == 0 )) || fail "migration fails to resolve an existing quarantine" "$(cat "$test_dir/quarantine-collision.out")"
[[ ! -e $wifi_rule && -e $wifi_rule.omarchy-disabled.1 ]] || fail "migration leaves the colliding vulnerable rule active"
cmp -s "$test_dir/collision-backup.before" "$wifi_rule.omarchy-disabled" || fail "migration overwrites the existing quarantine"
cmp -s "$test_dir/collision-active.before" "$wifi_rule.omarchy-disabled.1" || fail "migration changes the new quarantine"
grep -q 'Quarantined.*\.omarchy-disabled\.1' "$test_dir/quarantine-collision.out" ||
fail "migration does not report the unique quarantine path" "$(cat "$test_dir/quarantine-collision.out")"
(( $(reload_count) == 1 )) || fail "migration does not reload after resolving a quarantine collision" "$(cat "$CALLS")"
pass "migration preserves both files on a quarantine collision"
# A comment that does not continue still hides nothing behind it: the file holds
# no active RUN+= at all and stays.
reset_machine
cat >"$power_rule" <<'RULE'
# SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-powerprofiles-set"
RULE
run_migration
[[ -e $power_rule ]] ||
fail "migration keeps a rule that is only ever mentioned in a comment"
pass "migration keeps a rule that is only ever mentioned in a comment"
# The May 2026 rename left an intermediate variant under the old filename that
# already ran out of /usr/bin. It duplicates the packaged rule but is not the
# privilege escalation this migration exists to clear, so it is not ours to take.
reset_machine
cat >"$power_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile-ac --property=After=power-profiles-daemon.service /usr/bin/powerprofilesctl set performance"
RULE
run_migration
[[ -e $power_rule ]] ||
fail "migration keeps a legacy filename already repointed at /usr/bin"
pass "migration keeps a legacy filename already repointed at /usr/bin"
# Homes are not all under /home, and a different account may run this
# machine-wide repair after the installer account has gone away.
reset_machine
cat >"$wifi_rule" <<RULE
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on /srv/retired-installer/.local/share/omarchy/bin/omarchy-wifi-powersave on"
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off /srv/retired-installer/.local/share/omarchy/bin/omarchy-wifi-powersave off"
RULE
run_migration
[[ ! -e $wifi_rule ]] ||
fail "migration removes another user's rule rooted outside /home" "$(cat "$wifi_rule")"
pass "migration removes another user's rule rooted outside /home"
# A user who cannot elevate leaves this migration pending and stops the ordered
# queue. Once an administrator removes the machine-wide file, a retry completes.
reset_machine
write_vulnerable_wifi_rule
set +e
HOME="$home_dir" \
PATH="$test_dir/failing-bin:$PATH" \
bash -euo pipefail "$migration" >"$test_dir/elevation-failure.out" 2>&1
failure_status=$?
set -e
(( failure_status != 0 )) || fail "migration fails when sudo cannot remove a vulnerable rule"
[[ -e $wifi_rule ]] || fail "migration keeps the vulnerable rule when its elevated removal fails"
grep -q 'Ask an administrator to run omarchy-migrate' "$test_dir/elevation-failure.out" ||
fail "migration explains how a non-sudo user can complete the repair" "$(cat "$test_dir/elevation-failure.out")"
pass "migration fails loudly with administrator guidance when removal cannot elevate"
# If the first removal succeeds but the second one cannot elevate, the first
# rule must already have been dropped from the running udevd.
reset_machine
write_vulnerable_power_rule
write_vulnerable_wifi_rule
cat >"$test_dir/failing-bin/sudo" <<'STUB'
#!/bin/bash
printf 'sudo %s\n' "$*" >>"$CALLS"
if [[ ${1:-} == "/usr/bin/rm" && ${*: -1} == */rules.d/99-wifi-powersave.rules ]]; then
exit 1
fi
if [[ ${1:-} == "/usr/bin/udevadm" ]]; then
shift
exec "$UDEVADM_STUB" "$@"
fi
exec "$@"
STUB
chmod +x "$test_dir/failing-bin/sudo"
: >"$CALLS"
set +e
HOME="$home_dir" \
PATH="$test_dir/failing-bin:$test_dir/bin:$PATH" \
bash -euo pipefail "$migration" >"$test_dir/partial-failure.out" 2>&1
partial_status=$?
set -e
(( partial_status != 0 )) || fail "migration fails when the second rule cannot be removed"
[[ ! -e $power_rule && -e $wifi_rule ]] || fail "migration preserves the expected partial-removal state"
[[ -e $wifi_reload_marker ]] || fail "migration records the second rule removal as still pending"
(( $(reload_count) == 1 )) ||
fail "migration reloads udev before a later removal failure" "$(cat "$CALLS")"
pass "a later removal failure cannot leave an already-deleted rule loaded"
# Nothing named the wrong binary is ours: the same path with a different command
# is a rule this migration cannot claim to know anything about.
reset_machine
cat >"$power_rule" <<'RULE'
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/home/someuser/.local/share/omarchy/bin/omarchy-wifi-powersave on"
RULE
run_migration
[[ -e $power_rule ]] ||
fail "migration matches the binary the filename promises, not any home path"
pass "migration matches the binary the filename promises, not any home path"
+28
View File
@@ -75,3 +75,31 @@ set -e
grep -q '^before-fail$' "$calls" || fail "migration runner started failing migration" grep -q '^before-fail$' "$calls" || fail "migration runner started failing migration"
! grep -q '^after-fail$' "$calls" || fail "migration runner stops failing migration under strict mode" ! grep -q '^after-fail$' "$calls" || fail "migration runner stops failing migration under strict mode"
pass "migration runner does not mark failed migrations complete" pass "migration runner does not mark failed migrations complete"
stdin_root="$test_tmp/stdin-omarchy"
stdin_home="$test_tmp/stdin-home"
stdin_calls="$test_tmp/stdin-calls"
mkdir -p "$stdin_root/migrations" "$stdin_home"
cat >"$stdin_root/migrations/100-reader.sh" <<'SH'
IFS= read -r value
printf 'reader:%s\n' "$value" >>"$TEST_CALLS"
SH
cat >"$stdin_root/migrations/200-after.sh" <<'SH'
echo after-reader >>"$TEST_CALLS"
SH
printf 'migration input\n' | \
HOME="$stdin_home" \
OMARCHY_PATH="$stdin_root" \
TEST_CALLS="$stdin_calls" \
"$ROOT/bin/omarchy-migrate" >"$test_tmp/stdin.out"
grep -q '^reader:migration input$' "$stdin_calls" ||
fail "migration runner preserves the caller's stdin for a migration" "$(cat "$stdin_calls")"
grep -q '^after-reader$' "$stdin_calls" ||
fail "a migration reading stdin does not swallow later queue entries" "$(cat "$stdin_calls")"
[[ -f $stdin_home/.local/state/omarchy/migrations/100-reader.sh &&
-f $stdin_home/.local/state/omarchy/migrations/200-after.sh ]] ||
fail "migration runner marks both stdin-isolated migrations complete"
pass "migration queue uses a private file descriptor instead of migration stdin"
+949 -24
View File
@@ -1,43 +1,968 @@
#!/bin/bash #!/bin/bash
set -uo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
test_tmp=$(mktemp -d) test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT [[ -n $test_tmp && -d $test_tmp ]] ||
fail "the test creates its own scratch directory before touching anything"
secret="$test_tmp/secret"
trap 'chmod 0600 "$secret" 2>/dev/null || true; rm -rf -- "$test_tmp"' EXIT
source_dir="$test_tmp/source" plymouth_theme_assets=(
theme_dir="$test_tmp/theme" bullet.png
entry.png
lock.png
logo.png
omarchy.plymouth
omarchy.script
preview-unlock.png
progress_bar.png
progress_box.png
)
plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png)
sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png)
sddm_default_assets=("${sddm_theme_assets[@]}" metadata.desktop theme.conf)
mkdir -m 0700 "$source_dir" # Keep the refresh allowlist synchronized with every packaged Plymouth asset.
mkdir -m 0755 "$theme_dir" # An added default file must make this test fail until its publication contract
touch "$source_dir/logo.png" # is explicitly reviewed and included above.
packaged_plymouth_assets=$(find "$ROOT/default/plymouth" -type f -printf '%P\n' | LC_ALL=C sort)
allowlisted_plymouth_assets=$(printf '%s\n' "${plymouth_default_assets[@]}" | LC_ALL=C sort)
[[ $packaged_plymouth_assets == "$allowlisted_plymouth_assets" ]] ||
fail "Plymouth refresh allowlist differs from the packaged asset set" "$packaged_plymouth_assets"
pass "Plymouth refresh allowlist covers every packaged asset"
cp -a --no-preserve=mode,ownership "$source_dir/." "$theme_dir/" # SDDM reset is also a fixed-file publication contract. New packaged files
# must fail this test until their trust and reset behavior are reviewed.
[[ $(stat -c %a "$theme_dir") == "755" ]] || packaged_sddm_assets=$(find "$ROOT/default/sddm/omarchy" -type f -printf '%P\n' | LC_ALL=C sort)
fail "Plymouth asset copy preserves the theme directory permissions" allowlisted_sddm_assets=$(printf '%s\n' "${sddm_default_assets[@]}" | LC_ALL=C sort)
[[ $packaged_sddm_assets == "$allowlisted_sddm_assets" ]] ||
grep -Fq \ fail "SDDM refresh allowlist differs from the packaged asset set" "$packaged_sddm_assets"
'cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"' \ pass "SDDM refresh allowlist covers every packaged asset"
"$ROOT/bin/omarchy-plymouth-set" ||
fail "omarchy-plymouth-set avoids copying staging directory ownership and mode"
pass "Plymouth asset copy preserves the package-owned directory metadata"
# omarchy-plymouth-set-by-theme hands over a theme's unlock.png from # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from
# ~/.config/omarchy/themes, and both copies below land in world-readable # ~/.config/omarchy/themes. Both installed copies are world-readable, so a
# /usr/share, so a symlink there would republish whatever it points at. # symlink there must not republish whatever it points at.
secret="$test_tmp/secret"
printf 'not yours\n' >"$secret" printf 'not yours\n' >"$secret"
ln -s "$secret" "$test_tmp/logo-link.png" ln -s "$secret" "$test_tmp/logo-link.png"
output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) output=$(OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1)
status=$? status=$?
(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" (( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo"
[[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output"
grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" ||
fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root"
pass "a themed logo cannot republish a file it merely points at" pass "a themed logo cannot republish a file it merely points at"
# The descriptor must be opened before elevation. Invoking the whole command
# as root would make the pre-open race privileged again, so refuse that shape
# before sudo or any publication can begin. User namespaces let this run
# without real privilege; retain a structural assertion on hosts that disable
# them so the invariant never becomes an untested skip.
if unshare --user --map-root-user true 2>/dev/null; then
output=$(unshare --user --map-root-user env OMARCHY_PATH="$ROOT" /bin/bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$secret" 2>&1)
status=$?
(( status != 0 )) || fail "omarchy-plymouth-set refuses to run as root"
[[ $output == *"as your user"* && $output == *"not under sudo"* ]] ||
fail "the root refusal explains how to invoke the publisher safely" "$output"
else
grep -A2 -Eq '^if \(\( EUID == 0 \)\); then$' "$ROOT/bin/omarchy-plymouth-set" ||
fail "omarchy-plymouth-set retains its root-invocation guard"
fi
pass "the logo descriptor can only be opened by an unprivileged caller"
# Style > Unlock picks a theme by name and hands the answer to
# omarchy-launch-floating-terminal-with-presentation, which joins its arguments
# into a script and runs that with `bash -c`. So the name is shell source
# unless the action quotes it -- and the name is a directory name under
# ~/.config/omarchy/themes, which a theme installed from a git repo gets from
# the repo URL. `a';id;'b` is a legal directory name.
require_command node
unlock_action=$(node -e '
const fs = require("fs")
const path = require("path")
const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js"))
const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8"))
process.stdout.write(items.find(item => item.id === "style.unlock").action)
')
[[ -n $unlock_action ]] || fail "the shipped menu still carries a style.unlock action"
stub_dir="$test_tmp/stubs"
mkdir -p "$stub_dir"
canary="$test_tmp/canary"
set_args="$test_tmp/set-args"
reset_marker="$test_tmp/reset-ran"
# What a name that got reparsed would reach. It is a command rather than a
# `touch` so that no quoting of the test's own paths is involved.
cat >"$stub_dir/omarchy-test-canary" <<STUB
#!/bin/bash
printf 'ran\n' >"$canary"
STUB
cat >"$stub_dir/omarchy-plymouth-switcher" <<'STUB'
#!/bin/bash
printf '%s\n' "$OMARCHY_TEST_UNLOCK_NAME"
STUB
# Run the real presentation wrapper while replacing only its terminal launcher.
# The launcher stub executes the final `bash -c` locally instead of opening a
# terminal window.
ln -s "$ROOT/bin/omarchy-launch-floating-terminal-with-presentation" "$stub_dir/omarchy-launch-floating-terminal-with-presentation"
cat >"$stub_dir/omarchy-restart-gum" <<'STUB'
#!/bin/bash
:
STUB
cat >"$stub_dir/setsid" <<'STUB'
#!/bin/bash
while (( $# >= 3 )); do
if [[ $1 == "bash" && $2 == "-c" ]]; then
exec bash -c "$3"
fi
shift
done
exit 97
STUB
# Records what actually arrived, so a name that survived as data is told apart
# from one that arrived split or partly eaten.
cat >"$stub_dir/omarchy-plymouth-set-by-theme" <<'STUB'
#!/bin/bash
printf '%s\n' "$#" "$@" >"$OMARCHY_TEST_SET_ARGS"
STUB
cat >"$stub_dir/omarchy-plymouth-reset" <<'STUB'
#!/bin/bash
printf 'ran\n' >"$OMARCHY_TEST_RESET_MARKER"
STUB
for command in omarchy-show-logo omarchy-show-done; do
printf '#!/bin/bash\nexit 0\n' >"$stub_dir/$command"
done
chmod +x "$stub_dir"/*
run_unlock_action() {
rm -f "$canary" "$set_args" "$reset_marker"
PATH="$stub_dir:$PATH" \
OMARCHY_TEST_UNLOCK_NAME="$1" \
OMARCHY_TEST_SET_ARGS="$set_args" \
OMARCHY_TEST_RESET_MARKER="$reset_marker" \
bash -c "$unlock_action" >/dev/null 2>&1
}
# A directory name cannot hold a slash or a NUL, and everything else is fair
# game -- these are the shapes that would run on the way to the picker.
for name in "a';omarchy-test-canary;'b" 'a$(omarchy-test-canary)b' 'a`omarchy-test-canary`b' 'a b' '-a'; do
run_unlock_action "$name"
[[ ! -e $canary ]] || fail "a theme name reaches the unlock screen as data, not as shell" "ran for: $name"
[[ $(cat "$set_args" 2>/dev/null) == $'1\n'"$name" ]] ||
fail "the unlock screen gets the theme name whole" "$name: $(cat "$set_args" 2>/dev/null)"
done
pass "a theme name cannot carry a command into the unlock screen"
# The two ordinary paths still work: a named theme is applied, and `default`
# resets rather than being looked up as a theme.
run_unlock_action "tokyo-night"
[[ $(cat "$set_args" 2>/dev/null) == $'1\ntokyo-night' ]] ||
fail "an ordinary theme name still reaches omarchy-plymouth-set-by-theme" "$(cat "$set_args" 2>/dev/null)"
run_unlock_action "default"
[[ -e $reset_marker ]] || fail "picking default still resets the unlock screen"
[[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")"
pass "the unlock picker still applies a theme and still resets on default"
fake_bin="$test_tmp/bin"
root_tools="$test_tmp/root-tools"
stages="$test_tmp/stages"
mkdir -p "$fake_bin" "$root_tools" "$stages"
cat >"$fake_bin/sudo" <<'SH'
#!/bin/bash
set -u
for argument in "$@"; do
if [[ $argument == *"$TEST_STAGES"* ]]; then
printf '%s\n' "$argument" >>"$TEST_LEAK_LOG"
fi
done
case "$1" in
/bin/bash)
[[ ${2:-} == -c && $# == 9 ]] || exit 90
code=$3
shell_name=$4
shift 4
printf 'root transaction\n' >>"$TEST_SUDO_LOG"
# The production helper intentionally resets PATH. For this unprivileged
# simulation only, substitute trusted tools and map fixed system destinations
# under the disposable fake root.
code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin}
code=${code/omarchy_conf=\/etc\/omarchy.conf/omarchy_conf=$TEST_OMARCHY_CONF}
code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy}
code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy}
# Each rewrite above silently no-ops if the production text drifts, which
# would point this simulation at the real /usr/share. Refuse instead.
[[ $code == *"PATH=$TEST_ROOT_TOOLS:/usr/bin:/bin"* ]] || exit 94
[[ $code == *"omarchy_conf=$TEST_OMARCHY_CONF"* ]] || exit 94
[[ $code == *"theme_dir=$TEST_FAKE_ROOT/usr/share/plymouth/themes/omarchy"* ]] || exit 94
[[ $code == *"sddm_dir=$TEST_FAKE_ROOT/usr/share/sddm/themes/omarchy"* ]] || exit 94
PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \
/bin/bash -c "$code" "$shell_name" "$@"
;;
plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio)
printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG"
exit 0
;;
*)
echo "unexpected sudo command: $*" >&2
exit 92
;;
esac
SH
cat >"$root_tools/stat" <<'SH'
#!/bin/bash
last=${!#}
if [[ ${1:-} == -c && ${2:-} == %u ]]; then
if [[ (-n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"*) ||
(-n ${TEST_UNTRUSTED_CONFIGURATION:-} && $last == "$TEST_UNTRUSTED_CONFIGURATION"*) ]]; then
printf '1000\n'
exit 0
fi
printf '0\n'
exit 0
fi
if [[ ${1:-} == -c && ${2:-} == %a && $last == /tmp ]]; then
printf '755\n'
exit 0
fi
exec /usr/bin/stat "$@"
SH
cat >"$root_tools/chown" <<'SH'
#!/bin/bash
last=${!#}
[[ $last == "$TEST_FAKE_ROOT"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93
exit 0
SH
cat >"$root_tools/install" <<'SH'
#!/bin/bash
mode=
while (( $# )); do
case "$1" in
-o | -g)
shift 2
;;
-m)
mode=$2
shift 2
;;
--)
shift
break
;;
*)
exit 96
;;
esac
done
(( $# == 2 )) || exit 96
[[ $mode == "0600" || $mode == "0644" ]] || exit 96
destination=$2
[[ $destination == "$TEST_FAKE_ROOT"* || $destination == /tmp/omarchy-plymouth.* ]] || exit 93
exec /usr/bin/install -m "$mode" -- "$1" "$destination"
SH
cat >"$root_tools/magick" <<'SH'
#!/bin/bash
source=$1
destination=${@: -1}
[[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination"
SH
cat >"$fake_bin/omarchy-cmd-present" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$fake_bin"/* "$root_tools"/*
printf 'caller-selected logo\n' >"$test_tmp/logo.png"
setup_run() {
run_dir=$(mktemp -d "$test_tmp/run.XXXXXXXX")
fake_root="$run_dir/root"
sudo_log="$run_dir/sudo.log"
leak_log="$run_dir/leaked-stage-path.log"
omarchy_conf="$run_dir/omarchy.conf"
theme="$fake_root/usr/share/plymouth/themes/omarchy"
sddm="$fake_root/usr/share/sddm/themes/omarchy"
mkdir -p "$theme/logos" "$sddm"
chmod 0755 \
"$fake_root/usr" \
"$fake_root/usr/share" \
"$fake_root/usr/share/plymouth" \
"$fake_root/usr/share/plymouth/themes" \
"$theme" \
"$theme/logos" \
"$fake_root/usr/share/sddm" \
"$fake_root/usr/share/sddm/themes" \
"$sddm"
local asset destination
for asset in "${plymouth_default_assets[@]}"; do
destination="$theme/$asset"
printf 'old plymouth %s\n' "$asset" >"$destination"
chmod 0600 "$destination"
done
for asset in "${sddm_default_assets[@]}"; do
destination="$sddm/$asset"
printf 'old sddm %s\n' "$asset" >"$destination"
chmod 0600 "$destination"
done
plymouth_victim="$run_dir/plymouth-victim"
sddm_victim="$run_dir/sddm-victim"
legacy_victim="$run_dir/legacy-victim"
printf 'PLYMOUTH VICTIM\n' >"$plymouth_victim"
printf 'SDDM VICTIM\n' >"$sddm_victim"
printf 'LEGACY VICTIM\n' >"$legacy_victim"
chmod 0600 "$plymouth_victim" "$sddm_victim" "$legacy_victim"
rm -f "$theme/omarchy.script" "$sddm/Main.qml"
ln -s "$plymouth_victim" "$theme/omarchy.script"
ln -s "$sddm_victim" "$sddm/Main.qml"
ln -s "$legacy_victim" "$sddm/logo.svg"
}
setup_fresh_run() {
local asset destination
setup_run
for asset in "${plymouth_default_assets[@]}"; do
destination="$theme/$asset"
rm -f -- "$destination"
/usr/bin/install -m 0644 -- "$ROOT/default/plymouth/$asset" "$destination"
done
for asset in "${sddm_default_assets[@]}"; do
destination="$sddm/$asset"
rm -f -- "$destination"
/usr/bin/install -m 0644 -- "$ROOT/default/sddm/omarchy/$asset" "$destination"
done
rm -f -- "$sddm/logo.svg"
}
run_in_fake_root() {
local requested_umask="$1"
shift
(
umask "$requested_umask"
PATH="$fake_bin:$ROOT/bin:$PATH" \
TMPDIR="$stages" \
OMARCHY_PATH="$ROOT" \
TEST_FAKE_ROOT="$fake_root" \
TEST_STAGES="$stages" \
TEST_ROOT_TOOLS="$root_tools" \
TEST_OMARCHY_CONF="$omarchy_conf" \
TEST_SUDO_LOG="$sudo_log" \
TEST_LEAK_LOG="$leak_log" \
"$@"
)
}
run_set_colors() {
local requested_umask="$1" background="$2" text="$3"
shift 3
run_in_fake_root "$requested_umask" "$@" \
/bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png"
}
run_set() {
local requested_umask="$1"
shift
run_set_colors "$requested_umask" '#1d2021' '#ebdbb2' "$@"
}
run_refresh_plymouth() {
run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-plymouth"
}
run_refresh_sddm() {
run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-refresh-sddm"
}
run_reset() {
run_in_fake_root 022 "$@" /bin/bash "$ROOT/bin/omarchy-plymouth-reset"
}
assert_no_temporary_files() {
local directory="$1" leftovers
leftovers=$(find "$directory" -name '.*.omarchy-new.*' -print)
[[ -z $leftovers ]] || fail "failed publication cleans up its root-side temporary file" "$leftovers"
}
assert_packaged_assets() {
local context=$1 source_dir=$2 destination_dir=$3
shift 3
local asset destination
for asset in "$@"; do
destination="$destination_dir/$asset"
cmp -s "$source_dir/$asset" "$destination" || fail "$context publishes the packaged $asset bytes"
[[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] ||
fail "$context publishes $asset as a regular mode-0644 file"
done
}
for requested_umask in 022 027 077; do
setup_fresh_run
output=$(run_set "$requested_umask" env 2>&1)
status=$?
(( status == 0 )) || fail "Plymouth publisher succeeds under umask $requested_umask" "$output"
for asset in "${plymouth_theme_assets[@]}"; do
destination="$theme/$asset"
[[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask"
[[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask"
[[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask"
done
for asset in "${sddm_theme_assets[@]}"; do
destination="$sddm/$asset"
[[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask"
[[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask"
[[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask"
done
cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask"
cmp -s "$test_tmp/logo.png" "$sddm/logo.png" || fail "SDDM receives the selected logo under umask $requested_umask"
grep -Fq '#1d2021' "$sddm/Main.qml" || fail "SDDM Main.qml receives the selected background under umask $requested_umask"
grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "Plymouth script receives the selected background under umask $requested_umask"
cmp -s "$ROOT/default/plymouth/logos/oma.png" "$theme/logos/oma.png" || fail "theme set leaves the packaged nested logo unchanged"
cmp -s "$ROOT/default/sddm/omarchy/metadata.desktop" "$sddm/metadata.desktop" || fail "theme set leaves packaged SDDM metadata unchanged"
cmp -s "$ROOT/default/sddm/omarchy/theme.conf" "$sddm/theme.conf" || fail "theme set leaves packaged SDDM configuration unchanged"
[[ ! -s $leak_log ]] || fail "no privileged command receives a user-writable staged pathname" "$(cat "$leak_log")"
[[ $(stat -c %a "$theme") == 755 && $(stat -c %a "$sddm") == 755 && $(stat -c %a "$theme/logos") == 755 ]] || fail "publication preserves destination directory modes under umask $requested_umask"
grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "theme set activates the published Plymouth theme"
grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "theme set rebuilds the initramfs"
assert_no_temporary_files "$fake_root"
done
pass "a fresh installation receives complete mode-0644 Plymouth and SDDM theme files across restrictive umasks"
# An upgraded machine may already contain restrictive modes, destination
# symlinks, and the legacy SDDM logo. Setting a theme must replace only the
# destination entries and must never write through those symlinks.
setup_run
output=$(run_set 022 env 2>&1)
status=$?
(( status == 0 )) || fail "theme set repairs migrated Plymouth and SDDM destinations" "$output"
[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "theme set replaces a migrated Plymouth destination symlink"
[[ -f $sddm/Main.qml && ! -L $sddm/Main.qml ]] || fail "theme set replaces a migrated SDDM destination symlink"
[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "theme set never changes a Plymouth symlink victim"
[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "theme set never changes an SDDM symlink victim"
[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "theme set never changes the legacy logo victim"
[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "theme set removes the legacy SDDM logo"
assert_no_temporary_files "$fake_root"
pass "theme set repairs migrated destinations without following existing symlinks"
# White uses #ffffff behind #000000 text. A direct two-expression sed first
# writes the white background and then consumes it as if it were the template's
# text placeholder, producing a black-on-black greeter.
setup_run
output=$(run_set_colors 022 '#ffffff' '#000000' env 2>&1)
status=$?
(( status == 0 )) || fail "White theme publishes through the safe asset pipeline" "$output"
grep -Fq 'color: "#ffffff"' "$sddm/Main.qml" || fail "White theme preserves its SDDM background color"
if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then
fail "SDDM color substitution left an intermediate token behind"
fi
pass "White theme keeps a white SDDM background instead of becoming black-on-black"
# Swap the selected logo to an unreadable file in the DEBUG hook immediately
# before Bash opens its descriptor. The caller-side open must fail, so sudo
# never starts and nothing is published.
setup_run
preopen_hook="$run_dir/preopen-hook"
preopen_marker="$run_dir/preopen-marker"
printf 'ROOT ONLY\n' >"$secret"
chmod 000 "$secret"
cat >"$preopen_hook" <<'SH'
if [[ $0 == */bin/omarchy-plymouth-set ]]; then
set -T
trap '
if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* &&
! -e $TEST_PREOPEN_MARKER ]]; then
mv -T -- "$logo_path" "$logo_path.before-preopen-swap"
ln -s -- "$TEST_SECRET" "$logo_path"
printf "swapped\n" >"$TEST_PREOPEN_MARKER"
fi
' DEBUG
fi
SH
output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1)
status=$?
chmod 0600 "$secret"
rm -f "$test_tmp/logo.png"
mv "$test_tmp/logo.png.before-preopen-swap" "$test_tmp/logo.png"
(( status != 0 )) || fail "an unreadable pre-open source swap aborts publication"
[[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged"
[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged"
if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then
fail "sudo started despite the caller-side open failure"
fi
assert_no_temporary_files "$fake_root"
pass "an unreadable source swap before open fails without publication"
# Opening a directory read-only succeeds on Linux, but the resulting descriptor
# is not a regular file. Swap one in immediately before exec: this gets past the
# open itself and makes the /proc descriptor check the only caller-side control
# that can stop sudo from starting.
setup_run
nonregular_hook="$run_dir/nonregular-hook"
nonregular_marker="$run_dir/nonregular-marker"
cat >"$nonregular_hook" <<'SH'
if [[ $0 == */bin/omarchy-plymouth-set ]]; then
set -T
trap '
if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* &&
! -e $TEST_NONREGULAR_MARKER ]]; then
mv -T -- "$logo_path" "$logo_path.before-nonregular-swap"
mkdir -- "$logo_path"
printf "swapped\n" >"$TEST_NONREGULAR_MARKER"
fi
' DEBUG
fi
SH
output=$(TEST_NONREGULAR_MARKER="$nonregular_marker" BASH_ENV="$nonregular_hook" run_set 077 env 2>&1)
status=$?
rmdir "$test_tmp/logo.png"
mv "$test_tmp/logo.png.before-nonregular-swap" "$test_tmp/logo.png"
(( status != 0 )) || fail "a non-regular opened logo descriptor aborts publication"
[[ -s $nonregular_marker ]] || fail "the non-regular pre-open source swap ran deterministically" "$output"
[[ $output == *"no longer a regular file"* ]] || fail "the descriptor check says why it refused the opened directory" "$output"
if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then
fail "sudo started despite the non-regular opened logo descriptor"
fi
[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "a non-regular opened logo leaves the live logo unchanged"
assert_no_temporary_files "$fake_root"
pass "the caller refuses an opened descriptor that is not a regular file"
# Plant both a malicious script and a root-file symlink where the old
# caller-owned stage lived. The privileged transaction must ignore that tree:
# executable/config assets come only from its root-trusted source and are built
# in its own root-owned stage.
setup_run
attacker_stage="$stages/tmp.attacker"
mkdir -p "$attacker_stage/plymouth"
printf 'MALICIOUS BOOT SCRIPT\n' >"$attacker_stage/plymouth/omarchy.script"
ln -s "$secret" "$attacker_stage/plymouth/logo.png"
output=$(run_set 022 env 2>&1)
status=$?
(( status == 0 )) || fail "a planted caller-owned stage cannot disrupt publication" "$output"
! grep -Rqs 'MALICIOUS BOOT SCRIPT' "$fake_root" || fail "caller-owned staged content reached the boot theme"
[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "the trusted Plymouth script replaces the planted destination symlink"
grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "the installed script was derived from the trusted packaged source"
unexpected_stages=$(find "$stages" -mindepth 1 -maxdepth 1 ! -name tmp.attacker -print)
[[ -z $unexpected_stages ]] || fail "the caller created an authoritative staging directory" "$unexpected_stages"
assert_no_temporary_files "$fake_root"
pass "caller-owned content cannot enter the root-owned boot-image stage"
# A user-owned source checkout would put the same pre-hash race on the input
# side of the root stage. Refuse it before any fixed destination is replaced.
setup_run
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth" 2>&1)
status=$?
(( status != 0 )) || fail "a user-owned packaged source tree is rejected"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged"
[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content"
[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root rejects packaged assets that a desktop process could rewrite"
# A packaged filename may not redirect root to some other readable file. Use
# the explicitly authorized development-source path so its ordinary file-mode
# checks are intentionally skipped and only the leaf symlink/canonical-file
# checks can decide this case.
setup_run
symlink_source_root=$(mktemp -d "$test_tmp/symlink-source.XXXXXXXX")
symlink_source_root=$(realpath -e -- "$symlink_source_root")
mkdir -p "$symlink_source_root/default"
cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$symlink_source_root/default/"
rm -f "$symlink_source_root/default/plymouth/bullet.png"
ln -s "$secret" "$symlink_source_root/default/plymouth/bullet.png"
printf 'export OMARCHY_PATH="%s"\n' "$symlink_source_root" >"$omarchy_conf"
chmod 0644 "$omarchy_conf"
output=$(run_set 022 env OMARCHY_PATH="$symlink_source_root" TEST_UNTRUSTED_SOURCE="$symlink_source_root" 2>&1)
status=$?
(( status != 0 )) || fail "a symlinked packaged asset is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a packaged source symlink leaves the live theme unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged source symlink says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root never follows a packaged asset symlink"
# A random user-owned OMARCHY_PATH remains untrusted. Only the exact canonical
# checkout recorded by root in /etc/omarchy.conf is the supported dev-link
# exception; an unrelated or stale authorization must not weaken the check.
setup_run
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1)
status=$?
(( status != 0 )) || fail "a user-owned OMARCHY_PATH is rejected"
[[ $output == *"user-owned"* ]] || fail "the refusal names the untrusted source tree" "$output"
[[ $output == *"omarchy dev link"* ]] || fail "the refusal names how to authorize a development checkout" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned OMARCHY_PATH leaves the live theme unchanged"
assert_no_temporary_files "$fake_root"
setup_run
printf 'export OMARCHY_PATH="/some/other/checkout"\n' >"$omarchy_conf"
chmod 0644 "$omarchy_conf"
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1)
status=$?
(( status != 0 )) || fail "a stale dev-link authorization is rejected"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a stale dev-link authorization leaves the live theme unchanged"
# The refusal has to name the authorization. Validating /etc/omarchy.conf walks
# its parents and leaves that walk's subject in failure_context, so without
# restoring ours this refuses with "directory / must be root-owned and not
# group- or world-writable" -- accusing a directory that passed and pointing the
# reader at a filesystem problem that does not exist.
[[ $output == *"$omarchy_conf"* ]] || fail "a stale dev-link refusal names the authorization it rejected" "$output"
[[ $output != *"directory / "* ]] || fail "a stale dev-link refusal does not blame the root directory" "$output"
assert_no_temporary_files "$fake_root"
setup_run
printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf"
chmod 0666 "$omarchy_conf"
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1)
status=$?
(( status != 0 )) || fail "a writable dev-link authorization is rejected"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable dev-link authorization leaves the live theme unchanged"
assert_no_temporary_files "$fake_root"
setup_run
authorization_target="$run_dir/authorization-target"
printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$authorization_target"
chmod 0644 "$authorization_target"
ln -s "$authorization_target" "$omarchy_conf"
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1)
status=$?
(( status != 0 )) || fail "a symlinked dev-link authorization is rejected"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked dev-link authorization leaves the live theme unchanged"
assert_no_temporary_files "$fake_root"
setup_run
printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf"
chmod 0644 "$omarchy_conf"
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" TEST_UNTRUSTED_CONFIGURATION="$omarchy_conf" 2>&1)
status=$?
(( status != 0 )) || fail "a user-owned dev-link authorization is rejected"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned dev-link authorization leaves the live theme unchanged"
assert_no_temporary_files "$fake_root"
setup_run
printf 'export OMARCHY_PATH="%s"\n' "$ROOT" >"$omarchy_conf"
chmod 0644 "$omarchy_conf"
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1)
status=$?
(( status == 0 )) || fail "the root-authorized development checkout can publish Plymouth assets" "$output"
cmp -s "$ROOT/default/plymouth/bullet.png" "$theme/bullet.png" || fail "the authorized development checkout supplies the packaged assets"
pass "only a regular root-owned authorization may name the exact development checkout"
# Root rejects both a symlinked parent and a group/world-writable parent before
# it creates a temporary file or touches the live destination.
setup_run
mv "$theme" "$theme.real"
ln -s "$theme.real" "$theme"
output=$(run_set 022 env 2>&1)
status=$?
(( status != 0 )) || fail "a symlinked destination parent is rejected"
[[ $(cat "$theme.real/bullet.png") == 'old plymouth bullet.png' ]] || fail "a symlinked parent leaves its target unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected symlinked parent says why it refused" "$output"
assert_no_temporary_files "$fake_root"
setup_run
chmod 0777 "$theme"
output=$(run_set 022 env 2>&1)
status=$?
(( status != 0 )) || fail "a writable destination parent is rejected"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable parent leaves its live destination unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected writable parent says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "publication rejects symlinked and non-root-writable destination parents"
# Walking the whole chain, not just the immediate parent, is what closes the
# rename race: a writable ancestor lets an attacker swap an entire validated
# directory out from under the leaf. Leave the destination itself pristine so
# only the ancestor can be at fault.
setup_run
chmod 0777 "$fake_root/usr/share/plymouth"
output=$(run_set 022 env 2>&1)
status=$?
chmod 0755 "$fake_root/usr/share/plymouth"
(( status != 0 )) || fail "a writable destination ancestor is rejected" "$output"
[[ $(stat -c %a "$theme") == 755 ]] || fail "only the ancestor, not the destination, was untrustworthy"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable ancestor leaves the live destination unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected ancestor says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "publication walks the whole parent chain, not only the immediate parent"
# Mode is not the only thing that decides a destination directory. One that is
# merely user-owned still lets its owner put the file back after we publish, so
# ownership has to refuse it even when 0755 looks harmless.
setup_run
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$theme" 2>&1)
status=$?
(( status != 0 )) || fail "a user-owned destination directory is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a user-owned destination directory keeps its live file"
[[ $output == *"refusing to publish"* ]] || fail "a rejected destination directory says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "publication refuses a destination directory root does not own"
# The packaged tree is validated file by file, not only directory by directory.
# A single user-owned asset inside an otherwise root-owned directory is still
# content a desktop process can rewrite, and the directory check cannot see it.
setup_run
output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth/bullet.png" 2>&1)
status=$?
(( status != 0 )) || fail "a single user-owned packaged asset is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "one untrusted asset leaves the live theme unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged asset says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root checks every packaged asset, not only its directory"
# Keep every file root-owned and mode 0644 while making only its containing
# directory writable. Per-file checks cannot close the rename race in that
# state; the packaged source parent-chain walk must reject it.
setup_run
writable_directory_root=$(mktemp -d "$test_tmp/writable-directory-source.XXXXXXXX")
writable_directory_root=$(realpath -e -- "$writable_directory_root")
mkdir -p "$writable_directory_root/default"
cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_directory_root/default/"
chmod 0777 "$writable_directory_root/default/plymouth"
output=$(run_set 022 env OMARCHY_PATH="$writable_directory_root" 2>&1)
status=$?
(( status != 0 )) || fail "a writable packaged source directory is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged directory leaves the live theme unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected packaged directory says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root validates the packaged source parent chain before copying"
# Ownership is not the only way a packaged asset stays rewritable: a group- or
# world-writable mode does it too. Stage a tree the shim reports as root-owned
# so only the real mode can decide, then loosen one asset.
setup_run
writable_root=$(mktemp -d "$test_tmp/writable-source.XXXXXXXX")
writable_root=$(realpath -e -- "$writable_root")
mkdir -p "$writable_root/default"
cp -a "$ROOT/default/plymouth" "$ROOT/default/sddm" "$writable_root/default/"
chmod 0666 "$writable_root/default/plymouth/bullet.png"
output=$(run_set 022 env OMARCHY_PATH="$writable_root" 2>&1)
status=$?
(( status != 0 )) || fail "a world-writable packaged asset is rejected" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "a writable packaged asset leaves the live theme unchanged"
[[ $output == *"refusing to publish"* ]] || fail "a rejected writable asset says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "root refuses a packaged asset its own mode leaves rewritable"
# The caller streams the logo to root over a descriptor, so root is the only
# place its length can be judged. An empty selection must not become an empty
# published logo.
setup_run
cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep"
: >"$test_tmp/logo.png"
output=$(run_set 022 env 2>&1)
status=$?
mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png"
(( status != 0 )) || fail "an empty logo is rejected" "$output"
[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an empty logo leaves the live logo unchanged"
assert_no_temporary_files "$fake_root"
pass "an empty logo cannot be published"
# Bound the descriptor read as well as the final destination. A sparse file
# makes the real 64 MiB + 1 byte boundary deterministic without storing a
# large fixture in the repository.
setup_run
cp -- "$test_tmp/logo.png" "$test_tmp/logo.png.keep"
truncate -s "$((64 * 1024 * 1024 + 1))" "$test_tmp/logo.png"
output=$(run_set 022 env 2>&1)
status=$?
mv -f -- "$test_tmp/logo.png.keep" "$test_tmp/logo.png"
(( status != 0 )) || fail "an oversized logo is rejected" "$output"
[[ $(cat "$theme/logo.png") == 'old plymouth logo.png' ]] || fail "an oversized logo leaves the live logo unchanged"
assert_no_temporary_files "$fake_root"
pass "a logo larger than the publication bound cannot be published"
# Refresh uses the same publisher but its explicit contract includes the
# packaged nested logos/oma.png asset. It must not touch the SDDM theme.
setup_run
output=$(run_refresh_plymouth 2>&1)
status=$?
(( status == 0 )) || fail "Plymouth refresh succeeds through the safe publisher" "$output"
assert_packaged_assets "Plymouth refresh" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}"
[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged"
! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets"
[[ ! -s $leak_log ]] || fail "refresh never gives root a user-writable source pathname" "$(cat "$leak_log")"
grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "Plymouth refresh activates the restored theme"
grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "Plymouth refresh rebuilds the initramfs"
pass "refresh safely publishes its complete fixed asset set, including logos/oma.png"
# SDDM refresh has the same fixed-file contract but must leave Plymouth and the
# boot image alone. It also replaces legacy destination symlinks without
# changing their victims.
setup_run
output=$(run_refresh_sddm 2>&1)
status=$?
(( status == 0 )) || fail "SDDM refresh succeeds through the safe publisher" "$output"
assert_packaged_assets "SDDM refresh" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}"
[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "SDDM refresh leaves Plymouth unchanged"
[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "SDDM refresh never changes a destination symlink victim"
[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "SDDM refresh never changes the legacy logo victim"
[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "SDDM refresh removes the legacy logo.svg"
! grep -Fq 'command plymouth-set-default-theme' "$sudo_log" || fail "SDDM refresh does not activate Plymouth"
! grep -Fq 'command mkinitcpio' "$sudo_log" || fail "SDDM refresh does not rebuild the initramfs"
[[ ! -s $leak_log ]] || fail "SDDM refresh never gives root a user-writable source pathname" "$(cat "$leak_log")"
pass "SDDM refresh safely restores its complete packaged asset set without rebuilding Plymouth"
# A fresh package installation already contains the complete default file set.
# Reset must be safe and idempotent in that ordinary state.
setup_fresh_run
output=$(run_reset 2>&1)
status=$?
(( status == 0 )) || fail "reset succeeds on a fresh installation" "$output"
assert_packaged_assets "fresh reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}"
assert_packaged_assets "fresh reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}"
grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "fresh reset activates the packaged Plymouth theme"
grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "fresh reset rebuilds the initramfs"
assert_no_temporary_files "$fake_root"
pass "reset is safe and idempotent on a fresh package installation"
# Exercise an existing hostile state: destination symlinks stand in for an
# upgraded machine that may already contain artifacts planted through the old
# paths. Each refresh must replace its own entries without following them.
setup_run
output=$(run_reset 2>&1)
status=$?
(( status == 0 )) || fail "combined Plymouth and SDDM reset succeeds" "$output"
assert_packaged_assets "migrated reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}"
assert_packaged_assets "migrated reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}"
[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "reset never changes a Plymouth destination symlink victim"
[[ $(cat "$sddm_victim") == 'SDDM VICTIM' && $(stat -c %a "$sddm_victim") == 600 ]] || fail "reset never changes an SDDM destination symlink victim"
[[ $(cat "$legacy_victim") == 'LEGACY VICTIM' && $(stat -c %a "$legacy_victim") == 600 ]] || fail "reset never changes the legacy logo victim"
[[ ! -e $sddm/logo.svg && ! -L $sddm/logo.svg ]] || fail "reset removes the legacy logo.svg"
grep -Fq 'command plymouth-set-default-theme omarchy' "$sudo_log" || fail "reset activates the restored Plymouth theme"
grep -Fq 'command mkinitcpio -P' "$sudo_log" || fail "reset rebuilds the initramfs"
[[ ! -s $leak_log ]] || fail "reset never gives root a user-writable source pathname" "$(cat "$leak_log")"
pass "reset safely repairs a migrated Plymouth and SDDM installation"
# A damaged installation may retain its package-owned directories while some
# destination files are missing. Reset must recreate every allowlisted leaf.
setup_run
for asset in "${plymouth_default_assets[@]}"; do
rm -f -- "$theme/$asset"
done
for asset in "${sddm_default_assets[@]}"; do
rm -f -- "$sddm/$asset"
done
rm -f -- "$sddm/logo.svg"
output=$(run_reset 2>&1)
status=$?
(( status == 0 )) || fail "reset repairs missing Plymouth and SDDM destinations" "$output"
assert_packaged_assets "missing-file reset Plymouth" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}"
assert_packaged_assets "missing-file reset SDDM" "$ROOT/default/sddm/omarchy" "$sddm" "${sddm_default_assets[@]}"
pass "reset recreates missing files in package-owned destination trees"
# The two refreshes are independently hardened. If Plymouth succeeds and SDDM
# then refuses its unsafe destination, the completed Plymouth refresh remains
# valid while SDDM and its symlink victims remain unchanged.
setup_run
chmod 0777 "$sddm"
output=$(run_reset 2>&1)
status=$?
(( status != 0 )) || fail "reset rejects an unsafe SDDM destination" "$output"
assert_packaged_assets "Plymouth before SDDM refusal" "$ROOT/default/plymouth" "$theme" "${plymouth_default_assets[@]}"
[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "the successful Plymouth refresh never changes its old symlink victim"
[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "a rejected reset leaves SDDM unchanged"
[[ $output == *"refusing to publish"* ]] || fail "an unsafe reset destination says why it refused" "$output"
assert_no_temporary_files "$fake_root"
pass "an SDDM refusal cannot make either refresh follow an unsafe destination"
# A packaged source that fails the root trust checks must stop the combined
# reset; it cannot fall through into a second legacy SDDM copy.
setup_run
output=$(run_reset env TEST_UNTRUSTED_SOURCE="$ROOT" 2>&1)
status=$?
(( status != 0 )) || fail "reset rejects an untrusted packaged source" "$output"
[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted reset source leaves Plymouth unchanged"
[[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "an untrusted reset source leaves SDDM unchanged"
[[ $output == *"refusing to publish"* ]] || fail "an untrusted reset source says why it refused" "$output"
[[ $(grep -c '^root transaction$' "$sudo_log") == 1 ]] || fail "reset stops before SDDM when Plymouth refuses"
assert_no_temporary_files "$fake_root"
pass "a reset refusal cannot fall through to an unhardened SDDM copy"
+960
View File
@@ -0,0 +1,960 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
# Omarchy does not write a root-owned file through a heredoc whose delimiter is
# unquoted, and this check enforces that.
#
# With an unquoted delimiter (<<EOF rather than <<'EOF') the *installing user's*
# shell expands the body before root ever sees the text, so any value that user
# controls is baked into the result as a literal. Send such a body to /etc and
# the file root later reads or executes carries a value an unprivileged user
# chose.
#
# Worked example -- an installer emitting a udev rule:
#
# sudo tee /etc/udev/rules.d/99-power-profile.rules <<EOF
# ACTION=="change", SUBSYSTEM=="power_supply", RUN+="$HOME/.local/share/omarchy/bin/omarchy-power-profile"
# EOF
#
# The delimiter is unquoted, so the rule that lands names
# /home/<user>/.local/share/omarchy/bin/..., and ~/.local/share/omarchy is a
# symlink that same user owns. Replacing the symlink and provoking a
# power_supply event gets their code run by udev as root. Quote the delimiter
# and the rule names a literal $HOME instead, which udev never expands, so
# there is nothing to aim at.
#
# The distinction that matters throughout: install-time expansion bakes a
# literal, user-controlled value into a file root later reads or executes --
# that is the bug. Runtime expansion (an escaped \$VAR left literal in the file
# for a root daemon that does not have the variable set) is a different
# mechanism and is not flagged. install/3-config.sh once used both in one
# heredoc on purpose: $USER expanded at install time because it is a username,
# while \$TERM stayed escaped for systemd to expand later.
# A file under one of these is owned by root, so its content is a root-level
# input no unprivileged user should be able to influence.
PRIVILEGED_PREFIXES=(/etc /usr /opt /srv /boot /var/lib)
# Path roots the installing user can replace outright -- by editing the
# directory, or by swapping a symlink like ~/.local/share/omarchy. An expansion
# anchored in one of these is the shape this check exists to catch.
USER_WRITABLE_VARS=(HOME PWD OLDPWD TMPDIR OMARCHY_PATH OMARCHY_INSTALL
XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME XDG_RUNTIME_DIR)
# Commands that carry a heredoc's output to its destination, and the ones that
# do it as root. `tee` counts unelevated too: several bin/ commands re-exec
# themselves as root and then tee straight into /etc.
WRITE_COMMANDS=(tee dd install cp mv)
ELEVATORS=(sudo as_root pkexec doas run0)
# A dollar the installing user's shell would act on: $name, ${name}, $1, or $(cmd).
# Kept in a variable because an unquoted `(` inside a bracket expression is a
# syntax error in [[ =~ ]].
EXPANSION_RE='\$[A-Za-z_{(0-9@*#?$!-]'
# One pattern for every expansion form, shared by masking and name extraction
# so the two stay in lockstep.
EXPANSION_SCAN_RE='^([^$]*)\$(\{[^}]*\}|\([^)]*\)|[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?|[0-9@*#?$!-])(.*)$'
# Stand-in name for a command substitution, which has no variable to report.
COMMAND_SUBSTITUTION="command-substitution"
# Sites that legitimately need install-time expansion declare it in a comment
# immediately above the heredoc:
#
# # omarchy:heredoc-expands paths=none -- $servers is a validated IP list
# # omarchy:heredoc-expands paths=storage,shared -- checked by valid_path
#
# `paths=` is the machine-checked half, and is what keeps this from being a
# rubber stamp: it must name exactly the expansions that are path-shaped, so
# adding a "$HOME/..." to an already-annotated heredoc makes the declaration
# false and trips the check again instead of inheriting the old exemption. The
# reason after `--` is for the reviewer.
ANNOTATION_RE='^[[:space:]]*#[[:space:]]*omarchy:heredoc-expands[[:space:]]+paths=([A-Za-z_][A-Za-z0-9_-]*(,[A-Za-z_][A-Za-z0-9_-]*)*|none)[[:space:]]+--[[:space:]]+([^[:space:]].*)$'
FINDINGS=()
starts_with_privileged_prefix() {
local candidate="$1" prefix
for prefix in "${PRIVILEGED_PREFIXES[@]}"; do
[[ $candidate == "$prefix"/* ]] && return 0
done
return 1
}
in_list() {
local needle="$1" item
shift
for item in "$@"; do
[[ $needle == "$item" ]] && return 0
done
return 1
}
# Drop escaped dollars, backticks and backslashes so what is left is only what
# the installing user's shell would actually expand. Escaped backslashes go
# first, otherwise "\\$TERM" would read as an escaped dollar.
strip_escapes() {
local text="$1"
text=${text//\\\\/}
text=${text//\\$/}
text=${text//\\\`/}
printf '%s' "$text"
}
# Replace every expansion in TEXT with \001 and list the variable names in
# order: the masked text first, then one name per line.
#
# Masking whole lines rather than whitespace-split words is what makes
# "DNS=${dns_servers//,/ }" readable. Those slashes belong to the substitution
# operator, not to a path, and the space inside the braces would otherwise
# split the expansion across two words and leave a bare "//,/" looking like a
# path. Because both halves come from one pass over one pattern, the Nth \001
# is the Nth name, so a token can be judged against the right variable.
mask_and_names() {
local text="$1" masked="" body inner tail name guard=0 nested_masked
local -a names=() nested_scan=() nested_names=()
# Normalize backtick substitution into $( ) so one pattern covers both.
while ((guard++ < 64)) && [[ $text =~ ^([^\`]*)\`([^\`]*)\`(.*)$ ]]; do
body=${BASH_REMATCH[2]//[()]/}
text="${BASH_REMATCH[1]}\$($body)${BASH_REMATCH[3]}"
done
guard=0
while ((guard++ < 128)) && [[ $text =~ $EXPANSION_SCAN_RE ]]; do
masked+="${BASH_REMATCH[1]}"$'\001'
body=${BASH_REMATCH[2]}
text=${BASH_REMATCH[4]}
nested_masked=""
nested_names=()
if [[ $body == \(* ]]; then
name=$COMMAND_SUBSTITUTION
elif [[ $body == \{* ]]; then
inner=${body:1:${#body}-2}
# ${name}, ${name:-default}, ${name//a/b}, ${#name}, ${!name} all start
# with the name once the decorations are stripped.
inner=${inner#[\#!]}
if [[ $inner =~ ^([A-Za-z_][A-Za-z0-9_]*) ]]; then
name=${BASH_REMATCH[1]}
tail=${inner#"$name"}
elif [[ $inner =~ ^[0-9@*#?$!-] ]]; then
name="shell-parameter"
tail=${inner:1}
else
name=$COMMAND_SUBSTITUTION
tail=$inner
fi
# The shell expands the operator payload too. Keep it as a synthetic
# adjacent token so its placeholders stay aligned with their names while
# the outer expansion remains independently classifiable. Without this,
# ${target:-$HOME/path} is consumed as only `target` and hides HOME.
if [[ $tail =~ $EXPANSION_RE || $tail == *'`'* ]]; then
mapfile -t nested_scan < <(mask_and_names "$tail")
nested_masked=${nested_scan[0]}
nested_names=("${nested_scan[@]:1}")
fi
else
name=${body%%\[*}
[[ $name =~ ^[A-Za-z_] ]] || name="shell-parameter"
fi
names+=("$name")
if ((${#nested_names[@]} > 0)); then
masked+=" $nested_masked"
names+=("${nested_names[@]}")
fi
done
printf '%s\n' "$masked$text"
if ((${#names[@]} > 0)); then
printf '%s\n' "${names[@]}"
fi
}
declare -A VARS=()
declare -A VARS_TAINTED=()
# Literal assignments in the file under scan, so a destination written as
# "$DROP_IN" or "$COMPOSE_FILE" can be judged as the path it actually is.
# First assignment wins: these scripts set a constant once, and an append like
# boot_params+=(...) is not an assignment this reads at all.
#
# VARS_TAINTED records, separately, any name that is assigned a value naming a
# root the user can replace -- at any point in the file, not just the assignment
# that won. That is what stops a name being introduced with a harmless packaged
# value and then reassigned under $HOME, which judging one assignment in
# isolation would miss in whichever direction it picked.
collect_vars() {
local -n source_lines="$1"
local line name value append
VARS=()
VARS_TAINTED=()
for line in "${source_lines[@]}"; do
[[ $line =~ ^[[:space:]]*# ]] && continue
[[ $line =~ ^[[:space:]]*(local|declare|export|readonly|typeset)?[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$ ]] || continue
name=${BASH_REMATCH[2]}
append=${BASH_REMATCH[3]}
value=${BASH_REMATCH[4]}
value=${value%%[[:space:]]#*}
value=${value%[[:space:]]}
if [[ $value == \"*\" || $value == \'*\' ]]; then
value=${value:1:${#value}-2}
fi
mentions_user_writable_root "$value" && VARS_TAINTED["$name"]=1
# An append never wins the value -- an array grown across a file resolves to
# nothing useful -- but it does carry the taint, or a name could reach a user
# root through += and never be judged on it.
[[ -n $append ]] && continue
[[ -v VARS[$name] ]] || VARS["$name"]=$value
done
}
# Expand what can be expanded from the file's own assignments. ${NAME:-default}
# falls back to the default, which is how RUNTIME_DIR reaches
# /var/lib/omarchy/windows; mktemp is unwrapped to the template it is handed, so
# a scratch file inside a privileged directory still reads as privileged.
resolve_value() {
local value="$1" outer=0 inner before name default replacement
while ((outer++ < 8)); do
before=$value
inner=0
while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*):?-([^}]*)\} ]]; do
name=${BASH_REMATCH[1]}
default=${BASH_REMATCH[2]}
if [[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]]; then
replacement=${VARS[$name]}
else
replacement=$default
fi
value=${value/"${BASH_REMATCH[0]}"/$replacement}
done
inner=0
while ((inner++ < 32)) && [[ $value =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*) ]]; do
name=${BASH_REMATCH[1]}
[[ -n $name ]] || name=${BASH_REMATCH[2]}
[[ -v VARS[$name] && ${VARS[$name]} != *"\$$name"* ]] || break
value=${value/"${BASH_REMATCH[0]}"/${VARS[$name]}}
done
if [[ $value =~ \$\(mktemp[^\)]*[[:space:]]\"?([^\"\)]+)\"?\) ]]; then
value=${BASH_REMATCH[1]}
fi
[[ $value == "$before" ]] && break
done
printf '%s' "$value"
}
# Strip a leading KEY= and any opening quote so a token's literal head can be
# compared against the privileged prefixes.
literal_head() {
local text="$1"
[[ $text =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] && text=${text#*=}
text=${text#[\"\']}
printf '%s' "$text"
}
# The literal value a name is assigned, when that value is knowable. A value
# read out of a command substitution is not: resolving it would treat the
# slashes in the command itself as a path, which made $autologin_user (an awk
# over /etc/sddm.conf.d) look like a path when it holds a username.
literal_value() {
local name="$1"
[[ -v VARS[$name] ]] || return 0
[[ ${VARS[$name]} != *'$('* && ${VARS[$name]} != *'`'* ]] || return 0
resolve_value "${VARS[$name]}"
}
# Does TEXT still reference a root the installing user can replace? Used on a
# value the scan could not fully resolve, where the remaining "$HOME" or
# "${XDG_DATA_HOME}" is the whole reason the value cannot be trusted as a
# root-owned path. A chain through a name this scan never saw assigned resolves
# to neither, and is judged on the rest of the evidence.
mentions_user_writable_root() {
local text="$1" name pattern
for name in "${USER_WRITABLE_VARS[@]}"; do
# Whole name only. A prefix match would read ${OMARCHY_INSTALL_USER:-}, which
# holds a username, as OMARCHY_INSTALL, which holds a path.
pattern='\$\{?'"$name"'([^A-Za-z0-9_]|$)'
[[ $text =~ $pattern ]] && return 0
done
return 1
}
# Is this expansion used as a path, and if so, is that path anchored somewhere
# root already owns? MASKED is the containing whitespace token with expansions
# replaced by \001.
#
# Path-shaped means the literal text left around the expansion contains a slash
# ("$HOME/.local/...", "$storage:/storage"), or the variable names a
# user-writable root, or it is assigned a literal value containing a slash.
# Anchored means the literal text *before* the expansion is itself a privileged
# path, as in "/etc/systemd/system/$unit" -- still a path, but one root owns end
# to end. Scoping the anchor test to the containing token is what keeps a udev
# RUN+= line honest: it can name /usr/bin/systemd-run earlier on the same line
# while the $HOME token stands alone.
classify_expansion() {
local masked="$1" name="$2" head literal piece
local path_shape=""
head=$(literal_head "$masked")
head=${head%%$'\001'*}
literal=$(literal_value "$name")
[[ $masked == */* ]] && path_shape+="token "
in_list "$name" "${USER_WRITABLE_VARS[@]}" && path_shape+="user-root "
[[ -v VARS_TAINTED[$name] ]] && path_shape+="tainted "
[[ -n $literal && $literal == */* ]] && path_shape+="literal "
[[ -n $path_shape ]] || return 1
# A path expansion anchored under a root-owned prefix cannot introduce a
# user-writable location, so it does not need declaring.
starts_with_privileged_prefix "$head" && return 1
# When the token itself is not a path and the shape came only from the
# assigned value, a variable holding root-owned absolute paths is not baking
# anything user-writable in: that is how $fprintd_gate carries
# /usr/bin/omarchy-hw-laptop-closed. This rescue deliberately does not apply
# when the token is a path, so "$storage:/storage" stays flagged.
#
# It also does not apply to a value this scan could not finish resolving whose
# unresolved part reaches a root the user can replace. One hop is all it takes
# to hide the shape: helper="$HOME/.local/share/omarchy/bin/agent" followed by
# ExecStart=$helper puts no slash in the token and no literal path in the value,
# so rescuing it would exempt exactly the write this check exists to catch. A
# value that merely fails to resolve -- a kernel parameter list, an escaped
# password -- is left to the rescue, since nothing in it names a user root.
# A name assigned a user root anywhere in the file is never rescued: the
# assignment that won may be the packaged path it was later reassigned away
# from, and the rescue would then clear it on evidence it no longer holds.
if [[ $path_shape == "literal " ]]; then
for piece in $literal; do
piece=$(literal_head "$piece")
if mentions_user_writable_root "$piece"; then
return 0
fi
if [[ $piece == /* ]] && ! starts_with_privileged_prefix "$piece"; then
return 0
fi
done
return 1
fi
return 0
}
# Destination paths a command line hands a heredoc's output, as written. An
# "\002elevated" marker is emitted when the line runs through sudo and friends.
command_destinations() {
local line="$1" token target elevated=1 copy_like=1 last="" index scan
local -a tokens=()
# Quotes only get in the way of splitting; the paths inside them do not
# contain spaces anywhere this check runs.
line=${line//\"/ }
line=${line//\'/ }
# `>|` overrides noclobber; the bar belongs to the operator, not to a pipe.
# Left alone it becomes the redirect's target and hides the privileged path
# behind it, so a `cat <<EOF >| /etc/...` heredoc reports no destination.
line=${line//">|"/">"}
# Preserve append redirects before detaching redirect operators from their
# targets, so ">> /etc/x" does not become two ">" tokens whose first target
# is the second operator.
line=${line//>>/$'\003'}
line=${line//>/ > }
line=${line//$'\003'/" >> "}
read -r -a tokens <<<"$line"
index=0
while ((index < ${#tokens[@]})); do
token=${tokens[index]}
index=$((index + 1))
in_list "$token" "${ELEVATORS[@]}" && elevated=0
if [[ $token == ">" || $token == ">>" ]]; then
target=${tokens[index]:-}
index=$((index + 1))
[[ -n $target && $target != "&"* && $target != /dev/* ]] && printf '%s\n' "$target"
continue
fi
if [[ $token == of=* ]]; then
printf '%s\n' "${token#of=}"
continue
fi
if in_list "$token" "${WRITE_COMMANDS[@]}"; then
if [[ $token == "tee" ]]; then
# Every non-flag argument to tee is a destination.
scan=$index
while ((scan < ${#tokens[@]})); do
target=${tokens[scan]}
scan=$((scan + 1))
[[ $target == "|" || $target == "&&" || $target == ";" ]] && break
[[ $target == -* || $target == "<"* || $target == ">" || $target == of=* ]] && continue
[[ $target == /dev/* ]] && continue
printf '%s\n' "$target"
done
elif [[ $token != "dd" ]]; then
# dd destinations are expressed only by of= operands, handled above.
copy_like=0
fi
continue
fi
[[ $token != -* && $token != "|" && $token != "<"* && $token != ">" ]] && last=$token
done
# install/cp/mv put the destination last.
if ((copy_like == 0)) && [[ -n $last ]]; then
printf '%s\n' "$last"
fi
if ((elevated == 0)); then
printf '%s\n' $'\002elevated'
fi
}
# Does LINE carry the same resolved value as DEST? Compare resolved tokens rather
# than source spelling so $tmp, ${tmp}, and an alias assigned from either form
# all identify the same scratch file.
line_carries_destination() {
local line="$1" dest="$2" resolved token candidate
local -a tokens=()
resolved=$(resolve_value "$dest")
line=${line//\"/ }
line=${line//\'/ }
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do
token=${token#[<>]}
token=${token%;}
candidate=$(resolve_value "$token")
[[ $candidate == "$resolved" ]] && return 0
done
return 1
}
# Does the heredoc on this line reach a root-owned file? Either directly, or in
# one hop: written to a scratch file that a later install/cp/mv carries into a
# privileged directory.
privileged_destination() {
local line="$1" start_index="$2"
local -n scan_lines="$3"
local dest resolved elevated=1 follow hop hop_dest
local -a unresolved=()
while IFS= read -r dest; do
if [[ $dest == $'\002elevated' ]]; then
elevated=0
continue
fi
resolved=$(resolve_value "$dest")
resolved=${resolved#\~}
if starts_with_privileged_prefix "$resolved"; then
printf '%s' "$resolved"
return 0
fi
if [[ $resolved == *'$'* ]]; then
unresolved+=("$dest")
fi
# One hop: a later copy of this same destination into a root-owned path.
# Literal scratch files need tracing just as much as variable destinations.
follow=$start_index
while ((follow < ${#scan_lines[@]})); do
hop=${scan_lines[follow]}
follow=$((follow + 1))
[[ $hop =~ (^|[[:space:]])(install|cp|mv)([[:space:]]|$) ]] || continue
line_carries_destination "$hop" "$dest" || continue
while IFS= read -r hop_dest; do
[[ $hop_dest == $'\002elevated' ]] && continue
[[ $hop_dest == "$dest" ]] && continue
hop_dest=$(resolve_value "$hop_dest")
if starts_with_privileged_prefix "$hop_dest"; then
printf '%s' "$hop_dest"
return 0
fi
done < <(command_destinations "$hop")
done
done < <(command_destinations "$line")
# An elevated write whose destination cannot be resolved counts as privileged:
# sudo tee is not aimed at a user's own dotfile.
if ((elevated == 0)) && ((${#unresolved[@]} > 0)); then
printf '%s' "${unresolved[0]} (unresolved destination of an elevated write)"
return 0
fi
return 1
}
# A pipeline may put the command consuming a heredoc after its terminator:
#
# cat <<EOF |
# body
# EOF
# sudo tee /etc/file
#
# Join only while the command is syntactically continued, leaving unrelated
# commands below the heredoc to be scanned independently.
continued_heredoc_command() {
local command="$1" next="$2"
local -n source_lines="$3"
while [[ $command =~ (\|\||&&|\|)[[:space:]]*$ ]] && ((next < ${#source_lines[@]})); do
while ((next < ${#source_lines[@]})) && [[ ${source_lines[next]} =~ ^[[:space:]]*(#.*)?$ ]]; do
next=$((next + 1))
done
((next < ${#source_lines[@]})) || break
command+=" ${source_lines[next]}"
next=$((next + 1))
done
printf '%s' "$command"
}
# Count the \001 placeholders in a masked token.
count_placeholders() {
local text="$1" count=0
while [[ $text == *$'\001'* ]]; do
count=$((count + 1))
text=${text#*$'\001'}
done
printf '%s' "$count"
}
normalize_path_set() {
local value="$1"
local -a names=()
if [[ $value == "none" ]]; then
printf 'none'
return 0
fi
IFS=, read -ra names <<<"$value"
mapfile -t names < <(printf '%s\n' "${names[@]}" | sort -u)
(
IFS=,
printf '%s' "${names[*]}"
)
}
inside_same_line_arithmetic() {
local prefix="$1" opens=0 closes=0
while [[ $prefix == *"(("* ]]; do
opens=$((opens + 1))
prefix=${prefix#*"(("}
done
while [[ $prefix == *"))"* ]]; do
closes=$((closes + 1))
prefix=${prefix#*"))"}
done
((opens > closes))
}
scan_file() {
local file="$1" display="${2:-$1}"
local -a lines=()
local index lineno line command scan rest raw operator match prefix guard slot delim candidate candidate_delim body_start
local body_text unescaped destination destination_command body_line masked_line token name
local declared_paths annotation look shown_paths shown_plain count next slots terminated
local hd_re='(<<-?)[[:space:]]*("[A-Za-z_][A-Za-z0-9_]*"|'"'"'[A-Za-z_][A-Za-z0-9_]*'"'"'|[A-Za-z_][A-Za-z0-9_]*)'
mapfile -t lines <"$file"
collect_vars lines
index=0
while ((index < ${#lines[@]})); do
line=${lines[index]}
lineno=$((index + 1))
index=$((index + 1))
[[ $line =~ ^[[:space:]]*# ]] && continue
# A backslash-escaped newline is removed before Bash parses the command, so
# a pipeline consumer can appear on the next physical line before heredoc
# body collection begins: `cat <<EOF | \\` then `sudo tee /etc/file`.
# Join those physical lines first. A bare newline after `|` instead starts
# the heredoc body and is handled after the terminator below.
command=$line
while [[ $command == *\\ ]] && ((index < ${#lines[@]})); do
command=${command%\\}
command+=" ${lines[index]}"
index=$((index + 1))
done
# Herestrings are not heredocs. Blanking them keeps <<<"$x" from reading as
# a heredoc while preserving every other offset on the line.
scan=${command//<<</ }
[[ $scan == *"<<"* ]] || continue
# Collect this line's heredoc delimiters in order. Quoted ones are safe by
# construction but still have to be tracked, or their bodies would be
# parsed as code.
local -a delims=() quoted=() strip_tabs=()
rest=$scan
guard=0
while ((guard++ < 8)) && [[ $rest =~ $hd_re ]]; do
match=${BASH_REMATCH[0]}
operator=${BASH_REMATCH[1]}
raw=${BASH_REMATCH[2]}
prefix=${rest%%"$match"*}
rest=${rest#*"$match"}
# `(( value << shift ))` and `$(( value << shift ))` are arithmetic, not
# heredocs. Without this guard the shift count becomes a phantom delimiter
# and can consume every real heredoc below it.
inside_same_line_arithmetic "$prefix" && continue
if [[ $raw == \"*\" || $raw == \'*\' ]]; then
delims+=("${raw:1:${#raw}-2}")
quoted+=(0)
else
delims+=("$raw")
quoted+=(1)
fi
[[ $operator == "<<-" ]] && strip_tabs+=(1) || strip_tabs+=(0)
done
((${#delims[@]} > 0)) || continue
for slot in "${!delims[@]}"; do
delim=${delims[slot]}
local -a body=()
body_start=$index
terminated=1
while ((index < ${#lines[@]})); do
candidate=${lines[index]}
index=$((index + 1))
candidate_delim=$candidate
if ((strip_tabs[slot] == 1)); then
while [[ $candidate_delim == $'\t'* ]]; do
candidate_delim=${candidate_delim#$'\t'}
done
fi
if [[ $candidate_delim == "$delim" ]]; then
terminated=0
break
fi
body+=("$candidate")
done
# A valid shell source cannot contain an unterminated heredoc. If this
# candidate has no terminator it was syntax such as a multi-line arithmetic
# shift that the lightweight matcher could not classify; resume scanning
# below it instead of swallowing the rest of the file.
if ((terminated != 0)); then
index=$body_start
continue
fi
# A quoted delimiter cannot expand anything.
((quoted[slot] == 1)) || continue
printf -v body_text '%s\n' "${body[@]:-}"
unescaped=$(strip_escapes "$body_text")
[[ $unescaped =~ $EXPANSION_RE || $unescaped == *'`'* ]] || continue
destination_command=$(continued_heredoc_command "$command" "$index" lines)
destination=$(privileged_destination "$destination_command" "$index" lines) || continue
# Sort the expansions into the ones that bake a path into the file and
# the ones that only interpolate a scalar.
local -a path_expansions=() plain_expansions=() scanned=() names=()
while IFS= read -r body_line; do
mapfile -t scanned < <(mask_and_names "$body_line")
masked_line=${scanned[0]}
names=("${scanned[@]:1}")
next=0
for token in $masked_line; do
count=$(count_placeholders "$token")
((count > 0)) || continue
for ((slots = 0; slots < count; slots++)); do
name=${names[next]:-}
next=$((next + 1))
[[ -n $name ]] || continue
if classify_expansion "$token" "$name"; then
in_list "$name" "${path_expansions[@]:-}" || path_expansions+=("$name")
else
in_list "$name" "${plain_expansions[@]:-}" || plain_expansions+=("$name")
fi
done
done
done <<<"$unescaped"
declared_paths=""
annotation=""
look=$((lineno - 2))
while ((look >= 0)) && [[ ${lines[look]} =~ ^[[:space:]]*# ]]; do
if [[ ${lines[look]} =~ $ANNOTATION_RE ]]; then
declared_paths=${BASH_REMATCH[1]}
annotation=${BASH_REMATCH[3]}
fi
look=$((look - 1))
done
shown_paths="none"
if ((${#path_expansions[@]} > 0)); then
shown_paths=$(
IFS=,
printf '%s' "${path_expansions[*]}"
)
fi
shown_plain="none"
if ((${#plain_expansions[@]} > 0)); then
shown_plain=$(
IFS=,
printf '%s' "${plain_expansions[*]}"
)
fi
if [[ -z $annotation ]]; then
FINDINGS+=("$display:$lineno: unquoted heredoc <<$delim expands values at install time and its output reaches $destination
path-shaped expansions: $shown_paths
other expansions: $shown_plain
Whatever expands here is baked into a file root owns. If it is a path the
installing user can replace, root later reads or executes attacker-controlled
content -- that is a local privilege escalation.
Fix, in order of preference:
1. quote the delimiter (<<'$delim') so nothing expands at install time;
2. hardcode an absolute root-owned path instead of expanding one;
3. if the expansion is genuinely required, declare it above the heredoc:
# omarchy:heredoc-expands paths=<expansions used as paths, or none> -- <why this is safe>
Decide that list yourself. The scan's own reading of it is above, and
where the scan is most likely wrong is exactly here -- a path it could
not follow reads as an ordinary value -- so pasting its verdict back
signs off on the case worth checking by hand.")
continue
fi
if [[ $(normalize_path_set "$declared_paths") != $(normalize_path_set "$shown_paths") ]]; then
FINDINGS+=("$display:$lineno: heredoc annotation declares paths=$declared_paths but the path-shaped expansions are $shown_paths
Writing to: $destination
Every expansion used as a path outside a root-owned prefix has to be named,
so adding one to an already-annotated heredoc trips this check again instead
of inheriting the old exemption.
Fix: drop the path expansion (hardcode an absolute root-owned path), or name
every path-shaped expansion in the declaration and say why root using it is
safe.")
fi
done
done
}
# bin/, install/ and migrations/ are where privileged writes live: bin/ holds
# the setup and upgrade commands, install/ runs during install, migrations/
# during update. default/ is scanned too even though the pattern is not
# reachable there today -- it ships bash functions and completions whose only
# "<<" uses are herestrings, with no privileged writes at all -- because the
# scan is cheap and default/ is sourced into every login shell, so a privileged
# write arriving there later should not arrive unchecked.
shell_sources() {
local file first
while IFS= read -r -d '' file; do
# Binary files and sources with no heredoc operator have nothing this check
# can classify. Filter them before collect_vars and the line-by-line scan.
grep -Iq '<<' "$file" 2>/dev/null || continue
case $file in
*.sh | *.hook)
printf '%s\0' "$file"
continue
;;
esac
IFS= read -r first <"$file" || true
if [[ $first =~ ^#!.*[[:space:]/](bash|sh)$ ]]; then
printf '%s\0' "$file"
fi
done < <(find "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" "$ROOT/default" \
-type f -print0 2>/dev/null | sort -z)
}
require_command find
require_command grep
sources=()
while IFS= read -r -d '' file; do
sources+=("$file")
done < <(shell_sources)
((${#sources[@]} > 50)) || fail "the scan reaches the privileged-write scripts" \
"only ${#sources[@]} shell sources found under bin/, install/, migrations/ and default/"
pass "the scan reaches the privileged-write scripts (${#sources[@]} files)"
for file in "${sources[@]}"; do
scan_file "$file" "${file#"$ROOT"/}"
done
if ((${#FINDINGS[@]} > 0)); then
fail "no privileged write embeds an install-time expansion through an unquoted heredoc" \
"$(printf '%s\n\n' "${FINDINGS[@]}")"
fi
pass "no privileged write embeds an install-time expansion through an unquoted heredoc"
# --- Non-vacuity ------------------------------------------------------------
#
# A check that cannot catch the bug it was written for is worthless, so the same
# scanner runs against fixtures: installer shapes taken verbatim from this
# repository's history, the routes other than a pipe into sudo tee, and the
# shapes that must stay quiet.
FIXTURES="$SHELL_TEST_DIR/fixtures/privileged-heredoc"
fixture_flags() {
local fixture="$1" description="$2" expected="${3:-}"
FINDINGS=()
scan_file "$FIXTURES/$fixture" "$fixture"
((${#FINDINGS[@]} > 0)) || fail "$description" "$fixture produced no finding"
if [[ -n $expected ]]; then
printf '%s\n' "${FINDINGS[@]}" | grep -qF -- "$expected" ||
fail "$description" "expected \"$expected\" in:$(printf '\n%s' "${FINDINGS[@]}")"
fi
pass "$description"
}
fixture_passes() {
local fixture="$1" description="$2"
FINDINGS=()
scan_file "$FIXTURES/$fixture" "$fixture"
((${#FINDINGS[@]} == 0)) || fail "$description" "$(printf '%s\n' "${FINDINGS[@]}")"
pass "$description"
}
# Verbatim installer shapes: two udev rules whose RUN+= resolves through a
# user's home, and a systemd unit whose ExecStop did the same. Kept as written
# rather than tidied, so the fixtures stay faithful to the real shape instead of
# a cleaned-up sketch of it.
fixture_flags udev-rule-home-path.sh \
"flags a power-profile udev rule whose RUN+= resolves under \$HOME" \
"path-shaped expansions: HOME"
fixture_flags wifi-rule-home-path.sh \
"flags a wifi-powersave udev rule whose RUN+= resolves under \$HOME" \
"path-shaped expansions: HOME"
fixture_flags shutdown-unit-home-execstop.sh \
"flags a shutdown unit with ExecStop=\$HOME/..." \
"path-shaped expansions: HOME"
# The exemption must not be a rubber stamp: the same file carrying a
# plausible-looking annotation still fails, because $HOME is path-shaped and
# the declaration does not say so.
fixture_flags annotated-paths-none-still-fails.sh \
"an annotation claiming paths=none cannot silence a baked \$HOME path" \
"declares paths=none but the path-shaped expansions are HOME"
fixture_flags annotated-special-parameter-before-home.sh \
"a shell special parameter cannot hide a later baked \$HOME path" \
"declares paths=none but the path-shaped expansions are HOME"
# A path can hide one or more hops away from the heredoc. In each of these the
# token in the body has no slash and the value never resolves to a literal path,
# so an annotation of paths=none looks plausible while the write still bakes the
# user's home into a root-owned file. The declaration has to name the expansion.
fixture_flags hop-variable-home-path.sh \
"an annotation cannot exempt a home path carried one variable hop away" \
"declares paths=none but the path-shaped expansions are helper"
fixture_flags hop-twice-home-path.sh \
"an annotation cannot exempt a home path carried two variable hops away" \
"declares paths=none but the path-shaped expansions are helper"
fixture_flags shadowed-assignment-home-path.sh \
"a later assignment under \$HOME is judged, not the packaged value it shadowed" \
"declares paths=none but the path-shaped expansions are target"
# Routes other than a direct pipe into sudo tee.
fixture_flags route-redirect.sh "flags a plain redirect into /etc"
fixture_flags route-sudo-dd.sh "flags sudo dd of= into a privileged path"
fixture_flags route-variable-path.sh \
"flags an elevated write whose destination is a variable resolving under /etc"
fixture_flags route-install-hop.sh \
"flags a scratch file that install(1) later copies into /usr"
fixture_flags route-install-hop-literal.sh \
"flags a literal scratch file that install(1) later copies into /etc"
fixture_flags route-install-hop-braced.sh \
"flags a scratch-file hop whose variable uses braces at the privileged copy"
fixture_flags route-install-hop-alias.sh \
"flags a scratch-file hop carried through an alias variable"
fixture_flags route-continued-pipeline.sh \
"flags a privileged pipeline command continued after the heredoc terminator"
fixture_flags route-prebody-escaped-pipeline.sh \
"flags an escaped-line pipeline consumer before the heredoc body"
fixture_flags route-dash-delimiter.sh "flags an indented <<- heredoc"
fixture_flags route-append-redirect.sh "flags an append redirect into /etc"
fixture_flags route-noclobber-redirect.sh \
"flags a noclobber-override redirect into /etc"
fixture_flags arithmetic-left-shift-before-heredoc.sh \
"an arithmetic left shift does not swallow a later privileged heredoc" \
"path-shaped expansions: HOME"
fixture_flags plain-heredoc-indented-pseudo-delimiter.sh \
"an indented delimiter does not terminate a plain heredoc" \
"path-shaped expansions: HOME"
fixture_flags nested-parameter-default.sh \
"a nested parameter default cannot hide a baked home path" \
"path-shaped expansions are HOME"
mapfile -t dd_destinations < <(command_destinations \
'sudo dd if=/tmp/input bs=4M status=none of=/etc/omarchy/image')
[[ ${dd_destinations[0]:-} == "/etc/omarchy/image" && ${dd_destinations[1]:-} == $'\002elevated' && ${#dd_destinations[@]} == 2 ]] ||
fail "dd emits only its of= destination" "$(printf '%q\n' "${dd_destinations[@]:-}")"
pass "dd emits only its of= destination"
# Negatives.
fixture_passes safe-quoted-delimiter.sh "a quoted delimiter passes"
fixture_passes safe-user-destination.sh \
"an unquoted heredoc expanding into the user's own ~/.config passes"
fixture_passes safe-no-expansion.sh \
"a privileged write with no expansion in the body passes"
fixture_passes safe-runtime-expansion.sh \
"an escaped \\\$VAR left for a root daemon to expand passes"
fixture_passes safe-annotated.sh "a declared, reasoned exemption passes"
fixture_passes safe-annotated-reordered-paths.sh \
"path declarations compare as sets rather than traversal order"
fixture_passes safe-root-anchored.sh \
"a path expansion anchored under /etc is truthfully declared paths=none"
fixture_passes safe-herestring.sh "a herestring is not mistaken for a heredoc"
+17 -43
View File
@@ -1,11 +1,8 @@
#!/bin/bash #!/bin/bash
# #
# The install scripts that grant group memberships must record them in the provisioning # Privileged groups are never granted by the default install. Docker remains an
# groups file (for first-boot user creation and factory reset) and only call # explicit opt-in, and raw input-device access is granted only by the optional
# usermod when the install user actually exists. # controller and ydotool installers.
#
# Docker is deliberately excluded: the docker group is root-equivalent, so it is
# no longer granted at install time (opt in with omarchy-setup-security-sudoless-docker).
set -euo pipefail set -euo pipefail
@@ -16,13 +13,7 @@ trap 'rm -rf "$TMPDIR"' EXIT
export OMARCHY_PROVISIONING_DIR="$TMPDIR/provisioning" export OMARCHY_PROVISIONING_DIR="$TMPDIR/provisioning"
# Stub getent/usermod: the fake system knows only the user "existing".
mkdir -p "$TMPDIR/bin" mkdir -p "$TMPDIR/bin"
cat >"$TMPDIR/bin/getent" <<'STUB'
#!/bin/bash
[[ $1 == passwd && $2 == existing ]] && { echo "existing:x:1000:1000::/home/existing:/bin/bash"; exit 0; }
exit 2
STUB
cat >"$TMPDIR/bin/usermod" <<STUB cat >"$TMPDIR/bin/usermod" <<STUB
#!/bin/bash #!/bin/bash
echo "\$@" >>"$TMPDIR/usermod.calls" echo "\$@" >>"$TMPDIR/usermod.calls"
@@ -44,48 +35,31 @@ cat >"$TMPDIR/bin/sudo" <<STUB
echo "\$@" >>"$TMPDIR/sudo.calls" echo "\$@" >>"$TMPDIR/sudo.calls"
exec "\$@" exec "\$@"
STUB STUB
chmod +x "$TMPDIR/bin"/{getent,usermod,groupadd,install,find,sudo} chmod +x "$TMPDIR/bin"/{usermod,groupadd,install,find,sudo}
export PATH="$TMPDIR/bin:$PATH" export PATH="$TMPDIR/bin:$PATH"
export OMARCHY_PATH="$ROOT" export OMARCHY_PATH="$ROOT"
# No install user (deferred-provisioning install): groups recorded, usermod not called. # A deferred-provisioning install records neither privileged group.
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh"
[[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" [[ ! -f $OMARCHY_PROVISIONING_DIR/groups ]] ||
grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" ! grep -Eq '^(docker|input)$' "$OMARCHY_PROVISIONING_DIR/groups" ||
! grep -qxF omarchy-browser-policy "$OMARCHY_PROVISIONING_DIR/groups" || fail "default install must not record docker or input groups"
fail "browser-policy group must not be recorded"
[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user"
[[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null || [[ ! -f $TMPDIR/groupadd.calls ]] || ! grep -F omarchy-browser-policy "$TMPDIR/groupadd.calls" >/dev/null ||
fail "browser-policy group is not created" fail "browser-policy group is not created"
grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null || grep -F -- '-d -m 0755 -o root -g root /etc/chromium/policies/managed' "$TMPDIR/install.calls" >/dev/null ||
fail "browser-policy directory is created root-owned" fail "browser-policy directory is created root-owned"
pass "deferred provisioning records groups without calling usermod" pass "deferred provisioning records no privileged groups"
# The docker group is root-equivalent and must never be granted automatically. # The same remains true when an install user already exists.
! grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group must not be recorded"
pass "docker group is not recorded at install"
# Missing user (defensive): no usermod either.
OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/browser-policy.sh"
[[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user"
pass "missing install user defers group grants"
# Re-running never duplicates entries.
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh"
[[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once"
OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/browser-policy.sh"
pass "group recording is idempotent"
# Existing user: usermod applies the recorded groups, and docker is never among them.
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh"
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh"
OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/browser-policy.sh"
grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" [[ ! -f $TMPDIR/usermod.calls ]] || fail "default install must not grant privileged groups"
! grep -q -- "omarchy-browser-policy" "$TMPDIR/usermod.calls" || pass "existing install user gets neither docker nor input access"
fail "usermod must not grant browser-policy to the install user"
! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" ! grep -q 'hardware/input-group.sh' "$ROOT/install/hardware/all.sh" ||
pass "existing install user gets input but never docker or browser-policy" fail "hardware setup must not call the removed input-group grant"
[[ ! -e $ROOT/install/hardware/input-group.sh ]] || fail "blanket input-group grant is removed"
pass "hardware setup has no blanket input-group grant"
+654
View File
@@ -0,0 +1,654 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
migration="$ROOT/migrations/1788025225.sh"
[[ -f $migration ]] || fail "the retired installer artifact migration exists at $migration"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin"
# sudo runs the real command, so the removals act on the redirected directories
# below and the elevated calls land in the log beside it.
cat >"$test_dir/bin/sudo" <<'STUB'
#!/bin/bash
printf 'sudo %s\n' "$*" >>"$CALLS"
exec "$@"
STUB
cat >"$test_dir/bin/systemctl" <<'STUB'
#!/bin/bash
printf 'systemctl %s\n' "$*" >>"$CALLS"
if [[ $* == "daemon-reload" && -n ${FAIL_DAEMON_RELOAD_ONCE_MARKER:-} && ! -e $FAIL_DAEMON_RELOAD_ONCE_MARKER ]]; then
touch "$FAIL_DAEMON_RELOAD_ONCE_MARKER"
exit 1
fi
STUB
chmod +x "$test_dir/bin/"*
# A second stub directory where sudo cannot elevate, standing in for a run with
# no terminal to read a password from.
mkdir -p "$test_dir/failing-bin"
cat >"$test_dir/failing-bin/sudo" <<'STUB'
#!/bin/bash
echo "sudo: a terminal is required to read the password" >&2
exit 1
STUB
cp "$test_dir/bin/systemctl" "$test_dir/failing-bin/systemctl"
chmod +x "$test_dir/failing-bin/"*
export CALLS="$test_dir/calls"
sudoers_dir="$test_dir/sudoers.d"
systemd_dir="$test_dir/systemd"
home_dir="$test_dir/home"
first_run="$sudoers_dir/first-run"
tsui="$sudoers_dir/tsui"
plymouth_unit="$systemd_dir/omarchy-plymouth-shutdown.service"
machine_marker="$test_dir/machine-marker"
reload_needed_marker="$machine_marker.daemon-reload"
reset_machine() {
rm -rf "$sudoers_dir" "$systemd_dir" "$home_dir" "$machine_marker" "$reload_needed_marker"
mkdir -p "$sudoers_dir" "$systemd_dir" "$home_dir"
}
run_migration() {
: >"$CALLS"
HOME="$home_dir" \
OMARCHY_SUDOERS_DIR="$sudoers_dir" \
OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \
OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER="$machine_marker" \
PATH="$test_dir/bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null
}
# /etc/sudoers.d is 0750 root:root on a real machine, so the migration has to
# escalate merely to see whether either grant is there. An empty call log is
# therefore the wrong invariant: what must be absent unless a file really is
# Omarchy's is a removal, or a unit being disabled or reloaded.
assert_changed_nothing() {
local label="$1"
! grep -qE '^(sudo rm|systemctl disable|systemctl daemon-reload)' "$CALLS" ||
fail "$label" "$(cat "$CALLS")"
pass "$label"
}
# The reads themselves must be elevated too. A plain [[ -f ]] or cat under a
# root-only directory returns nothing as the logged-in user, which would make the
# migration report success having looked at nothing at all.
assert_read_elevated() {
local file="$1" label="$2"
grep -qF "bash $file" "$CALLS" ||
fail "$label" "$(cat "$CALLS")"
grep -qF "sudo cat $file" "$CALLS" ||
fail "$label" "$(cat "$CALLS")"
pass "$label"
}
write_plymouth_unit() {
cat >"$plymouth_unit" <<EOF
[Unit]
Description=Sync Plymouth Theme on Shutdown
DefaultDependencies=yes
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=$1
[Install]
WantedBy=multi-user.target
EOF
}
# Every distinct body the first-run-mode installer wrote across its nine rewrites,
# oldest first. Only the later ones carry both Cmnd_Alias lines, so a predicate
# keyed on those would leave the earlier grants on disk. The last body is the one
# install/post-install/first-run-mode.sh shipped until it was retired: its cleanup
# alias names /usr/bin/rm as well as /bin/rm, and it still grants passwordless
# /usr/bin/systemctl.
first_run_variants=(
'installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /bin/rm -f /home/installer/.local/state/omarchy/first-run.mode'
'installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /bin/rm -f /etc/sudoers.d/first-run'
'Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
'Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
'Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache
installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
'Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache
installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
'Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache
installer ALL=(ALL) NOPASSWD: /usr/bin/tee /etc/udev/rules.d/*
installer ALL=(ALL) NOPASSWD: /usr/bin/udevadm
installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
'Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/99-omarchy-installer-reboot
Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache
installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
'Cmnd_Alias FIRST_RUN_CLEANUP = /usr/bin/rm -f /etc/sudoers.d/first-run, /bin/rm -f /etc/sudoers.d/first-run
Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
installer ALL=(ALL) NOPASSWD: /usr/bin/gtk-update-icon-cache
installer ALL=(ALL) NOPASSWD: SYMLINK_RESOLVED
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP'
)
variant=0
for body in "${first_run_variants[@]}"; do
variant=$(( variant + 1 ))
reset_machine
printf '%s\n' "$body" >"$first_run"
run_migration
[[ ! -e $first_run ]] ||
fail "migration removes first-run grant variant $variant" "$(cat "$first_run")"
done
pass "migration removes every first-run sudoers grant the installer ever wrote"
[[ -f $machine_marker ]] || fail "migration records the machine-wide repair"
pass "migration records the machine-wide repair"
grep -q '^sudo rm -f .*/sudoers\.d/first-run$' "$CALLS" ||
fail "migration removes the first-run grant with elevated privileges" "$(cat "$CALLS")"
pass "migration removes the first-run grant with elevated privileges"
# The grant is only recognisable as Omarchy's because every line in it is one the
# installer emitted. One line an administrator added and the file is theirs.
reset_machine
cat >"$first_run" <<'EOF'
Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
Cmnd_Alias SYMLINK_RESOLVED = /usr/bin/ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: /usr/local/bin/our-own-deploy-script
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP
EOF
before=$(cat "$first_run")
run_migration
[[ -e $first_run ]] || fail "migration keeps a first-run file carrying a hand-written rule"
[[ $(cat "$first_run") == "$before" ]] ||
fail "migration leaves a hand-written first-run file byte for byte"
assert_changed_nothing "migration changes nothing for a hand-written first-run file"
pass "migration keeps a first-run file carrying a hand-written rule"
# The installer wrote one account name consistently. A whitelisted command for
# another user is an administrator extension, not part of the generated body.
reset_machine
printf '%s\n' "${first_run_variants[-1]}" >"$first_run"
printf '%%wheel ALL=(ALL) NOPASSWD: /usr/bin/systemctl\n' >>"$first_run"
before=$(cat "$first_run")
run_migration
[[ -e $first_run ]] || fail "migration keeps a generated file extended for another sudoers user"
[[ $(cat "$first_run") == "$before" ]] ||
fail "migration leaves a generated file extended for another user byte for byte"
pass "migration does not delete an administrator grant that uses a generated command"
# The oldest installer variant expanded the same $USER into the sudoers account
# and the /home/<user>/ cleanup path. A different account in the path proves the
# line was edited or hand-written and makes the whole file administrator-owned.
reset_machine
cat >"$first_run" <<'EOF'
alice ALL=(ALL) NOPASSWD: /bin/rm -f /home/bob/.local/state/omarchy/first-run.mode
EOF
before=$(cat "$first_run")
run_migration
[[ -e $first_run ]] || fail "migration keeps a first-run cleanup path for another account"
[[ $(cat "$first_run") == "$before" ]] ||
fail "migration leaves the cross-account first-run file byte for byte"
pass "migration requires the cleanup path account to match the granted account"
# Nothing in this file ties it to Omarchy's first run: no self-cleanup line.
reset_machine
cat >"$first_run" <<'EOF'
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw
installer ALL=(ALL) NOPASSWD: /usr/bin/ufw-docker
EOF
run_migration
[[ -e $first_run ]] ||
fail "migration keeps a same-named file that never cleaned itself up"
pass "migration keeps a same-named file that never cleaned itself up"
# A spec continued onto the next line is one logical line, and a comment's own
# trailing backslash swallows nothing: `visudo -cf` on "# note \" plus a bogus
# token reports the error on line 2, so the spec below a commented line is live.
# The hand-written spec sits under the comment on purpose -- above it, the file
# is kept under either reading and the assertion cannot fail.
reset_machine
cat >"$first_run" <<'EOF'
# Retired, keeping the old grant here for reference: \
installer ALL=(ALL) NOPASSWD: /usr/local/bin/our-own-deploy-script
Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
installer ALL=(ALL) NOPASSWD: \
/usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP
EOF
run_migration
[[ -e $first_run ]] ||
fail "migration keeps a spec left live under a commented continuation"
pass "migration keeps a spec left live under a commented continuation"
reset_machine
printf 'installer ALL=(ALL) NOPASSWD: %s/.local/bin/tsui\n' "$home_dir" >"$tsui"
run_migration
[[ ! -e $tsui ]] || fail "migration removes the tsui grant pointing into the user's home"
grep -q '^sudo rm -f .*/sudoers\.d/tsui$' "$CALLS" ||
fail "migration removes the tsui grant with elevated privileges" "$(cat "$CALLS")"
pass "migration removes the tsui grant pointing into the user's home"
# The feature is gone from Omarchy either way, and unrestricted NOPASSWD on a TUI
# that can shell out escalates from a root-owned path too.
reset_machine
printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' >"$tsui"
run_migration
[[ ! -e $tsui ]] || fail "migration removes the tsui grant wherever the path points"
pass "migration removes the tsui grant wherever the path points"
reset_machine
cat >"$tsui" <<'EOF'
# Kept after Omarchy dropped tsui, extended for our operators
installer ALL=(ALL) NOPASSWD: /usr/bin/tsui
operator ALL=(ALL) NOPASSWD: /usr/bin/tsui
EOF
before=$(cat "$tsui")
run_migration
[[ -e $tsui ]] || fail "migration keeps a tsui file an administrator extended"
[[ $(cat "$tsui") == "$before" ]] || fail "migration leaves an extended tsui file byte for byte"
assert_changed_nothing "migration changes nothing for an extended tsui file"
pass "migration keeps a tsui file an administrator extended"
reset_machine
printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tailscale\n' >"$tsui"
run_migration
[[ -e $tsui ]] || fail "migration keeps a lone grant for some other command"
pass "migration keeps a lone grant for some other command"
reset_machine
write_plymouth_unit "/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync"
run_migration
[[ ! -e $plymouth_unit ]] ||
fail "migration removes the shutdown unit that runs out of a user home"
pass "migration removes the shutdown unit that runs out of a user home"
# Stopping the unit is exactly what runs ExecStop, which is the path being taken
# away from root. Disabling only drops the multi-user.target symlink.
! grep -q '^systemctl stop' "$CALLS" ||
fail "migration never stops the unit, which would run ExecStop as root" "$(cat "$CALLS")"
pass "migration never stops the unit, which would run ExecStop as root"
disable_at=$(grep -n '^systemctl disable omarchy-plymouth-shutdown\.service$' "$CALLS" | cut -d: -f1)
remove_at=$(grep -n '^sudo rm -f .*omarchy-plymouth-shutdown\.service$' "$CALLS" | cut -d: -f1)
reload_at=$(grep -n '^systemctl daemon-reload$' "$CALLS" | cut -d: -f1)
[[ -n $disable_at && -n $remove_at && -n $reload_at ]] ||
fail "migration disables, removes, then reloads the unit" "$(cat "$CALLS")"
(( disable_at < remove_at && remove_at < reload_at )) ||
fail "migration disables before removing and reloads last" "$(cat "$CALLS")"
pass "migration disables the unit, removes it, then reloads systemd in that order"
# Homes are not all under /home, and the account running this machine-wide
# repair may not be the account that installed the unit.
reset_machine
write_plymouth_unit "/srv/retired-installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync"
run_migration
[[ ! -e $plymouth_unit ]] ||
fail "migration removes another user's shutdown unit rooted outside /home"
[[ -e $machine_marker ]] || fail "migration marks the cross-user Plymouth repair complete"
pass "migration removes another user's shutdown unit rooted outside /home"
reset_machine
write_plymouth_unit "/usr/bin/omarchy-plymouth-shutdown-sync"
run_migration
[[ -e $plymouth_unit ]] ||
fail "migration keeps a same-named unit that runs a packaged command"
assert_changed_nothing "migration changes nothing for a packaged shutdown unit"
pass "migration keeps a same-named unit that runs a packaged command"
# systemd takes ';' as a comment too, and an ExecStop behind one runs nothing.
reset_machine
cat >"$plymouth_unit" <<'EOF'
[Service]
Type=oneshot
; ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
ExecStop=/usr/bin/true
EOF
run_migration
[[ -e $plymouth_unit ]] || fail "migration keeps a unit whose home ExecStop is commented out"
pass "migration keeps a unit whose home ExecStop is commented out"
# Non-empty ExecStop= assignments append; a packaged command after the retired
# home command does not replace it, so the vulnerable command remains live.
reset_machine
cat >"$plymouth_unit" <<'EOF'
[Service]
Type=oneshot
ExecStart=/usr/bin/true
ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
ExecStop=/usr/bin/true
EOF
run_migration
[[ ! -e $plymouth_unit ]] ||
fail "migration removes a home ExecStop followed by another command" "$(cat "$plymouth_unit")"
pass "migration removes a home ExecStop followed by another command"
reset_machine
run_migration
assert_changed_nothing "migration changes nothing when no retired artifact is present"
pass "migration leaves a machine without any retired artifact alone"
# All three at once, then the same run again: what a second account on the
# machine does, and what running omarchy-migrate twice does.
reset_machine
printf '%s\n' "${first_run_variants[-1]}" >"$first_run"
printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' >"$tsui"
write_plymouth_unit "/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync"
run_migration
[[ ! -e $first_run && ! -e $tsui && ! -e $plymouth_unit ]] ||
fail "migration clears all three retired artifacts in one pass"
pass "migration clears all three retired artifacts in one pass"
run_migration
assert_changed_nothing "migration changes nothing on a second run"
pass "migration is a no-op on a second run"
# sudo does not treat every '#' as a comment. plugins/sudoers/toke.l matches
# ^#include and ^#includedir as directives in the INITIAL state, and its comment
# rule excludes '#' followed by a digit or -digit so those reach the ID token as a
# numeric uid user spec -- sudoers(5) says the same. Dropping such a line as a
# comment would let a file that still carries an active directive read as though
# it held only generated lines, and be deleted.
for directive in \
'#include /etc/sudoers.local' \
'#includedir /etc/sudoers.d.local' \
'#1000 ALL=(ALL) NOPASSWD: ALL' \
'#-1000 ALL=(ALL) NOPASSWD: ALL'; do
reset_machine
{
printf '%s\n' "$directive"
printf '%s\n' "${first_run_variants[-1]}"
} >"$first_run"
before=$(cat "$first_run")
run_migration
[[ -e $first_run ]] ||
fail "migration keeps a first-run file carrying the active directive $directive"
[[ $(cat "$first_run") == "$before" ]] ||
fail "migration leaves a first-run file with $directive byte for byte"
pass "migration keeps a first-run file carrying the active directive $directive"
reset_machine
{
printf '%s\n' "$directive"
printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n'
} >"$tsui"
before=$(cat "$tsui")
run_migration
[[ -e $tsui ]] ||
fail "migration keeps a tsui file carrying the active directive $directive"
[[ $(cat "$tsui") == "$before" ]] ||
fail "migration leaves a tsui file with $directive byte for byte"
pass "migration keeps a tsui file carrying the active directive $directive"
done
# A sudoers comment ending in a backslash does not swallow the line beneath it:
# toke.l's comment rule consumes to the newline and clears the continuation flag.
# Joining before testing for a comment would hide this administrator's grant.
reset_machine
cat >"$tsui" <<'EOF'
# retired, kept for reference \
ops ALL=(ALL) NOPASSWD: /usr/bin/tsui
installer ALL=(ALL) NOPASSWD: /usr/bin/tsui
EOF
before=$(cat "$tsui")
run_migration
[[ -e $tsui ]] ||
fail "migration keeps a tsui file whose second grant survives a commented continuation"
[[ $(cat "$tsui") == "$before" ]] ||
fail "migration leaves that tsui file byte for byte"
pass "migration keeps a tsui file whose second grant survives a commented continuation"
# Both grants live under a root-only directory, so seeing them at all takes
# elevation. Pin that the migration reads them elevated rather than silently
# reading nothing.
reset_machine
printf '%s\n' "${first_run_variants[-1]}" >"$first_run"
run_migration
assert_read_elevated "$first_run" "migration reads the first-run grant with elevated privileges"
reset_machine
printf 'installer ALL=(ALL) NOPASSWD: /usr/bin/tsui\n' >"$tsui"
run_migration
assert_read_elevated "$tsui" "migration reads the tsui grant with elevated privileges"
# sudo ends a logical line at a comment and keeps what came before it: visudo -cf
# reads a spec ending in a backslash, then a comment, then a second spec as two
# live specs. Dropping the pending half would hide this administrator's grant and
# let the file read as though the installer had written all of it.
reset_machine
cat >"$first_run" <<'EOF'
Cmnd_Alias FIRST_RUN_CLEANUP = /bin/rm -f /etc/sudoers.d/first-run
operator ALL=(ALL) NOPASSWD: /usr/local/bin/deploy \
# kept deliberately
installer ALL=(ALL) NOPASSWD: /usr/bin/systemctl
installer ALL=(ALL) NOPASSWD: FIRST_RUN_CLEANUP
EOF
before=$(cat "$first_run")
run_migration
[[ -e $first_run ]] ||
fail "migration keeps a first-run file whose hand-written spec precedes a comment"
[[ $(cat "$first_run") == "$before" ]] ||
fail "migration leaves that first-run file byte for byte"
pass "migration keeps a first-run file whose hand-written spec precedes a comment"
reset_machine
cat >"$tsui" <<'EOF'
operator ALL=(ALL) NOPASSWD: /usr/local/bin/deploy \
# kept deliberately
installer ALL=(ALL) NOPASSWD: /usr/bin/tsui
EOF
before=$(cat "$tsui")
run_migration
[[ -e $tsui ]] ||
fail "migration keeps a tsui file whose hand-written spec precedes a comment"
[[ $(cat "$tsui") == "$before" ]] ||
fail "migration leaves that tsui file byte for byte"
pass "migration keeps a tsui file whose hand-written spec precedes a comment"
# systemd resumes a continuation across a comment: systemd-analyze verify on
# "ExecStop=\" + "; c" + a path resolves that path. The unit is live and has to go.
reset_machine
cat >"$plymouth_unit" <<'EOF'
[Service]
Type=oneshot
ExecStart=/usr/bin/true
ExecStop=\
; still one directive
/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
EOF
run_migration
[[ ! -e $plymouth_unit ]] ||
fail "migration removes a unit whose ExecStop continues across a comment" "$(cat "$plymouth_unit")"
pass "migration removes a unit whose ExecStop continues across a comment"
# An empty ExecStop= resets the list: `systemd-analyze verify` reports the missing
# command for a unit with one ExecStop=, and reports nothing once a bare
# ExecStop= follows it. An administrator who neutralised the unit that way runs
# nothing at shutdown and keeps their file.
reset_machine
cat >"$plymouth_unit" <<'EOF'
[Service]
Type=oneshot
ExecStart=/usr/bin/true
ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
ExecStop=
EOF
before=$(cat "$plymouth_unit")
run_migration
[[ -e $plymouth_unit ]] ||
fail "migration keeps a unit whose ExecStop list was reset to empty"
[[ $(cat "$plymouth_unit") == "$before" ]] ||
fail "migration leaves that unit byte for byte"
pass "migration keeps a unit whose ExecStop list was reset to empty"
# A reset followed by a fresh home ExecStop= is live again.
reset_machine
cat >"$plymouth_unit" <<'EOF'
[Service]
Type=oneshot
ExecStart=/usr/bin/true
ExecStop=
ExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
EOF
run_migration
[[ ! -e $plymouth_unit ]] ||
fail "migration removes a unit whose ExecStop is set again after a reset"
pass "migration removes a unit whose ExecStop is set again after a reset"
# systemd honours a directive whose line ends the file mid-continuation:
# `systemd-analyze verify` resolves an ExecStop= written that way.
reset_machine
printf '[Service]\nType=oneshot\nExecStart=/usr/bin/true\nExecStop=/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync \\\n' >"$plymouth_unit"
run_migration
[[ ! -e $plymouth_unit ]] ||
fail "migration removes a unit whose last line ends mid-continuation"
pass "migration removes a unit whose last line ends mid-continuation"
# If removing the unit succeeds but daemon-reload fails, the loaded unit still
# needs to be forgotten. Persist that half of the repair so the retry reloads
# systemd even though the unit file is already gone.
reset_machine
write_plymouth_unit "/home/installer/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync"
reload_failure_seen="$test_dir/reload-failure-seen"
rm -f "$reload_failure_seen"
set +e
FAIL_DAEMON_RELOAD_ONCE_MARKER="$reload_failure_seen" run_migration
reload_status=$?
set -e
(( reload_status != 0 )) ||
fail "migration fails after a failed daemon-reload" "status=$reload_status"
[[ ! -e $plymouth_unit && -e $reload_needed_marker && ! -e $machine_marker ]] ||
fail "migration records the pending reload without marking the repair complete"
run_migration
[[ ! -e $reload_needed_marker && -e $machine_marker ]] ||
fail "migration completes a pending daemon-reload on retry"
grep -q '^systemctl daemon-reload$' "$CALLS" ||
fail "migration retries daemon-reload after the unit file is gone" "$(cat "$CALLS")"
pass "migration retries daemon-reload after the unit file is gone"
# sudo cannot prompt without a terminal, and omarchy-migrate runs from places that
# have none. bin/omarchy-migrate writes the completion marker on a zero exit, so
# reporting success after failing to look would mark this migration done for good.
# Observed on a real machine before this guard existed: the run printed sudo's
# "a terminal is required" and still exited 0.
reset_machine
readable="$test_dir/readable-sudoers"
rm -rf "$readable"
mkdir -p "$readable"
chmod 755 "$readable"
printf '%s\n' "${first_run_variants[-1]}" >"$readable/first-run"
: >"$CALLS"
set +e
HOME="$home_dir" \
OMARCHY_SUDOERS_DIR="$readable" \
OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \
OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER="$machine_marker" \
PATH="$test_dir/failing-bin:$PATH" \
bash -euo pipefail "$migration" >"$test_dir/gate.out" 2>&1
gate_status=$?
set -e
(( gate_status != 0 )) ||
fail "migration fails when it cannot elevate to inspect the sudoers directory" "status=$gate_status$(printf '\n%s' "$(cat "$test_dir/gate.out")")"
[[ -e $readable/first-run ]] ||
fail "migration keeps a live grant when elevation fails"
[[ ! -e $machine_marker ]] ||
fail "migration leaves the machine repair unmarked when elevation fails"
grep -q 'An administrator must run omarchy-migrate' "$test_dir/gate.out" ||
fail "migration explains how the machine-wide repair can complete" "$(cat "$test_dir/gate.out")"
pass "migration fails without marking the machine repaired and names the administrator action"
# After one privileged account completes the machine repair, a non-sudo user
# can finish their per-user migration without probing sudo again.
touch "$machine_marker"
HOME="$home_dir" \
OMARCHY_SUDOERS_DIR="$readable" \
OMARCHY_SYSTEMD_SYSTEM_DIR="$systemd_dir" \
OMARCHY_RETIRED_INSTALLER_ARTIFACTS_MARKER="$machine_marker" \
PATH="$test_dir/failing-bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null
pass "machine marker lets a non-sudo user complete after the repair"
+2 -1
View File
@@ -144,11 +144,12 @@ pass "keyboard prompt propagates Esc and Ctrl+C without dying under set -e"
# Username # Username
TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:dhh" "0:david" TAKEN_USERS=dhh run_prompt omarchy_prompt_username "0:Not A Username" "0:root" "0:cups-browsed" "0:dhh" "0:david"
assert_status 0 "username prompt accepts a valid name" assert_status 0 "username prompt accepts a valid name"
[[ $(field username) == "david" ]] || fail "username prompt keeps re-asking until the name is valid" [[ $(field username) == "david" ]] || fail "username prompt keeps re-asking until the name is valid"
assert_notices "username prompt explains each rejection" "Username must be alphanumeric with no spaces assert_notices "username prompt explains each rejection" "Username must be alphanumeric with no spaces
Username is reserved for system Username is reserved for system
Username is reserved for system
That username already exists on this machine" That username already exists on this machine"
pass "username prompt rejects malformed, reserved, and taken names" pass "username prompt rejects malformed, reserved, and taken names"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/omarchy-pkg-add" <<'STUB'
#!/bin/bash
printf 'pkg %s\n' "$*" >>"${CALL_LOG:?}"
STUB
cat >"$stub_bin/omarchy-cmd-missing" <<'STUB'
#!/bin/bash
exit 0
STUB
cat >"$stub_bin/systemctl" <<'STUB'
#!/bin/bash
printf 'systemctl %s\n' "$*" >>"${CALL_LOG:?}"
STUB
cat >"$stub_bin/sshd" <<'STUB'
#!/bin/bash
case $1 in
-t)
[[ ${SSHD_SYNTAX_VALID:-1} == 1 ]]
;;
-T)
# OpenSSH 10.x dumps keywords in CamelCase; 9.x dumped them lowercase.
if [[ ${SSHD_DUMP_LOWERCASE:-0} == 1 ]]; then
printf 'passwordauthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}"
printf 'kbdinteractiveauthentication %s\n' "${SSHD_KBD_AUTH:-no}"
else
printf 'PasswordAuthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}"
printf 'KbdInteractiveAuthentication %s\n' "${SSHD_KBD_AUTH:-no}"
fi
;;
*)
exit 2
;;
esac
STUB
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
case $1 in
install)
destination="${TEST_ROOT:?}${4:?}"
/usr/bin/mkdir -p "${destination%/*}"
/usr/bin/install -Dm644 /dev/stdin "$destination"
;;
rm)
/usr/bin/rm -f "${TEST_ROOT:?}${3:?}"
;;
*)
exec "$@"
;;
esac
STUB
chmod +x "$stub_bin"/*
ssh-keygen -q -t ed25519 -N "" -f "$test_dir/key"
public_key=$(<"$test_dir/key.pub")
run_setup() {
local scenario="$1"
local home="$test_dir/$scenario/home"
local root="$test_dir/$scenario/root"
mkdir -p "$home" "$root"
: >"$test_dir/$scenario.calls"
HOME="$home" TEST_ROOT="$root" CALL_LOG="$test_dir/$scenario.calls" \
SSHD_SYNTAX_VALID="${SSHD_SYNTAX_VALID:-1}" \
SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \
SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \
PATH="$stub_bin:$PATH" \
bash "$ROOT/bin/omarchy-setup-security-sshd" --key="$public_key"
}
output=$(run_setup success)
config="$test_dir/success/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"
grep -qxF "PasswordAuthentication no" "$config" || fail "SSH setup disables password authentication"
grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH setup disables keyboard-interactive authentication"
grep -qxF "systemctl reload sshd.service" "$test_dir/success.calls" || fail "SSH setup reloads the validated config"
grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening after it succeeds"
pass "SSH setup authorizes a key and disables password logins"
output=$(SSHD_DUMP_LOWERCASE=1 run_setup success-legacy)
config="$test_dir/success-legacy/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"
[[ -e $config ]] || fail "SSH setup accepts the lowercase sshd -T dump of OpenSSH 9.x"
grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening on OpenSSH 9.x"
pass "SSH setup verifies settings across sshd -T keyword casings"
if SSHD_PASSWORD_AUTH=yes run_setup ineffective >"$test_dir/ineffective.output" 2>&1; then
fail "SSH setup must fail when password authentication remains effective"
fi
[[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH setup removes an ineffective hardening config"
! grep -qF "systemctl reload sshd.service" "$test_dir/ineffective.calls" ||
fail "SSH setup must not reload ineffective hardening"
! grep -q "Password logins are off" "$test_dir/ineffective.output" ||
fail "SSH setup must not claim ineffective hardening succeeded"
pass "SSH setup verifies the effective daemon settings"
if SSHD_SYNTAX_VALID=0 run_setup invalid >"$test_dir/invalid.output" 2>&1; then
fail "SSH setup must fail when sshd rejects its config"
fi
[[ ! -e $test_dir/invalid/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH setup removes a rejected hardening config"
! grep -qF "systemctl reload sshd.service" "$test_dir/invalid.calls" ||
fail "SSH setup must not reload a rejected config"
! grep -q "Password logins are off" "$test_dir/invalid.output" ||
fail "SSH setup must not claim rejected hardening succeeded"
pass "SSH setup fails safely when sshd rejects the config"
+213
View File
@@ -0,0 +1,213 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
migration="$ROOT/migrations/1788124236.sh"
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/systemctl" <<'STUB'
#!/bin/bash
printf 'systemctl %s\n' "$*" >>"${CALL_LOG:?}"
case "$1 $2" in
"is-enabled --quiet") [[ ${SSHD_ENABLED:-0} == 1 ]] ;;
"is-active --quiet") [[ ${SSHD_ACTIVE:-0} == 1 ]] ;;
"reload sshd.service") [[ ${SSHD_RELOAD_VALID:-1} == 1 ]] ;;
"disable --now") ;;
*) exit 2 ;;
esac
STUB
cat >"$stub_bin/sshd" <<'STUB'
#!/bin/bash
printf 'sshd %s\n' "$*" >>"${CALL_LOG:?}"
case $1 in
-t) [[ ${SSHD_SYNTAX_VALID:-1} == 1 ]] ;;
-T)
printf 'PasswordAuthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}"
printf 'KbdInteractiveAuthentication %s\n' "${SSHD_KBD_AUTH:-no}"
;;
*) exit 2 ;;
esac
STUB
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
printf 'sudo %s\n' "$*" >>"${CALL_LOG:?}"
if [[ ${SUDO_ALLOWED:-1} != 1 ]]; then
exit 1
fi
exec "$@"
STUB
chmod +x "$stub_bin"/*
ssh-keygen -q -t ed25519 -N "" -f "$test_dir/key"
public_key=$(<"$test_dir/key.pub")
run_migration() {
local scenario=$1
local home="$test_dir/$scenario/home"
local root="$test_dir/$scenario/root"
local config="$root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"
mkdir -p "$home/.ssh" "${config%/*}"
chmod "${HOME_MODE:-755}" "$home"
: >"$test_dir/$scenario.calls"
case "${AUTHORIZED_KEY_STATE:-valid}" in
valid) printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" ;;
invalid) printf 'not a public key\n' >"$home/.ssh/authorized_keys" ;;
private) cat "$test_dir/key" >"$home/.ssh/authorized_keys" ;;
symlink)
printf '%s\n' "$public_key" >"$home/.ssh/imported_key"
ln -s imported_key "$home/.ssh/authorized_keys"
;;
unreadable)
printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys"
chmod 000 "$home/.ssh/authorized_keys"
;;
esac
if [[ ${LOOSE_SSH_PERMS:-0} == 1 ]]; then
chmod 755 "$home/.ssh"
chmod 644 "$home/.ssh/authorized_keys"
fi
if [[ ${ALREADY_HARDENED:-0} == 1 ]]; then
printf 'PasswordAuthentication no\n' >"$config"
fi
# Keep the privileged production destination fixed in the shipped migration.
# For this isolated test only, rewrite that one assignment in the input fed to
# bash so no scenario can touch the host's /etc.
sed "s|^config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf$|config=$config|" "$migration" |
HOME="$home" CALL_LOG="$test_dir/$scenario.calls" PATH="$stub_bin:$PATH" \
SSHD_ENABLED="${SSHD_ENABLED:-0}" SSHD_ACTIVE="${SSHD_ACTIVE:-0}" \
SSHD_SYNTAX_VALID="${SSHD_SYNTAX_VALID:-1}" \
SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \
SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \
SSHD_RELOAD_VALID="${SSHD_RELOAD_VALID:-1}" \
SUDO_ALLOWED="${SUDO_ALLOWED:-1}" \
bash -euo pipefail
}
sshd_disabled() {
grep -qxF "sudo systemctl disable --now sshd.service" "$test_dir/$1.calls"
}
SSHD_ENABLED=0 SSHD_ACTIVE=0 run_migration disabled
[[ ! -e $test_dir/disabled/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration leaves a disabled daemon alone"
! grep -q '^sudo ' "$test_dir/disabled.calls" || fail "disabled SSH does not prompt for privileges"
pass "SSH migration no-ops when sshd is not enabled or active"
ALREADY_HARDENED=1 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration hardened >/dev/null
[[ ! -s $test_dir/hardened.calls ]] || fail "an already-hardened machine must not touch sshd or prompt"
grep -qxF "PasswordAuthentication no" "$test_dir/hardened/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" ||
fail "the existing hardening config is left alone"
pass "SSH migration no-ops when the hardening config already exists"
# Without a usable key, sshd only accepts password logins — the hole the old
# setup command could leave open. The migration closes it by disabling sshd.
AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-key >/dev/null
[[ ! -e $test_dir/no-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration must not write the hardening config without an authorized key"
sshd_disabled no-key || fail "SSH migration disables a password-only sshd"
pass "SSH migration disables sshd when no key is authorized"
AUTHORIZED_KEY_STATE=invalid SSHD_ENABLED=1 run_migration invalid-key >/dev/null
sshd_disabled invalid-key || fail "a malformed authorized_keys leaves sshd password-only"
pass "SSH migration disables sshd when authorized_keys holds no valid key"
# ssh-keygen -lf accepts a whole private-key file, so only a per-line check
# catches the classic `cp id_ed25519 authorized_keys` slip that sshd cannot use.
AUTHORIZED_KEY_STATE=private SSHD_ENABLED=1 run_migration private-key >/dev/null
[[ ! -e $test_dir/private-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration must not treat a private key as an authorized key"
sshd_disabled private-key || fail "a private-key authorized_keys leaves sshd password-only"
pass "SSH migration disables sshd when authorized_keys holds a private key"
# A dotfiles-managed symlink with a working key is a key-based setup, not a
# keyless one; it must be hardened, never disabled.
AUTHORIZED_KEY_STATE=symlink SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration symlink-key >/dev/null
grep -qxF "PasswordAuthentication no" "$test_dir/symlink-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" ||
fail "SSH migration hardens a symlinked authorized_keys with a valid key"
! sshd_disabled symlink-key || fail "SSH migration must not disable sshd when the symlinked key is usable"
pass "SSH migration follows an authorized_keys symlink to its key"
# An unreadable file answers neither "keyless" nor "key-based": touch nothing.
if (( EUID != 0 )); then
AUTHORIZED_KEY_STATE=unreadable SSHD_ENABLED=1 run_migration unreadable >/dev/null
[[ ! -e $test_dir/unreadable/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration must not harden against an unverifiable authorized_keys"
! grep -q '^sudo ' "$test_dir/unreadable.calls" || fail "an unreadable authorized_keys does not prompt or disable"
pass "SSH migration leaves an unreadable authorized_keys alone"
fi
# StrictModes makes sshd ignore authorized_keys under a group-writable home,
# so the key that validated would be unusable and passwords the only way in.
HOME_MODE=775 SSHD_ENABLED=1 run_migration loose-home >/dev/null
[[ ! -e $test_dir/loose-home/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration must not disable passwords when sshd would ignore the key"
! grep -q '^sudo ' "$test_dir/loose-home.calls" || fail "a group-writable home does not prompt for privileges"
pass "SSH migration leaves a group-writable home directory alone"
LOOSE_SSH_PERMS=1 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration active >/dev/null
config="$test_dir/active/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"
grep -qxF "PasswordAuthentication no" "$config" || fail "SSH migration disables password authentication"
grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH migration disables keyboard-interactive authentication"
[[ $(stat -c '%a' "$test_dir/active/home/.ssh") == "700" ]] ||
fail "SSH migration tightens ~/.ssh so StrictModes accepts the key"
[[ $(stat -c '%a' "$test_dir/active/home/.ssh/authorized_keys") == "600" ]] ||
fail "SSH migration tightens authorized_keys so StrictModes accepts the key"
grep -qxF "sudo sshd -t" "$test_dir/active.calls" || fail "SSH migration validates sshd syntax"
grep -qxF "sudo sshd -T" "$test_dir/active.calls" || fail "SSH migration validates effective sshd settings"
grep -qxF "sudo systemctl reload sshd.service" "$test_dir/active.calls" || fail "SSH migration reloads an active daemon"
pass "SSH migration hardens and reloads an existing key-based SSH setup"
SSHD_ENABLED=1 SSHD_ACTIVE=0 run_migration stopped >/dev/null
[[ -e $test_dir/stopped/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration hardens an enabled but stopped daemon"
! grep -qF 'reload sshd.service' "$test_dir/stopped.calls" || fail "SSH migration must not start or reload a stopped daemon"
pass "SSH migration hardens an enabled daemon without starting it"
# Conditions the migration cannot repair complete with a notice — leaving the
# machine as it was — so they never block the migrations queued behind this one.
SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_PASSWORD_AUTH=yes run_migration ineffective >"$test_dir/ineffective.output" 2>&1 ||
fail "an ineffective drop-in must complete without blocking later migrations"
[[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration removes an ineffective config"
! grep -qF 'reload sshd.service' "$test_dir/ineffective.calls" || fail "SSH migration must not reload ineffective hardening"
pass "SSH migration backs off when another rule keeps password authentication enabled"
SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_SYNTAX_VALID=0 run_migration invalid-config >"$test_dir/invalid-config.output" 2>&1 ||
fail "a rejected config must complete without blocking later migrations"
[[ ! -e $test_dir/invalid-config/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "SSH migration removes a rejected config"
! grep -qF 'reload sshd.service' "$test_dir/invalid-config.calls" || fail "SSH migration must not reload rejected hardening"
pass "SSH migration backs off when sshd rejects the config"
# The installed config is valid, so a failed reload only delays it until the
# next sshd restart; keep it staged rather than failing or removing it.
SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_RELOAD_VALID=0 run_migration reload-fail >"$test_dir/reload-fail.output" 2>&1 ||
fail "a failed reload must complete without blocking later migrations"
[[ -e $test_dir/reload-fail/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "a failed reload keeps the valid hardening config staged"
pass "SSH migration keeps the hardening staged when sshd cannot reload"
# Privileges are the one genuinely retryable failure: stay pending so the
# login notifier prompts for a terminal run.
if SUDO_ALLOWED=0 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration no-sudo >"$test_dir/no-sudo.output" 2>&1; then
fail "SSH migration must stay pending when privileges are unavailable"
fi
[[ ! -e $test_dir/no-sudo/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] ||
fail "no hardening config is left behind without privileges"
pass "SSH migration stays pending until privileges are granted"
if SUDO_ALLOWED=0 AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-sudo-keyless >"$test_dir/no-sudo-keyless.output" 2>&1; then
fail "SSH migration must stay pending when it cannot disable a password-only sshd"
fi
pass "SSH migration stays pending when disabling sshd needs privileges"
+17 -10
View File
@@ -1,10 +1,7 @@
#!/bin/bash #!/bin/bash
# #
# The docker group is root-equivalent, so no automatic path may grant it. These # Docker is root-equivalent, so no automatic path may grant it. Raw input access
# tests guard the paths that are not exercised by a fresh-install run: first-boot # is likewise excluded unless a feature that explicitly needs it is installed.
# provisioning replaying a recorded (or factory-snapshot) group list, and the
# Quattro upgrade. Opting in stays a deliberate, warned step
# (omarchy-setup-security-sudoless-docker).
set -euo pipefail set -euo pipefail
@@ -13,11 +10,15 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT trap 'rm -rf "$TMPDIR"' EXIT
# First-boot provisioning must never grant docker even when it is recorded (an # First-boot provisioning must not replay old privileged defaults.
# older install, or a factory snapshot predating the opt-in default).
mkdir -p "$TMPDIR/bin" mkdir -p "$TMPDIR/bin"
printf '#!/bin/bash\nexit 0\n' >"$TMPDIR/bin/getent" # every group "exists" printf '#!/bin/bash\nexit 0\n' >"$TMPDIR/bin/getent" # every group "exists"
chmod +x "$TMPDIR/bin/getent" cat >"$TMPDIR/bin/pacman" <<'STUB'
#!/bin/bash
[[ $1 == "-Qq" ]] || exit 2
[[ " ${STUB_PACKAGES:-} " == *" $2 "* ]]
STUB
chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/pacman"
export PATH="$TMPDIR/bin:$PATH" export PATH="$TMPDIR/bin:$PATH"
PROVISIONING_DIR="$TMPDIR/prov" PROVISIONING_DIR="$TMPDIR/prov"
@@ -29,9 +30,15 @@ eval "$(sed -n '/^user_groups() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner")"
groups=$(user_groups) groups=$(user_groups)
[[ ",$groups," == *",wheel,"* ]] || fail "user_groups always includes wheel" [[ ",$groups," == *",wheel,"* ]] || fail "user_groups always includes wheel"
[[ ",$groups," == *",input,"* ]] || fail "user_groups includes recorded non-docker groups" [[ ",$groups," != *",input,"* ]] || fail "user_groups must not replay the blanket input grant"
[[ ",$groups," == *",docker,"* ]] && fail "user_groups must never grant the docker group" [[ ",$groups," == *",docker,"* ]] && fail "user_groups must never grant the docker group"
pass "first-boot user_groups includes recorded groups but never docker" pass "first-boot user_groups replays neither privileged default"
groups=$(STUB_PACKAGES=xpadneo-dkms user_groups)
[[ ",$groups," == *",input,"* ]] || fail "user_groups keeps input for installed controller support"
groups=$(STUB_PACKAGES=ydotool user_groups)
[[ ",$groups," == *",input,"* ]] || fail "user_groups keeps input for installed ydotool support"
pass "first-boot user_groups keeps deliberate input-group opt-ins"
# The Quattro upgrade must not re-add the user to docker. # The Quattro upgrade must not re-add the user to docker.
if rg -q 'usermod -aG docker' "$ROOT/bin/omarchy-upgrade-to-quattro"; then if rg -q 'usermod -aG docker' "$ROOT/bin/omarchy-upgrade-to-quattro"; then
+86 -2
View File
@@ -71,8 +71,15 @@ done
pass "a URL naming a transport git does not implement never reaches git" pass "a URL naming a transport git does not implement never reaches git"
# The checker is a separate command, so its absence has to refuse the URL rather # The checker is a separate command, so its absence has to refuse the URL rather
# than wave it through to git. # than wave it through to git. Installed machines carry the packaged checker in
if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then # /usr/bin, so absence is simulated by shadowing it with a stub that reports
# command-not-found instead of thinning the PATH.
missing_checker_bin="$test_tmp/missing-checker-bin"
mkdir -p "$missing_checker_bin"
printf '#!/bin/bash\nexit 127\n' >"$missing_checker_bin/omarchy-git-url-check"
chmod +x "$missing_checker_bin/omarchy-git-url-check"
if install_theme "https://github.com/example/omarchy-cool-theme.git" "$missing_checker_bin:$mock_bin:$ROOT/bin:$PATH"; then
fail "omarchy-theme-install refuses a URL it cannot check" fail "omarchy-theme-install refuses a URL it cannot check"
fi fi
@@ -92,6 +99,83 @@ done
pass "a URL whose name would climb out of the themes directory never reaches git" pass "a URL whose name would climb out of the themes directory never reaches git"
# The derived name outlives the clone: it is the theme's directory name, and
# Style > Unlock builds a command line out of the name the picker returned. A
# repo whose name carries shell syntax would hand that picker its own command,
# so the name is refused here rather than quoted at each place it lands.
for url in \
"https://example.com/omarchy-a';id;'b-theme.git" \
'https://example.com/a$(id).git' \
'https://example.com/a`id`.git' \
"https://example.com/a b.git" \
"https://example.com/-a.git"; do
if install_theme "$url"; then
fail "omarchy-theme-install refuses the derived name from '$url'"
fi
[[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")"
done
pass "a URL whose name would be shell syntax never reaches git"
# And the check is an allowlist, so the punctuation a real theme name uses has
# to keep working.
install_theme "https://github.com/example/omarchy-tokyo_night.2-theme.git" ||
fail "omarchy-theme-install accepts the punctuation a theme name uses"
grep -Fq "/themes/tokyo_night.2" "$git_calls" ||
fail "omarchy-theme-install derives a name carrying an underscore and a dot" "$(cat "$git_calls")"
pass "a theme name may still hold an underscore, a dot, and a dash"
# A plus is neither path-climb nor shell syntax, and a leading underscore is
# neither the `..` climb nor the dash that reads as an option, so the allowlist
# keeps both rather than stranding a repo that names itself with them.
install_theme "https://github.com/example/omarchy-c++-theme.git" ||
fail "omarchy-theme-install accepts a name holding a plus"
grep -Fq "/themes/c++" "$git_calls" ||
fail "omarchy-theme-install derives a name carrying a plus" "$(cat "$git_calls")"
install_theme "https://github.com/example/_private.git" ||
fail "omarchy-theme-install accepts a name starting with an underscore"
grep -Fq "/themes/_private" "$git_calls" ||
fail "omarchy-theme-install derives a name starting with an underscore" "$(cat "$git_calls")"
pass "a plus and a leading underscore are still usable theme names"
# git reads a colon before any slash as the scp-style separator, so the path
# after it does not have to hold one. Without that reading, the whole URL becomes
# the theme name and the allowlist above refuses a repo that clones fine.
install_theme "git@example.com:omarchy-blue-theme.git" ||
fail "omarchy-theme-install accepts a home-relative scp-style URL"
grep -Fq "/themes/blue" "$git_calls" ||
fail "omarchy-theme-install names the theme after the repo, not the whole URL" "$(cat "$git_calls")"
# A colon that is part of a local path, not an scp separator, keeps its prefix.
install_theme "/srv/git:mirrors/omarchy-blue-theme.git" ||
fail "omarchy-theme-install accepts a local path holding a colon"
grep -Fq "/themes/blue" "$git_calls" ||
fail "omarchy-theme-install reads a colon after a slash as part of the path" "$(cat "$git_calls")"
pass "an scp-style URL with no slash after the colon still names the theme"
# The allowlist is a bracket range, and a range follows the locale's collation
# rather than ASCII: under en_US.UTF-8 an unpinned `[a-z]` takes in `é`, so the
# same URL would install on one desktop and be refused on the next.
if locale -a 2>/dev/null | grep -qix 'en_US.utf-\?8'; then
for locale_name in C en_US.UTF-8; do
if LC_ALL=$locale_name install_theme "https://github.com/example/omarchy-café-theme.git"; then
fail "omarchy-theme-install refuses a non-ASCII theme name under LC_ALL=$locale_name" "$(cat "$git_calls")"
fi
[[ ! -s $git_calls ]] ||
fail "omarchy-theme-install refuses a non-ASCII name before running git" "$(cat "$git_calls")"
done
pass "the accepted set does not move with the desktop's locale"
else
pass "no en_US.UTF-8 locale; skipping the locale-pinning check"
fi
# basename reads a leading dash as an option once the scp-style prefix is gone. # basename reads a leading dash as an option once the scp-style prefix is gone.
install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL" install_theme "host:-s/foo.git" || fail "omarchy-theme-install accepts a normal scp-style URL"
grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")" grep -Fq -- "-- host:-s/foo.git" "$git_calls" || fail "omarchy-theme-install passes the URL after --" "$(cat "$git_calls")"

Some files were not shown because too many files have changed in this diff Show More