Author SHA1 Message Date
0ae1694830 Constrain the tzupdate sudoers rule to a single timezone argument (#8194)
The wildcard granted passwordless root for timedatectl set-timezone plus any trailing arguments, so -H/--host and -M/--machine reached the SSH and machine transports as root. Systemd 261 guards argv injection into ssh, but -H still drives root's SSH client at an attacker-chosen host, and the transport resolves its helper through PATH; only Defaults secure_path stands between that and a planted ssh running as root. Match the argument with an anchored POSIX ERE that admits exactly one timezone token (no whitespace, no leading-dash segment, no traversal component), so no second argument and no option can ever match. The sole caller, omarchy-menu-timezone, passes one list-timezones value and is unaffected.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-25 22:30:09 +02:00
77305ed3b9 Enable Dell XPS 13 sidecar speaker amplifiers (#7032)
The Dell XPS 13 DX13260 drives its two CS35L56 sidecar speaker amplifiers through a quirk that Linux only gains in 7.2, so until Arch ships that kernel the machine plays through one amplifier with no bass. The dell-xps13-sidecar-amps package selects the same driver path with a module override; this installs it on that exact machine and nowhere else.

The detector requires both the DX13260 product name and SKU 0E53, because the override forces a quirk value rather than merging into one, and a machine that gets it wrong loses whatever quirk the kernel would have chosen for itself.

Pacman registers a package even when its post_install scriptlet fails, so the leaf calls dell-xps13-sidecar-amps-apply itself instead of trusting the install to have applied: a failed cleanup or boot-image rebuild has to reach the caller rather than hide behind a package pacman considers installed. That is also why the migration marks reboot-required only after the apply succeeds — a migration that exits non-zero keeps no completion marker and retries the apply on the next run, even though pacman already has the package.

The leaf runs after intel/ptl-kernel.sh rather than beside the other Dell leaf at the top of install/hardware/all.sh, so its boot-image rebuild sees the Panther Lake kernel that step swaps in rather than the stock one it removes.

Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-25 22:29:27 +02:00
Naeem MalikandGitHub 9301092404 Update Omarchy on Mac guide link to omarchy-mac repo (#8192) 2026-08-25 17:04:13 +02:00
4cd8a081cb Fix Codex usage collection on 0.149 (#7649)
* Fix Codex usage collector approval policy

* Capture codex argv with boundaries in the scanner test

The stub joined its arguments with "$*", so the assertion compared one
flattened string and could not tell five arguments from fewer containing
spaces. Passing "-s read-only" and "-a on-request" as single arguments --
which codex rejects as an unexpected argument -- passed the test. NUL
separation and an array comparison keep the boundaries the assertion is
about.

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

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-25 16:07:17 +02:00
23dab9ec4d [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path (#7904)
* [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path

pamu2fcfg wrote to /tmp/fido2 and the registration was then moved into place
with `sudo mv`. Any other local user can pre-create /tmp/fido2, and rename(2)
does not dereference the final component, so the privileged move installed the
attacker's symlink itself as pam_u2f's global authfile -- a file consulted by
`sufficient` lines in /etc/pam.d/sudo and /etc/pam.d/polkit-1.

The same move also carried the staged file's ownership into /etc, so on every
install to date /etc/fido2/fido2 is owned by the invoking user at mode 0644.
That needs no attacker: anything running as that uid can add its own credential
and satisfy the machine's sudo prompt without root.

Stage under mktemp and hand the bytes to `install` instead, so the authfile is
always a fresh root-owned regular file rather than an inode a non-root user
still controls. Guard the already-registered check with -L, which -f would
otherwise follow, and reject a symlinked /etc/fido2 in the remove path for the
same reason. A migration takes ownership of authfiles left behind by the old
code; it reports a symlink rather than repairing one, since chown would follow
it and removing it would strip sudo from anyone whose only credential is the
token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6WagC5iUigCGoK1tQeFz

* Install the FIDO2 authfile with -T and refuse a non-regular path

`install SRC DEST` without -T treats an existing directory at DEST as a
destination directory: it drops the credential inside as
/etc/fido2/fido2/tmp.XXXX, exits 0, and setup reports a successful registration
while PAM goes on reading a path that is not a file. -T makes that an error.

The already-registered check has the same blind spot from the other side. -f
follows symlinks, so it reads a symlinked authfile as a registration and leaves
it in place, and is false for a directory, so it tries to register over one.
Only a regular file is a pam_u2f authfile; anything else is now refused with the
same advice to remove it and set FIDO2 up again.

The test deleted every staged path that fell outside its scratch directory,
taking the path from the script under test and already resolved through any
symlink -- so a script staging through one would have named a file of the user's
and had it unlinked. It now unlinks only a file its own stub wrote into.

On a machine that already has /etc/fido2/fido2 the staging assertions cannot run
at all, and the file used to pass without exercising one of them. That branch now
asserts what the host state promises instead: a regular authfile still has to be
recognised as a registration and left alone, and anything else has to be refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Replace the FIDO2 authfile inode rather than chowning it in place

Permission is checked at open(2), not at write(2), so a descriptor the
registering user opened on the authfile while it was still theirs stays writable
through chown and chmod alike. pam_u2f resolves /etc/fido2/fido2 to that same
inode, so the repair left the account it authenticates able to append a
credential it controls -- the exact state the migration exists to end, now
recorded as migrated and never revisited.

Installing a fresh root-owned copy and renaming it over the path leaves any such
descriptor writing to a file nothing reads. Credit to #7703, which reached the
same conclusion independently. An interrupted run heals: the staged copy is
root-owned 600 and inert, no marker is written, and the next run replaces it.

A directory or device at the authfile path is no more ours to rewrite than a
symlink is, and chmod 600 on a directory would only make it untraversable, so
both are now reported rather than repaired.

The repair had no test, because it names an absolute path no unprivileged suite
can write. It is exercised through a scratch copy with that one literal
retargeted, rather than by reading the path from the environment: the migration
hands `install` and `mv` root, and an operand the caller can choose is a
privileged write to anywhere. The copy is only as honest as the substitution, so
the test fails if the migration stops naming the path exactly once.

Covered: the no-op on a machine that never registered a key, which must not cost
a password prompt; the repair itself; the new inode; the absence of a staged copy
afterwards; a second account finding it done; and the symlink and non-regular
cases. Each assertion was checked against a mutation that defeats it -- notably a
repair with the right install call, mode, content and cleanup that writes through
the old inode, which only the inode assertion catches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Finish hardening FIDO2 authfile installation

* Guard the FIDO2 directory and the stage path the setup writes through

install -d follows a symlink at /etc/fido2 and applies the mode and ownership to whatever it points at, so the credential would be staged and published inside the link target and that directory silently reopened to root:root 755. The leaf guard above it only covered fido2 itself, and this is the same threat omarchy-remove-security-fido2 already names on its side.

mktemp's output is an operand for a privileged tee, chmod, mv and rm. The migration validates it before any of them run; the setup did not, so take only the name it asked for there too.

The suite was guarded on the host's own /etc/fido2/fido2 and exited early when one existed, which meant the staging assertions asserted nothing on exactly the machines that use FIDO2. Drive a retargeted copy the way the migration suite already does, so every branch is a fixture and all of them run everywhere.

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

* Stop the FIDO2 migration recording a repair it never made

omarchy-migrate writes the per-user completion marker on any zero exit, so the two states this migration cannot repair got one line in the update terminal and were then silenced for good: no login notice, no re-run, the migration recorded as done having repaired nothing. Those are precisely the machines where the authfile may already be under someone else's control, so raise them through omarchy-notification-send as well, where they outlive the scrollback. Delivery is best-effort: a machine with no user bus or no notification server must not abort the migration and take every later one with it.

The early exit had the same shape of problem. It read the authfile unprivileged, and the old setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the caller's umask and sudoers' 0022 — so registering under `umask 077` left the directory mode 0700 with the user-owned authfile still inside. Absence and "cannot look" are the same answer to those tests, and the migration exited 0 and marked itself complete. Ask root whether a registration is actually behind an untraversable directory before reopening it, so an aborted setup that left an empty directory, or one an administrator keeps private, does not have its mode widened and its group and special bits discarded for a repair it does not need. A machine that never set FIDO2 up has no directory here and still reaches exit 0 without a password prompt.

The notification assertion checks argument shape rather than a substring of the command line. The glyph is a private-use codepoint, and losing it shifts every argument left: -g swallows the headline, the body becomes the title, and the message goes out with no description — which a substring match reads as a pass.

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

* Cover the FIDO2 removal's symlink guard

The -d to -e || -L change is load-bearing for the threat its own comment names — a dangling link at /etc/fido2 that -d reads as absent, left for the next setup to install an authfile through — and it was the one part of this work with no test behind it. Name the directory once so the suite can retarget a copy, the same seam the setup and migration suites use, and assert both halves: the link goes, and the directory it pointed at does not.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-25 12:09:17 +02:00
9285b19d6a [Security] Stop USB device names from being executed as Hyprland Lua (#8129)
* Stop device names from being executed as Hyprland Lua

Hyprland input-device and monitor names come from USB descriptors and
hyprctl output, so they are attacker-influenceable, yet the toggle and
monitor commands interpolated them straight into hyprctl eval and into
generated Lua that Hyprland re-executes on every reload. The input-device
toggle keys are bound with locked = true, so a malicious USB name reached
Lua code execution from the lock screen; a persisted disable made it run
on every start. This closes that class everywhere it appeared.

- The touchpad/touchscreen disable is now the device name in a plain-text
  sidecar file, read back by a packaged Lua module on reload, never a
  generated Lua file. hyprctl eval Lua-quotes the name and control
  characters are rejected outright.
- Dropped the shipped *-disabled.lua templates so nothing seeds a
  disabled state to /etc/skel, making the name file the single source of
  truth read from a hardcoded ~/.local/state to match the sibling tools.
- The reload loader excludes those two legacy filenames, so a leftover
  generated *-disabled.lua on a not-yet-migrated install can never be
  sourced as code again; a migration then recovers the device name from
  it and deletes it, sanitizing installs that ran the vulnerable version.
- All four monitor scripts (internal, mirror, clamshell, scaling) now
  validate an output name against a plain-connector-name pattern before
  writing it as Lua, closing the same latent pattern in the siblings.
- paths.lua treats a set-but-empty XDG_STATE_HOME as unset, matching the
  bash side so state is never read from the filesystem root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144ZDt44vtxjyF8j9Y88NrM

* Let a failing Lua assertion fail the test

lua discards the status of a chunk read from stdin, so a blown assert printed its traceback and still exited 0: the surrounding `set -euo pipefail` never fired and the following `pass` printed `ok`. Every Lua block in these two files was unenforced, including the assertion that a quoted `hyprctl eval` cannot reach `os.execute` and the negative control that proves the test can detect the injection at all. Passing the chunk as a script argument makes lua report the failure.

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

* Re-apply a recovered input-device disable to the running session

The package hook reloads Hyprland during `omarchy-update-system-pkgs`, before `omarchy-migrate` runs, and at that reload the generated Lua is already excluded while the name file does not exist yet — so a touchpad or touchscreen the user had switched off comes back on, and stays on until their next login. Reload once more once the name has been recovered, which is the same path a login already takes to read it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Omarchybot <omabot@omarchy.org>
Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-25 11:03:12 +02:00
4637735aa2 Pin trusted PATH in privileged DNS helper (#8172)
* Pin PATH to trusted dirs when omarchy-dns holds root

A dev link prepends a user-writable checkout bin/ to sudo's secure_path,
so the passwordless `omarchy-dns Cloudflare` sudoers rule lets root
resolve a bare helper (dirname, install, tee, nmcli, ...) out of that
checkout — turning checkout-write access into arbitrary root execution.
Pin PATH to trusted system directories once EUID is 0, leaving the
unprivileged wrapper phase free to locate sudo/pkexec on the caller's
PATH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUWoHbBoKMjsjV6X3nu1H5

* Assert the trusted-PATH pin is gated on root, not merely present

The EUID assertion matched `(( EUID == 0 ))` anywhere in the file, and require_root has carried that exact test since long before the pin existed. Deleting the pin left the assertion passing, so it stood for nothing: a run with the pin neutered reached the behavioural probe with both greps green. Anchor on the unindented guard and require the pin to be the line it opens, which no other construct in the script satisfies.

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

* Skip the DNS trusted-PATH probe where user namespaces are unavailable

`fail` ends the file, so a sandbox or hardened kernel that refuses unprivileged user namespaces did not just lose the probe — it took the two elevation assertions below it down as well, reporting a product defect where there was only a missing capability. The non-graphical suites are meant to run on any machine and treat a skip as a passing test, the way require_compositor and plugin-add-test.sh already do. Gate the probe on the namespace it needs and say so when it is absent; the static checks above and the elevation checks below run either way.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-25 09:37:55 +02:00
68ab12f77d Share the git URL check, and refuse the transports Omarchy does not clone from (#8174)
* Share the git URL check between theme-install and plugin-add

Both commands clone a URL a stranger can choose, and each carried its own copy of the rule that refuses a git option or a `<helper>::<address>` transport helper before cloning. Two copies of a security check drift: the second one arrived four months after the first, and only because someone went looking for it.

The rule now lives in omarchy-git-url-check and the callers ask it. Its absence refuses the URL rather than waving it through, since the callers read a non-zero status as a refusal and a missing command exits 127.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Refuse a git URL naming a transport Omarchy does not clone from

`<helper>::<address>` is only one of the two ways a URL reaches a remote helper. git also resolves git-remote-<scheme> for `<scheme>://<address>` whenever the scheme is not one it connects itself, so `ext::sh -c id` and `ext://sh -c id` arrive at the same helper while only the first was refused.

That shape cannot be refused outright, because it is also how every legitimate URL arrives, so the scheme is checked against the transports git still connects itself. `git+ssh` and `ssh+git` are on that list: they are spelled like a helper and read as plain ssh, and leaving them off would refuse a URL that clones today. `ext` and `fd` are off it deliberately -- git ships a helper for each, and `ext` runs whatever command the URL carries.

Co-Authored-By: Claude Opus 5 <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 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-25 09:10:20 +02:00
BastiandGitHub 30471bf35a Guard plugin-add against git transport-helper URLs (match theme-install) (#8067)
* Guard plugin-add against git transport-helper URLs

omarchy-plugin-add cloned a user-supplied git URL without the
transport-helper guard that omarchy-theme-install already applies
(added in #7884, which did not touch plugin-add). Port that guard
(reject ext::/fd:: and leading-dash forms, keep https/ssh/scp-style
incl. IPv6) and add a regression test. Stock systems are unaffected
(git default protocol.ext.allow=never); this removes the silent
dependency on that default and aligns the two install paths.

* Test the plugin-add guard's leading-dash arm via the gum input path

The prior leading-dash cases only exercised the argv option parser, not
the guard (removing the guard's -* arm left them green). Drive a dash
value through the interactive gum prompt under a pty so the post-input
guard is actually covered; skip cleanly where util-linux script is
unavailable.
2026-08-25 08:29:48 +02:00
Sameer PokharelandGitHub b86d4505c1 Renumber the README manual TOC after Extra themes was dropped (#8089)
Dropping manual/43-extra-themes.md shifted every chapter after it down
by one. The TOC rode along on the old numbers, so its last ten links
404.
2026-08-24 21:59:17 +02:00
06a3dbca42 Offer to reboot when toggling sudoless Docker; show only the relevant menu entry (#8098)
* Offer to reboot when toggling sudoless Docker; show only the relevant menu entry

Group membership only takes effect on a fresh session, and in practice a logout
or newgrp isn't enough — only a reboot reliably applies it. So the setup/remove
commands now flag the reboot and offer to do it now with a gum confirm (like the
GPU toggle), and the notices say "after a reboot" instead of pointing at logout
or newgrp. The existing-user migration passes OMARCHY_DEFER_REBOOT so it does not
prompt mid-update — omarchy-update-restart still handles the reboot once the whole
update finishes.

The Setup > Security menu also showed Sudoless Docker under both Setup and
Remove. Condition the Setup entry on the group being absent (Remove already
conditions on it being present), so only the applicable one appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

* Ask omarchy-sudo-docker whether Docker needs sudo

Every place that chooses between talking to Docker directly and elevating was
testing group membership by hand, and the menu guards tested the wrong thing:
they read the running session's groups, which do not change until the reboot,
so after enabling sudoless Docker the menu still offered Setup — the one action
that could no longer do anything — while Remove stayed hidden.

Add omarchy-sudo-docker as the single answer, with the two questions that
actually differ in that window. By default it asks whether this session can
reach the socket, which is what decides if a command must elevate: lazydocker
and the Windows VM keep prompting until the reboot lands. With --configured it
asks whether the account is set up for sudoless Docker, which is what the menu
and the toggles need, so the menu switches to the action that can change state
as soon as the group is written.

Also correct a comment: nothing surfaces reboot-required in the bar; it is
omarchy-update-restart that reads it during a later update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 21:58:27 +02:00
52 changed files with 2800 additions and 1874 deletions
-1747
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
# OmarchyCN 执行任务清单
进度真相文件。按顺序执行,禁止跳项。每项完成后勾选并记录结果。
范围裁剪:本轮交付 = 用户目标三项(公开仓库 + 基线 ISO + Gitea Release),即 PRD M0 中的
Canonical Repo 与「未修改功能的基线 ISO」两项。M0 其余项(上游同步 MR 自动化、Release Key
与签名体系、法务/治理文档)以及 M1–M5 功能章节均不在本轮范围。本轮校验产物为 SHA-256
checksum,不含 PGP 签名(PRD §15.1 签名体系属后续 M0 收尾项)。
- [x] T1 复核 PRD 全文(§1–28),确认本轮交付物 = 公开仓库 + 基线 ISO + ReleaseM0 部分交付)
- [x] T2 探测 git.zacharyzhang.com Gitea API:版本、token 身份、release 附件大小上限
- [x] T3 通过 API 创建公开仓库 omarchycn
- [x] T4 提交 PRD 与本清单到 quattro 分支,推送全部历史到 Gitea
- [x] T5 准备 WSL 构建环境:克隆 omacom-io/omarchy-iso 与 omacom-io/omarchy-pkgs,按其 bin 脚本要求装依赖
- [x] T6 构建基线 ISO./bin/omarchy-iso-make --no-boot-offer --local-source ../omarchy ../omarchy-pkgs
- [x] T7 校验 ISO:生成 SHA-256WSL 有 /dev/kvm,尽可能做 QEMU UEFI 启动冒烟测试
- [ ] T8 创建 Gitea Releasetag 4.0.0.alpha-cn.1):ISO 传 generic package registryrelease 附 sha256 与下载链接
- [ ] T9 输出最终核对表:仓库 URL、Release URL、逐条验收结果
## 执行记录
(每项完成后在此追加一行:任务号、结果、产物路径/URL)
- T1 完成:PRD §1–28 全文已复核。本轮交付=M0 部分交付(Canonical Repo + 基线 ISO + SHA-256 校验产物)。
- T2 完成:Gitea 1.27.1token=ZacharyZhang-NY(admin)。附件限制 100MB 且不允许 .isorelease asset API 无 external_url。
已验证 generic package registry 可上传(201)。策略:ISO 传 generic registryrelease 附 sha256(.txt) 与下载链接。
如需 ISO 直接作为 release 附件,需管理员改服务器 app.ini 的 [attachment] ALLOWED_TYPES/MAX_SIZE。
- T3 完成:https://git.zacharyzhang.com/ZacharyZhang-NY/omarchycn public,默认分支 quattro)。
- T4 完成:quattro 全历史已推送,HEAD=2c8a6874(含 PRD 与本清单)。
- T5 完成:WSL Ubuntu 24.04 装 docker.io 29.1.3systemd 托管,active);~/omarchycn-build 下
三个 checkoutomarchyquattro 5fa312e0,克隆自本地)、omarchy-isoquattro)、omarchy-pkgsmaster)。
构建命令按 omarchy-iso-make 实际要求:--keep-pkg-cache 跳过交互 sudo--no-boot-offer 跳过 gum。
- T6 完成:release/omarchy-2026.08.24-x86_64-local.iso5.8G,含离线镜像仓库),mkarchiso DoneEXIT=0。
- T7 完成:SHA-256=1d5421d1427cf97c0950c766778380c0543f180c9062e0a7c06f99b457dd8300.sha256 已落盘)。
QEMU 8.2 + OVMF + KVM 冒烟测试通过:UEFI 启动进入安装器欢迎屏(Press Return to Start Install),
QMP screendump 三张截图确认。曾出现两次环境故障并已修根因:WSL 随最后一个客户端退出导致 nohup QEMU
被杀(改为前台任务保活);/dev/kvm 权限(用户加入 kvm 组)。
+9 -10
View File
@@ -61,19 +61,18 @@ its screenshots are also hosted.
- [Prompt](manual/40-prompt.md)
- [Branding](manual/41-branding.md)
- [Common tweaks](manual/42-common-tweaks.md)
- [Extra themes](manual/43-extra-themes.md)
- [Making your own theme](manual/44-making-your-own-theme.md)
- [Making your own theme](manual/43-making-your-own-theme.md)
**The Rest**
- [Mac support](manual/45-mac-support.md)
- [Troubleshooting](manual/46-troubleshooting.md)
- [FAQ](manual/47-faq.md)
- [System snapshots](manual/48-system-snapshots.md)
- [Security](manual/49-security.md)
- [Omarchy on...](manual/50-omarchy-on.md)
- [Dual Boot Install](manual/51-dual-boot-install.md)
- [Unattended Installs](manual/52-unattended-installs.md)
- [Mac support](manual/44-mac-support.md)
- [Troubleshooting](manual/45-troubleshooting.md)
- [FAQ](manual/46-faq.md)
- [System snapshots](manual/47-system-snapshots.md)
- [Security](manual/48-security.md)
- [Omarchy on...](manual/49-omarchy-on.md)
- [Dual Boot Install](manual/50-dual-boot-install.md)
- [Unattended Installs](manual/51-unattended-installs.md)
## License
+1 -1
View File
@@ -528,7 +528,7 @@ def fetch_codex_rpc():
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "untrusted", "app-server"],
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
+12
View File
@@ -6,6 +6,18 @@
set -euo pipefail
# Whenever this runs as root — invoked directly through the passwordless
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path)
# prepends a user-writable checkout bin/ to it. Every helper this script calls
# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system
# tool, never an omarchy-* command, so pin PATH to trusted system directories
# and keep root from resolving one out of that checkout. The unprivileged
# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec.
if (( EUID == 0 )); then
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
fi
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
provider_from_arg() {
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# omarchy:summary=Check that a git URL names a repository, not a transport helper
# omarchy:args=<git-url>
# omarchy:hidden=true
set -euo pipefail
# git picks a remote helper -- an executable it runs at clone time -- out of a URL
# in exactly two shapes, and no others: `<helper>::<address>`, and
# `<scheme>://<address>` for any scheme git does not handle itself. A single
# colon is always scp-style ssh, and a bare path is always a path; neither can
# reach a helper. So constraining those two shapes covers the whole surface.
#
# The `::` shape is refused outright, because no helper reachable that way is one
# a theme or plugin URL has business naming, and `ext::` runs a shell command.
# The `://` shape cannot be refused the same way, since it is also how every
# legitimate URL arrives -- so it is allowlisted instead. The list is the
# transports git still connects itself, `git+ssh` and `ssh+git` included: those
# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are
# left out deliberately -- git ships a helper for each, and `ext` runs whatever
# command the URL carries.
TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file)
fail() {
echo "omarchy-git-url-check: $*" >&2
exit 1
}
url="${1-}"
if [[ -z $url ]]; then
fail "a git URL is required"
fi
if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
fail "'$url' names a git option or transport helper, not a repository."
fi
if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then
scheme="${BASH_REMATCH[1]}"
for transport in "${TRANSPORTS[@]}"; do
if [[ $scheme == "$transport" ]]; then
exit 0
fi
done
fail "'$url' names the '$scheme' transport, which Omarchy does not clone from."
fi
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# omarchy:summary=Match the Dell XPS 13 DX13260 that requires the sidecar amplifier workaround.
product_sku="${OMARCHY_DMI_PRODUCT_SKU:-/sys/class/dmi/id/product_sku}"
omarchy-hw-match "DX13260" &&
grep -qix "0E53" "$product_sku" 2>/dev/null
+8
View File
@@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
INTERNAL=$(omarchy-hyprland-monitor-laptop)
# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a
# name that is not a plain connector string could execute on the next reload.
# Names come from hyprctl; a user-created headless output can carry anything.
if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe internal monitor name" >&2
exit 1
fi
valid_scale() {
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
}
+7
View File
@@ -28,6 +28,13 @@ off() {
exit 1
fi
# The name is written into generated Lua below, so only a plain connector
# name may pass; anything else could execute on the next reload.
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
if ! omarchy-hyprland-monitor-external-active; then
omarchy-notification-send -g 󰍹 "Can't disable the only active display"
exit 1
@@ -22,6 +22,15 @@ on() {
exit 1
fi
# Both names are written into generated Lua below, so only plain connector
# names may pass; a user-created headless output can carry any name.
for output in "$INTERNAL" "$EXTERNAL"; do
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
done
omarchy-hyprland-toggle $DISABLE_TOGGLE off
if omarchy-hyprland-toggle-disabled $TOGGLE; then
+8
View File
@@ -80,6 +80,14 @@ set_scale() {
local width="$(echo "$monitor_info" | jq -r '.width')"
local height="$(echo "$monitor_info" | jq -r '.height')"
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
# active_monitor is written into the Lua string eval'd below, so only a plain
# connector name may pass; a hostile output name could execute otherwise.
if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe monitor name" >&2
exit 1
fi
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
# factor even when the monitor scale itself is fractional.
+7 -5
View File
@@ -9,10 +9,12 @@
# no prompt. lazydocker needs the root-owned Docker socket, so when the group is
# absent, gate that access behind a polkit prompt. If the user has opted into
# sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already
# reachable, so run lazydocker directly. pkexec sanitizes the environment, so
# carry TERM through for the TUI to render and run lazydocker from root's PATH.
if id -nG 2>/dev/null | grep -qw docker; then
exec lazydocker
else
# reachable, so run lazydocker directly — omarchy-sudo-docker answers that for
# this session, so the prompt stays until the reboot that grants the group.
# pkexec sanitizes the environment, so carry TERM through for the TUI to render
# and run lazydocker from root's PATH.
if omarchy-sudo-docker; then
exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker
else
exec lazydocker
fi
+7
View File
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
[[ -n $url ]] || fail "a git URL is required"
fi
# Refuse a URL that names a git option or a transport helper before cloning, so
# an untrusted URL cannot run a command before the plugin is validated or
# enabled. The check is shared with omarchy-theme-install and explains itself; a
# missing checker leaves this non-zero, which refuses the URL rather than
# cloning it.
omarchy-git-url-check "$url" || exit 1
if (( ! ASSUME_YES )); then
cat >&2 <<WARN
+7 -2
View File
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
remove_pam_config
if [[ -d /etc/fido2 ]]; then
authdir=/etc/fido2
# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and
# a later setup would install the authfile through it. rm -rf on a symlink
# removes the link itself, never the directory it points at.
if [[ -e $authdir || -L $authdir ]]; then
echo "Removing FIDO2 configuration..."
sudo rm -rf /etc/fido2
sudo rm -rf "$authdir"
fi
echo "Removing FIDO2 packages..."
+17 -8
View File
@@ -5,7 +5,10 @@
set -e
if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then
# Ask about the configured groups, not this session's: right after enabling,
# sudoless Docker is on for the account even though the running session still
# needs a prompt, and this command is what turns it back off.
if omarchy-sudo-docker --configured; then
echo "Sudoless Docker is not enabled: $USER is not in the docker group."
exit 0
fi
@@ -13,13 +16,19 @@ fi
echo "Removing $USER from the docker group..."
sudo gpasswd -d "$USER" docker >/dev/null
# Group membership is fixed at login, so the running session keeps its docker
# access until it ends. Flag a reboot so omarchy-update-restart prompts for one
# (and the bar shows it pending); a plain log out and back in works too.
# Group membership is only re-read by a fresh session, and in practice logging
# out or newgrp isn't enough — only a reboot reliably applies it. Record it so a
# later `omarchy update` still prompts (omarchy-update-restart reads this), then
# offer to do it now.
omarchy-state set reboot-required
echo ""
echo "Sudoless Docker DISABLED. Reboot (or log out and back in) for the change to take effect."
echo "Docker access now goes through a polkit/sudo prompt again: the Docker TUI"
echo "(Super + Shift + D) and the Windows VM will ask when they need it, and the"
echo "plain 'docker' CLI runs under sudo."
echo "Sudoless Docker DISABLED. Docker access goes through a polkit/sudo prompt"
echo "again: the Docker TUI (Super + Shift + D) and the Windows VM ask when they"
echo "need it, and the plain 'docker' CLI runs under sudo. It takes effect after a reboot."
echo ""
# The migration reuses this command during 'omarchy update' and defers the
# reboot to omarchy-update-restart, so it doesn't cut the update short.
if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then
omarchy-system-reboot
fi
+71 -4
View File
@@ -4,6 +4,7 @@
# omarchy:requires-sudo=true
set -e
set -o pipefail
check_fido2_hardware() {
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
fi
# Create the pamu2fcfg file
if [[ ! -f /etc/fido2/fido2 ]]; then
sudo mkdir -p /etc/fido2
authdir=/etc/fido2
authfile=/etc/fido2/fido2
# install -d follows a symlink here and applies the mode and ownership to
# whatever it points at, so the credential would be staged and published inside
# the link target and that directory reopened to root:root 755. This is the
# threat omarchy-remove-security-fido2 already names on its side.
if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then
echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m"
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
exit 1
fi
# -f follows symlinks, so the already-registered check below reads a symlinked
# authfile as a registration and leaves it in place, and is false for a
# directory, so it tries to register over one. Only a regular file is a valid
# pam_u2f authfile.
if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then
echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m"
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
exit 1
fi
if [[ ! -f $authfile ]]; then
sudo install -d -m 755 -o root -g root "$authdir"
echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m"
echo -e "Touch your FIDO2 key when it lights up...\n"
if pamu2fcfg >/tmp/fido2; then
sudo mv /tmp/fido2 /etc/fido2/fido2
# A unique sibling created by root cannot be replaced by another process
# running as this user. Stream pamu2fcfg into it instead of asking root to
# reopen a caller-owned path: an observed temporary name could otherwise be
# replaced with a symlink before the privileged copy. The final rename is
# atomic, and -T refuses a directory at the destination. Mode 644 keeps the
# root-owned global authfile readable when pam_u2f uses openasuser; only root
# can still rewrite it.
stage=""
# mktemp's output is an operand for four privileged commands below, one of
# them an rm. Take only the name this script asked for rather than whatever
# came back on stdout.
safe_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
cleanup_stage() {
local status=$?
if safe_stage_path "$stage"; then
sudo rm -f -- "$stage" || true
fi
return "$status"
}
trap cleanup_stage EXIT
stage=$(sudo mktemp "$authfile.new.XXXXXX")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m"
exit 1
fi
if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then
sudo chmod 644 "$stage"
sudo mv -Tf "$stage" "$authfile"
stage=""
trap - EXIT
echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
else
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
+15 -7
View File
@@ -5,7 +5,9 @@
set -e
if id -nG "$USER" 2>/dev/null | grep -qw docker; then
# Ask about the configured groups, not this session's: once enabled it stays
# enabled for the account even before the reboot that lets this session use it.
if ! omarchy-sudo-docker --configured; then
echo "Sudoless Docker is already enabled: $USER is in the docker group."
echo "To disable it again, run: omarchy-remove-security-sudoless-docker"
exit 0
@@ -28,14 +30,20 @@ echo ""
if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then
sudo usermod -aG docker "$USER"
# Group membership is fixed at login, so docker won't be reachable without a
# prompt until the session restarts. Flag a reboot so omarchy-update-restart
# prompts for one (and the bar shows it pending).
# A new docker group membership is only picked up by a fresh session, and in
# practice logging out or newgrp isn't enough — only a reboot reliably applies
# it. Record it so a later `omarchy update` still prompts
# (omarchy-update-restart reads this), then offer to do it now.
omarchy-state set reboot-required
echo ""
echo "Sudoless Docker ENABLED. Reboot, or log out and back in (or run 'newgrp docker'),"
echo "for the new group membership to take effect."
echo "To disable it again, run: omarchy-remove-security-sudoless-docker"
echo "Sudoless Docker ENABLED. It takes effect after a reboot."
echo "To disable it again: Setup > Security > Sudoless Docker."
echo ""
# The migration reuses this command during 'omarchy update' and defers the
# reboot to omarchy-update-restart, so it doesn't cut the update short.
if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then
omarchy-system-reboot
fi
else
echo "Aborted. No changes made. Docker access still goes through a prompt."
fi
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# omarchy:summary=Succeed when Docker needs sudo, fail when it can be used directly
# omarchy:args=[--configured]
# omarchy:examples=omarchy-sudo-docker && echo "needs sudo" | omarchy-sudo-docker --configured
# omarchy:hidden=true
# The docker group is root-equivalent, so Omarchy leaves users out of it by
# default and reaches the daemon through a prompt instead. Everything that has
# to make that choice asks here rather than testing group membership itself.
#
# Two questions, because they have different answers between toggling sudoless
# Docker and the reboot that applies it (group membership is fixed when the
# session is created):
#
# (default) Does Docker need sudo *right now*? Answered by whether this
# process can actually reach the socket, which is what decides
# if a command must elevate. Still true in the window after
# sudoless Docker is enabled but before the reboot.
# --configured Will it need sudo once the account's groups take effect?
# Answered from the account's configured groups, so the menu
# offers the toggle that can actually change state.
#
# Succeeds (exit 0) when sudo is needed, so it reads as `if omarchy-sudo-docker`.
DOCKER_SOCKET="${OMARCHY_DOCKER_SOCKET:-/var/run/docker.sock}"
case "${1:-}" in
--configured)
# An account in the docker group will not need sudo after the next login.
id -nG "$USER" 2>/dev/null | grep -qw docker && exit 1
exit 0
;;
"")
# A socket we can write is a daemon we can drive without elevating. A missing
# socket counts as needing sudo: reaching it means starting it as root anyway.
[[ -w $DOCKER_SOCKET ]] && exit 1
exit 0
;;
*)
echo "Usage: omarchy-sudo-docker [--configured]" >&2
exit 2
;;
esac
+4 -8
View File
@@ -16,14 +16,10 @@ if [[ -z $REPO_URL ]]; then
exit 1
fi
# git reads a leading dash as an option, and `<helper>::<address>` as a remote
# helper to run. The helper name is a bare word at the very start, which is what
# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git
# carries `::` too and must still clone.
if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
echo "Error: '$REPO_URL' names a git option or transport helper, not a repository."
exit 1
fi
# Refuse a URL that names a git option or a transport helper before cloning. The
# check is shared with omarchy-plugin-add and explains itself; a missing checker
# leaves this non-zero, which refuses the URL rather than cloning it.
omarchy-git-url-check "$REPO_URL" || exit 1
THEMES_DIR="$HOME/.config/omarchy/themes"
+40 -14
View File
@@ -7,44 +7,70 @@
KIND="${1:-}"
ACTION="${2:-toggle}"
usage() {
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
}
case "$KIND" in
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
*)
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
usage
exit 1
;;
esac
# Hyprland sources this directory on reload, so the disabled state survives restarts
STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua"
# The persisted disable is the device name stored as plain data; on every
# reload default/hypr/disabled-input-device.lua reads it back and disables the
# device. Names come from USB descriptors and must not be interpolated into
# shell or Lua. The path is hardcoded to ~/.local/state like the sibling
# toggle tools, so it keeps working when XDG_STATE_HOME diverges.
NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name"
device="$("omarchy-hw-$KIND")"
if [[ -z $device ]]; then
echo "No $KIND device found" >&2
exit 1
fi
require_device() {
if [[ -z $device ]]; then
echo "No $KIND device found" >&2
exit 1
fi
if [[ $device == *[[:cntrl:]]* ]]; then
echo "Invalid $KIND device name" >&2
exit 1
fi
}
apply_device() {
local enabled=$1
local quoted=${device//\\/\\\\}
quoted=${quoted//\"/\\\"}
hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null
}
enable() {
hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null
rm -f "$STATE_FILE"
# Clear the persisted state before requiring a usable device, so a device
# that stops reporting a valid name can never wedge the disable in place.
rm -f "$NAME_FILE"
require_device
apply_device true
omarchy-osd -i "$ICON" -m "$LABEL enabled"
}
disable() {
hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null
mkdir -p "$(dirname "$STATE_FILE")"
printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE"
require_device
apply_device false
mkdir -p "$(dirname "$NAME_FILE")"
printf '%s\n' "$device" >"$NAME_FILE"
omarchy-osd -i "$ICON" -m "$LABEL disabled"
}
case "$ACTION" in
on) enable ;;
off) disable ;;
toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;;
toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;;
*)
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
usage
exit 1
;;
esac
+6 -3
View File
@@ -31,8 +31,11 @@ CONTAINER="omarchy-windows"
# --- privilege helpers -------------------------------------------------------
# True when the user can reach the Docker socket directly (sudoless Docker on).
in_docker_group() { id -nG 2>/dev/null | grep -qw docker; }
# True when this session can reach the Docker socket directly (sudoless Docker
# on and in effect). Asking about the socket rather than the configured groups
# keeps the prompt in place through the window where sudoless Docker is enabled
# but the reboot that grants the group has not happened yet.
docker_needs_sudo() { omarchy-sudo-docker; }
# The command to hand pkexec for the privileged re-exec. pkexec runs whatever
# executable it is given (after authorization) and only shows the path in the
@@ -64,7 +67,7 @@ priv_target() {
priv() {
local action="$1"
shift
if [[ $action != write_compose ]] && in_docker_group; then
if [[ $action != write_compose ]] && ! docker_needs_sudo; then
"__priv_$action" "$@"
return
fi
+21
View File
@@ -0,0 +1,21 @@
-- Disable a Hyprland input device whose name was stored as data, not Lua.
-- Device names come from USB descriptors and must never be loaded as code.
local paths = require("default.hypr.paths")
return function(kind)
-- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the
-- sibling bash toggle tools, which all write there regardless of
-- XDG_STATE_HOME.
local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r")
if not file then
return
end
local name = file:read("*l")
file:close()
if name and name ~= "" then
hl.device({ name = name, enabled = false })
end
end
+13 -3
View File
@@ -4,9 +4,19 @@
local home = os.getenv("HOME")
-- A variable that is set but empty means "unset" (XDG Base Directory spec);
-- bash's ${VAR:-fallback} in the sibling tools treats it the same way.
local function env_or(name, fallback)
local value = os.getenv(name)
if value == nil or value == "" then
return fallback
end
return value
end
return {
home = home,
config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"),
state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"),
omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy",
config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"),
state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"),
omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"),
}
+14 -8
View File
@@ -4,6 +4,8 @@
-- Pass a module prefix for normal package.path modules, e.g.
-- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps")
-- Pass nil as the prefix when the directory itself has been added to package.path.
-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy
-- file that must never be loaded as code stays on disk for a migration to remove.
local M = {}
@@ -12,19 +14,23 @@ local function shell_quote(path)
end
function M.files(dir, module_prefix, options)
local exclude = options and options.exclude or {}
local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort")
if handle then
for filename in handle:lines() do
local module = filename:gsub("%.lua$", "")
if module_prefix then
module = module_prefix .. "." .. module
end
local name = filename:gsub("%.lua$", "")
if not exclude[name] then
local module = name
if module_prefix then
module = module_prefix .. "." .. module
end
if options and options.reload then
package.loaded[module] = nil
end
if options and options.reload then
package.loaded[module] = nil
end
require(module)
require(module)
end
end
handle:close()
end
+15 -1
View File
@@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all")
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
package.path = toggles_dir .. "/?.lua;" .. package.path
require_all.files(toggles_dir, nil, { reload = true })
-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older
-- versions and could carry an injected USB device name. They must never be loaded
-- as code again: exclude them so a not-yet-migrated install cannot execute a
-- leftover payload on reload. The migration recovers the name and deletes them.
require_all.files(toggles_dir, nil, {
reload = true,
exclude = {
["touchpad-disabled"] = true,
["touchscreen-disabled"] = true,
},
})
local disabled_input_device = require("default.hypr.disabled-input-device")
disabled_input_device("touchpad")
disabled_input_device("touchscreen")
require("default.hypr.workspace-layouts")
+2 -2
View File
@@ -179,7 +179,7 @@
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
"setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
"setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
"setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
"setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
"setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""},
"setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"},
"setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"},
@@ -291,7 +291,7 @@
"remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"},
"remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"},
"remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"},
"remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"id -nG | grep -qw docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"},
"remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"},
"remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"},
"remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"},
"remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"},
+1 -1
View File
@@ -1 +1 @@
%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *
%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$
+4
View File
@@ -25,6 +25,10 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh"
run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh"
run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh"
# Rebuilds the boot image, so it has to follow the Panther Lake kernel swap
# above rather than sit with the other Dell leaf at the top of this file.
run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh"
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh"
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-display.sh"
run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-b9406-touchpad.sh"
@@ -0,0 +1,10 @@
# Enable the temporary sidecar amplifier workaround on the exact Dell XPS 13 model that needs it.
#
# Pacman registers a package even when its post_install scriptlet fails, so the
# apply command runs explicitly here: a failed cleanup or boot-image rebuild has
# to reach the caller rather than hide behind a successfully registered package.
if omarchy-hw-dell-xps13-sidecar-amps; then
omarchy-pkg-add dell-xps13-sidecar-amps &&
sudo dell-xps13-sidecar-amps-apply
fi
+1
View File
@@ -61,6 +61,7 @@ linux-firmware-marvell
# Dell laptop support packages
dell-xps-touchpad-haptics
dell-xps13-sidecar-amps
# Speaker tunings (LV2 limiter every tuning ends in)
lsp-plugins-lv2
+1 -1
View File
@@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle <thing>`. Run `omarchy
| Suspend | — | `omarchy toggle suspend` |
| Hybrid GPU | — | `omarchy toggle hybrid gpu` |
The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup.
The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again.
The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`).
+1 -1
View File
@@ -2,7 +2,7 @@
### Apple M1/M2 chips
[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://codeberg.org/malik-na/omarchy-mac).
[Asahi Alarm](https://asahi-alarm.org/) is a version of Arch for Apple M1/M2 computers built on top of [Asahi Linux](https://asahilinux.org/). You can get Omarchy running on top of that with some effort. See [the user-driven guide](https://github.com/omarchy-mac/omarchy-mac).
### Apple Virtual Machine
+116
View File
@@ -0,0 +1,116 @@
echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root"
authfile="/etc/fido2/fido2"
# omarchy-migrate records this migration as complete whenever it exits zero, so
# a line printed here scrolls past once in the update terminal and is never
# shown again. The states below cannot be repaired without deciding what to do
# with a file we do not own, and they are exactly the ones where the authfile
# may already be under someone else's control, so say so where it outlives the
# scrollback as well.
report_unrepairable() {
echo " $1"
echo " $2"
omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true
}
# Nothing to repair on any machine that never set FIDO2 up, which is almost all
# of them. Checked before any sudo so those machines never see a password
# prompt. -L as well as -e: a dangling symlink is invisible to -e.
if [[ ! -L $authfile && ! -e $authfile ]]; then
# Absence and "cannot look" are the same answer to the tests above. The old
# setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the
# caller's umask and sudoers' 0022, so anyone registering under `umask 077`
# left it mode 0700 with the user-owned authfile still inside. Escalate for
# that case alone -- a machine that never set FIDO2 up has no directory here
# and still reaches exit 0 without a password prompt. Not through a symlink:
# chmod would act on whatever it points at.
authdir=${authfile%/*}
if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then
exit 0
fi
# Ask root whether a registration is behind it before touching the directory
# itself. An aborted setup that left an empty 0700 directory, or one an
# administrator deliberately keeps private, must not have its mode widened
# and its group and special bits discarded for a repair it does not need.
if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then
exit 0
fi
sudo chmod 755 "$authdir"
fi
# The old privileged move could install a symlink here if its fixed staging path
# was redirected. Reported, not repaired: chown follows symlinks and would take
# ownership of the target instead, and removing it would strip sudo and polkit
# from anyone whose only credential is the token.
if [[ -L $authfile ]]; then
report_unrepairable "$authfile is a symlink, not a regular file." \
"Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2."
exit 0
fi
# A directory or a device here is no more ours to rewrite than a symlink is,
# and changing a directory's mode would alter an object we do not own.
if [[ ! -f $authfile ]]; then
report_unrepairable "$authfile is not a regular file." \
"Leaving it alone. Remove it and re-run Setup > Security > Fido2."
exit 0
fi
# Migration state is per-user, so every account re-runs this. The file's own
# ownership is the state check: the second account finds the repair already
# done and exits without escalating.
owner=$(stat -c %U "$authfile" 2>/dev/null) || owner=""
group=$(stat -c %G "$authfile" 2>/dev/null) || group=""
mode=$(stat -c %a "$authfile" 2>/dev/null) || mode=""
if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then
exit 0
fi
# Setup used to `mv` this in from /tmp, which carried the invoking user's
# ownership into /etc. Root ownership stops that user from rewriting their own
# PAM credential without root. Mode 644 keeps the public credential mapping
# readable when pam_u2f opens an absolute authfile as the authenticating user.
#
# Rename a fresh copy over the path rather than chowning in place. A descriptor
# opened while the file was still the user's own stays writable on that inode
# through any later chmod or chown, since permission is checked at open(2), and
# pam_u2f resolving the path would keep landing on it. Replacing the inode
# leaves that descriptor writing to a file nothing reads.
stage=""
safe_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
cleanup_stage() {
local status=$?
if safe_stage_path "$stage"; then
sudo rm -f -- "$stage" || true
fi
return "$status"
}
trap cleanup_stage EXIT
stage=$(sudo mktemp "$authfile.new.XXXXXX")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
echo " Could not create a safe staging file beside $authfile."
exit 1
fi
sudo install -T -m 644 -o root -g root "$authfile" "$stage"
sudo mv -Tf "$stage" "$authfile"
stage=""
trap - EXIT
+7 -5
View File
@@ -2,13 +2,15 @@ echo "Move this install to the opt-in docker group default (the group is root-eq
# The docker group grants passwordless root (a container can bind-mount / and
# rewrite the host), so Omarchy no longer puts users in it by default. Bring
# existing installs in line: remove this user from the group if present. It takes
# effect at next login, and the current session keeps working until then. Anyone
# who wants passwordless docker back can opt in, behind a warning, with
# existing installs in line: remove this user from the group if present. The
# change applies after a reboot, so it stays reachable until then. Anyone who
# wants passwordless docker back can opt in, behind a warning, with
# Setup > Security > Sudoless Docker. Reuses the removal command so there is one
# source of truth for the privileged change and its notice.
# source of truth for the privileged change and its notice; DEFER_REBOOT keeps
# it from prompting mid-update — omarchy-update-restart handles the reboot once
# the whole update has finished.
if id -nG "$USER" | grep -qw docker; then
omarchy-remove-security-sudoless-docker
OMARCHY_DEFER_REBOOT=1 omarchy-remove-security-sudoless-docker
fi
# The Docker app entry copied into ~/.local/share/applications used to run
+39
View File
@@ -0,0 +1,39 @@
echo "Store Hyprland input-device names as data instead of generated Lua"
# omarchy-toggle-input-device used to interpolate hyprctl device names into
# hyprctl eval and a generated Lua file. Those names come from USB descriptors,
# so recover the plain device name as data and delete the generated Lua. A name
# that could have broken out of the old Lua string literal is discarded, not
# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME.
toggles_dir="$HOME/.local/state/omarchy/toggles/hypr"
reapply=0
for kind in touchpad touchscreen; do
state_file="$toggles_dir/$kind-disabled.lua"
name_file="$toggles_dir/$kind-disabled-name"
[[ -f $state_file ]] || continue
if [[ ! -f $name_file && -r $state_file ]]; then
old=$(<"$state_file")
pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$'
if [[ $old =~ $pattern ]]; then
printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file"
fi
fi
rm -f "$state_file"
if [[ -f $name_file ]]; then
reapply=1
fi
done
# The package hook reloads Hyprland before migrations run, so this session has
# already dropped the disable: the generated Lua is no longer loaded and the
# name file did not exist yet to replace it. Reload once more now that it does,
# or the device the user switched off stays on until their next login.
if (( reapply )); then
hyprctl reload >/dev/null 2>&1 || true
fi
+6
View File
@@ -0,0 +1,6 @@
echo "Enable Dell XPS 13 sidecar speaker amplifiers"
if omarchy-hw-dell-xps13-sidecar-amps; then
source "$OMARCHY_PATH/install/hardware/dell-xps13-sidecar-amps.sh"
omarchy-state set reboot-required
fi
+14 -1
View File
@@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin"
cat >"$TEST_HOME/bin/codex" <<'EOF'
#!/bin/bash
if [[ -n ${CODEX_ARGS_FILE:-} ]]; then
printf '%s\0' "$@" >"$CODEX_ARGS_FILE"
fi
while read -r request; do
id=$(jq -r '.id // empty' <<<"$request")
method=$(jq -r '.method // empty' <<<"$request")
@@ -40,9 +44,18 @@ cat >"$session" <<EOF
{"timestamp":"$timestamp","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":180,"cached_input_tokens":110,"output_tokens":30,"reasoning_output_tokens":8,"total_tokens":210},"last_token_usage":{"input_tokens":80,"cached_input_tokens":50,"output_tokens":10,"reasoning_output_tokens":3,"total_tokens":90}}}}
EOF
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" CODEX_ARGS_FILE="$TEST_HOME/codex-args" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
"$ROOT/bin/omarchy-agent-usage-codex")
# NUL-separated, so the assertion sees argument boundaries: a single "-a on-request"
# would flatten to the same text as two arguments but is not a policy codex accepts.
expected_args=(-s read-only -a on-request app-server)
mapfile -d '' -t codex_args <"$TEST_HOME/codex-args"
[[ ${codex_args[*]@Q} == "${expected_args[*]@Q}" ]] ||
fail "Codex collector uses the supported approval policy" "${codex_args[*]@Q}"
pass "Codex collector uses the supported approval policy"
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "210" ]] ||
fail "Codex collector counts each turn once" "$result"
pass "Codex collector counts each turn once"
+54
View File
@@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null ||
pass "dns sudoers rule is scoped to the stock providers"
# The privileged half runs as root under sudo's secure_path, and a dev link
# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it.
# Every helper the script calls by bare name -- dirname, install, tee, nmcli,
# systemctl, awk -- is a system tool, so once it holds root the script pins PATH
# to trusted system directories and never resolves one of them out of the
# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why
# the pin is gated on EUID rather than set unconditionally.
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" ||
fail "omarchy-dns pins PATH to trusted system directories when it holds root"
# require_root carries its own `(( EUID == 0 ))`, so matching that text alone
# would pass with the pin deleted. Anchor on the unindented guard and require the
# pin to be the line it opens.
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true)
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
fail "omarchy-dns gates the trusted-PATH pin on holding root"
# The no-argument path only reads DNS config, so exercise the privileged phase
# directly when the suite is root and as namespaced root otherwise. This reaches
# tr while EUID is 0 without giving an ordinary test run any host privileges.
root_runner=()
if (( EUID != 0 )); then
root_runner=(unshare --user --map-root-user)
fi
# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and
# the non-graphical suites have to stay green on any machine -- a skip is a
# passing test. Only the runtime probe needs the namespace; the static checks
# above and the elevation checks below run either way.
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
poison_dir=$(mktemp -d)
poison_ran="$poison_dir/ran"
for helper in tr awk dirname install tee; do
cat >"$poison_dir/$helper" <<SH
#!/bin/bash
printf 'x' >"$poison_ran"
exec "/usr/bin/$helper" "\$@"
SH
chmod +x "$poison_dir/$helper"
done
if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" </dev/null >/dev/null 2>&1; then
rm -rf "$poison_dir"
fail "root omarchy-dns failed its read-only trusted-PATH probe"
fi
if [[ -e $poison_ran ]]; then
rm -rf "$poison_dir"
fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path"
fi
rm -rf "$poison_dir"
pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH"
else
pass "no unprivileged user namespace; skipping the root trusted-PATH probe"
fi
# require_root returns immediately for root, so the stubs below would not stand
# between the script and the host's real NetworkManager and resolved config.
if (( EUID == 0 )); then
+18 -3
View File
@@ -39,16 +39,30 @@ cat >"$stub_bin/gpasswd" <<'STUB'
#!/bin/bash
echo "$@" >>"${GPASSWD_CALLS:?}"
STUB
chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd"
# gum confirm always says yes, and reboot records that it fired: the migration
# must still NOT reboot (it defers to omarchy-update-restart), so neither should
# be reached.
cat >"$stub_bin/gum" <<'STUB'
#!/bin/bash
[[ $1 == confirm ]] && exit 0
exit 0
STUB
cat >"$stub_bin/omarchy-system-reboot" <<'STUB'
#!/bin/bash
touch "${REBOOT_CALLED:?}"
STUB
chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" "$stub_bin/gum" "$stub_bin/omarchy-system-reboot"
reboot_flag="$home/.local/state/omarchy/reboot-required"
gpasswd_calls="$test_dir/gpasswd-calls"
reboot_called="$test_dir/reboot-called"
launcher="$home/.local/share/applications/Docker.desktop"
run_migration() {
rm -f "$gpasswd_calls" "$reboot_flag"
rm -f "$gpasswd_calls" "$reboot_flag" "$reboot_called"
HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \
GPASSWD_CALLS="$gpasswd_calls" PATH="$stub_bin:$ROOT/bin:$PATH" \
GPASSWD_CALLS="$gpasswd_calls" REBOOT_CALLED="$reboot_called" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null 2>&1
}
@@ -56,6 +70,7 @@ run_migration() {
run_migration "wheel input docker" || fail "migration runs when the user is in the docker group"
grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group"
[[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect"
[[ ! -f $reboot_called ]] || fail "migration must defer the reboot (not reboot mid-update)"
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry"
pass "migration removes the group, flags a reboot, and refreshes the launcher"
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
set -euo pipefail
# omarchy-git-url-check decides which URLs omarchy-theme-install and
# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote
# helper -- a program it runs at clone time -- from exactly two URL shapes,
# `<helper>::<address>` and `<scheme>://<address>`, so those are the two shapes
# asserted here, alongside every legitimate form a user is likely to paste.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
check() {
"$ROOT/bin/omarchy-git-url-check" "$@" 2>&1
}
# `<helper>::<address>`, the shape that runs a program. `ext::` is the dangerous
# one: git runs the rest as a shell command once protocol.ext.allow permits it.
for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the transport helper '$url'" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "omarchy-git-url-check names the helper rejection for '$url'" "$output"
done
pass "a <helper>::<address> URL is refused"
# `<scheme>://<address>`, the shape #8067 left open: git looks up
# git-remote-<scheme> for any scheme it does not implement itself, so an
# allowlist is the only form of this check that holds.
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the '$url' transport" "$output"
grep -qF "which Omarchy does not clone from" <<<"$output" ||
fail "omarchy-git-url-check names the transport rejection for '$url'" "$output"
done
pass "a <scheme>://<address> URL outside git's own transports is refused"
# A leading dash is an option to git, not a URL.
for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do
output=$(check "$url") &&
fail "omarchy-git-url-check refuses the option '$url'" "$output"
done
pass "a URL shaped like a git option is refused"
output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output"
output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output"
pass "an empty URL is refused"
# Everything a user actually pastes. The scp-style forms carry a single colon,
# which git never reads as a helper, and the IPv6 host carries `::` inside
# brackets rather than at the start.
for url in \
"https://github.com/acme/omarchy-weather.git" \
"http://example.com/a/b.git" \
"https://user:token@github.com/acme/repo.git" \
"ssh://git@github.com/acme/repo.git" \
"ssh://git@[2001:db8::1]:22/org/repo.git" \
"git://example.com/repo.git" \
"git+ssh://git@example.com/acme/repo.git" \
"ssh+git://git@example.com/acme/repo.git" \
"ftp://example.com/repo.git" \
"ftps://example.com/repo.git" \
"file:///home/me/repo" \
"git@github.com:acme/repo.git" \
"git@[2001:db8::1]:org/repo.git" \
"host:-s/foo.git" \
"/home/me/repo" \
"./repo" \
"../repo" \
"repo"; do
output=$(check "$url") ||
fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output"
done
pass "the URL forms a user pastes are accepted"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command lua
run_paths() {
lua - <<'LUA'
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
local paths = require("default.hypr.paths")
assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home)
assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home)
LUA
}
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
XDG_CONFIG_HOME= XDG_STATE_HOME= \
EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \
run_paths
pass "empty XDG path variables fall back to their defaults"
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \
EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \
run_paths
pass "set XDG path variables are honored"
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
stub_dir="$tmpdir/bin"
home_dir="$tmpdir/home"
monitors_json="$tmpdir/monitors.json"
flag_dir="$home_dir/.local/state/omarchy/toggles/hypr"
mkdir -p "$stub_dir" "$flag_dir"
make_stub() {
local name=$1
local body=$2
printf '#!/bin/bash\n%s\n' "$body" >"$stub_dir/$name"
chmod +x "$stub_dir/$name"
}
make_stub omarchy-notification-send ':'
make_stub omarchy-hyprland-monitor-external-active 'exit 0'
make_stub omarchy-hyprland-toggle-disabled 'exit 0'
make_stub omarchy-hyprland-toggle ':'
make_stub omarchy-hyprland-monitor-internal ':'
make_stub omarchy-hyprland-monitor-internal-mirror ':'
make_stub omarchy-hw-clamshell 'exit 0'
make_stub omarchy-hyprland-monitor-laptop 'printf "%s\n" "$LAPTOP_NAME"'
make_stub hyprctl 'case "$1" in
monitors) cat "$MONITORS_JSON" ;;
eval) printf "%s\n" "$2" >>"$EVAL_LOG" ;;
esac'
eval_log="$tmpdir/eval.log"
run_monitor() {
local command=$1
shift
: >"$eval_log"
HOME="$home_dir" \
XDG_STATE_HOME="$home_dir/.local/state" \
LAPTOP_NAME="${LAPTOP_NAME:-eDP-1}" \
MONITORS_JSON="$monitors_json" \
EVAL_LOG="$eval_log" \
PATH="$stub_dir:$ROOT/bin:$PATH" \
"$ROOT/bin/$command" "$@"
}
printf '[{"name":"eDP-1"},{"name":"DP-3"}]\n' >"$monitors_json"
disable_flag="$flag_dir/internal-monitor-disable.lua"
run_monitor omarchy-hyprland-monitor-internal off
grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$disable_flag" >/dev/null ||
fail "internal off writes the connector name into the toggle flag"
pass "internal off accepts a plain connector name"
rm -f "$disable_flag"
set +e
LAPTOP_NAME='eDP-1", disabled = false })os.execute("calc")--' \
run_monitor omarchy-hyprland-monitor-internal off >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "internal off rejects a monitor name with Lua metacharacters"
[[ ! -e $disable_flag ]] || fail "an unsafe monitor name is not written as Lua"
pass "internal off refuses an unsafe monitor name"
mirror_flag="$flag_dir/internal-monitor-mirror.lua"
run_monitor omarchy-hyprland-monitor-internal-mirror on
grep -Fx 'hl.monitor({ output = "DP-3", mode = "preferred", position = "auto", scale = 1, mirror = "eDP-1" })' \
"$mirror_flag" >/dev/null ||
fail "mirror on writes the connector names into the toggle flag"
pass "mirror on accepts plain connector names"
rm -f "$mirror_flag"
printf '[{"name":"eDP-1"},{"name":"HEAD\\" })os.execute(\\"calc\\")--"}]\n' >"$monitors_json"
set +e
run_monitor omarchy-hyprland-monitor-internal-mirror on >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "mirror on rejects an external name with Lua metacharacters"
[[ ! -e $mirror_flag ]] || fail "an unsafe external monitor name is not written as Lua"
pass "mirror on refuses an unsafe headless output name"
# The clamshell sync writes the internal-monitor name into generated Lua too.
clamshell_flag="$flag_dir/internal-monitor-clamshell.lua"
printf '[{"name":"eDP-1"}]\n' >"$monitors_json"
rm -f "$clamshell_flag"
run_monitor omarchy-hyprland-monitor-clamshell
grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$clamshell_flag" >/dev/null ||
fail "clamshell disable writes the connector name into the toggle flag"
pass "clamshell disable accepts a plain connector name"
rm -f "$clamshell_flag"
set +e
LAPTOP_NAME='eDP-1", disabled = true })os.execute("calc")--' \
run_monitor omarchy-hyprland-monitor-clamshell >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "clamshell rejects a monitor name with Lua metacharacters"
[[ ! -e $clamshell_flag ]] || fail "an unsafe internal monitor name is not written as clamshell Lua"
pass "clamshell refuses an unsafe internal monitor name"
# The scaling command eval's the focused-monitor name into a Lua string.
printf '[{"name":"eDP-1","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \
>"$monitors_json"
run_monitor omarchy-hyprland-monitor-scaling 1.6
grep -F 'hl.monitor({ output = "eDP-1"' "$eval_log" >/dev/null ||
fail "scaling eval's the focused connector name"
pass "scaling accepts a plain connector name"
printf '[{"name":"eDP-1\\" })os.execute(\\"calc\\")--","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \
>"$monitors_json"
set +e
run_monitor omarchy-hyprland-monitor-scaling 1.6 >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "scaling rejects a focused monitor name with Lua metacharacters"
[[ ! -s $eval_log ]] || fail "an unsafe focused monitor name is not eval'd as Lua"
pass "scaling refuses an unsafe focused monitor name"
+119
View File
@@ -56,3 +56,122 @@ grep -qF "plugin id 'acme.same' is already used by" <<<"$output" ||
[[ ! -e $test_home/.config/omarchy/plugins/acme.same ]] ||
fail "plugin add leaves a target behind after refusing a duplicate id"
pass "plugin add refuses an installed manifest id regardless of directory name"
# --- URL transport-helper guard -------------------------------------------
#
# The guard refuses git transport helpers (`<name>::…`) and option-shaped URLs
# before `git clone` runs, matching omarchy-theme-install. A git stub records
# whether clone was reached, so the guard is exercised with no network: reaching
# the stub proves a URL passed the guard; not reaching it proves the guard
# rejected the URL first.
guard_stubs="$TMPDIR/guard-stubs"
mkdir -p "$guard_stubs"
cat >"$guard_stubs/omarchy-shell" <<'STUB'
#!/bin/bash
exit 0
STUB
chmod +x "$guard_stubs/omarchy-shell"
clone_marker="$TMPDIR/git-clone-reached"
cat >"$guard_stubs/git" <<STUB
#!/bin/bash
if [[ \$1 == "clone" ]]; then
touch "$clone_marker"
exit 1
fi
exit 0
STUB
chmod +x "$guard_stubs/git"
# A gum stub that answers `gum input` with a caller-chosen value, so a test can
# drive any URL through the interactive prompt path.
cat >"$guard_stubs/gum" <<'STUB'
#!/bin/bash
if [[ $1 == "input" ]]; then
printf '%s\n' "$GUM_INPUT_VALUE"
fi
STUB
chmod +x "$guard_stubs/gum"
add_url() {
HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$guard_stubs:$ROOT/bin:$PATH" \
omarchy-plugin-add "$1" --yes 2>&1
}
# Transport helpers reach the guard, are named as such, and never reach clone.
for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do
rm -f "$clone_marker"
output=$(add_url "$bad") &&
fail "plugin add rejects a transport-helper URL: $bad" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "plugin add names the transport-helper rejection: $bad" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for a transport-helper URL: $bad"
done
pass "plugin add rejects transport-helper URLs before cloning"
# The `://` spelling of the same thing: git resolves git-remote-<scheme> for any
# scheme it does not implement itself, so `ext::` and `ext://` reach the same
# helper and both have to be refused.
for bad in "ext://sh -c id" "gcrypt://example.com/x"; do
rm -f "$clone_marker"
output=$(add_url "$bad") &&
fail "plugin add rejects a transport-scheme URL: $bad" "$output"
grep -qF "which Omarchy does not clone from" <<<"$output" ||
fail "plugin add names the transport-scheme rejection: $bad" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for a transport-scheme URL: $bad"
done
pass "plugin add rejects transport-scheme URLs before cloning"
# Option-shaped URLs on argv are refused before clone — by the option parser
# (`-*` falls to "unknown add option"), not the guard. The guard's own
# leading-dash arm is only reachable through the interactive gum prompt and is
# exercised separately below.
for bad in "-oProxyCommand=x" "--upload-pack=x"; do
rm -f "$clone_marker"
output=$(add_url "$bad") &&
fail "plugin add rejects an option-shaped URL: $bad" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for an option-shaped URL: $bad"
done
pass "plugin add rejects option-shaped URLs before cloning"
# The guard's leading-dash arm is only reachable through `gum input`: argv
# dashes die in the option parser first. interactive() requires a TTY on stdin
# and stdout, so run this one case on a pty via util-linux `script -qec` (the
# suite's existing pty idiom); gum itself is stubbed, so no rendering happens.
# Probe script's util-linux syntax first and skip cleanly where it is missing.
if script -qec true /dev/null >/dev/null 2>&1; then
rm -f "$clone_marker"
status=0
raw=$(GUM_INPUT_VALUE="-oProxyCommand=x" HOME="$test_home" OMARCHY_PATH="$ROOT" \
PATH="$guard_stubs:$ROOT/bin:$PATH" \
script -qec "omarchy-plugin-add --yes" /dev/null) || status=$?
output=$(tr -d '\r' <<<"$raw")
(( status != 0 )) ||
fail "plugin add rejects an option-shaped URL from the gum prompt" "$output"
grep -qF "names a git option or transport helper" <<<"$output" ||
fail "plugin add names the guard rejection for the gum-prompt URL" "$output"
[[ ! -e $clone_marker ]] ||
fail "plugin add reached git clone for an option-shaped gum-prompt URL"
pass "plugin add guard rejects an option-shaped URL from the interactive prompt"
else
pass "script -qec unavailable; skipping the interactive gum-prompt guard case"
fi
# Legitimate URL forms pass the guard and reach git clone (stubbed, no network).
for good in \
"https://github.com/acme/omarchy-weather.git" \
"git@github.com:acme/repo.git" \
"ssh://git@github.com/acme/repo.git" \
"git@[2001:db8::1]:org/repo.git"; do
rm -f "$clone_marker"
output=$(add_url "$good") || true
! grep -qF "names a git option or transport helper" <<<"$output" ||
fail "plugin add wrongly rejected a legitimate URL: $good" "$output"
[[ -e $clone_marker ]] ||
fail "plugin add did not reach git clone for a legitimate URL: $good" "$output"
done
pass "plugin add lets legitimate git URLs reach git clone"
+557
View File
@@ -0,0 +1,557 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
migration="$ROOT/migrations/1787494718.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
calls="$test_tmp/calls.log"
stages="$test_tmp/stages.log"
notifications="$test_tmp/notifications.log"
# A directory of its own, not $test_tmp: the migration derives the FIDO2
# directory from the authfile, and the case below where that directory is
# untraversable has to be able to take the permissions off it.
authdir="$test_tmp/etc-fido2"
authfile="$authdir/fido2"
migration_copy="$test_tmp/migration.sh"
mkdir -p "$stub_bin" "$authdir"
: >"$stages"
: >"$notifications"
# The migration repairs an absolute path no unprivileged suite can write, and an
# environment override in the shipped file would hand a root install and mv an
# operand the caller chooses. Retarget a scratch copy instead, and fail if the
# path is not named exactly once, so this seam cannot quietly stop standing for
# the file it copies.
occurrences=$(grep -Fo /etc/fido2/fido2 "$migration" | wc -l) || occurrences=0
(( occurrences == 1 )) ||
fail "the migration names its authfile exactly once, so the test can retarget a copy" \
"found $occurrences occurrences"
grep -Fxq 'authfile="/etc/fido2/fido2"' "$migration" ||
fail "the production authfile path is a fixed literal, not caller-controlled"
pass "migration names its authfile once, and the test drives a retargeted copy"
# Log every escalation, then execute only the expected bare sudo forms. Each
# operand is matched against the scratch authfile or a stage this stub created.
# This contains malformed calls made through that interface; arbitrary direct
# privileged commands in the migration are outside this harness.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
set -euo pipefail
reject() {
printf 'refusing unexpected sudo invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
exit 97
}
if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
safe_stage_path() {
local candidate=$1
local prefix="$TEST_AUTHFILE.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
recorded_stage() {
local candidate=$1
safe_stage_path "$candidate" || return 1
[[ -f $candidate && ! -L $candidate ]] || return 1
/usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES"
}
case "$1" in
mktemp)
if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then
reject "$@"
fi
case ${TEST_MKTEMP_MODE:-normal} in
normal)
stage=$(/usr/bin/mktemp -- "$2")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
reject "$@"
fi
printf '%s\n' "$stage" >>"$TEST_STAGES"
printf '%s\n' "$stage"
;;
malformed)
stage="$TEST_AUTHFILE.new.A/BCDE"
/usr/bin/mkdir -- "${stage%/*}"
: >"$stage"
printf '%s\n' "$stage"
;;
nonregular)
stage="$TEST_AUTHFILE.new.BAD123"
/usr/bin/mkdir -- "$stage"
printf '%s\n' "$stage"
;;
*)
reject "$@"
;;
esac
;;
install)
if (( $# != 10 )) || [[ $2 != "-T" || $3 != "-m" || $4 != "644" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHFILE" ]] || ! recorded_stage "${10}"; then
reject "$@"
fi
if [[ ${TEST_FAIL_INSTALL:-0} == "1" ]]; then
exit 71
fi
if (( EUID == 0 )); then
exec /usr/bin/install -T -m 644 -o root -g root "$9" "${10}"
else
exec /usr/bin/install -T -m 644 "$9" "${10}"
fi
;;
mv)
if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ ${TEST_FAIL_MV:-0} == "1" ]]; then
exit 72
fi
exec /usr/bin/mv -Tf -- "$3" "$4"
;;
chmod)
# Only ever the FIDO2 directory, and only back to the mode the setup
# installs. Nothing here may reopen the authfile itself.
if (( $# != 3 )) || [[ $2 != "755" || $3 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
exec /usr/bin/chmod 755 "$TEST_AUTHDIR"
;;
test)
# Looking behind an untraversable directory, never a write. This stub is not
# really root, so open the directory just long enough to answer the way root
# would and put its mode straight back -- the suite then still sees whether
# production left the mode alone.
if (( $# != 3 )) || [[ $2 != "-e" && $2 != "-L" ]] || [[ $3 != "$TEST_AUTHFILE" ]]; then
reject "$@"
fi
saved_mode=$(/usr/bin/stat -c %a "$TEST_AUTHDIR")
/usr/bin/chmod 755 "$TEST_AUTHDIR"
probe_status=0
/usr/bin/test "$2" "$3" || probe_status=$?
/usr/bin/chmod "$saved_mode" "$TEST_AUTHDIR"
exit "$probe_status"
;;
rm)
if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]]; then
reject "$@"
fi
if [[ ${TEST_MKTEMP_MODE:-normal} == "nonregular" && $4 == "$TEST_AUTHFILE.new.BAD123" && -d $4 && ! -L $4 ]]; then
exit 73
fi
recorded_stage "$4" || reject "$@"
exec /usr/bin/rm -f -- "$4"
;;
*)
reject "$@"
;;
esac
SH
chmod +x "$stub_bin/sudo"
cat >"$stub_bin/stat" <<'SH'
#!/bin/bash
set -euo pipefail
if [[ ${TEST_FAKE_STAT:-0} == "1" && ${TEST_AUTHFILE:-} == "${TEST_AUTHDIR:-}/fido2" ]] &&
(( $# == 3 )) && [[ $1 == "-c" && $3 == "$TEST_AUTHFILE" ]]; then
case "$2" in
%U) printf '%s\n' "$TEST_STAT_OWNER" ;;
%G) printf '%s\n' "$TEST_STAT_GROUP" ;;
%a) printf '%s\n' "$TEST_STAT_MODE" ;;
*) exec /usr/bin/stat "$@" ;;
esac
else
exec /usr/bin/stat "$@"
fi
SH
chmod +x "$stub_bin/stat"
# omarchy-migrate records this migration complete on any zero exit, so the
# states it cannot repair have to reach the user somewhere that outlives the
# update terminal's scrollback.
cat >"$stub_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf 'notify' >>"$TEST_NOTIFICATIONS"
printf '\t%s' "$@" >>"$TEST_NOTIFICATIONS"
printf '\n' >>"$TEST_NOTIFICATIONS"
exit "${TEST_NOTIFY_STATUS:-0}"
SH
chmod +x "$stub_bin/omarchy-notification-send"
run_migration() {
local fail_install="${1:-0}"
local fail_mv="${2:-0}"
local stat_owner="${3:-}"
local stat_group="${4:-}"
local stat_mode="${5:-}"
local mktemp_mode="${6:-normal}"
local notify_status="${7:-0}"
local fake_stat=0
if [[ -n $stat_owner || -n $stat_group || -n $stat_mode ]]; then
[[ -n $stat_owner && -n $stat_group && -n $stat_mode ]] ||
fail "a fake stat fixture supplies owner, group and mode together"
fake_stat=1
fi
: >"$calls"
: >"$notifications"
sed "s|/etc/fido2/fido2|$authfile|" "$migration" >"$migration_copy"
PATH="$stub_bin:$PATH" TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" \
TEST_FAIL_INSTALL="$fail_install" TEST_FAIL_MV="$fail_mv" TEST_FAKE_STAT="$fake_stat" \
TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_NOTIFICATIONS="$notifications" \
TEST_NOTIFY_STATUS="$notify_status" TEST_STAGES="$stages" TEST_STAT_GROUP="$stat_group" \
TEST_STAT_MODE="$stat_mode" TEST_STAT_OWNER="$stat_owner" TEST_TMP="$test_tmp" \
bash -euo pipefail "$migration_copy" >/dev/null
}
safe_fixture_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
# Every repair case is about an authfile its own user can still rewrite. The
# calls below give stat an explicit caller-owned state, so the same assertions
# work as an ordinary user, as real root, and in a namespace mapping only UID 0.
write_authfile() {
printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authfile"
chmod "$1" "$authfile"
}
# Almost every machine has never registered a key, and establishing that must
# not cost those users a password prompt.
rm -f "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a machine with no authfile escalates nothing" "$(cat "$calls")"
pass "migration skips a machine that never set FIDO2 up"
# What the old `sudo mv` left behind on every machine that did: the authfile PAM
# consults for sudo, owned by the account it authenticates, at the caller's umask.
write_authfile 644 || fail "the test can stage a non-root-owned authfile"
before_inode=$(stat -c %i "$authfile")
run_migration 0 0 caller caller 644
grep -Fq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" ||
fail "the repair asks root for a unique sibling stage" "$(cat "$calls")"
grep -Fq $'sudo\tinstall\t-T\t-m\t644\t-o\troot\t-g\troot\t'"$authfile"$'\t' "$calls" ||
fail "a user-owned authfile is reinstalled root:root and mode 644" "$(cat "$calls")"
grep -Fq $'sudo\tmv\t-Tf\t' "$calls" ||
fail "the staged authfile is atomically renamed over the live path" "$(cat "$calls")"
if grep -Fq $'sudo\tchown\t' "$calls"; then
fail "the repair replaces the authfile rather than chowning it" "$(cat "$calls")"
fi
if grep -Fq $'sudo\trm\t' "$calls"; then
fail "a successful repair disarms its EXIT cleanup" "$(cat "$calls")"
fi
pass "migration stages and atomically installs a root-owned authfile"
[[ $(stat -c %a "$authfile") == "644" ]] ||
fail "the repaired authfile is mode 644" "got: $(stat -c %a "$authfile")"
[[ $(cat "$authfile") == "tester:credential-handle,public-key,es256,+presence" ]] ||
fail "the repaired authfile keeps its credential" "got: $(cat "$authfile")"
if (( EUID == 0 )) && [[ $(stat -c %U:%G "$authfile") != "root:root" ]]; then
fail "the repaired authfile is root:root" "got: $(stat -c %U:%G "$authfile")"
fi
pass "migration preserves the credential with its PAM-readable mode"
# The whole point of replacing rather than chowning. Permission is checked at
# open(2), so a descriptor the registering user opened before the update stays
# writable on the old inode through any chmod or chown -- and pam_u2f resolving
# the authfile path would keep reading exactly that inode.
[[ $(stat -c %i "$authfile") != "$before_inode" ]] ||
fail "the repair lands on a new inode, orphaning any descriptor already open on the old one"
pass "migration replaces the inode a pre-existing writer would still hold"
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 1 )) ||
fail "the first repair creates exactly one stage" "got: ${staged_paths[*]}"
first_stage=${staged_paths[0]}
safe_fixture_stage_path "$first_stage" ||
fail "the stage is a unique sibling of the authfile" "got: $first_stage"
[[ ! -e $first_stage && ! -L $first_stage ]] ||
fail "the staged copy does not outlive the repair" "left behind: $first_stage"
pass "migration uses a unique sibling and leaves no staged copy behind"
# Treat mktemp's output as untrusted even though sudo normally resolves the
# system binary. This existing regular path has a six-character suffix only if
# `/` is accepted as one of the characters, as the old ?????? glob did. The
# strict shape check must reject it before any privileged write or cleanup.
write_authfile 644 || fail "the test can stage the malformed-output fixture"
before_inode=$(stat -c %i "$authfile")
malformed_parent="$authfile.new.A"
malformed_stage="$malformed_parent/BCDE"
if run_migration 0 0 caller caller 644 malformed; then
fail "malformed mktemp output fails the migration"
fi
grep -Fq $'sudo\tmktemp\t' "$calls" ||
fail "the malformed-output fixture reaches mktemp" "$(cat "$calls")"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls" || grep -Fq $'sudo\trm\t' "$calls"; then
fail "malformed mktemp output reaches no install, rename or cleanup" "$(cat "$calls")"
fi
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "malformed mktemp output leaves the live authfile inode alone"
[[ -f $malformed_stage && ! -L $malformed_stage ]] ||
fail "the malformed-output fixture remains a regular scratch file" "got: $malformed_stage"
/usr/bin/rm -- "$malformed_stage"
/usr/bin/rmdir -- "$malformed_parent"
pass "migration rejects malformed mktemp output before any privileged write"
# A name can have the right prefix and six-character suffix but still name an
# object mktemp would never return. Production must reject that object before
# install/mv; its cleanup may address only that validated scratch sibling and
# must not recursively remove the unexpected directory.
write_authfile 644 || fail "the test can stage the nonregular-output fixture"
before_inode=$(stat -c %i "$authfile")
nonregular_stage="$authfile.new.BAD123"
if run_migration 0 0 caller caller 644 nonregular; then
fail "nonregular mktemp output fails the migration"
fi
safe_fixture_stage_path "$nonregular_stage" ||
fail "the nonregular fixture uses a syntactically valid stage name" "got: $nonregular_stage"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then
fail "nonregular mktemp output is rejected before install or rename" "$(cat "$calls")"
fi
grep -Fq $'sudo\trm\t-f\t--\t'"$nonregular_stage" "$calls" ||
fail "cleanup addresses only the validated nonregular sibling" "$(cat "$calls")"
[[ -d $nonregular_stage && ! -L $nonregular_stage ]] ||
fail "cleanup does not recursively remove a nonregular stage" "got: $nonregular_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "nonregular mktemp output leaves the live authfile inode alone"
/usr/bin/rmdir -- "$nonregular_stage"
pass "migration rejects and safely handles nonregular mktemp output"
# A caller-owned file still needs a fresh inode and root ownership whatever its
# current mode.
write_authfile 600 || fail "the test can restage a non-root-owned authfile"
run_migration 0 0 caller caller 600
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a mode-600 authfile the user still owns is repaired" "$(cat "$calls")"
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 2 )) ||
fail "two repairs create two stages" "got: ${staged_paths[*]}"
second_stage=${staged_paths[1]}
[[ ! -e $second_stage && ! -L $second_stage ]] ||
fail "the second staged copy does not outlive the repair" "left behind: $second_stage"
pass "migration repairs a user-owned authfile whatever its mode and cleans its stage"
# A failure after mktemp must remove only the exact stage the stub created. The
# live authfile stays on its original inode because mv was never reached.
write_authfile 644 || fail "the test can stage the cleanup fixture"
before_inode=$(stat -c %i "$authfile")
if run_migration 1 0 caller caller 644; then
fail "an install failure propagates out of the migration"
fi
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 3 )) ||
fail "the failed repair creates one stage" "got: ${staged_paths[*]}"
failed_stage=${staged_paths[2]}
grep -Fq $'sudo\trm\t-f\t--\t'"$failed_stage" "$calls" ||
fail "the EXIT trap removes the failed repair's exact stage" "$(cat "$calls")"
[[ ! -e $failed_stage && ! -L $failed_stage ]] ||
fail "the failed stage is cleaned up" "left behind: $failed_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "a failed repair leaves the live authfile inode alone"
pass "migration cleans its unique stage after a failed repair"
# A failure after install has the same cleanup obligation. In particular, the
# EXIT trap must still be armed when mv fails.
write_authfile 644 || fail "the test can stage the mv-failure fixture"
before_inode=$(stat -c %i "$authfile")
if run_migration 0 1 caller caller 644; then
fail "an mv failure propagates out of the migration"
fi
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 4 )) ||
fail "the mv-failed repair creates one stage" "got: ${staged_paths[*]}"
failed_mv_stage=${staged_paths[3]}
grep -Fq $'sudo\tmv\t-Tf\t'"$failed_mv_stage"$'\t'"$authfile" "$calls" ||
fail "the injected mv failure occurs after install" "$(cat "$calls")"
grep -Fq $'sudo\trm\t-f\t--\t'"$failed_mv_stage" "$calls" ||
fail "the EXIT trap removes the mv-failed repair's exact stage" "$(cat "$calls")"
[[ ! -e $failed_mv_stage && ! -L $failed_mv_stage ]] ||
fail "the mv-failed stage is cleaned up" "left behind: $failed_mv_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "an mv failure leaves the live authfile inode alone"
pass "migration cleans its unique stage after a failed rename"
# The state a completed repair leaves, which is also where every machine that
# registers after this fix starts. A second account, and a second run for the
# same account, must find it done and escalate nothing. Fake only stat's view of
# the scratch authfile so this stays deterministic without borrowing a host
# file or requiring the suite itself to run as root.
write_authfile 644 || fail "the test can stage the settled-state fixture"
run_migration 0 0 root root 644
[[ ! -s $calls ]] ||
fail "an already root:root mode-644 authfile escalates nothing" "$(cat "$calls")"
pass "migration deterministically no-ops on its settled state"
# Owner, group and mode are independent parts of that state check. Hold two at
# their settled values while making each third value wrong, and require repair.
write_authfile 644 || fail "the test can stage the wrong-owner fixture"
run_migration 0 0 nobody root 644
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a non-root-owned authfile is repaired even when group and mode are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong owner"
write_authfile 644 || fail "the test can stage the wrong-group fixture"
run_migration 0 0 root nobody 644
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a non-root-group authfile is repaired even when owner and mode are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong group"
write_authfile 644 || fail "the test can stage the wrong-mode fixture"
run_migration 0 0 root root 600
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a mode-600 authfile is repaired even when owner and group are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong mode"
# Neither of these is ours to rewrite, and both must say so without escalating:
# chown follows a symlink and would take the target instead, while changing a
# directory's mode would alter an object the migration does not own.
rm -rf "$authfile"
ln -s "$test_tmp/elsewhere" "$authfile"
: >"$test_tmp/elsewhere"
run_migration
[[ ! -s $calls ]] || fail "a symlinked authfile escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] ||
fail "a symlinked authfile is raised where the update terminal cannot swallow it"
rm -f "$authfile"
ln -s "$test_tmp/missing" "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a dangling symlink escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] || fail "a dangling symlink is raised the same way"
pass "migration reports a symlinked authfile and repairs nothing"
rm -f "$authfile"
mkdir -p "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a directory at the authfile path escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] || fail "a non-regular authfile is raised the same way"
pass "migration reports a non-regular authfile and repairs nothing"
# omarchy-migrate writes this migration's completion marker on any zero exit, so
# a machine it cannot repair gets one shot at telling the user. The states above
# are exactly the ones where the authfile may already be under someone else's
# control, and a line in the update terminal scrolls past.
# Assert the argument shape rather than a substring. The glyph is a private-use
# codepoint that an edit can silently drop, and losing it shifts every argument
# left: -g swallows the headline, the body becomes the title, and the message
# goes out with no description. A substring match sees all of that as fine.
awk -F'\t' '
$1 == "notify" && NF == 7 && $2 == "-u" && $3 == "critical" && $4 == "-g" &&
$5 != "" && $6 == "FIDO2 authfile needs attention" && $7 != "" { found = 1 }
END { exit !found }
' "$notifications" ||
fail "the notification passes a glyph, headline and body as separate arguments" \
"$(cat -A "$notifications")"
pass "migration raises its unrepairable states as a desktop notification"
# The old setup created the FIDO2 directory with `sudo mkdir -p`, which took the
# caller's umask: registering under `umask 077` left it mode 0700 with the
# user-owned authfile still inside. Absence and "cannot look" are the same
# answer to an unprivileged test, so keying the early exit on the authfile
# recorded a repair on exactly the machines that still needed one.
rm -rf "$authfile"
write_authfile 644 || fail "the test can stage the untraversable-directory fixture"
before_inode=$(stat -c %i "$authfile")
chmod 000 "$authdir"
run_migration 0 0 caller caller 644
[[ $(stat -c %a "$authdir") == "755" ]] ||
fail "the migration reopens the directory the old umask closed" "got: $(stat -c %a "$authdir")"
grep -Fxq $'sudo\tchmod\t755\t'"$authdir" "$calls" ||
fail "the migration asks root to reopen the FIDO2 directory" "$(cat "$calls")"
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "an authfile hidden behind an untraversable directory is still repaired" "$(cat "$calls")"
[[ $(stat -c %i "$authfile") != "$before_inode" ]] ||
fail "the repair behind an untraversable directory still replaces the inode"
pass "migration repairs an authfile an unreadable directory hid from it"
# The narrow escalation above must not reach a machine that never registered a
# key, which is almost all of them.
rm -f "$authfile"
rm -rf "$authdir"
run_migration
[[ ! -s $calls ]] ||
fail "a machine with no FIDO2 directory still escalates nothing" "$(cat "$calls")"
mkdir -p "$authdir"
run_migration
[[ ! -s $calls ]] ||
fail "an empty readable FIDO2 directory escalates nothing" "$(cat "$calls")"
pass "migration still costs no password prompt on a machine that never set FIDO2 up"
# An aborted setup can leave the directory behind with nothing in it, and an
# administrator may keep one deliberately private. Looking costs a probe, but
# neither may have its mode widened, or its group and special bits discarded,
# for a repair that is not needed.
rm -f "$authfile"
chmod 000 "$authdir"
run_migration
[[ $(stat -c %a "$authdir") == "0" ]] ||
fail "an empty inaccessible FIDO2 directory keeps its mode" "got: $(stat -c %a "$authdir")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "an empty inaccessible FIDO2 directory is never reopened" "$(cat "$calls")"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then
fail "an empty inaccessible FIDO2 directory is never repaired" "$(cat "$calls")"
fi
chmod 755 "$authdir"
pass "migration looks behind an inaccessible FIDO2 directory without widening it"
# Notification delivery fails on a machine with no user bus or no notification
# server. That must not abort the migration under `bash -euo pipefail` and take
# every later migration with it.
rm -f "$authfile"
ln -s "$test_tmp/missing" "$authfile"
run_migration 0 0 "" "" "" normal 1
[[ -s $notifications ]] ||
fail "the failing notification was still attempted" "$(cat "$notifications")"
pass "migration survives a notification it could not deliver"
rm -f "$authfile"
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
remove="$ROOT/bin/omarchy-remove-security-fido2"
test_tmp=$(mktemp -d)
stub_bin="$test_tmp/bin"
calls="$test_tmp/calls.log"
authdir="$test_tmp/etc-fido2"
elsewhere="$test_tmp/elsewhere"
remove_copy="$test_tmp/remove.sh"
mkdir -p "$stub_bin"
cleanup() {
rm -rf "$test_tmp"
return 0
}
trap cleanup EXIT
# The same seam the setup and migration suites use: the removal deletes an
# absolute path no unprivileged suite can own, and an environment override in
# the shipped command would hand a privileged rm -rf an operand the caller
# chooses. Retarget a copy instead, and fail if the path is not named exactly
# once so this seam cannot quietly stop standing for the command it copies.
occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$remove") || occurrences=0
(( occurrences == 1 )) ||
fail "the removal names its FIDO2 directory exactly once" "found $occurrences occurrences"
pass "removal names its FIDO2 directory once, and the test drives a retargeted copy"
sed "s|^authdir=/etc/fido2$|authdir=$authdir|" "$remove" >"$remove_copy"
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
set -euo pipefail
reject() {
printf 'refusing unexpected sudo invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
exit 97
}
if [[ ${TEST_AUTHDIR:-} != /* || ${TEST_LOG:-} != /* ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
case "${1:-}" in
rm)
if (( $# != 3 )) || [[ $2 != "-rf" || $3 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
exec /usr/bin/rm -rf "$TEST_AUTHDIR"
;;
sed)
if (( $# != 4 )) || [[ $2 != "-i" ]]; then
reject "$@"
fi
;;
*)
reject "$@"
;;
esac
SH
cat >"$stub_bin/omarchy-pkg-drop" <<'SH'
#!/bin/bash
SH
chmod +x "$stub_bin/sudo" "$stub_bin/omarchy-pkg-drop"
invoke_remove() {
: >"$calls"
TEST_AUTHDIR="$authdir" TEST_LOG="$calls" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash "$remove_copy" </dev/null >/dev/null
}
# The ordinary case: a real directory holding a registration.
rm -rf "$authdir"
mkdir -p "$authdir"
printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authdir/fido2"
invoke_remove
grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" ||
fail "removal deletes the FIDO2 directory" "$(cat "$calls")"
[[ ! -e $authdir ]] || fail "the FIDO2 directory is gone"
pass "removal deletes a real FIDO2 directory"
# -d is false for a dangling link, so the guard it replaced left one sitting
# there for the next setup to install an authfile through.
rm -rf "$authdir"
ln -s "$test_tmp/missing" "$authdir"
invoke_remove
grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" ||
fail "removal deletes a dangling symlink at the FIDO2 directory" "$(cat "$calls")"
[[ ! -e $authdir && ! -L $authdir ]] ||
fail "the dangling symlink is gone"
pass "removal deletes a dangling symlink where -d would have skipped it"
# rm -rf on a symlink unlinks the link. Whatever it pointed at is not ours.
rm -rf "$authdir"
rm -rf "$elsewhere"
mkdir -p "$elsewhere"
printf 'keep me\n' >"$elsewhere/canary"
ln -s "$elsewhere" "$authdir"
invoke_remove
[[ ! -e $authdir && ! -L $authdir ]] ||
fail "the symlink at the FIDO2 directory is gone"
[[ -d $elsewhere && -f $elsewhere/canary ]] ||
fail "removal takes the symlink, never the directory it points at"
pass "removal takes a symlink itself and leaves its target intact"
# Nothing there at all: no escalation, so removing FIDO2 twice costs no prompt.
rm -rf "$authdir"
invoke_remove
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "removal escalates no rm when there is no FIDO2 directory" "$(cat "$calls")"
pass "removal escalates nothing when there is no FIDO2 directory"
+480
View File
@@ -0,0 +1,480 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
setup="$ROOT/bin/omarchy-setup-security-fido2"
test_tmp=$(mktemp -d)
stub_bin="$test_tmp/bin"
stages="$test_tmp/stages.log"
calls="$test_tmp/calls.log"
pamu_targets="$test_tmp/pamu-targets.log"
bare_mktemp="$test_tmp/bare-mktemp.log"
credential="tester:credential-handle,public-key,es256,+presence"
authdir="$test_tmp/etc-fido2"
authfile="$authdir/fido2"
setup_copy="$test_tmp/setup.sh"
mkdir -p "$stub_bin"
cleanup() {
rm -rf "$test_tmp"
return 0
}
trap cleanup EXIT
# The setup installs to an absolute path no unprivileged suite can write, and an
# environment override in the shipped command would hand its privileged install
# and mv an operand the caller chooses. Retarget a scratch copy instead, and
# fail if either path is not named exactly once, so this seam cannot quietly
# stop standing for the command it copies. Keying the suite on the host's own
# /etc/fido2 instead is what let the staging checks below pass without asserting
# anything on the machines that actually use FIDO2.
occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$setup") || occurrences=0
(( occurrences == 1 )) ||
fail "the setup names its FIDO2 directory exactly once" "found $occurrences occurrences"
occurrences=$(grep -Fxc 'authfile=/etc/fido2/fido2' "$setup") || occurrences=0
(( occurrences == 1 )) ||
fail "the setup names its authfile exactly once" "found $occurrences occurrences"
pass "setup names its FIDO2 paths once each, and the test drives a retargeted copy"
sed -e "s|^authdir=/etc/fido2$|authdir=$authdir|" \
-e "s|^authfile=/etc/fido2/fido2$|authfile=$authfile|" "$setup" >"$setup_copy"
# The setup must not create a caller-owned named file for pamu2fcfg. A bare
# mktemp is therefore a test failure; only the sudo stub below may invoke the
# real command, and it does so with an absolute scratch template.
cat >"$stub_bin/mktemp" <<'SH'
#!/bin/bash
printf 'mktemp' >>"$TEST_BARE_MKTEMP"
printf '\t%s' "$@" >>"$TEST_BARE_MKTEMP"
printf '\n' >>"$TEST_BARE_MKTEMP"
exit 98
SH
# Execute only the setup's expected bare-sudo protocol. The production mktemp
# template is logged exactly, but its root-created sibling is represented by a
# unique regular file inside the scratch directory. The whitelisted operations
# map every write into that directory; arbitrary direct commands are outside
# this harness.
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
set -euo pipefail
reject() {
printf 'refusing unexpected sudo invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
exit 97
}
if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ! ${TEST_FAIL_CHMOD:-} =~ ^[01]$ || ! ${TEST_FAIL_MV:-} =~ ^[01]$ ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
safe_stage_path() {
local candidate=$1
local prefix="$TEST_AUTHFILE.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
recorded_stage() {
local candidate=$1
safe_stage_path "$candidate" || return 1
[[ -f $candidate && ! -L $candidate ]] || return 1
/usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES"
}
case "${1:-}" in
install)
if (( $# != 9 )) || [[ $2 != "-d" || $3 != "-m" || $4 != "755" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
if (( EUID == 0 )); then
exec /usr/bin/install -d -m 755 -o root -g root "$TEST_AUTHDIR"
else
exec /usr/bin/install -d -m 755 "$TEST_AUTHDIR"
fi
;;
mktemp)
if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then
reject "$@"
fi
case ${TEST_MKTEMP_MODE:-normal} in
normal)
stage=$(/usr/bin/mktemp -- "$2")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
reject "$@"
fi
printf '%s\n' "$stage" >>"$TEST_STAGES"
printf '%s\n' "$stage"
;;
malformed)
stage="$TEST_AUTHFILE.new.A/BCDE"
/usr/bin/mkdir -- "${stage%/*}"
: >"$stage"
printf '%s\n' "$stage"
;;
nonregular)
stage="$TEST_AUTHFILE.new.BAD123"
/usr/bin/mkdir -- "$stage"
printf '%s\n' "$stage"
;;
*)
reject "$@"
;;
esac
;;
tee)
if (( $# == 2 )) && recorded_stage "$2"; then
exec /usr/bin/tee "$2"
elif (( $# == 2 )) && [[ $2 == "/etc/pam.d/polkit-1" ]]; then
/usr/bin/cat >/dev/null
else
reject "$@"
fi
;;
test)
if (( $# != 3 )) || [[ $2 != "-s" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
/usr/bin/test -s "$3"
;;
chmod)
if (( $# != 3 )) || [[ $2 != "644" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ $TEST_FAIL_CHMOD == "1" ]]; then
exit 73
fi
exec /usr/bin/chmod 644 "$3"
;;
mv)
if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ $TEST_FAIL_MV == "1" ]]; then
exit 74
fi
exec /usr/bin/mv -Tf -- "$3" "$TEST_AUTHFILE"
;;
rm)
if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]] || ! recorded_stage "$4"; then
reject "$@"
fi
exec /usr/bin/rm -f -- "$4"
;;
sed)
if (( $# != 4 )) || [[ $2 != "-i" ]]; then
reject "$@"
fi
if [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/sudo" ]]; then
exit 0
elif [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/polkit-1" ]]; then
exit 0
else
reject "$@"
fi
;;
echo)
if (( $# != 2 )) || [[ $2 != "FIDO2 authentication test successful" ]]; then
reject "$@"
fi
;;
*)
reject "$@"
;;
esac
SH
cat >"$stub_bin/fido2-token" <<'SH'
#!/bin/bash
echo '/dev/hidraw0: vendor=0x1050, product=0x0407 (Yubico YubiKey)'
SH
cat >"$stub_bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
SH
# Record what pamu2fcfg's stdout actually targets. The fixed implementation
# gives it a pipe to privileged tee; refusing a regular-file descriptor keeps a
# regression from writing credential bytes into a caller-owned named file.
cat >"$stub_bin/pamu2fcfg" <<'SH'
#!/bin/bash
set -euo pipefail
target=$(readlink /proc/self/fd/1)
printf '%s\n' "$target" >>"$TEST_PAMU_TARGETS"
[[ $target == pipe:* ]] || exit 96
case "$TEST_PAMU_MODE" in
success)
printf '%s\n' "$TEST_CREDENTIAL"
;;
fail)
printf '%s\n' "$TEST_CREDENTIAL"
exit 23
;;
empty)
exit 0
;;
*)
exit 95
;;
esac
SH
chmod +x "$stub_bin/mktemp" "$stub_bin/sudo" "$stub_bin/fido2-token" \
"$stub_bin/omarchy-pkg-add" "$stub_bin/pamu2fcfg"
reset_run() {
: >"$calls"
: >"$stages"
: >"$pamu_targets"
: >"$bare_mktemp"
rm -rf "$authdir"
}
invoke_setup() {
local pamu_mode="${1:-success}"
local fail_chmod="${2:-0}"
local fail_mv="${3:-0}"
local mktemp_mode="${4:-normal}"
TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" TEST_BARE_MKTEMP="$bare_mktemp" \
TEST_CREDENTIAL="$credential" TEST_FAIL_CHMOD="$fail_chmod" TEST_FAIL_MV="$fail_mv" \
TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_PAMU_MODE="$pamu_mode" \
TEST_PAMU_TARGETS="$pamu_targets" TEST_STAGES="$stages" TEST_TMP="$test_tmp" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash "$setup_copy" </dev/null >/dev/null
}
run_setup() {
invoke_setup "${1:-success}" ||
fail "FIDO2 setup registers a device that answers fido2-token" "sudo calls:
$(cat "$calls")"
}
safe_fixture_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
single_stage() {
local count
count=$(wc -l <"$stages")
(( count == 1 )) || fail "setup creates exactly one privileged stage" "got $count stages"
head -n 1 "$stages"
}
assert_pipe_target() {
local count target
count=$(wc -l <"$pamu_targets")
(( count == 1 )) || fail "setup invokes pamu2fcfg exactly once" "got $count invocations"
target=$(head -n 1 "$pamu_targets")
[[ $target == pipe:* ]] ||
fail "pamu2fcfg writes only to a pipe, never a caller-owned named file" "got: $target"
}
assert_failed_stage_cleanup() {
local stage_path
stage_path=$(single_stage)
safe_fixture_stage_path "$stage_path" ||
fail "the failed setup stage is a unique scratch sibling" "got: $stage_path"
grep -Fxq $'sudo\trm\t-f\t--\t'"$stage_path" "$calls" ||
fail "failed setup removes its exact privileged stage" "$(cat "$calls")"
[[ ! -e $stage_path && ! -L $stage_path ]] ||
fail "the failed setup stage is gone" "left behind: $stage_path"
[[ ! -e $authfile ]] || fail "failed setup never publishes a credential"
}
# Each branch below is a fixture rather than whatever the host happens to have
# at /etc/fido2, so all of them run on every machine and the staging assertions
# that follow are reached even on one that already uses FIDO2.
reset_run
mkdir -p "$authdir"
printf '%s\n' "$credential" >"$authfile"
run_setup
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing when a registration already exists"
! grep -Fq $'sudo\tmktemp\t' "$calls" ||
fail "FIDO2 setup creates no stage over an existing registration" "$(cat "$calls")"
pass "FIDO2 setup leaves an existing registration alone"
reset_run
mkdir -p "$authdir"
ln -s /dev/null "$authfile"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a symlinked authfile"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing against a symlinked authfile"
[[ -L $authfile ]] || fail "FIDO2 setup leaves the symlinked authfile in place"
pass "FIDO2 setup refuses a symlinked authfile"
reset_run
mkdir -p "$authfile"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a directory where the authfile belongs"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing against a directory authfile"
pass "FIDO2 setup refuses a non-regular authfile"
# install -d follows a symlink at the directory and applies its mode and
# ownership to whatever it points at, so the credential would be staged and
# published inside the target and that directory reopened to root:root 755.
reset_run
mkdir -p "$test_tmp/elsewhere"
chmod 700 "$test_tmp/elsewhere"
ln -s "$test_tmp/elsewhere" "$authdir"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a symlinked FIDO2 directory"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing through a symlinked FIDO2 directory"
! grep -Fq $'sudo\tinstall\t' "$calls" ||
fail "FIDO2 setup never runs install -d through a symlink" "$(cat "$calls")"
[[ $(stat -c %a "$test_tmp/elsewhere") == "700" ]] ||
fail "FIDO2 setup leaves the symlink target's mode alone" "got: $(stat -c %a "$test_tmp/elsewhere")"
[[ ! -e $test_tmp/elsewhere/fido2 ]] ||
fail "FIDO2 setup publishes nothing inside the symlink target"
pass "FIDO2 setup refuses a symlinked FIDO2 directory and leaves its target alone"
reset_run
run_setup
stage_path=$(single_stage)
safe_fixture_stage_path "$stage_path" ||
fail "FIDO2 setup uses a unique sibling stage" "got: $stage_path"
assert_pipe_target
[[ ! -s $bare_mktemp ]] ||
fail "FIDO2 setup never creates a caller-owned temporary file" "$(cat "$bare_mktemp")"
grep -Fxq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" ||
fail "FIDO2 setup asks root to create a unique sibling stage" "$(cat "$calls")"
grep -Fxq $'sudo\ttee\t'"$stage_path" "$calls" ||
fail "pamu2fcfg is piped into the exact privileged stage" "$(cat "$calls")"
grep -Fxq $'sudo\tchmod\t644\t'"$stage_path" "$calls" ||
fail "FIDO2 setup makes the completed authfile PAM-readable" "$(cat "$calls")"
grep -Fxq $'sudo\tmv\t-Tf\t'"$stage_path"$'\t'"$authfile" "$calls" ||
fail "FIDO2 setup atomically publishes the exact privileged stage" "$(cat "$calls")"
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "successful setup leaves its cleanup trap inert" "$(cat "$calls")"
[[ ! -e $stage_path && ! -L $stage_path ]] ||
fail "the privileged stage path is gone after publication" "left behind: $stage_path"
[[ -f $authfile && $(<"$authfile") == "$credential" ]] ||
fail "the published authfile contains the generated credential"
[[ $(stat -c %a "$authfile") == "644" ]] ||
fail "the published authfile is mode 644" "got: $(stat -c %a "$authfile")"
pass "FIDO2 setup pipes the credential into a unique root-created stage and publishes it atomically"
# A chmod failure happens after a complete credential has been written but
# before publication. It must abort the setup and leave the EXIT trap armed.
reset_run
if invoke_setup success 1 >/dev/null 2>&1; then
fail "a failed chmod propagates out of FIDO2 setup"
fi
failed_stage=$(single_stage)
assert_pipe_target
grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" ||
fail "the injected chmod failure targets the exact privileged stage" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a stage whose chmod failed is never published" "$(cat "$calls")"
assert_failed_stage_cleanup
pass "FIDO2 setup propagates chmod failure and cleans its privileged stage"
# A failed atomic rename has the same cleanup obligation. The completed stage
# must not survive beside the live authfile when publication fails.
reset_run
if invoke_setup success 0 1 >/dev/null 2>&1; then
fail "a failed mv propagates out of FIDO2 setup"
fi
failed_stage=$(single_stage)
assert_pipe_target
grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" ||
fail "the mv-failure fixture reaches a completed mode-644 stage" "$(cat "$calls")"
grep -Fxq $'sudo\tmv\t-Tf\t'"$failed_stage"$'\t'"$authfile" "$calls" ||
fail "the injected mv failure targets the exact privileged stage" "$(cat "$calls")"
assert_failed_stage_cleanup
pass "FIDO2 setup propagates mv failure and cleans its privileged stage"
# Emit a valid credential and then fail. Without pipefail, tee's success masks
# pamu2fcfg's status and the nonempty file would be published.
reset_run
if invoke_setup fail >/dev/null 2>&1; then
fail "a failing pamu2fcfg pipeline fails setup"
fi
assert_pipe_target
assert_failed_stage_cleanup
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a failed pamu2fcfg result is never prepared for publication" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a failed pamu2fcfg result is never published" "$(cat "$calls")"
pass "FIDO2 setup propagates pamu2fcfg failure and cleans its privileged stage"
# A successful pipeline can still produce no credential. Reject that before
# chmod or rename, and clean the exact stage just as on command failure.
reset_run
if invoke_setup empty >/dev/null 2>&1; then
fail "an empty pamu2fcfg result fails setup"
fi
assert_pipe_target
assert_failed_stage_cleanup
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "an empty pamu2fcfg result is never prepared for publication" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "an empty pamu2fcfg result is never published" "$(cat "$calls")"
pass "FIDO2 setup rejects an empty credential and cleans its privileged stage"
# mktemp's output is an operand for a privileged tee, chmod, mv and rm. Take
# only the name this script asked for: a stage path outside that shape must stop
# the setup before any of them runs, exactly as the migration does.
reset_run
invoke_setup success 0 0 malformed >/dev/null 2>&1 &&
fail "a malformed mktemp result fails setup"
! grep -Fq $'sudo\ttee\t' "$calls" ||
fail "no credential is written to a malformed stage path" "$(cat "$calls")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a malformed stage path never reaches a privileged chmod" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a malformed stage path is never published" "$(cat "$calls")"
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "a malformed stage path never reaches a privileged rm" "$(cat "$calls")"
[[ ! -e $authfile ]] || fail "a malformed stage publishes no authfile"
pass "FIDO2 setup rejects malformed mktemp output before any privileged write"
reset_run
invoke_setup success 0 0 nonregular >/dev/null 2>&1 &&
fail "a nonregular mktemp result fails setup"
! grep -Fq $'sudo\ttee\t' "$calls" ||
fail "no credential is written into a nonregular stage" "$(cat "$calls")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a nonregular stage never reaches a privileged chmod" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a nonregular stage is never published" "$(cat "$calls")"
[[ ! -e $authfile ]] || fail "a nonregular stage publishes no authfile"
pass "FIDO2 setup rejects nonregular mktemp output before any privileged write"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
#
# omarchy-sudo-docker is the single answer to "does Docker need sudo", and it
# answers two different questions on purpose. The default asks whether this
# session can reach the socket, which is what decides if a command must elevate.
# --configured asks whether the account is set up for sudoless Docker, which is
# what the menu needs so it offers the toggle that can change state. Between
# enabling sudoless Docker and the reboot that grants the group, those disagree.
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
command="$ROOT/bin/omarchy-sudo-docker"
# Stub id so the configured groups are controllable.
mkdir -p "$TMPDIR/bin"
cat >"$TMPDIR/bin/id" <<'STUB'
#!/bin/bash
printf '%s\n' "${STUB_GROUPS:-wheel input}"
STUB
chmod +x "$TMPDIR/bin/id"
# A writable stand-in means the socket is reachable; an unwritable one means it
# is not. Test the file mode rather than a live daemon.
reachable_socket="$TMPDIR/reachable.sock"
blocked_socket="$TMPDIR/blocked.sock"
touch "$reachable_socket" "$blocked_socket"
chmod 600 "$reachable_socket"
chmod 400 "$blocked_socket"
run() { # SOCKET GROUPS [--configured]
env PATH="$TMPDIR/bin:$PATH" OMARCHY_DOCKER_SOCKET="$1" STUB_GROUPS="$2" USER=tester \
bash "$command" ${3:+"$3"}
}
# Default mode follows the socket, not the group list.
run "$blocked_socket" "wheel input" || fail "an unreachable socket means Docker needs sudo"
run "$reachable_socket" "wheel input" && fail "a reachable socket means Docker does not need sudo"
pass "default mode answers from the socket this session can reach"
# A socket that isn't there at all still needs elevation (starting it is root work).
run "$TMPDIR/absent.sock" "wheel input docker" || fail "a missing socket means Docker needs sudo"
pass "a missing socket counts as needing sudo"
# --configured follows the account's groups, not the socket.
run "$blocked_socket" "wheel input docker" --configured && fail "a configured docker group means no sudo is needed"
run "$reachable_socket" "wheel input" --configured || fail "no docker group means sudo is needed"
pass "--configured answers from the account's groups"
# The window this split exists for: sudoless Docker has just been enabled, so the
# account carries the group while the running session still cannot use it. The
# menu must offer Remove (--configured says no sudo) while lazydocker and the
# Windows VM must still prompt (default says sudo).
run "$blocked_socket" "wheel input docker" || fail "the session still needs sudo before the reboot"
run "$blocked_socket" "wheel input docker" --configured && fail "the account is already configured for sudoless Docker"
pass "the two modes disagree between enabling sudoless Docker and the reboot"
# An unknown argument is a usage error, not a silent answer either way.
run "$reachable_socket" "wheel input" --bogus 2>/dev/null && fail "an unknown flag exits non-zero"
status=0
run "$reachable_socket" "wheel input" --bogus >/dev/null 2>&1 || status=$?
(( status == 2 )) || fail "an unknown flag exits 2, not the boolean 1"
pass "an unknown flag is a usage error"
@@ -0,0 +1,93 @@
#!/bin/bash
#
# Toggling sudoless Docker changes the docker group, which only takes effect on a
# reboot. The setup/remove commands must flag the reboot and offer to do it now
# (gum confirm), but defer it when OMARCHY_DEFER_REBOOT is set (the migration
# reuses them inside `omarchy update`, where omarchy-update-restart handles it).
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
home="$test_dir/home"
stub_bin="$test_dir/bin"
mkdir -p "$home" "$stub_bin"
cat >"$stub_bin/id" <<'STUB'
#!/bin/bash
printf '%s\n' "${STUB_GROUPS:-wheel input}"
STUB
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
cat >"$stub_bin/usermod" <<'STUB'
#!/bin/bash
echo "$@" >>"${USERMOD_CALLS:?}"
STUB
cat >"$stub_bin/gpasswd" <<'STUB'
#!/bin/bash
echo "$@" >>"${GPASSWD_CALLS:?}"
STUB
cat >"$stub_bin/gum" <<'STUB'
#!/bin/bash
touch "${GUM_CALLED:?}"
exit "${GUM_ANSWER:-0}"
STUB
cat >"$stub_bin/omarchy-system-reboot" <<'STUB'
#!/bin/bash
touch "${REBOOT_CALLED:?}"
STUB
chmod +x "$stub_bin"/*
reboot_flag="$home/.local/state/omarchy/reboot-required"
gum_called="$test_dir/gum-called"
reboot_called="$test_dir/reboot-called"
gpasswd_calls="$test_dir/gpasswd-calls"
usermod_calls="$test_dir/usermod-calls"
run() { # command STUB_GROUPS GUM_ANSWER DEFER(0|1)
rm -f "$reboot_flag" "$gum_called" "$reboot_called" "$gpasswd_calls" "$usermod_calls"
local defer_env=()
[[ ${4:-0} == 1 ]] && defer_env=(OMARCHY_DEFER_REBOOT=1)
env HOME="$home" USER="tester" STUB_GROUPS="$2" GUM_ANSWER="$3" \
GUM_CALLED="$gum_called" REBOOT_CALLED="$reboot_called" \
GPASSWD_CALLS="$gpasswd_calls" USERMOD_CALLS="$usermod_calls" \
PATH="$stub_bin:$ROOT/bin:$PATH" "${defer_env[@]}" \
bash "$ROOT/bin/$1" >/dev/null 2>&1
}
# Remove, interactive, reboot confirmed -> group removed, flag set, reboot fired.
run omarchy-remove-security-sudoless-docker "wheel input docker" 0 0
grep -q -- "-d tester docker" "$gpasswd_calls" || fail "remove drops the user from the docker group"
[[ -f $reboot_flag ]] || fail "remove flags a reboot"
[[ -f $reboot_called ]] || fail "remove reboots when the prompt is confirmed"
pass "remove drops the group, flags a reboot, and reboots on confirm"
# Remove, interactive, reboot declined -> flag set, but no reboot.
run omarchy-remove-security-sudoless-docker "wheel input docker" 1 0
[[ -f $reboot_flag ]] || fail "remove still flags a reboot when the prompt is declined"
[[ ! -f $reboot_called ]] || fail "remove does not reboot when the prompt is declined"
pass "remove leaves the reboot to the user when declined"
# Remove, deferred (migration/update) -> flag set, prompt never shown.
run omarchy-remove-security-sudoless-docker "wheel input docker" 0 1
[[ -f $reboot_flag ]] || fail "deferred remove still flags a reboot"
[[ ! -f $gum_called ]] || fail "deferred remove must not prompt to reboot"
[[ ! -f $reboot_called ]] || fail "deferred remove must not reboot"
pass "deferred remove flags the reboot without prompting"
# Remove, already out of the group -> no-op, nothing flagged.
run omarchy-remove-security-sudoless-docker "wheel input" 0 0
[[ ! -f $gpasswd_calls ]] || fail "remove is a no-op when the user is not in the docker group"
[[ ! -f $reboot_flag ]] || fail "remove does not flag a reboot when nothing changed"
pass "remove is a no-op when sudoless Docker is already off"
# Setup, enable confirmed then reboot confirmed -> group added, flag set, reboot.
run omarchy-setup-security-sudoless-docker "wheel input" 0 0
grep -q -- "-aG docker tester" "$usermod_calls" || fail "setup adds the user to the docker group"
[[ -f $reboot_flag ]] || fail "setup flags a reboot"
[[ -f $reboot_called ]] || fail "setup reboots when the prompt is confirmed"
pass "setup adds the group, flags a reboot, and reboots on confirm"
+24 -1
View File
@@ -40,7 +40,7 @@ install_theme() {
: >"$git_calls"
: >"$theme_calls"
HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \
HOME="$test_tmp/home" PATH="${2-$mock_bin:$ROOT/bin:$PATH}" \
OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \
bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $?
}
@@ -58,6 +58,29 @@ done
pass "a URL that names a git option or a transport helper never reaches git"
# git resolves git-remote-<scheme> for any scheme it does not implement itself,
# so the `://` spelling of a helper has to be refused as well as the `::` one.
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x"; do
if install_theme "$url"; then
fail "omarchy-theme-install refuses the URL '$url'"
fi
[[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")"
done
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
# than wave it through to git.
if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then
fail "omarchy-theme-install refuses a URL it cannot check"
fi
[[ ! -s $git_calls ]] ||
fail "omarchy-theme-install refuses an unchecked URL before running git" "$(cat "$git_calls")"
pass "a missing url checker refuses the URL instead of cloning it"
# A URL whose derived name would escape the themes directory.
for url in "https://example.com/..git" "https://example.com/.git"; do
if install_theme "$url"; then
+4 -1
View File
@@ -7,9 +7,12 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
timezone_menu="$ROOT/bin/omarchy-menu-timezone"
sudoers_file="$ROOT/etc/sudoers.d/omarchy-tzupdate"
grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *' "$sudoers_file" >/dev/null ||
grep -F '%wheel ALL=(root) NOPASSWD: /usr/bin/timedatectl ^set-timezone [A-Za-z0-9_+][A-Za-z0-9_+.-]*(/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$' "$sudoers_file" >/dev/null ||
fail "timezone sudoers rule allows passwordless timedatectl timezone changes"
! grep -F 'set-timezone *' "$sudoers_file" >/dev/null ||
fail "timezone sudoers rule uses a bare wildcard that admits extra arguments like -H and -M"
! grep -F 'tzupdate' "$sudoers_file" >/dev/null ||
fail "timezone sudoers rule does not grant passwordless tzupdate"
+286
View File
@@ -0,0 +1,286 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command lua
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
stub_dir="$tmpdir/bin"
home_dir="$tmpdir/home"
xdg_decoy="$tmpdir/xdg-decoy"
log_file="$tmpdir/hyprctl.log"
marker="$tmpdir/marker"
mkdir -p "$stub_dir" "$home_dir" "$xdg_decoy"
state_dir="$home_dir/.local/state/omarchy/toggles/hypr"
name_file="$state_dir/touchpad-disabled-name"
state_lua="$state_dir/touchpad-disabled.lua"
cat >"$stub_dir/hyprctl" <<'EOF'
#!/bin/bash
case $1 in
eval) printf '%s\n' "$2" >>"$HYPRCTL_LOG" ;;
reload) printf 'reload\n' >>"$HYPRCTL_LOG" ;;
esac
EOF
chmod +x "$stub_dir/hyprctl"
cat >"$stub_dir/omarchy-osd" <<'EOF'
#!/bin/bash
:
EOF
chmod +x "$stub_dir/omarchy-osd"
stub_device() {
local kind=$1
local name=$2
cat >"$stub_dir/omarchy-hw-$kind" <<EOF
#!/bin/bash
printf '%s\n' '$name'
EOF
chmod +x "$stub_dir/omarchy-hw-$kind"
}
# XDG_STATE_HOME deliberately points away from HOME everywhere below: the
# input-device state is hardcoded to ~/.local/state like the sibling toggle
# tools and the pre-migration script, so nothing may read or write the XDG
# directory.
run_toggle() {
HOME="$home_dir" \
XDG_STATE_HOME="$xdg_decoy" \
HYPRCTL_LOG="$log_file" \
PATH="$stub_dir:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-toggle-input-device" "$@"
}
assert_decoy_untouched() {
[[ -z $(find "$xdg_decoy" -mindepth 1 -print -quit 2>/dev/null) ]] ||
fail "input-device state must ignore XDG_STATE_HOME"
}
: >"$log_file"
stub_device touchpad 'elan-touchpad'
run_toggle touchpad off
[[ $(<"$name_file") == "elan-touchpad" ]] || fail "touchpad disable stores the device name as data"
[[ ! -e $state_lua ]] || fail "touchpad disable writes no generated Lua"
grep -Fx 'hl.device({ name = "elan-touchpad", enabled = false })' "$log_file" >/dev/null ||
fail "touchpad disable applies a quoted Lua device name"
assert_decoy_untouched
pass "touchpad disable persists the device name as data"
: >"$log_file"
run_toggle touchpad on
[[ ! -e $name_file ]] || fail "touchpad enable clears the persisted device name"
grep -Fx 'hl.device({ name = "elan-touchpad", enabled = true })' "$log_file" >/dev/null ||
fail "touchpad enable applies a quoted Lua device name"
pass "touchpad enable clears persisted disable state"
run_toggle touchpad
[[ -f $name_file ]] || fail "default toggle action disables an enabled touchpad"
run_toggle touchpad
[[ ! -e $name_file ]] || fail "default toggle action enables a disabled touchpad"
pass "default toggle action flips the persisted state"
: >"$log_file"
stub_device touchscreen 'wacom-hid-52eb-finger'
ts_name_file="$state_dir/touchscreen-disabled-name"
run_toggle touchscreen off
[[ $(<"$ts_name_file") == "wacom-hid-52eb-finger" ]] ||
fail "touchscreen disable stores the device name as data"
grep -Fx 'hl.device({ name = "wacom-hid-52eb-finger", enabled = false })' "$log_file" >/dev/null ||
fail "touchscreen disable applies a quoted Lua device name"
run_toggle touchscreen on
[[ ! -e $ts_name_file ]] || fail "touchscreen enable clears the persisted device name"
pass "touchscreen routes through the same persisted-name state"
: >"$log_file"
rm -f "$marker"
stub_device touchpad 'touchpad"; touch '"$marker"'; echo "'
run_toggle touchpad off
[[ ! -e $marker ]] || fail "touchpad disable does not execute metacharacters in the device name"
[[ $(<"$name_file") == 'touchpad"; touch '"$marker"'; echo "' ]] ||
fail "a hostile device name is stored only as data"
[[ ! -e $state_lua ]] || fail "a hostile device name is not written as Lua"
grep -F 'hl.device({ name = "touchpad\"' "$log_file" >/dev/null ||
fail "hyprctl eval Lua-quotes quotes in the device name" "$(<"$log_file")"
pass "touchpad disable treats USB device names as data"
HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" MARKER="$marker" lua - <<'LUA'
local seen = {}
hl = {
device = function(opts)
table.insert(seen, opts)
end,
}
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.toggles")
assert(#seen == 1, "reload disables one device")
assert(seen[1].enabled == false)
assert(seen[1].name == 'touchpad"; touch ' .. os.getenv("MARKER") .. '; echo "', "device name is passed as a string")
LUA
pass "Hyprland reload loads the device name as a string"
# Public PoC device name: USB iProduct is interpolated into hl.device({ name = "..." }).
# os.execute is stubbed so the string is only checked as data.
poc_name='trackpad"})os.execute("~/calc&")--'
stub_device touchpad "$poc_name"
run_toggle touchpad on
: >"$log_file"
run_toggle touchpad off
[[ $(<"$name_file") == "$poc_name" ]] || fail "PoC device name is stored only as data"
[[ ! -e $state_lua ]] || fail "PoC device name is not written as Lua"
HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" \
POC_NAME="$poc_name" EVAL_SNIPPET="$(<"$log_file")" lua - <<'LUA'
local poc = os.getenv("POC_NAME")
local snippet = os.getenv("EVAL_SNIPPET")
local seen, executed = {}, false
hl = {
device = function(opts)
table.insert(seen, opts)
end,
}
os.execute = function()
executed = true
end
assert(load(snippet, "eval", "t"))()
assert(executed == false, "quoted hyprctl eval must not run os.execute")
assert(#seen == 1)
assert(seen[1].name == poc)
assert(seen[1].enabled == false)
seen, executed = {}, false
assert(load('hl.device({ name = "' .. poc .. '", enabled = false })', "unquoted", "t"))()
assert(executed == true, "unquoted interpolation is the Lua injection")
seen, executed = {}, false
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.toggles")
assert(executed == false, "reload must not run os.execute")
assert(#seen == 1)
assert(seen[1].name == poc)
LUA
pass "PoC device name cannot execute via eval or reload"
cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF'
#!/bin/bash
printf 'evil\nname\n'
EOF
chmod +x "$stub_dir/omarchy-hw-touchpad"
rm -f "$name_file"
set +e
run_toggle touchpad off >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "disable rejects a device name with a newline"
[[ ! -e $name_file ]] || fail "a rejected device name is not persisted"
pass "disable rejects control characters in a device name"
printf 'elan-touchpad\n' >"$name_file"
set +e
run_toggle touchpad on >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "enable still reports an invalid device name"
[[ ! -e $name_file ]] || fail "enable clears persisted state even with an invalid device name"
pass "a bad device name cannot wedge the persisted disable"
cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF'
#!/bin/bash
:
EOF
chmod +x "$stub_dir/omarchy-hw-touchpad"
set +e
run_toggle touchpad off >/dev/null 2>&1
status=$?
set -e
(( status != 0 )) || fail "disable errors when no device is found"
[[ ! -e $name_file ]] || fail "no state is written when no device is found"
pass "disable errors when no device is found"
# The migration runs with the same XDG decoy: legacy files were written to
# ~/.local/state, so that is where it must look no matter what XDG says.
run_migration() {
HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" HYPRCTL_LOG="$log_file" \
PATH="$stub_dir:$ROOT/bin:$PATH" \
bash -euo pipefail "$ROOT/migrations/1787618700.sh" >/dev/null
}
mkdir -p "$state_dir"
rm -f "$state_dir"/*-disabled-name
printf 'hl.device({ name = "synps/2-synaptics-touchpad", enabled = false })\n' >"$state_lua"
printf 'hl.device({ name = "hostile\\"")", enabled = false })\n' >"$state_dir/touchscreen-disabled.lua"
: >"$log_file"
run_migration
[[ $(<"$name_file") == "synps/2-synaptics-touchpad" ]] ||
fail "migration recovers a device name containing a slash"
[[ ! -e $state_lua ]] || fail "migration deletes the generated touchpad Lua"
[[ ! -e $state_dir/touchscreen-disabled-name ]] ||
fail "migration does not copy a hostile name out of generated Lua"
[[ ! -e $state_dir/touchscreen-disabled.lua ]] ||
fail "migration deletes hostile generated Lua even when no name is recovered"
assert_decoy_untouched
# The package hook reloads Hyprland before migrations run, so the disable was
# already dropped for this session; the migration has to put it back.
grep -Fx 'reload' "$log_file" >/dev/null ||
fail "migration reloads so the recovered disable applies to this session"
pass "migration recovers plain names and discards hostile generated Lua"
printf 'kept-name\n' >"$name_file"
printf 'hl.device({ name = "other-touchpad", enabled = false })\n' >"$state_lua"
run_migration
[[ $(<"$name_file") == "kept-name" ]] || fail "migration keeps an existing device-name file"
[[ ! -e $state_lua ]] || fail "migration still deletes the generated Lua"
pass "migration is idempotent over an existing device-name file"
rm -f "$name_file"
printf 'garbage\n' >"$state_lua"
chmod 000 "$state_lua"
run_migration
[[ ! -e $state_lua ]] || fail "migration removes an unreadable generated Lua"
[[ ! -e $name_file ]] || fail "no name is recovered from an unreadable file"
pass "an unreadable state file does not wedge the migration"
: >"$log_file"
run_migration
[[ ! -s $log_file ]] || fail "migration with nothing to migrate does not reload"
pass "migration no-ops with nothing left to migrate"
# A compromised install carries a leftover generated touchpad-disabled.lua whose
# device name broke out into os.execute. Until the migration deletes it, a reload
# must not source it. toggles.lua excludes those two names from require_all, so the
# payload never runs, while a current name-file disable still applies.
reload_home="$tmpdir/reload-home"
reload_state="$reload_home/.local/state/omarchy/toggles/hypr"
mkdir -p "$reload_state"
reload_marker="$tmpdir/reload-executed"
rm -f "$reload_marker"
printf 'hl.device({ name = "trackpad"})os.execute("touch %s")--", enabled = false })\n' "$reload_marker" \
>"$reload_state/touchpad-disabled.lua"
printf 'elan-touchpad\n' >"$reload_state/touchpad-disabled-name"
HOME="$reload_home" XDG_STATE_HOME="$reload_home/.local/state" OMARCHY_PATH="$ROOT" lua - <<'LUA'
local disabled = {}
hl = { device = function(opts) table.insert(disabled, opts) end }
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.toggles")
assert(#disabled == 1, "only the current name-file disable is applied")
assert(disabled[1].name == "elan-touchpad", "disable uses the stored device name")
assert(disabled[1].enabled == false)
LUA
[[ ! -e $reload_marker ]] || fail "a leftover legacy generated toggle Lua must not execute on reload"
pass "reload excludes leftover legacy toggle Lua while applying the data disable"
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
detector="$ROOT/bin/omarchy-hw-dell-xps13-sidecar-amps"
leaf="$ROOT/install/hardware/dell-xps13-sidecar-amps.sh"
all="$ROOT/install/hardware/all.sh"
migration=$(grep -l "dell-xps13-sidecar-amps" "$ROOT"/migrations/*.sh | head -1)
grep -q 'run_logged .*hardware/dell-xps13-sidecar-amps.sh' "$all" ||
fail "the sidecar amplifier workaround runs during hardware setup"
pass "the sidecar amplifier workaround runs during hardware setup"
# The apply step rebuilds the boot image, so it has to see the Panther Lake
# kernel that ptl-kernel.sh swaps in rather than the stock one it replaces.
ptl_line=$(grep -n 'hardware/intel/ptl-kernel.sh' "$all" | cut -d: -f1)
amps_line=$(grep -n 'hardware/dell-xps13-sidecar-amps.sh' "$all" | cut -d: -f1)
((ptl_line < amps_line)) ||
fail "the sidecar amplifier workaround runs after the Panther Lake kernel swap"
pass "the sidecar amplifier workaround runs after the Panther Lake kernel swap"
[[ -n $migration ]] || fail "a migration enables the workaround on existing installs"
pass "a migration enables the workaround on existing installs"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mkdir -p "$test_tmp/bin"
cat >"$test_tmp/bin/omarchy-hw-match" <<'SH'
#!/bin/bash
[[ ${TEST_PRODUCT_NAME:-} == *"$1"* ]]
SH
cat >"$test_tmp/bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
printf 'pkg-add %s\n' "$*" >>"$CALL_LOG"
exit "${TEST_PKG_ADD_STATUS:-0}"
SH
cat >"$test_tmp/bin/sudo" <<'SH'
#!/bin/bash
exec "$@"
SH
cat >"$test_tmp/bin/dell-xps13-sidecar-amps-apply" <<'SH'
#!/bin/bash
printf 'apply\n' >>"$CALL_LOG"
exit "${TEST_APPLY_STATUS:-0}"
SH
cat >"$test_tmp/bin/omarchy-state" <<'SH'
#!/bin/bash
printf 'state %s\n' "$*" >>"$CALL_LOG"
SH
chmod +x "$test_tmp/bin"/*
sku_file="$test_tmp/product_sku"
call_log="$test_tmp/calls.log"
run_detector() {
printf '%s\n' "${2-0E53}" >"$sku_file"
PATH="$test_tmp/bin:$PATH" \
TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \
OMARCHY_DMI_PRODUCT_SKU="${3-$sku_file}" \
bash "$detector"
}
run_detector || fail "the detector matches the DX13260 with SKU 0E53"
pass "the detector matches the DX13260 with SKU 0E53"
run_detector "XPS 13 DX13261" && fail "the detector rejects another model"
pass "the detector rejects another model"
run_detector "XPS 13 DX13260" "0E54" && fail "the detector rejects another SKU"
pass "the detector rejects another SKU"
# An exact match must not be satisfied by a SKU that merely contains it.
run_detector "XPS 13 DX13260" "0E530" && fail "the detector rejects a longer SKU"
pass "the detector rejects a longer SKU"
run_detector "XPS 13 DX13260" "0E53" "$test_tmp/absent" &&
fail "the detector fails closed when the SKU attribute is missing"
pass "the detector fails closed when the SKU attribute is missing"
# Sourced the way run_logged runs it.
run_leaf() {
: >"$call_log"
printf '0E53\n' >"$sku_file"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" \
CALL_LOG="$call_log" \
TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \
TEST_PKG_ADD_STATUS="${2:-0}" \
TEST_APPLY_STATUS="${3:-0}" \
OMARCHY_DMI_PRODUCT_SKU="$sku_file" \
bash -c 'source "$1"' bash "$leaf"
}
run_leaf || fail "the leaf installs and applies on the target machine"
grep -q 'pkg-add dell-xps13-sidecar-amps' "$call_log" ||
fail "the leaf installs the package on the target machine"
grep -q '^apply$' "$call_log" ||
fail "the leaf applies the workaround on the target machine"
pass "the leaf installs and applies on the target machine"
run_leaf "ThinkPad X1" || fail "the leaf no-ops on other hardware"
[[ -s $call_log ]] && fail "the leaf no-ops on other hardware"
pass "the leaf no-ops on other hardware"
# Pacman registers a package even when its scriptlet fails, so a failing apply
# has to surface rather than be swallowed by a successful install.
run_leaf "XPS 13 DX13260" 0 1 && fail "a failing apply fails the leaf"
pass "a failing apply fails the leaf"
run_leaf "XPS 13 DX13260" 1 && fail "a failing package install fails the leaf"
grep -q '^apply$' "$call_log" && fail "a failing package install skips the apply"
pass "a failing package install fails the leaf without applying"
# The migration runner uses bash -euo pipefail and only records the migration
# when it exits clean, so a failed apply has to leave reboot-required unset.
run_migration() {
: >"$call_log"
printf '0E53\n' >"$sku_file"
PATH="$test_tmp/bin:$ROOT/bin:$PATH" \
CALL_LOG="$call_log" \
OMARCHY_PATH="$ROOT" \
TEST_PRODUCT_NAME="${1-XPS 13 DX13260}" \
TEST_APPLY_STATUS="${2:-0}" \
OMARCHY_DMI_PRODUCT_SKU="$sku_file" \
bash -euo pipefail "$migration" >/dev/null
}
run_migration || fail "the migration applies the workaround and asks for a reboot"
grep -q 'state set reboot-required' "$call_log" ||
fail "the migration applies the workaround and asks for a reboot"
pass "the migration applies the workaround and asks for a reboot"
run_migration "XPS 13 DX13260" 1 && fail "a failing apply leaves the migration pending"
grep -q 'state set reboot-required' "$call_log" &&
fail "a failing apply does not mark reboot-required"
pass "a failing apply leaves the migration pending without marking reboot-required"
run_migration "ThinkPad X1" || fail "the migration no-ops on other hardware"
[[ -s $call_log ]] && fail "the migration no-ops on other hardware"
pass "the migration no-ops on other hardware"